diff --git a/Cargo.lock b/Cargo.lock index 3aad1a8c..d95aa6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3617,6 +3617,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ticket" +version = "0.1.0" +dependencies = [ + "chrono", + "fs4", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "time" version = "0.3.47" diff --git a/Cargo.toml b/Cargo.toml index c68f3432..39b89a63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/tools", "crates/tui", "crates/memory", + "crates/ticket", "crates/workflow", ] @@ -34,6 +35,7 @@ llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } manifest = { path = "crates/manifest" } lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } +ticket = { path = "crates/ticket" } pod = { path = "crates/pod" } yoi = { path = "crates/yoi" } pod-registry = { path = "crates/pod-registry" } diff --git a/crates/ticket/Cargo.toml b/crates/ticket/Cargo.toml new file mode 100644 index 00000000..62b87835 --- /dev/null +++ b/crates/ticket/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ticket" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +fs4 = { workspace = true, features = ["sync"] } +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs new file mode 100644 index 00000000..d5154ffc --- /dev/null +++ b/crates/ticket/src/lib.rs @@ -0,0 +1,1728 @@ +//! Ticket domain types and the local `work-items/` file backend. +//! +//! The public domain name is **Ticket**. `LocalTicketBackend` preserves the +//! repository's current `work-items/{open,pending,closed}//` layout and the +//! event format used by `tickets.sh` while exposing typed Rust operations. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Component, Path, PathBuf}; + +use chrono::Utc; +use fs4::fs_std::FileExt; +use thiserror::Error; + +const STATUSES: [TicketStatus; 3] = [ + TicketStatus::Open, + TicketStatus::Pending, + TicketStatus::Closed, +]; +const REQUIRED_FIELDS: [&str; 11] = [ + "id", + "slug", + "title", + "status", + "kind", + "priority", + "labels", + "created_at", + "updated_at", + "assignee", + "legacy_ticket", +]; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum TicketError { + #[error("ticket backend I/O error at {path}: {source}")] + Io { path: PathBuf, source: io::Error }, + #[error("ticket not found: {0}")] + NotFound(String), + #[error("ambiguous ticket query {query}: {matches:?}")] + Ambiguous { + query: String, + matches: Vec, + }, + #[error("invalid local ticket status for mutation: {0}")] + InvalidLocalStatus(String), + #[error("invalid ticket filename component: {0}")] + InvalidPathComponent(String), + #[error("ticket path escapes configured root: {path}")] + PathEscapesRoot { path: PathBuf }, + #[error("ticket backend is locked: {path}")] + Locked { path: PathBuf }, + #[error("ticket conflict: {0}")] + Conflict(String), + #[error("ticket parse error in {path}: {message}")] + Parse { path: PathBuf, message: String }, +} + +fn io_err(path: impl Into, source: io::Error) -> TicketError { + TicketError::Io { + path: path.into(), + source, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TicketStatus { + Open, + Pending, + Closed, +} + +impl TicketStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Pending => "pending", + Self::Closed => "closed", + } + } + + pub fn parse_local(value: &str) -> Option { + match value { + "open" => Some(Self::Open), + "pending" => Some(Self::Pending), + "closed" => Some(Self::Closed), + _ => None, + } + } +} + +impl fmt::Display for TicketStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtensibleTicketStatus { + Open, + Pending, + Closed, + Other(String), +} + +impl ExtensibleTicketStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Open => "open", + Self::Pending => "pending", + Self::Closed => "closed", + Self::Other(value) => value.as_str(), + } + } + + pub fn as_local(&self) -> Option { + match self { + Self::Open => Some(TicketStatus::Open), + Self::Pending => Some(TicketStatus::Pending), + Self::Closed => Some(TicketStatus::Closed), + Self::Other(_) => None, + } + } +} + +impl From<&str> for ExtensibleTicketStatus { + fn from(value: &str) -> Self { + match value { + "open" => Self::Open, + "pending" => Self::Pending, + "closed" => Self::Closed, + other => Self::Other(other.to_string()), + } + } +} + +impl From for ExtensibleTicketStatus { + fn from(value: TicketStatus) -> Self { + match value { + TicketStatus::Open => Self::Open, + TicketStatus::Pending => Self::Pending, + TicketStatus::Closed => Self::Closed, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarkdownText(pub String); + +impl MarkdownText { + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From<&str> for MarkdownText { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl From for MarkdownText { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TicketIdOrSlug { + Id(String), + Slug(String), + Query(String), +} + +impl TicketIdOrSlug { + fn as_query(&self) -> &str { + match self { + Self::Id(value) | Self::Slug(value) | Self::Query(value) => value.as_str(), + } + } +} + +impl From<&str> for TicketIdOrSlug { + fn from(value: &str) -> Self { + Self::Query(value.to_string()) + } +} + +impl From for TicketIdOrSlug { + fn from(value: String) -> Self { + Self::Query(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TicketEventKind { + Create, + Comment, + Plan, + Decision, + ImplementationReport, + Review, + StatusChanged, + Close, + Other(String), +} + +impl TicketEventKind { + pub fn as_str(&self) -> &str { + match self { + Self::Create => "create", + Self::Comment => "comment", + Self::Plan => "plan", + Self::Decision => "decision", + Self::ImplementationReport => "implementation_report", + Self::Review => "review", + Self::StatusChanged => "status_changed", + Self::Close => "close", + Self::Other(value) => value.as_str(), + } + } + + fn heading(&self) -> String { + match self { + Self::Create => "Created".to_string(), + Self::Comment => "Comment".to_string(), + Self::Plan => "Plan".to_string(), + Self::Decision => "Decision".to_string(), + Self::ImplementationReport => "Implementation report".to_string(), + Self::Review => "Review".to_string(), + Self::StatusChanged => "Status changed".to_string(), + Self::Close => "Closed".to_string(), + Self::Other(value) => value.clone(), + } + } +} + +impl From<&str> for TicketEventKind { + fn from(value: &str) -> Self { + match value { + "create" => Self::Create, + "comment" => Self::Comment, + "plan" => Self::Plan, + "decision" => Self::Decision, + "implementation_report" => Self::ImplementationReport, + "review" => Self::Review, + "status_changed" => Self::StatusChanged, + "close" | "closed" => Self::Close, + other => Self::Other(other.to_string()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TicketReviewResult { + Approve, + RequestChanges, + Other(String), +} + +impl TicketReviewResult { + pub fn as_str(&self) -> &str { + match self { + Self::Approve => "approve", + Self::RequestChanges => "request_changes", + Self::Other(value) => value.as_str(), + } + } + + fn heading(&self) -> String { + match self { + Self::Approve => "Review: approve".to_string(), + Self::RequestChanges => "Review: request changes".to_string(), + Self::Other(value) => format!("Review: {value}"), + } + } +} + +impl From<&str> for TicketReviewResult { + fn from(value: &str) -> Self { + match value { + "approve" => Self::Approve, + "request_changes" => Self::RequestChanges, + other => Self::Other(other.to_string()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketReference { + pub kind: String, + pub target: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewTicketEvent { + pub kind: TicketEventKind, + pub author: Option, + pub body: MarkdownText, + pub references: Vec, +} + +impl NewTicketEvent { + pub fn new(kind: TicketEventKind, body: impl Into) -> Self { + Self { + kind, + author: None, + body: body.into(), + references: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketReview { + pub result: TicketReviewResult, + pub author: Option, + pub body: MarkdownText, +} + +impl TicketReview { + pub fn approve(body: impl Into) -> Self { + Self { + result: TicketReviewResult::Approve, + author: None, + body: body.into(), + } + } + + pub fn request_changes(body: impl Into) -> Self { + Self { + result: TicketReviewResult::RequestChanges, + author: None, + body: body.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewTicket { + pub title: String, + pub slug: Option, + pub kind: String, + pub priority: String, + pub labels: Vec, + pub body: MarkdownText, + pub author: Option, + pub assignee: Option, + pub legacy_ticket: Option, + pub readiness: Option, + pub needs_preflight: Option, + pub risk_flags: Vec, + pub action_required: Option, +} + +impl NewTicket { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + slug: None, + kind: "task".to_string(), + priority: "P2".to_string(), + labels: Vec::new(), + body: MarkdownText::new( + "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n", + ), + author: None, + assignee: None, + legacy_ticket: None, + readiness: None, + needs_preflight: None, + risk_flags: Vec::new(), + action_required: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketFilter { + pub status: Option, +} + +impl TicketFilter { + pub fn all() -> Self { + Self { status: None } + } + + pub fn status(status: TicketStatus) -> Self { + Self { + status: Some(status), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRef { + pub id: String, + pub slug: String, + pub status: TicketStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketMeta { + pub id: String, + pub slug: String, + pub title: String, + pub status: ExtensibleTicketStatus, + pub kind: String, + pub priority: String, + pub labels: Vec, + pub created_at: Option, + pub updated_at: Option, + pub assignee: Option, + pub legacy_ticket: Option, + pub readiness: Option, + pub needs_preflight: Option, + pub risk_flags: Vec, + pub action_required: Option, + pub raw: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketSummary { + pub id: String, + pub slug: String, + pub title: String, + pub status: ExtensibleTicketStatus, + pub kind: String, + pub priority: String, + pub labels: Vec, + pub readiness: Option, + pub needs_preflight: Option, + pub action_required: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketDocument { + pub body: MarkdownText, + pub raw_frontmatter: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketEvent { + pub kind: TicketEventKind, + pub author: Option, + pub at: Option, + pub status: Option, + pub heading: Option, + pub body: MarkdownText, + pub references: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketArtifactRef { + /// Path relative to the ticket's `artifacts/` directory. + pub relative_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ticket { + pub meta: TicketMeta, + pub document: TicketDocument, + pub events: Vec, + pub artifacts: Vec, + pub resolution: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TicketDoctorSeverity { + Error, + Warning, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketDoctorDiagnostic { + pub severity: TicketDoctorSeverity, + pub message: String, + pub path: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketDoctorReport { + pub diagnostics: Vec, +} + +impl TicketDoctorReport { + pub fn is_ok(&self) -> bool { + self.error_count() == 0 + } + + pub fn error_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|d| d.severity == TicketDoctorSeverity::Error) + .count() + } + + pub fn push_error(&mut self, message: impl Into, path: Option) { + self.diagnostics.push(TicketDoctorDiagnostic { + severity: TicketDoctorSeverity::Error, + message: message.into(), + path, + }); + } +} + +pub trait TicketBackend { + fn list(&self, filter: TicketFilter) -> Result>; + fn show(&self, id: TicketIdOrSlug) -> Result; + fn create(&self, input: NewTicket) -> Result; + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; + fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; + fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; + fn doctor(&self) -> Result; +} + +#[derive(Debug, Clone)] +pub struct LocalTicketBackend { + root: PathBuf, +} + +impl LocalTicketBackend { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + self.root.as_path() + } + + fn ensure_backend_dirs(&self) -> Result<()> { + for status in STATUSES { + let dir = self.status_dir(status); + fs::create_dir_all(&dir).map_err(|e| io_err(dir, e))?; + } + Ok(()) + } + + fn status_dir(&self, status: TicketStatus) -> PathBuf { + self.root.join(status.as_str()) + } + + fn acquire_lock(&self) -> Result { + fs::create_dir_all(&self.root).map_err(|e| io_err(&self.root, e))?; + let path = self.root.join(".ticket-backend.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|e| io_err(&path, e))?; + match FileExt::try_lock_exclusive(&file) { + Ok(true) => Ok(BackendLock { file }), + Ok(false) => Err(TicketError::Locked { path }), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => Err(TicketError::Locked { path }), + Err(e) => Err(io_err(path, e)), + } + } + + fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result> { + let mut dirs = Vec::new(); + for status in STATUSES { + if let Some(filter_status) = filter.status { + if status != filter_status { + continue; + } + } + let status_dir = self.status_dir(status); + if !status_dir.exists() { + continue; + } + let entries = fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))?; + for entry in entries { + let entry = entry.map_err(|e| io_err(&status_dir, e))?; + let path = entry.path(); + if path.is_dir() { + dirs.push((status, path)); + } + } + } + dirs.sort_by(|(_, a), (_, b)| a.cmp(b)); + Ok(dirs) + } + + fn find_ticket_dir(&self, query: &TicketIdOrSlug) -> Result { + let query = query.as_query(); + let mut matches = Vec::new(); + for (_, dir) in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + if !item.exists() { + continue; + } + let parsed = read_item_file(&item)?; + let id = parsed.frontmatter.get("id").map(String::as_str); + let slug = parsed.frontmatter.get("slug").map(String::as_str); + if id == Some(query) || slug == Some(query) { + matches.push(dir); + } + } + match matches.len() { + 0 => Err(TicketError::NotFound(query.to_string())), + 1 => Ok(matches.remove(0)), + _ => Err(TicketError::Ambiguous { + query: query.to_string(), + matches, + }), + } + } + + fn ticket_from_dir(&self, dir: &Path) -> Result { + let item_path = dir.join("item.md"); + let parsed = read_item_file(&item_path)?; + let meta = ticket_meta(parsed.frontmatter.clone()); + let document = TicketDocument { + body: MarkdownText::new(parsed.body), + raw_frontmatter: parsed.frontmatter, + }; + let thread_path = dir.join("thread.md"); + let events = if thread_path.exists() { + parse_thread(&thread_path)? + } else { + Vec::new() + }; + let artifacts = collect_artifacts(&dir.join("artifacts"))?; + let resolution_path = dir.join("resolution.md"); + let resolution = if resolution_path.exists() { + Some(MarkdownText::new( + fs::read_to_string(&resolution_path).map_err(|e| io_err(&resolution_path, e))?, + )) + } else { + None + }; + Ok(Ticket { + meta, + document, + events, + artifacts, + resolution, + }) + } + + fn append_thread_event( + &self, + dir: &Path, + event: &str, + heading: &str, + author: &str, + status: Option<&str>, + body: &MarkdownText, + ) -> Result<()> { + let at = now_utc(); + let thread = dir.join("thread.md"); + if !thread.exists() { + File::create(&thread).map_err(|e| io_err(&thread, e))?; + } + let mut file = OpenOptions::new() + .append(true) + .open(&thread) + .map_err(|e| io_err(&thread, e))?; + write!(file, "\n\n\n## {heading}\n\n{}\n\n---\n", body.as_str()) + .map_err(|e| io_err(&thread, e))?; + file.sync_data().map_err(|e| io_err(&thread, e))?; + self.set_frontmatter_fields(&dir.join("item.md"), &[("updated_at", at.as_str())]) + } + + fn set_frontmatter_fields(&self, item: &Path, updates: &[(&str, &str)]) -> Result<()> { + let content = fs::read_to_string(item).map_err(|e| io_err(item, e))?; + let updated = replace_frontmatter_fields(&content, updates).map_err(|message| { + TicketError::Parse { + path: item.to_path_buf(), + message, + } + })?; + atomic_write(item, updated.as_bytes()) + } +} + +impl TicketBackend for LocalTicketBackend { + fn list(&self, filter: TicketFilter) -> Result> { + let mut tickets = Vec::new(); + for (_, dir) in self.iter_ticket_dirs(filter)? { + let item = dir.join("item.md"); + if !item.exists() { + continue; + } + let parsed = read_item_file(&item)?; + let meta = ticket_meta(parsed.frontmatter); + tickets.push(TicketSummary { + id: meta.id, + slug: meta.slug, + title: meta.title, + status: meta.status, + kind: meta.kind, + priority: meta.priority, + labels: meta.labels, + readiness: meta.readiness, + needs_preflight: meta.needs_preflight, + action_required: meta.action_required, + updated_at: meta.updated_at, + }); + } + Ok(tickets) + } + + fn show(&self, id: TicketIdOrSlug) -> Result { + let dir = self.find_ticket_dir(&id)?; + self.ticket_from_dir(&dir) + } + + fn create(&self, input: NewTicket) -> Result { + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + if input.title.trim().is_empty() { + return Err(TicketError::Conflict( + "ticket title must not be empty".to_string(), + )); + } + let slug = slugify(input.slug.as_deref().unwrap_or(&input.title)); + let slug = if slug.is_empty() { + "item".to_string() + } else { + slug + }; + ensure_safe_component(&slug)?; + let stamp = compact_now_utc(); + let mut id = format!("{stamp}-{slug}"); + ensure_safe_component(&id)?; + let mut dir = self.status_dir(TicketStatus::Open).join(&id); + if dir.exists() { + id = format!("{id}-{}", std::process::id()); + ensure_safe_component(&id)?; + dir = self.status_dir(TicketStatus::Open).join(&id); + } + if dir.exists() { + return Err(TicketError::Conflict(format!( + "target already exists: {}", + dir.display() + ))); + } + ensure_child_of(&self.root, &dir)?; + fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?; + atomic_write(&dir.join("artifacts/.gitkeep"), b"")?; + let created = now_utc(); + let mut fields = Vec::new(); + fields.push(("id".to_string(), id.clone())); + fields.push(("slug".to_string(), slug.clone())); + fields.push(("title".to_string(), input.title)); + fields.push(("status".to_string(), "open".to_string())); + fields.push(("kind".to_string(), input.kind)); + fields.push(("priority".to_string(), input.priority)); + fields.push(("labels".to_string(), labels_yaml(&input.labels))); + fields.push(("created_at".to_string(), created.clone())); + fields.push(("updated_at".to_string(), created.clone())); + fields.push(( + "assignee".to_string(), + input.assignee.unwrap_or_else(|| "null".to_string()), + )); + fields.push(( + "legacy_ticket".to_string(), + input.legacy_ticket.unwrap_or_else(|| "null".to_string()), + )); + if let Some(readiness) = input.readiness { + fields.push(("readiness".to_string(), readiness)); + } + if let Some(needs_preflight) = input.needs_preflight { + fields.push(("needs_preflight".to_string(), needs_preflight.to_string())); + } + if !input.risk_flags.is_empty() { + fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags))); + } + if let Some(action_required) = input.action_required { + fields.push(("action_required".to_string(), action_required)); + } + let item = serialize_item(&fields, input.body.as_str()); + atomic_write(&dir.join("item.md"), item.as_bytes())?; + let author = input + .author + .unwrap_or_else(|| "LocalTicketBackend".to_string()); + let thread = format!( + "\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n" + ); + atomic_write(&dir.join("thread.md"), thread.as_bytes())?; + Ok(TicketRef { + id, + slug, + status: TicketStatus::Open, + }) + } + + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> { + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let author = event.author.unwrap_or_else(default_author); + self.append_thread_event( + &dir, + event.kind.as_str(), + &event.kind.heading(), + &author, + None, + &event.body, + ) + } + + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> { + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let author = review.author.unwrap_or_else(default_author); + self.append_thread_event( + &dir, + "review", + &review.result.heading(), + &author, + Some(review.result.as_str()), + &review.body, + ) + } + + fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()> { + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + let old_dir = self.find_ticket_dir(&id)?; + let item = old_dir.join("item.md"); + let parsed = read_item_file(&item)?; + let ticket_id = + parsed.frontmatter.get("id").cloned().ok_or_else(|| { + TicketError::Conflict(format!("missing id in {}", item.display())) + })?; + ensure_safe_component(&ticket_id)?; + let new_dir = self.status_dir(status).join(&ticket_id); + ensure_child_of(&self.root, &new_dir)?; + if old_dir != new_dir { + if new_dir.exists() { + return Err(TicketError::Conflict(format!( + "target already exists: {}", + new_dir.display() + ))); + } + fs::rename(&old_dir, &new_dir).map_err(|e| io_err(&new_dir, e))?; + } + let at = now_utc(); + self.set_frontmatter_fields( + &new_dir.join("item.md"), + &[("status", status.as_str()), ("updated_at", &at)], + ) + } + + fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> { + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + let old_dir = self.find_ticket_dir(&id)?; + let item = old_dir.join("item.md"); + let parsed = read_item_file(&item)?; + let ticket_id = + parsed.frontmatter.get("id").cloned().ok_or_else(|| { + TicketError::Conflict(format!("missing id in {}", item.display())) + })?; + ensure_safe_component(&ticket_id)?; + let closed_dir = self.status_dir(TicketStatus::Closed).join(&ticket_id); + ensure_child_of(&self.root, &closed_dir)?; + if old_dir != closed_dir { + if closed_dir.exists() { + return Err(TicketError::Conflict(format!( + "target already exists: {}", + closed_dir.display() + ))); + } + fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?; + } + let at = now_utc(); + self.set_frontmatter_fields( + &closed_dir.join("item.md"), + &[("status", "closed"), ("updated_at", &at)], + )?; + atomic_write( + &closed_dir.join("resolution.md"), + resolution.as_str().as_bytes(), + )?; + let author = default_author(); + self.append_thread_event( + &closed_dir, + "close", + "Closed", + &author, + Some("closed"), + &resolution, + ) + } + + fn doctor(&self) -> Result { + let mut report = TicketDoctorReport::default(); + for status in STATUSES { + let dir = self.status_dir(status); + if !dir.is_dir() { + report.push_error(format!("missing directory: {}", dir.display()), Some(dir)); + } + } + + let mut ids: HashMap = HashMap::new(); + let mut duplicate_ids: BTreeSet = BTreeSet::new(); + let mut slugs: HashMap = HashMap::new(); + let mut duplicate_slugs: BTreeSet = BTreeSet::new(); + + for status in STATUSES { + let status_dir = self.status_dir(status); + if !status_dir.is_dir() { + continue; + } + for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? { + let entry = entry.map_err(|e| io_err(&status_dir, e))?; + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let item = dir.join("item.md"); + let thread = dir.join("thread.md"); + let artifacts = dir.join("artifacts"); + if !item.is_file() { + report.push_error( + format!("missing item.md: {}", dir.display()), + Some(dir.clone()), + ); + continue; + } + if !thread.is_file() { + report.push_error( + format!("missing thread.md: {}", dir.display()), + Some(thread.clone()), + ); + } + if !artifacts.is_dir() { + report.push_error( + format!("missing artifacts/: {}", dir.display()), + Some(artifacts.clone()), + ); + } + let parsed = match read_item_file(&item) { + Ok(parsed) => parsed, + Err(TicketError::Parse { message, .. }) => { + report.push_error(message, Some(item.clone())); + continue; + } + Err(e) => return Err(e), + }; + for field in REQUIRED_FIELDS { + if parsed + .frontmatter + .get(field) + .is_none_or(|value| value.is_empty()) + { + report.push_error( + format!("missing required field '{field}': {}", item.display()), + Some(item.clone()), + ); + } + } + if let Some(id) = parsed.frontmatter.get("id") { + if ids.insert(id.clone(), item.clone()).is_some() { + duplicate_ids.insert(id.clone()); + } + if dir.file_name().and_then(|name| name.to_str()) != Some(id.as_str()) { + report.push_error( + format!("directory id mismatch: {} has id {id}", dir.display()), + Some(dir.clone()), + ); + } + } + if let Some(slug) = parsed.frontmatter.get("slug") { + if slugs.insert(slug.clone(), item.clone()).is_some() { + duplicate_slugs.insert(slug.clone()); + } + } + let fm_status = parsed + .frontmatter + .get("status") + .map(String::as_str) + .unwrap_or(""); + if fm_status != status.as_str() { + report.push_error( + format!( + "status mismatch: {} has '{fm_status}' under '{}'", + item.display(), + status.as_str() + ), + Some(item.clone()), + ); + } + if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() { + report.push_error( + format!("missing resolution.md for closed ticket: {}", dir.display()), + Some(dir.join("resolution.md")), + ); + } + if thread.exists() { + for diagnostic in doctor_thread_events(&thread)? { + report.push_error(diagnostic, Some(thread.clone())); + } + } + if artifacts.exists() { + doctor_artifacts(&artifacts, &mut report)?; + } + } + } + for duplicate in duplicate_ids { + report.push_error(format!("duplicate id: {duplicate}"), None); + } + for duplicate in duplicate_slugs { + report.push_error(format!("duplicate slug: {duplicate}"), None); + } + + let todo = self + .root + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("TODO.md"); + if todo.is_file() { + let content = fs::read_to_string(&todo).map_err(|e| io_err(&todo, e))?; + if content.contains("tickets/") + && (content.contains(".md") || content.contains(".review.md")) + { + report.push_error("TODO.md still references legacy tickets/*.md", Some(todo)); + } + } + let legacy_dir = self + .root + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("tickets"); + if legacy_dir.is_dir() { + for entry in fs::read_dir(&legacy_dir).map_err(|e| io_err(&legacy_dir, e))? { + let entry = entry.map_err(|e| io_err(&legacy_dir, e))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + report.push_error( + format!("legacy ticket file remains: {}", path.display()), + Some(path), + ); + } + } + } + Ok(report) + } +} + +struct BackendLock { + file: File, +} + +impl Drop for BackendLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +#[derive(Debug, Clone)] +struct ParsedItem { + frontmatter: BTreeMap, + body: String, +} + +fn read_item_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + parse_item(&content).map_err(|message| TicketError::Parse { + path: path.to_path_buf(), + message, + }) +} + +fn parse_item(content: &str) -> std::result::Result { + let mut lines = content.lines(); + let Some(first) = lines.next() else { + return Err("item.md is empty".to_string()); + }; + if first != "---" { + return Err("item.md missing frontmatter opener".to_string()); + } + let mut frontmatter = BTreeMap::new(); + let mut found_close = false; + let mut body = String::new(); + for line in &mut lines { + if line == "---" { + found_close = true; + break; + } + if let Some((key, value)) = line.split_once(':') { + frontmatter.insert(key.trim().to_string(), value.trim_start().to_string()); + } + } + if !found_close { + return Err("item.md missing frontmatter closer".to_string()); + } + let rest: Vec<&str> = lines.collect(); + if !rest.is_empty() { + body.push_str(&rest.join("\n")); + if content.ends_with('\n') { + body.push('\n'); + } + } + Ok(ParsedItem { frontmatter, body }) +} + +fn ticket_meta(frontmatter: BTreeMap) -> TicketMeta { + let id = frontmatter.get("id").cloned().unwrap_or_default(); + let slug = frontmatter.get("slug").cloned().unwrap_or_default(); + let title = frontmatter.get("title").cloned().unwrap_or_default(); + let status = frontmatter + .get("status") + .map(|value| ExtensibleTicketStatus::from(value.as_str())) + .unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new())); + let kind = frontmatter.get("kind").cloned().unwrap_or_default(); + let priority = frontmatter.get("priority").cloned().unwrap_or_default(); + let labels = frontmatter + .get("labels") + .map(|value| parse_yaml_list(value)) + .unwrap_or_default(); + let risk_flags = frontmatter + .get("risk_flags") + .or_else(|| frontmatter.get("risks")) + .map(|value| parse_yaml_list(value)) + .unwrap_or_default(); + TicketMeta { + id, + slug, + title, + status, + kind, + priority, + labels, + created_at: frontmatter.get("created_at").cloned(), + updated_at: frontmatter.get("updated_at").cloned(), + assignee: frontmatter.get("assignee").cloned().filter(|v| v != "null"), + legacy_ticket: frontmatter + .get("legacy_ticket") + .cloned() + .filter(|v| v != "null"), + readiness: frontmatter.get("readiness").cloned(), + needs_preflight: frontmatter + .get("needs_preflight") + .or_else(|| frontmatter.get("needs-preflight")) + .and_then(|value| parse_bool(value)), + risk_flags, + action_required: frontmatter.get("action_required").cloned(), + raw: frontmatter, + } +} + +fn parse_bool(value: &str) -> Option { + match value.trim() { + "true" | "yes" | "1" => Some(true), + "false" | "no" | "0" => Some(false), + _ => None, + } +} + +fn parse_yaml_list(value: &str) -> Vec { + let trimmed = value.trim(); + if let Some(inner) = trimmed.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { + return inner + .split(',') + .map(|part| part.trim().trim_matches('"').trim_matches('\'')) + .filter(|part| !part.is_empty()) + .map(ToOwned::to_owned) + .collect(); + } + if trimmed.is_empty() || trimmed == "null" { + Vec::new() + } else { + vec![trimmed.to_string()] + } +} + +fn labels_yaml(labels: &[String]) -> String { + if labels.is_empty() { + return "[]".to_string(); + } + format!( + "[{}]", + labels + .iter() + .map(|label| label.trim()) + .filter(|label| !label.is_empty()) + .collect::>() + .join(", ") + ) +} + +fn serialize_item(fields: &[(String, String)], body: &str) -> String { + let mut out = String::from("---\n"); + for (key, value) in fields { + out.push_str(key); + out.push_str(": "); + out.push_str(value); + out.push('\n'); + } + out.push_str("---\n\n"); + out.push_str(body); + if !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn replace_frontmatter_fields( + content: &str, + updates: &[(&str, &str)], +) -> std::result::Result { + let mut lines: Vec = content.lines().map(ToOwned::to_owned).collect(); + if lines.first().map(String::as_str) != Some("---") { + return Err("item.md missing frontmatter opener".to_string()); + } + let Some(end) = lines + .iter() + .enumerate() + .skip(1) + .find_map(|(idx, line)| (line == "---").then_some(idx)) + else { + return Err("item.md missing frontmatter closer".to_string()); + }; + let mut seen = BTreeSet::new(); + for line in lines.iter_mut().take(end).skip(1) { + if let Some((key, _)) = line.split_once(':') { + let key = key.trim().to_string(); + if let Some((_, value)) = updates.iter().find(|(update_key, _)| *update_key == key) { + *line = format!("{key}: {value}"); + seen.insert(key); + } + } + } + let mut insert_at = end; + for (key, value) in updates { + if !seen.contains(*key) { + lines.insert(insert_at, format!("{key}: {value}")); + insert_at += 1; + } + } + let mut out = lines.join("\n"); + if content.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + +fn parse_thread(path: &Path) -> Result> { + let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + let mut events = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + let mut idx = 0; + while idx < lines.len() { + let line = lines[idx].trim(); + if let Some(comment) = line + .strip_prefix("")) + { + let attrs = parse_event_comment(comment); + let kind = attrs + .get("event") + .map(|value| TicketEventKind::from(value.as_str())) + .unwrap_or_else(|| TicketEventKind::Other(String::new())); + idx += 1; + while idx < lines.len() && lines[idx].trim().is_empty() { + idx += 1; + } + let mut heading = None; + if idx < lines.len() { + if let Some(stripped) = lines[idx].strip_prefix("## ") { + heading = Some(stripped.to_string()); + idx += 1; + } + } + while idx < lines.len() && lines[idx].trim().is_empty() { + idx += 1; + } + let mut body_lines = Vec::new(); + while idx < lines.len() { + if lines[idx].trim() == "---" { + idx += 1; + break; + } + body_lines.push(lines[idx]); + idx += 1; + } + let mut body = body_lines.join("\n"); + while body.ends_with('\n') { + body.pop(); + } + events.push(TicketEvent { + kind, + author: attrs.get("author").cloned(), + at: attrs.get("at").cloned(), + status: attrs.get("status").cloned(), + heading, + body: MarkdownText::new(body), + references: Vec::new(), + }); + } else { + idx += 1; + } + } + Ok(events) +} + +fn parse_event_comment(comment: &str) -> BTreeMap { + // tickets.sh emits unquoted `key: value` pairs separated by spaces. Values + // currently do not contain spaces; this parser intentionally preserves the + // compatibility shape instead of treating thread.md as strict YAML. + let mut attrs = BTreeMap::new(); + let mut iter = comment.split_whitespace().peekable(); + while let Some(token) = iter.next() { + if let Some(key) = token.strip_suffix(':') { + if let Some(value) = iter.next() { + attrs.insert(key.to_string(), value.to_string()); + } + } + } + attrs +} + +fn doctor_thread_events(path: &Path) -> Result> { + let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + let mut diagnostics = Vec::new(); + for (line_no, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with("") { + diagnostics.push(format!( + "malformed thread event comment at {}:{}", + path.display(), + line_no + 1 + )); + } + if let Some(comment) = trimmed + .strip_prefix("")) + { + let attrs = parse_event_comment(comment); + if attrs.contains_key("event") && attrs.get("at").is_none() { + diagnostics.push(format!( + "thread event missing at: {}:{}", + path.display(), + line_no + 1 + )); + } + if attrs.get("event").map(String::as_str) == Some("review") { + match attrs.get("status").map(String::as_str) { + Some("approve" | "request_changes") => {} + _ => diagnostics.push(format!( + "review event missing valid status at {}:{}", + path.display(), + line_no + 1 + )), + } + } + } + } + Ok(diagnostics) +} + +fn collect_artifacts(dir: &Path) -> Result> { + let mut artifacts = Vec::new(); + if !dir.exists() { + return Ok(artifacts); + } + collect_artifacts_inner(dir, dir, &mut artifacts)?; + artifacts.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + Ok(artifacts) +} + +fn collect_artifacts_inner( + root: &Path, + dir: &Path, + artifacts: &mut Vec, +) -> Result<()> { + for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { + let entry = entry.map_err(|e| io_err(dir, e))?; + let path = entry.path(); + if path.is_dir() { + collect_artifacts_inner(root, &path, artifacts)?; + } else if path.file_name().and_then(|name| name.to_str()) != Some(".gitkeep") { + let relative_path = path + .strip_prefix(root) + .map_err(|_| TicketError::PathEscapesRoot { path: path.clone() })? + .to_path_buf(); + artifacts.push(TicketArtifactRef { relative_path }); + } + } + Ok(()) +} + +fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> { + for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { + let entry = entry.map_err(|e| io_err(dir, e))?; + let path = entry.path(); + if path.is_dir() { + doctor_artifacts(&path, report)?; + } else if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + report.push_error( + format!("artifact path escapes artifacts/: {}", path.display()), + Some(path), + ); + } + } + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| TicketError::PathEscapesRoot { + path: path.to_path_buf(), + })?; + fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| TicketError::InvalidPathComponent(path.display().to_string()))?; + let tmp = parent.join(format!(".{file_name}.tmp.{}", std::process::id())); + { + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp) + .map_err(|e| io_err(&tmp, e))?; + file.write_all(bytes).map_err(|e| io_err(&tmp, e))?; + file.sync_data().map_err(|e| io_err(&tmp, e))?; + } + fs::rename(&tmp, path).map_err(|e| io_err(path, e))?; + Ok(()) +} + +fn ensure_child_of(root: &Path, path: &Path) -> Result<()> { + let root = root.components().collect::>(); + let path_components = path.components().collect::>(); + if path_components.starts_with(&root) { + Ok(()) + } else { + Err(TicketError::PathEscapesRoot { + path: path.to_path_buf(), + }) + } +} + +fn ensure_safe_component(value: &str) -> Result<()> { + let invalid = value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + || value.contains('\0'); + if invalid { + Err(TicketError::InvalidPathComponent(value.to_string())) + } else { + Ok(()) + } +} + +fn slugify(value: &str) -> String { + let mut out = String::new(); + let mut previous_dash = false; + for ch in value.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + out.push(ch); + previous_dash = false; + } else if !previous_dash { + out.push('-'); + previous_dash = true; + } + } + out.trim_matches('-').to_string() +} + +fn now_utc() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +fn compact_now_utc() -> String { + Utc::now().format("%Y%m%d-%H%M%S").to_string() +} + +fn default_author() -> String { + std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn backend(dir: &TempDir) -> LocalTicketBackend { + LocalTicketBackend::new(dir.path().join("work-items")) + } + + fn script_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..//tickets.sh") + } + + fn run_tickets_sh(work_items: &Path, args: &[&str]) -> std::process::Output { + std::process::Command::new(script_path()) + .current_dir(work_items.parent().unwrap()) + .env("WORK_ITEMS_DIR", work_items) + .args(args) + .output() + .unwrap() + } + + fn assert_script_ok(work_items: &Path, args: &[&str]) -> String { + let output = run_tickets_sh(work_items, args); + assert!( + output.status.success(), + "tickets.sh {:?} failed\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() + } + + #[test] + fn parses_item_frontmatter_and_optional_fields() { + let item = r#"--- +id: 20260605-000000-example +slug: example +title: Example +status: open +kind: task +priority: P1 +labels: [ticket, backend] +created_at: 2026-06-05T00:00:00Z +updated_at: 2026-06-05T00:00:00Z +assignee: null +legacy_ticket: null +readiness: implementation-ready +needs_preflight: false +risk_flags: [low, local] +action_required: none +--- + +## Body +"#; + let parsed = parse_item(item).unwrap(); + let meta = ticket_meta(parsed.frontmatter); + assert_eq!(meta.id, "20260605-000000-example"); + assert_eq!(meta.labels, vec!["ticket", "backend"]); + assert_eq!(meta.readiness.as_deref(), Some("implementation-ready")); + assert_eq!(meta.needs_preflight, Some(false)); + assert_eq!(meta.risk_flags, vec!["low", "local"]); + assert_eq!(meta.action_required.as_deref(), Some("none")); + } + + #[test] + fn create_writes_tickets_sh_compatible_layout() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut input = NewTicket::new("Example Ticket"); + input.labels = vec!["ticket".into(), "backend".into()]; + let ticket = backend.create(input).unwrap(); + let dir = tmp.path().join("work-items/open").join(&ticket.id); + assert!(dir.join("item.md").exists()); + assert!(dir.join("thread.md").exists()); + assert!(dir.join("artifacts/.gitkeep").exists()); + assert_eq!(ticket.slug, "example-ticket"); + assert_script_ok(&tmp.path().join("work-items"), &["doctor"]); + } + + #[test] + fn add_event_review_status_and_close_are_script_compatible() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap(); + backend + .add_event( + TicketIdOrSlug::Slug(ticket.slug.clone()), + NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."), + ) + .unwrap(); + backend + .review( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketReview::approve("Looks good."), + ) + .unwrap(); + backend + .set_status(TicketIdOrSlug::Id(ticket.id.clone()), TicketStatus::Pending) + .unwrap(); + let pending_item = tmp + .path() + .join("work-items/pending") + .join(&ticket.id) + .join("item.md"); + assert!(pending_item.exists()); + backend + .close( + TicketIdOrSlug::Id(ticket.id.clone()), + MarkdownText::new("Done.\n"), + ) + .unwrap(); + let closed_dir = tmp.path().join("work-items/closed").join(&ticket.id); + assert!(closed_dir.join("resolution.md").exists()); + let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap(); + assert!(thread.contains("\n", + ) + .unwrap(); + fs::create_dir_all(root.join("pending/other/artifacts")).unwrap(); + fs::write( + root.join("pending/other/item.md"), + "---\nid: other\nslug: dup\ntitle: Dup\nstatus: pending\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n", + ) + .unwrap(); + fs::write(root.join("pending/other/thread.md"), "").unwrap(); + fs::create_dir_all(root.join("closed")).unwrap(); + let report = LocalTicketBackend::new(&root).doctor().unwrap(); + let messages = report + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect::>() + .join("\n"); + assert!(!report.is_ok()); + assert!(messages.contains("directory id mismatch")); + assert!(messages.contains("status mismatch")); + assert!(messages.contains("duplicate id: other")); + assert!(messages.contains("duplicate slug: dup")); + assert!(messages.contains("review event missing valid status")); + } + + #[test] + fn lock_conflict_is_reported() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + fs::create_dir_all(backend.root()).unwrap(); + let lock_path = backend.root().join(".ticket-backend.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .unwrap(); + FileExt::lock_exclusive(&file).unwrap(); + let err = backend.create(NewTicket::new("Locked")).unwrap_err(); + FileExt::unlock(&file).unwrap(); + assert!(matches!(err, TicketError::Locked { .. })); + } + + #[test] + fn rejects_unsafe_components_for_status_moves() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("work-items"); + fs::create_dir_all(root.join("open/bad/artifacts")).unwrap(); + fs::write( + root.join("open/bad/item.md"), + "---\nid: ../bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n", + ) + .unwrap(); + fs::write(root.join("open/bad/thread.md"), "").unwrap(); + fs::create_dir_all(root.join("pending")).unwrap(); + fs::create_dir_all(root.join("closed")).unwrap(); + let err = LocalTicketBackend::new(&root) + .set_status(TicketIdOrSlug::Slug("bad".into()), TicketStatus::Pending) + .unwrap_err(); + assert!(matches!(err, TicketError::InvalidPathComponent(_))); + } +} diff --git a/package.nix b/package.nix index 8d3dea1b..0b189005 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-iickLtGGmqc0raCZp7giowKajAMLn5+jwtQ9c5hZmhA="; + cargoHash = "sha256-zf8YS4d/ia/nGTH7MbkWO8ipqjc1ZNnUsnKlS5rH2pQ="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,