From 283712770f07a5c8be70004f8a5461e9089bba63 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 14:17:09 -0800 Subject: [PATCH] feat(nbd): implement partial ID matching [c9d551] Add `resolve_id` to `store.rs` that resolves a full ticket ID from an exact match or a unique prefix (like git short-SHA resolution). Use it in `cmd_read`, `cmd_update`, and `validate_deps` so all three accept short prefixes. Ambiguous prefixes produce an error listing all matches. Co-Authored-By: Claude Sonnet 4.6 --- nbd/src/main.rs | 31 ++++++++++++------- nbd/src/store.rs | 57 ++++++++++++++++++++++++++++++++++ nbd/src/tests.rs | 67 +++++++++++++++++++++++++++++++++++++++- nbd/tests/integration.rs | 65 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 577120c..b224368 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -14,7 +14,8 @@ mod tests; use clap::{Parser, Subcommand}; use crate::store::{ - ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, write_ticket, + ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, resolve_id, + write_ticket, }; use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; @@ -226,16 +227,21 @@ fn parse_deps(deps: Option<&str>) -> Vec { /// Verify that every ID in `deps` refers to an existing ticket. /// +/// Each entry may be a full ID or a unique prefix; `resolve_id` is used to +/// expand prefixes before checking existence. The `deps` slice is mutated +/// in-place so that all entries are replaced with their resolved full IDs. +/// /// # Errors /// -/// Returns an error that names the first missing dependency. -async fn validate_deps(root: &std::path::Path, deps: &[String]) -> store::Result<()> { - for dep_id in deps { - read_ticket(root, dep_id).await.map_err( +/// Returns an error that names the first missing or ambiguous dependency. +async fn validate_deps(root: &std::path::Path, deps: &mut [String]) -> store::Result<()> { + for dep_id in deps.iter_mut() { + let resolved = resolve_id(root, dep_id).await.map_err( |_| -> Box { format!("dependency '{dep_id}' not found").into() }, )?; + *dep_id = resolved; } Ok(()) } @@ -279,8 +285,8 @@ async fn cmd_create( let root = find_nbd_root()?; ensure_tickets_dir(&root).await?; - let dependencies = parse_deps(deps.as_deref()); - validate_deps(&root, &dependencies).await?; + let mut dependencies = parse_deps(deps.as_deref()); + validate_deps(&root, &mut dependencies).await?; let id = generate_id(); let mut ticket = Ticket::new(id, title); @@ -301,9 +307,10 @@ async fn cmd_create( Ok(()) } -/// Read a ticket by ID and print it. +/// Read a ticket by ID (or unique prefix) and print it. async fn cmd_read(id: String, json: bool) -> store::Result<()> { let root = find_nbd_root()?; + let id = resolve_id(&root, &id).await?; let ticket = read_ticket(&root, &id).await?; if json { @@ -332,7 +339,8 @@ async fn cmd_list(json: bool) -> store::Result<()> { /// Update the specified fields of an existing ticket, persist it, and print it. /// /// Only the flags explicitly passed on the command line are applied; all other -/// fields keep their current values. +/// fields keep their current values. `id` may be a full 6-character ID or a +/// unique prefix. #[allow(clippy::too_many_arguments)] async fn cmd_update( id: String, @@ -345,6 +353,7 @@ async fn cmd_update( json: bool, ) -> store::Result<()> { let root = find_nbd_root()?; + let id = resolve_id(&root, &id).await?; let mut ticket = read_ticket(&root, &id).await?; if let Some(t) = title { @@ -365,8 +374,8 @@ async fn cmd_update( ticket.ticket_type = parse_ticket_type(&tt)?; } if deps.is_some() { - let dependencies = parse_deps(deps.as_deref()); - validate_deps(&root, &dependencies).await?; + let mut dependencies = parse_deps(deps.as_deref()); + validate_deps(&root, &mut dependencies).await?; ticket.dependencies = dependencies; } diff --git a/nbd/src/store.rs b/nbd/src/store.rs index 3ff2f16..c3c1554 100644 --- a/nbd/src/store.rs +++ b/nbd/src/store.rs @@ -113,6 +113,63 @@ pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { Ok(()) } +/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix. +/// +/// If `id_or_prefix` is already an exact match for a ticket on disk, it is +/// returned unchanged. Otherwise all ticket filenames whose stem starts with +/// `id_or_prefix` are collected and: +/// +/// - **0 matches** → error: `"no ticket found matching '{prefix}'"` +/// - **1 match** → the full 6-character ID is returned +/// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."` +/// +/// # Errors +/// +/// Returns an error if no match is found, the prefix is ambiguous, or the +/// tickets directory cannot be read. +pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { + let dir = tickets_dir(root); + + // Fast path: if exactly 6 chars and the file exists, return it directly. + if id_or_prefix.len() == 6 && dir.join(format!("{id_or_prefix}.json")).is_file() { + return Ok(id_or_prefix.to_string()); + } + + // Scan directory for prefix matches. + if !dir.is_dir() { + return Err(format!("no ticket found matching '{id_or_prefix}'").into()); + } + + let mut entries = fs::read_dir(&dir).await?; + let mut matches: Vec = Vec::new(); + + while let Some(entry) = entries.next().await { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + if stem.starts_with(id_or_prefix) { + matches.push(stem.to_string()); + } + } + } + + match matches.len() { + 0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()), + 1 => Ok(matches.remove(0)), + _ => { + matches.sort(); + Err(format!( + "ambiguous prefix '{id_or_prefix}' matches: {}", + matches.join(", ") + ) + .into()) + } + } +} + /// Read and deserialise the ticket with the given `id` from disk. /// /// The `id` is not stored inside the JSON file; it is injected from the diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index f8174ef..e6cc624 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -163,7 +163,7 @@ mod store { use std::path::Path; use crate::store::{ - ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, ticket_path, + ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path, tickets_dir, write_ticket, }; use crate::ticket::{Status, Ticket, TicketType}; @@ -323,6 +323,71 @@ mod store { drop(tmp); } + /// `resolve_id` returns the full ID when given an exact 6-char match. + #[async_std::test] + async fn resolve_id_exact_match() { + let (tmp, root) = setup_store().await; + let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string()); + write_ticket(&root, &ticket).await.unwrap(); + + let resolved = resolve_id(&root, "a3f9c2").await.unwrap(); + assert_eq!(resolved, "a3f9c2"); + drop(tmp); + } + + /// `resolve_id` resolves a unique 3-char prefix to the full ID. + #[async_std::test] + async fn resolve_id_prefix_match() { + let (tmp, root) = setup_store().await; + let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string()); + write_ticket(&root, &ticket).await.unwrap(); + + let resolved = resolve_id(&root, "a3f").await.unwrap(); + assert_eq!(resolved, "a3f9c2"); + drop(tmp); + } + + /// `resolve_id` returns an error for an unknown prefix. + #[async_std::test] + async fn resolve_id_not_found() { + let (tmp, root) = setup_store().await; + let result = resolve_id(&root, "zzz").await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("zzz"), + "error should mention the prefix, got: {msg}" + ); + drop(tmp); + } + + /// `resolve_id` returns an error listing all matches for an ambiguous prefix. + #[async_std::test] + async fn resolve_id_ambiguous_prefix() { + let (tmp, root) = setup_store().await; + let t1 = Ticket::new("aabbcc".to_string(), "First".to_string()); + let t2 = Ticket::new("aaddee".to_string(), "Second".to_string()); + write_ticket(&root, &t1).await.unwrap(); + write_ticket(&root, &t2).await.unwrap(); + + let result = resolve_id(&root, "aa").await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("ambiguous"), + "error should say ambiguous, got: {msg}" + ); + assert!( + msg.contains("aabbcc"), + "error should list first match, got: {msg}" + ); + assert!( + msg.contains("aaddee"), + "error should list second match, got: {msg}" + ); + drop(tmp); + } + /// `list_tickets` returns an empty vec when the tickets directory is absent. #[async_std::test] async fn list_empty_when_no_tickets_dir() { diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index aab5596..dbe5a7b 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -382,6 +382,71 @@ fn migrate_with_json_flag() { assert_eq!(parsed["already_current"], 1, "one file should be current"); } +/// `read` accepts a unique prefix instead of a full 6-char ID. +#[test] +fn read_with_prefix() { + let env = TestEnv::new(); + let id = env.create(&["--title", "Prefix read test"]); + + // Use a 3-char prefix. + let prefix = &id[..3]; + let read = env.run(&["read", prefix]); + assert!( + read.status.success(), + "read with prefix failed: {}", + String::from_utf8_lossy(&read.stderr) + ); + let stdout = String::from_utf8(read.stdout).unwrap(); + assert!( + stdout.contains("Prefix read test"), + "output should contain the title" + ); +} + +/// `update` accepts a unique prefix instead of a full 6-char ID. +#[test] +fn update_with_prefix() { + let env = TestEnv::new(); + let id = env.create(&["--title", "Prefix update test"]); + + let prefix = &id[..3]; + let update = env.run(&["update", prefix, "--status", "done"]); + assert!( + update.status.success(), + "update with prefix failed: {}", + String::from_utf8_lossy(&update.stderr) + ); + + let read = env.run(&["read", &id, "--json"]); + let stdout = String::from_utf8(read.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed["status"], "done", "status should be updated to done"); +} + +/// An ambiguous prefix exits non-zero with an informative message. +#[test] +fn ambiguous_prefix_exits_nonzero() { + let env = TestEnv::new(); + + // We can't reliably generate two IDs that share a 3-char prefix, but we + // can manually write two ticket files whose names share a prefix. + let ticket_dir = env.root.join(".nbd").join("tickets"); + let json = r#"{"title":"A","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; + fs::write(ticket_dir.join("ff0001.json"), json).unwrap(); + fs::write(ticket_dir.join("ff0002.json"), json).unwrap(); + + let result = env.run(&["read", "ff0"]); + assert!( + !result.status.success(), + "ambiguous prefix should exit non-zero" + ); + let stderr = String::from_utf8(result.stderr).unwrap(); + assert!( + stderr.contains("ambiguous"), + "error should say ambiguous, got: {stderr}" + ); +} + /// `update --deps` replaces the dependency list. #[test] fn update_deps_replaces_list() {