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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent df71fc9e09
commit 283712770f

@ -14,7 +14,8 @@ mod tests;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use crate::store::{ 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}; use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
@ -226,16 +227,21 @@ fn parse_deps(deps: Option<&str>) -> Vec<String> {
/// Verify that every ID in `deps` refers to an existing ticket. /// 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 /// # Errors
/// ///
/// Returns an error that names the first missing dependency. /// Returns an error that names the first missing or ambiguous dependency.
async fn validate_deps(root: &std::path::Path, deps: &[String]) -> store::Result<()> { async fn validate_deps(root: &std::path::Path, deps: &mut [String]) -> store::Result<()> {
for dep_id in deps { for dep_id in deps.iter_mut() {
read_ticket(root, dep_id).await.map_err( let resolved = resolve_id(root, dep_id).await.map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> { |_| -> Box<dyn std::error::Error + Send + Sync> {
format!("dependency '{dep_id}' not found").into() format!("dependency '{dep_id}' not found").into()
}, },
)?; )?;
*dep_id = resolved;
} }
Ok(()) Ok(())
} }
@ -279,8 +285,8 @@ async fn cmd_create(
let root = find_nbd_root()?; let root = find_nbd_root()?;
ensure_tickets_dir(&root).await?; ensure_tickets_dir(&root).await?;
let dependencies = parse_deps(deps.as_deref()); let mut dependencies = parse_deps(deps.as_deref());
validate_deps(&root, &dependencies).await?; validate_deps(&root, &mut dependencies).await?;
let id = generate_id(); let id = generate_id();
let mut ticket = Ticket::new(id, title); let mut ticket = Ticket::new(id, title);
@ -301,9 +307,10 @@ async fn cmd_create(
Ok(()) 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<()> { async fn cmd_read(id: String, json: bool) -> store::Result<()> {
let root = find_nbd_root()?; let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
let ticket = read_ticket(&root, &id).await?; let ticket = read_ticket(&root, &id).await?;
if json { 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. /// 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 /// 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)] #[allow(clippy::too_many_arguments)]
async fn cmd_update( async fn cmd_update(
id: String, id: String,
@ -345,6 +353,7 @@ async fn cmd_update(
json: bool, json: bool,
) -> store::Result<()> { ) -> store::Result<()> {
let root = find_nbd_root()?; let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
let mut ticket = read_ticket(&root, &id).await?; let mut ticket = read_ticket(&root, &id).await?;
if let Some(t) = title { if let Some(t) = title {
@ -365,8 +374,8 @@ async fn cmd_update(
ticket.ticket_type = parse_ticket_type(&tt)?; ticket.ticket_type = parse_ticket_type(&tt)?;
} }
if deps.is_some() { if deps.is_some() {
let dependencies = parse_deps(deps.as_deref()); let mut dependencies = parse_deps(deps.as_deref());
validate_deps(&root, &dependencies).await?; validate_deps(&root, &mut dependencies).await?;
ticket.dependencies = dependencies; ticket.dependencies = dependencies;
} }

@ -113,6 +113,63 @@ pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> {
Ok(()) 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<String> {
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<String> = 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. /// 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 /// The `id` is not stored inside the JSON file; it is injected from the

@ -163,7 +163,7 @@ mod store {
use std::path::Path; use std::path::Path;
use crate::store::{ 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, tickets_dir, write_ticket,
}; };
use crate::ticket::{Status, Ticket, TicketType}; use crate::ticket::{Status, Ticket, TicketType};
@ -323,6 +323,71 @@ mod store {
drop(tmp); 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. /// `list_tickets` returns an empty vec when the tickets directory is absent.
#[async_std::test] #[async_std::test]
async fn list_empty_when_no_tickets_dir() { async fn list_empty_when_no_tickets_dir() {

@ -382,6 +382,71 @@ fn migrate_with_json_flag() {
assert_eq!(parsed["already_current"], 1, "one file should be current"); 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. /// `update --deps` replaces the dependency list.
#[test] #[test]
fn update_deps_replaces_list() { fn update_deps_replaces_list() {

Loading…
Cancel
Save