From fb28b831f3d153503de8957356d979f6a53de975 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 21 Jul 2026 15:23:55 +0200 Subject: [PATCH] feat: show resolves a unique content-id prefix (closes #302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aura {process|campaign} show now accepts the 8-hex prefix every surface prints: an exact match is tried first, then the prefix is resolved against the store's candidate ids — a unique match prints the identical stored bytes as the full id (#164 byte discipline), an ambiguous prefix refuses listing every sharing candidate, and a genuinely-unknown id keeps its established refusal. The resolution mirrors the semantics reproduce shipped with #298, closing the asymmetry the #300 fieldtest named (df_4). Registry gains list_process_ids/list_campaign_ids (missing store dir reads as empty, matching the blueprint-scan discipline); campaign_not_found_prose becomes the single source for show_campaign's two not-found arms. The ambiguity arm is pinned by a unit test at the resolution seam — hash-derived ids make a shared-prefix E2E fixture impractical. --- crates/aura-cli/src/research_docs.rs | 107 ++++++++++++++++++++++----- crates/aura-registry/src/lib.rs | 34 +++++++++ 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 8b4cb02..bdae1ed 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -271,6 +271,28 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> { Ok(()) } +/// Resolve `id` against a document store's full candidate content-id list — +/// mirroring the exact-first / unique-match / else-refuse shape +/// `aura_runner::reproduce::load_family` established for #298's family/run +/// handle. Called only once the caller's own exact-match lookup has already +/// missed, so a filter match here is always a strict, shorter prefix (ids +/// are fixed-length hashes; a full-length miss cannot be a prefix of +/// another). `Ok(None)`: no candidate matches — genuinely unknown, the +/// caller's existing not-found prose applies unchanged. `Err`: two or more +/// candidates share the prefix — refused by name, listing every candidate, +/// rather than guessed. +fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result, String> { + let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect(); + match matches.as_slice() { + [] => Ok(None), + [only] => Ok(Some((*only).clone())), + many => { + let listed = many.iter().map(|s| s.as_str()).collect::>().join(", "); + Err(format!("ambiguous id \"{prefix}\": candidates {listed}")) + } + } +} + fn show_process(id: &str, env: &Env) -> Result<(), String> { if env.provenance().is_none() { let cwd = std::env::current_dir() @@ -282,15 +304,23 @@ fn show_process(id: &str, env: &Env) -> Result<(), String> { )); } let registry = env.registry(); - match registry.get_process(id).map_err(|e| e.to_string())? { - Some(bytes) => { - // `print!`, not `println!`: `bytes` is the content-addressed canonical - // form the store keyed `id` on — the CLI is a transport and must not - // frame the canonical bytes with a trailing newline (#164, mirroring - // `graph_construct::build_cmd`). - print!("{bytes}"); - Ok(()) - } + if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? { + // `print!`, not `println!`: `bytes` is the content-addressed canonical + // form the store keyed `id` on — the CLI is a transport and must not + // frame the canonical bytes with a trailing newline (#164, mirroring + // `graph_construct::build_cmd`). + print!("{bytes}"); + return Ok(()); + } + let candidates = registry.list_process_ids().map_err(|e| e.to_string())?; + match resolve_id_prefix(id, &candidates)? { + Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? { + Some(bytes) => { + print!("{bytes}"); + Ok(()) + } + None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))), + }, None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))), } } @@ -392,6 +422,14 @@ fn prefix_hint(id: &str) -> &'static str { } } +/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault` +/// sibling (unlike processes' `ref_fault_prose`), so this is the single +/// source both the exact-miss and prefix-resolved-but-then-missing arms +/// route through, keeping the two copies from drifting. +fn campaign_not_found_prose(id: &str) -> String { + format!("campaign {id} not found in the project store{}", prefix_hint(id)) +} + pub(crate) fn ref_fault_prose(f: &RefFault) -> String { match f { RefFault::ProcessNotFound(id) => { @@ -621,14 +659,22 @@ fn show_campaign(id: &str, env: &Env) -> Result<(), String> { )); } let registry = env.registry(); - match registry.get_campaign(id).map_err(|e| e.to_string())? { - Some(bytes) => { - // `print!`, not `println!`: see `show_process` — the CLI must not - // frame the canonical bytes with a trailing newline (#164). - print!("{bytes}"); - Ok(()) - } - None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))), + if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? { + // `print!`, not `println!`: see `show_process` — the CLI must not + // frame the canonical bytes with a trailing newline (#164). + print!("{bytes}"); + return Ok(()); + } + let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?; + match resolve_id_prefix(id, &candidates)? { + Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? { + Some(bytes) => { + print!("{bytes}"); + Ok(()) + } + None => Err(campaign_not_found_prose(id)), + }, + None => Err(campaign_not_found_prose(id)), } } @@ -815,4 +861,31 @@ mod tests { ); } } + + /// #302: the ambiguity arm of `resolve_id_prefix` — content ids are + /// hash-derived, not choosable, so two documents sharing a printed + /// prefix cannot be constructed honestly through an E2E fixture + /// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the + /// unique-match arm end-to-end instead). This unit test is the only pin + /// the ambiguous-refusal arm gets: a prefix shared by more than one + /// candidate refuses rather than guesses, and the refusal names every + /// sharing candidate so the author can pick the intended one. + #[test] + fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() { + let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()]; + + let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse"); + assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}"); + assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}"); + assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}"); + + // Companion sanity for the two other arms at the same seam: a unique + // prefix resolves, and no matching prefix reports as unresolved + // rather than fabricating one. + assert_eq!( + resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"), + Some("9f3a0000".to_string()) + ); + assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None); + } } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 9650404..c2caafb 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -189,6 +189,28 @@ impl Registry { } } + /// Every content id currently stored for one document kind — the + /// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one + /// mapping, read back), for prefix resolution (#302). A missing store + /// dir (nothing registered yet) is treat-as-empty, not an error, the + /// same discipline `find_blueprint_by_identity`'s scan applies to a + /// missing blueprints dir. + fn list_doc_ids(&self, kind: &str) -> Result, RegistryError> { + let entries = match fs::read_dir(self.doc_dir(kind)) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(RegistryError::Io(e)), + }; + let mut ids = Vec::new(); + for entry in entries { + let entry = entry?; + if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) { + ids.push(id); + } + } + Ok(ids) + } + /// Store a canonical process document; returns its content id. pub fn put_process(&self, canonical_json: &str) -> Result { self.put_doc("processes", canonical_json) @@ -199,6 +221,12 @@ impl Registry { self.get_doc("processes", content_id) } + /// Every content id currently registered in the process store — the + /// candidate list `show`'s prefix resolution (#302) filters against. + pub fn list_process_ids(&self) -> Result, RegistryError> { + self.list_doc_ids("processes") + } + /// Store a canonical campaign document; returns its content id. pub fn put_campaign(&self, canonical_json: &str) -> Result { self.put_doc("campaigns", canonical_json) @@ -209,6 +237,12 @@ impl Registry { self.get_doc("campaigns", content_id) } + /// Every content id currently registered in the campaign store — the + /// candidate list `show`'s prefix resolution (#302) filters against. + pub fn list_campaign_ids(&self) -> Result, RegistryError> { + self.list_doc_ids("campaigns") + } + /// The store path a process document with this content id lives at — /// the same single mapping the put/get pair routes through, exposed so /// consumers never re-derive the layout.