1use 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
15pub 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 argument.sensitive = suffixed && !is_flag;
54
55 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
78pub 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
87pub 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
102const SHAPES_PROSE: &str = include_str!("cli_reference/shapes.md");
114const CLI_ERRORS_PROSE: &str = include_str!("cli_reference/cli-errors.md");
115
116pub 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 let baseline = baseline_output(&commands);
169 out.push_str("## Global arguments\n\n");
170 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 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 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 out.push_str(&format!(
280 "| `{}` | {about} |\n",
281 crate::cli_spec::argument_key(argument)
282 ));
283 }
284 out.push('\n');
285 }
286
287 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 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
320fn 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
347fn 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
359fn 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
404fn 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
436pub 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 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
455 }),
456 }
457}
458
459pub 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 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 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 #[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 #[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 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 #[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 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 #[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 assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
698 assert!(serialized.contains("run `demo --help`"));
701 }
702
703 #[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 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 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}