feat(cli): 0106 tasks 11-12 — aura campaign verb family + workspace gates

The campaign half of research_docs.rs: validate (intrinsic always;
referential tier when a project env is discovered, with the explicit
skip line outside one), introspect (--vocabulary/--block/--unwired/
--content-id, same exactly-one-of usage guard), register via the
registry's campaign_path accessor, and RefFault prose (Display-free,
unit-pinned). Five binary seam tests (skip line, empty-axis prose +
exit 1, section vocabulary, spec-example open slots, content-addressed
registration) reuse the file's temp_cwd helper — the plan's
tempfile::tempdir() snippet deviated from the crate's zero-extra-dep
test convention and was adapted, disclosed in the loop's report.

The referential CLI branch inside a real loadable project stays
fieldtest territory by the plan's own scope note (project::load needs a
built dylib); its logic is pinned by the aura-registry tests.

Gates: cargo test --workspace 916/0 (885 pre-cycle + 31 new), clippy -D
warnings clean, cargo doc --no-deps 0 warnings. The worked spec example
runs end-to-end headless: author both documents as text, validate with
per-tier report lines, introspect vocabularies and open slots, obtain
stable content ids, register into the store — no Rust compile, no run
executed.

closes #189
This commit is contained in:
2026-07-03 16:18:22 +02:00
parent db09e5de52
commit a9e047eca7
3 changed files with 471 additions and 6 deletions
+3
View File
@@ -3763,6 +3763,8 @@ enum Command {
New(NewCmd),
/// Validate, introspect, and register process documents (methodology).
Process(research_docs::ProcessCmd),
/// Validate, introspect, and register campaign documents (experiment intent).
Campaign(research_docs::CampaignCmd),
}
#[derive(Args)]
@@ -4712,6 +4714,7 @@ fn main() {
Command::Reproduce(a) => dispatch_reproduce(a, &env),
Command::New(a) => dispatch_new(a, &env),
Command::Process(a) => research_docs::process_cmd(a, &env),
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
}
}
+185 -6
View File
@@ -1,14 +1,17 @@
//! CLI surface for the research-artifact documents: the `aura process` verb
//! family (the `aura campaign` family lands in a later task). Presentation
//! layer only — parsing/validation/introspection live in aura-research;
//! stores and the referential tier in aura-registry.
//! CLI surface for the research-artifact documents: the `aura process` and
//! `aura campaign` verb families. Presentation layer only —
//! parsing/validation/introspection live in aura-research; stores and the
//! referential tier in aura-registry.
use std::path::PathBuf;
use aura_research::{
describe_block, open_slots_process, parse_process, process_to_json, process_vocabulary,
slot_kind_label, validate_process, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
campaign_to_json, campaign_vocabulary, describe_block, open_slots_campaign,
open_slots_process, parse_campaign, parse_process, process_to_json, process_vocabulary,
slot_kind_label, validate_campaign, validate_process, CampaignDoc, DocError, DocFault,
DocKind, ProcessDoc, StageBlock,
};
use aura_registry::RefFault;
use crate::project::Env;
@@ -223,3 +226,179 @@ fn print_open_slots(slots: &[aura_research::OpenSlot]) {
println!("no open slots");
}
}
#[derive(clap::Args)]
pub struct CampaignCmd {
#[command(subcommand)]
pub sub: CampaignSub,
}
#[derive(clap::Subcommand)]
pub enum CampaignSub {
/// Validate a campaign document (intrinsic + referential inside a project).
Validate { file: PathBuf },
/// Introspect the campaign vocabulary or a document.
Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root.
Register { file: PathBuf },
}
fn ref_fault_prose(f: &RefFault) -> String {
match f {
RefFault::ProcessNotFound(id) => format!("process {id} not found in the project store"),
RefFault::StrategyNotFound(id) => format!("strategy {id} not found in the blueprint store"),
RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"),
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
}
}
}
/// `aura campaign …` — the dispatch entry main.rs calls.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
guard_one_mode(i, "campaign");
introspect_campaign(i)
}
CampaignSub::Register { file } => register_campaign(file, env),
};
if let Err(m) = result {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {
let text = read_doc(file)?;
let doc = parse_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
let faults = validate_campaign(&doc);
if !faults.is_empty() {
return Err(fault_block(
"campaign document invalid:",
faults.iter().map(doc_fault_prose).collect(),
));
}
Ok(doc)
}
fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
let doc = parse_valid_campaign(file)?;
let axes: usize = doc.strategies.iter().map(|s| s.axes.len()).sum();
let points: usize = doc
.strategies
.iter()
.map(|s| s.axes.values().map(|a| a.values.len()).product::<usize>())
.sum();
println!(
"campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} window(s)",
doc.strategies.len(),
axes,
points,
doc.data.windows.len()
);
if env.provenance().is_some() {
let resolve = |t: &str| env.resolve(t);
let faults = env
.registry()
.validate_campaign_refs(&doc, &resolve)
.map_err(|e| e.to_string())?;
if !faults.is_empty() {
return Err(fault_block(
"campaign references do not resolve:",
faults.iter().map(ref_fault_prose).collect(),
));
}
println!("campaign document valid (referential): all references resolve, axes are in the param space");
} else {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
println!("referential checks skipped (no Aura.toml found up from {cwd})");
}
Ok(())
}
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
let doc = parse_valid_campaign(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = campaign_to_json(&doc);
let registry = env.registry();
let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?;
println!(
"registered campaign content:{id} ({})",
registry.campaign_path(&id).display()
);
Ok(())
}
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.vocabulary {
for b in campaign_vocabulary() {
println!("{:<18} {}", b.id, b.doc);
}
return Ok(());
}
if let Some(id) = &cmd.block {
return describe_one_block(id);
}
if let Some(file) = &cmd.unwired {
let text = read_doc(file)?;
let slots =
open_slots_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
print_open_slots(&slots);
return Ok(());
}
if let Some(file) = &cmd.content_id {
let doc = parse_valid_campaign(file)
.map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
println!("content:{}", aura_research::content_id_of(&campaign_to_json(&doc)));
return Ok(());
}
unreachable!("guard_one_mode enforces one mode")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_fault_prose_is_debug_free() {
let cases = [
ref_fault_prose(&RefFault::ProcessNotFound("4e2d".into())),
ref_fault_prose(&RefFault::StrategyNotFound("9f3a".into())),
ref_fault_prose(&RefFault::IdentityUnmatched("7b1c".into())),
ref_fault_prose(&RefFault::StrategyUnloadable {
id: "9f3a".into(),
error: "UnknownKnob(\"fast\")".into(),
}),
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
axis: "fast".into(),
}),
];
assert_eq!(cases[0], "process 4e2d not found in the project store");
assert_eq!(cases[1], "strategy 9f3a not found in the blueprint store");
assert_eq!(cases[2], "identity id 7b1c matches no stored blueprint");
assert_eq!(cases[3], "strategy 9f3a cannot be loaded: UnknownKnob(\"fast\")");
assert_eq!(cases[4], "strategy 9f3a: axis \"nope\" is not in the param space");
assert!(cases[5].contains("declares a kind that is not the param's kind"));
for c in &cases {
assert!(
!c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"),
"Debug leak: {c}"
);
}
}
}