Skip to main content

agent_first_data/
cli_afdata.rs

1//! AFDATA adapter for the closed-world CLI core.
2//!
3//! [`crate::cli_spec`] decides whether an invocation is legal and what it
4//! resolved to. It deliberately knows nothing about how AFDATA expresses that
5//! decision. This module is the only place where the two meet: it turns a
6//! resolved outcome into a protocol event and owns AFDATA's `_secret` naming
7//! convention.
8
9use crate::cli_spec::SourceScheme;
10use crate::cli_spec::{
11    ArgValueType, BuiltCliSpec, CliError, CliSpec, CliSpecError, ResolvedHelp, ResolvedVersion,
12};
13use crate::protocol::{Event, json_error, json_result};
14
15/// Build a registry under AFDATA's naming conventions.
16///
17/// `ArgSpec::sensitive` is not an independent switch. AFDATA already decides
18/// what a secret is by the `_secret` suffix, and the same convention drives
19/// config keys, log fields, and redaction. Deriving the bit from the argument
20/// id keeps one source of truth: an argument cannot be sensitive to the parser
21/// while staying invisible to redaction downstream. Marking a differently named
22/// argument sensitive is the reverse mistake, so it fails the build instead of
23/// silently diverging.
24pub fn build_afdata_cli(mut spec: CliSpec) -> Result<BuiltCliSpec, CliSpecError> {
25    for command in &mut spec.commands {
26        for argument in &mut command.arguments {
27            let suffixed = argument.argument_id.ends_with("_secret");
28            let is_flag = matches!(argument.value_type, ArgValueType::Flag);
29            if argument.sensitive && !suffixed {
30                return Err(CliSpecError {
31                    rule: "sensitive_without_secret_suffix",
32                    message: format!(
33                        "argument `{}` is marked sensitive; rename it to `{}_secret` so AFDATA \
34                         redaction covers it too",
35                        argument.argument_id, argument.argument_id
36                    ),
37                });
38            }
39            if argument.sensitive && is_flag {
40                return Err(CliSpecError {
41                    rule: "sensitive_flag",
42                    message: format!(
43                        "flag `{}` is marked sensitive, but a flag carries no value to redact",
44                        argument.argument_id
45                    ),
46                });
47            }
48            // A flag has no value, so `_secret` in its name says what the flag
49            // is *about* — `--reveal-secret` asks to reveal one, it does not
50            // carry one. Marking it would put the sensitive bit on something
51            // that structurally cannot leak, which reads as a real credential
52            // to anything downstream that trusts the bit.
53            argument.sensitive = suffixed && !is_flag;
54
55            // A prompt suppresses terminal echo and blocks until a person
56            // types — the first only means something for a secret, and the
57            // second is a hang in the agent-run case this CLI is for. So the
58            // source is available exactly where it earns its cost.
59            if let Some(sources) = &argument.sources
60                && sources.accepts(SourceScheme::Prompt)
61                && !argument.sensitive
62            {
63                return Err(CliSpecError {
64                    rule: "prompt_source_without_secret",
65                    message: format!(
66                        "argument `{}` accepts the `prompt` source; rename it to `{}_secret`, or \
67                         drop the source — prompting blocks on a terminal for a value that is not \
68                         a credential",
69                        argument.argument_id, argument.argument_id
70                    ),
71                });
72            }
73        }
74    }
75    spec.build()
76}
77
78/// Wrap a resolved help response in a `cli-help-v2` result event.
79pub fn cli_help_event(help: &ResolvedHelp) -> Event {
80    json_result(serde_json::json!({
81        "code": "help",
82        "help": help.model(),
83    }))
84    .build()
85}
86
87/// Wrap a resolved version response in a protocol result event.
88///
89/// This delegates to [`crate::build_cli_version`] rather than assembling its
90/// own payload. There is one version shape — `{code, name, version}` plus
91/// `display_name`/`build` when the registry carries them — and it is the same
92/// one the Go, Python, and TypeScript SDKs emit.
93pub fn cli_version_event(version: &ResolvedVersion) -> Event {
94    crate::cli::build_cli_version(
95        version.name(),
96        version.display_name(),
97        version.version(),
98        version.build(),
99    )
100}
101
102/// Static prose for the generated CLI reference.
103///
104/// Kept as Markdown files rather than escaped Rust string literals: these are
105/// paragraphs, and as `\`-continued literals they cannot be read as prose in a
106/// diff, need every backtick and quote escaped, and turn a wording change into
107/// an exercise in line continuations. The surrounding structure — headings,
108/// tables, per-command sections — stays in code, because that part is generated
109/// from the registry and is not prose.
110///
111/// This reference is emitted by every spore's `--docs`, not just afdata's, so
112/// the text is shared library data and belongs beside the generator.
113const SHAPES_PROSE: &str = include_str!("cli_reference/shapes.md");
114const CLI_ERRORS_PROSE: &str = include_str!("cli_reference/cli-errors.md");
115
116/// Render an offline Markdown reference for a whole registry.
117///
118/// help-v2 is deliberately lossy: it answers "how do I call this" one command
119/// at a time, in as few tokens as possible. A reference manual wants the
120/// opposite, so it is a separate capability rather than a format on the
121/// discovery path — the agent's `--help` never grows a documentation mode, and
122/// the manual can never disagree with the parser, because both are this
123/// registry. `--docs` is injected into every registry, so a tool exposes this
124/// without registering a command, and without spending a line of its
125/// subcommand listing on something no agent calls.
126///
127/// What is deliberately *not* repeated: a lone combination's description and
128/// id — the command heading already carries the description, and the id only
129/// matters for telling siblings apart — plus the shared output defaults and
130/// each command's arguments per combination. help repeats those because each help
131/// response is read alone; a document is read in order.
132pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
133    let spec = cli.spec();
134    let name = spec.name.as_str();
135    let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
136        .commands
137        .iter()
138        .filter(|command| !command.combinations.is_empty())
139        .collect();
140    commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
141
142    let path_of = |command: &crate::cli_spec::CommandSpec| {
143        if command.command_path.is_empty() {
144            name.to_string()
145        } else {
146            format!("{name} {}", command.command_path.join(" "))
147        }
148    };
149
150    let mut out = String::new();
151    out.push_str(&format!("# {name} CLI reference\n\n"));
152    out.push_str(&format!(
153        "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
154    ));
155    if let Some(about) = &spec.about {
156        out.push_str(&format!("{about}\n\n"));
157    }
158    out.push_str(&format!(
159        "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
160         typed invocation values, which parameter combinations are legal, output contracts, and \
161         help. An invocation runs only when it matches exactly one registered combination.\n\n"
162    ));
163
164    // Everything AFDATA registers on the caller's behalf, in one place. Split
165    // across sections it reads as unrelated trivia, and `--version`/`--docs`
166    // fall through the gap entirely — they belong to no command, so no command
167    // section would ever mention them.
168    let baseline = baseline_output(&commands);
169    out.push_str("## Global arguments\n\n");
170    // Not "no command declares them": `--version` and `--docs` are answered by
171    // the root alone, so a command may declare its own argument under that
172    // spelling — where one does, the entry below is still only the root's.
173    out.push_str(
174        "AFDATA registers these itself, so the syntax in [Commands](#commands) \
175         leaves them out.\n\n",
176    );
177    out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
178    out.push_str(
179        "| `--help` | every command | Every legal shape of that command, complete, plus its \
180         subcommands. JSON by default; `--output plain` for a terminal. |\n",
181    );
182    out.push_str(&format!(
183        "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
184         |\n"
185    ));
186    out.push_str(&format!(
187        "| `--docs` | {name} only | This document, rendered from the registry. |\n"
188    ));
189    if let Some(crate::cli_spec::OutputSpec::Protocol {
190        formats,
191        destinations,
192        default_format,
193        default_destination,
194        ..
195    }) = &baseline
196    {
197        out.push_str(&format!(
198            "| `--output <FORMAT>` | per output contract | Render as {} (default \
199             `{default_format}`). |\n",
200            formats.join(", ")
201        ));
202        out.push_str(&format!(
203            "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
204             to {} (default `{default_destination}`). |\n",
205            destinations.join(", ")
206        ));
207    }
208    out.push_str(
209        "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
210         stream to a file instead. |\n\n",
211    );
212    let baseline_line = baseline.as_ref().map(describe_output);
213    // The table above already lists the arguments and their values; the only
214    // thing left to say is what a successful call actually writes.
215    if baseline.is_some() {
216        out.push_str(
217            "Success output is protocol events, on those terms, unless a command's own \
218             **Output** line says otherwise.\n\n",
219        );
220    }
221    out.push_str(SHAPES_PROSE);
222    out.push('\n');
223
224    out.push_str("## Commands\n\n");
225    for command in &commands {
226        let path = path_of(command);
227        let anchor = path.replace(' ', "-");
228        let about = command.about.as_deref().unwrap_or("");
229        out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
230    }
231    out.push('\n');
232
233    for command in &commands {
234        let path = path_of(command);
235        out.push_str(&format!("### `{path}`\n\n"));
236        if let Some(about) = &command.about {
237            out.push_str(&format!("{about}\n\n"));
238        }
239
240        let Some(model) = cli.help(&command.command_path) else {
241            continue;
242        };
243        for shape in &model.shapes {
244            if model.shapes.len() > 1 {
245                let differs = shape.about.as_deref().unwrap_or_default();
246                out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
247            }
248            out.push_str(&format!(
249                "```\n{}\n```\n\n",
250                trim_output_arguments(&shape.usage)
251            ));
252        }
253
254        let combinations: Vec<&crate::cli_spec::Combination> =
255            command.combinations.iter().collect();
256        let contracts = output_contracts(&combinations);
257        let is_baseline =
258            matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
259        if !is_baseline {
260            out.push_str(&render_output(&contracts));
261        }
262
263        let documented: Vec<(&crate::cli_spec::ArgSpec, String)> = command
264            .arguments
265            .iter()
266            .filter_map(|argument| Some((argument, argument.rendered_about()?)))
267            .collect();
268        if !documented.is_empty() {
269            if model.shapes.len() > 1 {
270                // One table per command, so it necessarily spans shapes that
271                // cannot be used together; the syntax above is what says which
272                // argument belongs where.
273                out.push_str("Arguments across every shape above:\n\n");
274            }
275            out.push_str("| Argument | Meaning |\n|---|---|\n");
276            for (argument, about) in documented {
277                // The same spelling the usage line above uses, so the table can
278                // be read against it without translating ids back to flags.
279                out.push_str(&format!(
280                    "| `{}` | {about} |\n",
281                    crate::cli_spec::argument_key(argument)
282                ));
283            }
284            out.push('\n');
285        }
286
287        // After the table, because it is the paragraph the table cannot hold.
288        if let Some(note) = &command.reference_note {
289            out.push_str(note.trim_end());
290            out.push_str("\n\n");
291        }
292    }
293
294    out.push_str("## Exit codes\n\n");
295    out.push_str(
296        "| Code | Meaning |\n|---|---|\n\
297         | 0 | The command ran and succeeded. |\n\
298         | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
299         | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
300         `cli_*` codes below. |\n",
301    );
302    // Sorted rather than left in declaration order: the table is read by code,
303    // and a registry author's ordering is not the reader's.
304    let mut declared_exit_codes: Vec<&crate::cli_spec::ExitCodeSpec> =
305        spec.exit_codes.iter().collect();
306    declared_exit_codes.sort_by_key(|exit| exit.code);
307    for exit in declared_exit_codes {
308        out.push_str(&format!("| {} | {} |\n", exit.code, exit.meaning));
309    }
310    out.push_str(
311        "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
312         retrying it unchanged cannot help, while exit 1 means it was.\n\n",
313    );
314
315    out.push_str("## CLI errors\n\n");
316    out.push_str(CLI_ERRORS_PROSE);
317    out
318}
319
320/// One line per command saying what its success output is, because the usage
321/// line can only show that by omission.
322fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
323    use crate::cli_spec::OutputSpec;
324    match output {
325        OutputSpec::Raw { file_sinks } => format!(
326            "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
327             strict JSON on stderr",
328            render_file_sinks(file_sinks)
329        ),
330        OutputSpec::Protocol {
331            formats,
332            destinations,
333            default_format,
334            default_destination,
335            file_sinks,
336            ..
337        } => format!(
338            "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
339             (default `{default_destination}`){}",
340            formats.join("/"),
341            destinations.join("/"),
342            render_file_sinks(file_sinks),
343        ),
344    }
345}
346
347/// Each distinct output contract a command exposes, in a stable order.
348fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
349    let mut lines: Vec<String> = Vec::new();
350    for combination in combinations {
351        let line = describe_output(&combination.output);
352        if !lines.contains(&line) {
353            lines.push(line);
354        }
355    }
356    lines
357}
358
359/// The output contract most commands share, if there is one worth hoisting.
360///
361/// Deterministic: ties break on the rendered description, never on registration
362/// order, so the same registry always renders the same document.
363fn baseline_output(
364    commands: &[&crate::cli_spec::CommandSpec],
365) -> Option<crate::cli_spec::OutputSpec> {
366    let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
367        std::collections::BTreeMap::new();
368    for command in commands {
369        let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
370        for combination in &command.combinations {
371            if !contracts.contains(&&combination.output) {
372                contracts.push(&combination.output);
373            }
374        }
375        if let [only] = contracts.as_slice() {
376            let entry = counts
377                .entry(describe_output(only))
378                .or_insert((0, (*only).clone()));
379            entry.0 += 1;
380        }
381    }
382    counts
383        .into_iter()
384        .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
385        .filter(|(_, (count, _))| *count > 1)
386        .map(|(_, (_, spec))| spec)
387}
388
389fn render_output(contracts: &[String]) -> String {
390    match contracts {
391        [] => String::new(),
392        [only] => format!("Output: {only}.\n\n"),
393        many => {
394            let mut out = String::from("Output differs by combination:\n\n");
395            for line in many {
396                out.push_str(&format!("- {line}\n"));
397            }
398            out.push('\n');
399            out
400        }
401    }
402}
403
404/// Drop the trailing AFDATA output arguments from a usage line.
405///
406/// The compiler always renders them last and in a fixed order, and their names
407/// are reserved, so no application argument can be mistaken for one. A document
408/// states the output contract once per command; only `--help`, whose response
409/// is read on its own, needs them inline.
410fn trim_output_arguments(usage: &str) -> &str {
411    let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
412        .iter()
413        .filter_map(|marker| usage.find(marker))
414        .min();
415    match cut {
416        Some(index) => usage[..index].trim_end(),
417        None => usage,
418    }
419}
420
421fn render_file_sinks(file_sinks: &[String]) -> String {
422    let mut names: Vec<&str> = Vec::new();
423    if file_sinks.iter().any(|sink| sink == "stdout") {
424        names.push("`--stdout-file`");
425    }
426    if file_sinks.iter().any(|sink| sink == "stderr") {
427        names.push("`--stderr-file`");
428    }
429    if names.is_empty() {
430        String::new()
431    } else {
432        format!("; redirect with {}", names.join(" or "))
433    }
434}
435
436/// Wrap a CLI-resolution failure in an event whose `code` names the failure.
437///
438/// The classification lives in `code`, the way `document_path_not_found` and
439/// its siblings already do — not in a second field beside a generic
440/// `cli_error`. One error taxonomy, one place to read it, and the skill's
441/// standing instruction ("branch on `error.code`") covers CLI errors too.
442///
443/// The event never carries raw argument values; `message` names the offending
444/// argument, and `hint` says what to run next.
445pub fn cli_error_event(error: &CliError) -> Event {
446    let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
447    match builder.build() {
448        Ok(event) => event,
449        Err(_) => json_error("cli_error", "failed to build CLI error")
450            .build()
451            .unwrap_or_else(|_| {
452                // All literals above are valid; this branch is unreachable
453                // but keeps production code panic-free.
454                json_result(serde_json::json!({"code":"internal_cli_error"})).build()
455            }),
456    }
457}
458
459/// The standard event for a program that misread its own resolved invocation.
460///
461/// Dispatch itself cannot fail —
462/// [`crate::cli_spec::BoundCliSpec::resolve_from`] binds the handler while
463/// resolving, so there is no undispatchable invocation to report. What remains
464/// is a handler reading an argument the selected combination does not supply:
465/// an id it does not declare, or one whose type does not match the accessor.
466/// [`crate::cli_spec::BoundCliSpec::call_every_combination`] catches the first
467/// from a test; this is for a program that also wants to say something at
468/// runtime.
469///
470/// It is a defect in the program, never in what the user typed, and the code
471/// and exit status have to say so. Reporting it as a usage error tells the
472/// caller to fix their command line and retry, and retrying cannot help — a
473/// mistake that has already been made in the wild, as
474/// `cli_invalid_argument_value` at exit 2. Emit this and exit 1; the
475/// application still owns the exit.
476pub fn cli_invocation_invalid_event(detail: &str) -> Event {
477    let builder = json_error("cli_invocation_invalid", detail)
478        .hint("this is a defect in the program, not in the command; report it");
479    match builder.build() {
480        Ok(event) => event,
481        Err(_) => json_error("cli_invocation_invalid", "invocation cannot be dispatched")
482            .build()
483            .unwrap_or_else(|_| {
484                // The literals above are valid, so this is unreachable; it
485                // keeps the path panic-free the way `cli_error_event` does.
486                json_result(serde_json::json!({"code":"internal_cli_error"})).build()
487            }),
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::cli_spec::SourceSet;
495    use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
496
497    fn output() -> OutputSpec {
498        OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
499    }
500
501    fn spec_with(argument: ArgSpec) -> CliSpec {
502        let id = argument.argument_id.clone();
503        CliSpec::new("demo", "1").command(
504            CommandSpec::root().arg(argument).combination(
505                Combination::new("only")
506                    .action("only")
507                    .required([id])
508                    .output(output()),
509            ),
510        )
511    }
512
513    #[test]
514    fn a_cli_declares_exit_codes_beyond_afdatas_own() {
515        let spec = CliSpec::new("demo", "1.0.0")
516            .lifecycle_output(output())
517            .exit_code(4, "The output could not be written.")
518            .exit_code(3, "The command ran and partly succeeded.")
519            .command(CommandSpec::root())
520            .build()
521            .unwrap();
522        let reference = render_cli_reference(&spec);
523        let table = reference
524            .split("## Exit codes")
525            .nth(1)
526            .expect("the reference documents exit codes");
527        // Sorted by code, not by declaration order: the table is read by code.
528        let partial = table.find("| 3 | The command ran and partly succeeded. |");
529        let write_failed = table.find("| 4 | The output could not be written. |");
530        assert!(partial.is_some() && write_failed.is_some(), "{table}");
531        assert!(partial < write_failed, "{table}");
532    }
533
534    /// The syntax lives in the declaration, so help and `--docs` render it and
535    /// no `about` string repeats it. This is the duplication the declaration
536    /// exists to remove.
537    #[test]
538    fn a_declared_source_set_renders_itself_into_help_and_docs() {
539        let built = build_afdata_cli(spec_with(
540            ArgSpec::option("--token-secret", "SOURCE")
541                .about("Token this host requires")
542                .sources(SourceSet::config().host_scheme("container", "container:NAME")),
543        ))
544        .expect("registry builds");
545
546        let reference = render_cli_reference(&built);
547        assert!(
548            reference.contains(
549                "Token this host requires (the value, or where to read it: env:NAME, \
550                 file[+FORMAT]:PATH#DOT_PATH, container:NAME, literal:VALUE)"
551            ),
552            "{reference}"
553        );
554
555        let CliOutcome::Help(help) = built
556            .resolve_from(vec!["demo", "--help"])
557            .expect("help resolves")
558        else {
559            panic!("--help must resolve to help");
560        };
561        let model = serde_json::to_value(help.model()).expect("help serializes");
562        let note = model["notes"]["--token-secret"]
563            .as_str()
564            .unwrap_or_default()
565            .to_string();
566        assert!(note.contains("file[+FORMAT]:PATH#DOT_PATH"), "{model}");
567    }
568
569    /// A value naming a scheme the argument does not accept is a usage error,
570    /// beside the other argv rejections — and nothing was opened to find out.
571    #[test]
572    fn an_unaccepted_scheme_is_an_argv_rejection() {
573        let built = build_afdata_cli(spec_with(
574            ArgSpec::option("--token-secret", "SOURCE").sources(SourceSet::config()),
575        ))
576        .expect("registry builds");
577        let error = built
578            .resolve_from(vec!["demo", "--token-secret", "prompt"])
579            .expect_err("prompt is not in config()");
580        assert_eq!(
581            error.rule,
582            crate::cli_spec::CliErrorRule::InvalidArgumentValue
583        );
584        assert!(error.message.contains("env:NAME"), "{}", error.message);
585
586        // …and one it does accept resolves to the raw string, unread.
587        let outcome = built
588            .resolve_from(vec!["demo", "--token-secret", "env:NAME"])
589            .expect("env is accepted");
590        let CliOutcome::Run(invocation) = outcome else {
591            panic!("must resolve to a run");
592        };
593        assert_eq!(
594            invocation.required("token_secret").as_str(),
595            Some("env:NAME")
596        );
597    }
598
599    /// Prompting suppresses echo and blocks on a terminal. Both only make sense
600    /// for a credential, so the source is refused anywhere else.
601    #[test]
602    fn the_prompt_source_is_refused_on_a_non_secret_argument() {
603        let error = build_afdata_cli(spec_with(
604            ArgSpec::option("--label", "LABEL").sources(SourceSet::stream()),
605        ))
606        .expect_err("prompt on a non-secret argument");
607        assert_eq!(error.rule, "prompt_source_without_secret");
608        // The same set is fine once the name says it carries a credential.
609        assert!(
610            build_afdata_cli(spec_with(
611                ArgSpec::option("--token-secret", "SOURCE").sources(SourceSet::stream())
612            ))
613            .is_ok()
614        );
615    }
616
617    #[test]
618    fn secret_suffix_drives_the_sensitive_bit() {
619        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
620        let argument = &built.spec().commands[0].arguments[0];
621        assert!(argument.sensitive);
622    }
623
624    #[test]
625    fn sensitive_without_the_suffix_fails_the_build() {
626        let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
627            .unwrap_err();
628        assert_eq!(error.rule, "sensitive_without_secret_suffix");
629    }
630
631    #[test]
632    fn a_plain_argument_stays_insensitive() {
633        let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
634        assert!(!built.spec().commands[0].arguments[0].sensitive);
635    }
636
637    // Locks the version payload shape. `--version` is a discovery entry point
638    // agents parse, and it lost `display_name`/`build` once before by going
639    // through a second, hand-rolled payload instead of `build_cli_version`.
640    #[test]
641    fn version_events_carry_the_full_documented_payload() {
642        let built = CliSpec::new("demo", "1.2.3")
643            .display_name("Demo Tool")
644            .build_id("abc1234")
645            .command(CommandSpec::root())
646            .build()
647            .unwrap();
648        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
649        else {
650            panic!("expected a version outcome");
651        };
652        assert_eq!(
653            cli_version_event(&version).as_value(),
654            &serde_json::json!({
655                "kind": "result",
656                "result": {
657                    "code": "version",
658                    "name": "demo",
659                    "display_name": "Demo Tool",
660                    "version": "1.2.3",
661                    "build": "abc1234",
662                },
663                "trace": {},
664            })
665        );
666    }
667
668    #[test]
669    fn version_events_omit_absent_metadata() {
670        let built = CliSpec::new("demo", "1.2.3")
671            .command(CommandSpec::root())
672            .build()
673            .unwrap();
674        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
675        else {
676            panic!("expected a version outcome");
677        };
678        let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
679        assert!(!payload.contains("display_name"), "{payload}");
680        assert!(!payload.contains("build"), "{payload}");
681    }
682
683    #[test]
684    fn cli_error_events_never_carry_a_secret_value() {
685        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
686        let error = built
687            .resolve_from([
688                "demo",
689                "--dsn-secret",
690                "postgres://user:password@example.test/db",
691                "--unknown",
692            ])
693            .unwrap_err();
694        let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
695        assert!(!serialized.contains("password"));
696        // The classification is the code, not a field beside it.
697        assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
698        // The command to run next reaches the caller through `hint`, which is
699        // the channel every error event already has.
700        assert!(serialized.contains("run `demo --help`"));
701    }
702
703    /// The point of shipping this helper is that every consumer reports the
704    /// same thing. Pin the code, so a consumer branching on it keeps working,
705    /// and pin that it is not a `cli_*` usage code — a caller must not be told
706    /// to fix their command line for a defect in the program.
707    #[test]
708    fn invocation_invalid_event_is_a_program_defect_not_a_usage_error() {
709        let event = cli_invocation_invalid_event("no handler for this registry's invocation");
710        let serialized = serde_json::to_string(event.as_value()).unwrap();
711
712        assert!(serialized.contains("\"code\":\"cli_invocation_invalid\""));
713        assert!(serialized.contains("no handler for this registry's invocation"));
714        // The hint has to say who should act. A usage hint here would send the
715        // caller to debug their own arguments for a dispatch-table bug.
716        assert!(serialized.contains("defect in the program"));
717        assert!(
718            crate::validate_protocol_event(event.as_value(), true).is_ok(),
719            "the helper must emit a strict event: {serialized}"
720        );
721    }
722
723    #[test]
724    fn a_secret_named_flag_is_not_marked_sensitive() {
725        // `--reveal-secret` asks to reveal a secret; it does not carry one, and
726        // a flag has no value that could leak. The suffix must not put the
727        // sensitive bit on it.
728        let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
729        let argument = built
730            .spec()
731            .commands
732            .iter()
733            .flat_map(|command| &command.arguments)
734            .find(|argument| argument.argument_id == "reveal_secret")
735            .expect("the flag is registered");
736        assert!(!argument.sensitive, "a flag has no value to redact");
737    }
738
739    #[test]
740    fn marking_a_flag_sensitive_is_a_contradiction() {
741        let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
742            .expect_err("a sensitive flag must not build");
743        assert_eq!(error.rule, "sensitive_flag");
744    }
745
746    #[test]
747    fn a_value_carrying_secret_argument_is_still_marked() {
748        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
749        let argument = built
750            .spec()
751            .commands
752            .iter()
753            .flat_map(|command| &command.arguments)
754            .find(|argument| argument.argument_id == "dsn_secret")
755            .expect("the option is registered");
756        assert!(argument.sensitive, "an option with a value still counts");
757    }
758}