feat: show resolves a unique content-id prefix (closes #302)
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.
This commit is contained in:
@@ -271,6 +271,28 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
|||||||
Ok(())
|
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<Option<String>, 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::<Vec<_>>().join(", ");
|
||||||
|
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
||||||
if env.provenance().is_none() {
|
if env.provenance().is_none() {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
@@ -282,15 +304,23 @@ fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let registry = env.registry();
|
let registry = env.registry();
|
||||||
match registry.get_process(id).map_err(|e| e.to_string())? {
|
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
|
||||||
Some(bytes) => {
|
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
||||||
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
// form the store keyed `id` on — the CLI is a transport and must not
|
||||||
// form the store keyed `id` on — the CLI is a transport and must not
|
// frame the canonical bytes with a trailing newline (#164, mirroring
|
||||||
// frame the canonical bytes with a trailing newline (#164, mirroring
|
// `graph_construct::build_cmd`).
|
||||||
// `graph_construct::build_cmd`).
|
print!("{bytes}");
|
||||||
print!("{bytes}");
|
return Ok(());
|
||||||
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()))),
|
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 {
|
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||||
match f {
|
match f {
|
||||||
RefFault::ProcessNotFound(id) => {
|
RefFault::ProcessNotFound(id) => {
|
||||||
@@ -621,14 +659,22 @@ fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let registry = env.registry();
|
let registry = env.registry();
|
||||||
match registry.get_campaign(id).map_err(|e| e.to_string())? {
|
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
|
||||||
Some(bytes) => {
|
// `print!`, not `println!`: see `show_process` — the CLI must not
|
||||||
// `print!`, not `println!`: see `show_process` — the CLI must not
|
// frame the canonical bytes with a trailing newline (#164).
|
||||||
// frame the canonical bytes with a trailing newline (#164).
|
print!("{bytes}");
|
||||||
print!("{bytes}");
|
return Ok(());
|
||||||
Ok(())
|
}
|
||||||
}
|
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
|
||||||
None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<String>, 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.
|
/// Store a canonical process document; returns its content id.
|
||||||
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||||
self.put_doc("processes", canonical_json)
|
self.put_doc("processes", canonical_json)
|
||||||
@@ -199,6 +221,12 @@ impl Registry {
|
|||||||
self.get_doc("processes", content_id)
|
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<Vec<String>, RegistryError> {
|
||||||
|
self.list_doc_ids("processes")
|
||||||
|
}
|
||||||
|
|
||||||
/// Store a canonical campaign document; returns its content id.
|
/// Store a canonical campaign document; returns its content id.
|
||||||
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||||
self.put_doc("campaigns", canonical_json)
|
self.put_doc("campaigns", canonical_json)
|
||||||
@@ -209,6 +237,12 @@ impl Registry {
|
|||||||
self.get_doc("campaigns", content_id)
|
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<Vec<String>, RegistryError> {
|
||||||
|
self.list_doc_ids("campaigns")
|
||||||
|
}
|
||||||
|
|
||||||
/// The store path a process document with this content id lives at —
|
/// The store path a process document with this content id lives at —
|
||||||
/// the same single mapping the put/get pair routes through, exposed so
|
/// the same single mapping the put/get pair routes through, exposed so
|
||||||
/// consumers never re-derive the layout.
|
/// consumers never re-derive the layout.
|
||||||
|
|||||||
Reference in New Issue
Block a user