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 cli_output_setup_failed_event(detail: &str) -> Event {
17 json_error(
18 "output_setup_failed",
19 if detail.is_empty() {
20 "an output sink could not be established"
21 } else {
22 detail
23 },
24 )
25 .build()
26 .unwrap_or_else(|_| cli_invocation_invalid_event("could not construct output setup diagnostic"))
27}
28
29pub fn cli_output_write_failed_event() -> Event {
31 json_error("output_write_failed", "could not write command output")
32 .build()
33 .unwrap_or_else(|_| {
34 cli_invocation_invalid_event("could not construct output write diagnostic")
35 })
36}
37
38impl<R> crate::cli_spec::BoundOutcome<R> {
39 pub fn into_run(self) -> Result<crate::cli_spec::BoundInvocation<R>, std::process::ExitCode> {
42 let (event, plan) = match self {
43 Self::Run(invocation) => return Ok(invocation),
44 Self::Docs(docs) => {
45 return Err(crate::cli::write_raw_exit(
46 &render_cli_reference(docs.registry()),
47 docs.output_plan(),
48 ));
49 }
50 Self::Help(help)
51 if help.output_plan().output_format() == Some(crate::OutputFormat::Plain) =>
52 {
53 return Err(crate::cli::write_raw_exit(
54 &help.plain(),
55 help.output_plan(),
56 ));
57 }
58 Self::Help(help) => (cli_help_event(&help), help.output_plan().clone()),
59 Self::Version(version) => (cli_version_event(&version), version.output_plan().clone()),
60 };
61 let mut emitter = crate::cli::CliEmitter::from_plan(&plan);
62 Err(std::process::ExitCode::from(emitter.finish(event, 0)))
63 }
64}
65
66pub fn build_afdata_cli(mut spec: CliSpec) -> Result<BuiltCliSpec, CliSpecError> {
76 for command in &mut spec.commands {
77 for argument in &mut command.arguments {
78 let suffixed = argument.argument_id.ends_with("_secret");
79 let is_flag = matches!(argument.value_type, ArgValueType::Flag);
80 if argument.sensitive && !suffixed {
81 return Err(CliSpecError {
82 rule: "sensitive_without_secret_suffix",
83 message: format!(
84 "argument `{}` is marked sensitive; rename it to `{}_secret` so AFDATA \
85 redaction covers it too",
86 argument.argument_id, argument.argument_id
87 ),
88 });
89 }
90 if argument.sensitive && is_flag {
91 return Err(CliSpecError {
92 rule: "sensitive_flag",
93 message: format!(
94 "flag `{}` is marked sensitive, but a flag carries no value to redact",
95 argument.argument_id
96 ),
97 });
98 }
99 argument.sensitive = suffixed && !is_flag;
105
106 if let Some(sources) = &argument.sources
111 && sources.accepts(SourceScheme::Prompt)
112 && !argument.sensitive
113 {
114 return Err(CliSpecError {
115 rule: "prompt_source_without_secret",
116 message: format!(
117 "argument `{}` accepts the `prompt` source; rename it to `{}_secret`, or \
118 drop the source — prompting blocks on a terminal for a value that is not \
119 a credential",
120 argument.argument_id, argument.argument_id
121 ),
122 });
123 }
124 }
125 }
126 spec.build()
127}
128
129pub fn cli_help_event(help: &ResolvedHelp) -> Event {
131 json_result(serde_json::json!({
132 "code": "help",
133 "help": help.model(),
134 }))
135 .build()
136}
137
138pub fn cli_version_event(version: &ResolvedVersion) -> Event {
145 crate::cli::build_cli_version(
146 version.name(),
147 version.display_name(),
148 version.version(),
149 version.build(),
150 )
151}
152
153const SHAPES_PROSE: &str = include_str!("cli_reference/shapes.md");
165const CLI_ERRORS_PROSE: &str = include_str!("cli_reference/cli-errors.md");
166
167pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
184 let spec = cli.spec();
185 let name = spec.name.as_str();
186 let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
187 .commands
188 .iter()
189 .filter(|command| !command.combinations.is_empty())
190 .collect();
191 commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
192
193 let path_of = |command: &crate::cli_spec::CommandSpec| {
194 if command.command_path.is_empty() {
195 name.to_string()
196 } else {
197 format!("{name} {}", command.command_path.join(" "))
198 }
199 };
200
201 let mut out = String::new();
202 out.push_str(&format!("# {name} CLI reference\n\n"));
203 out.push_str(&format!(
204 "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
205 ));
206 if let Some(about) = &spec.about {
207 out.push_str(&format!("{about}\n\n"));
208 }
209 out.push_str(&format!(
210 "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
211 typed invocation values, which parameter combinations are legal, output contracts, and \
212 help. An invocation runs only when it matches exactly one registered combination.\n\n"
213 ));
214
215 let baseline = baseline_output(&commands);
220 out.push_str("## Global arguments\n\n");
221 out.push_str(
225 "AFDATA registers these itself, so the syntax in [Commands](#commands) \
226 leaves them out.\n\n",
227 );
228 out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
229 out.push_str(
230 "| `--help` | every command | Every legal shape of that command, complete, plus its \
231 subcommands. JSON by default; `--output plain` for a terminal. |\n",
232 );
233 out.push_str(&format!(
234 "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
235 |\n"
236 ));
237 out.push_str(&format!(
238 "| `--docs` | {name} only | This document, rendered from the registry. |\n"
239 ));
240 if let Some(crate::cli_spec::OutputSpec::Protocol {
241 formats,
242 destinations,
243 default_format,
244 default_destination,
245 ..
246 }) = &baseline
247 {
248 out.push_str(&format!(
249 "| `--output <FORMAT>` | per output contract | Render as {} (default \
250 `{default_format}`). |\n",
251 formats.join(", ")
252 ));
253 out.push_str(&format!(
254 "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
255 to {} (default `{default_destination}`). |\n",
256 destinations.join(", ")
257 ));
258 }
259 out.push_str(
260 "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
261 stream to a file instead. |\n\n",
262 );
263 let baseline_line = baseline.as_ref().map(describe_output);
264 if baseline.is_some() {
267 out.push_str(
268 "Success output is protocol events, on those terms, unless a command's own \
269 **Output** line says otherwise.\n\n",
270 );
271 }
272 out.push_str(SHAPES_PROSE);
273 out.push('\n');
274
275 out.push_str("## Commands\n\n");
276 for command in &commands {
277 let path = path_of(command);
278 let anchor = path.replace(' ', "-");
279 let about = command.about.as_deref().unwrap_or("");
280 out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
281 }
282 out.push('\n');
283
284 for command in &commands {
285 let path = path_of(command);
286 out.push_str(&format!("### `{path}`\n\n"));
287 if let Some(about) = &command.about {
288 out.push_str(&format!("{about}\n\n"));
289 }
290
291 let Some(model) = cli.help(&command.command_path) else {
292 continue;
293 };
294 for shape in &model.shapes {
295 if model.shapes.len() > 1 {
296 let differs = shape.about.as_deref().unwrap_or_default();
297 out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
298 }
299 out.push_str(&format!(
300 "```\n{}\n```\n\n",
301 trim_output_arguments(&shape.usage)
302 ));
303 }
304
305 let combinations: Vec<&crate::cli_spec::Combination> =
306 command.combinations.iter().collect();
307 let contracts = output_contracts(&combinations);
308 let is_baseline =
309 matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
310 if !is_baseline {
311 out.push_str(&render_output(&contracts));
312 }
313
314 let documented: Vec<(&crate::cli_spec::ArgSpec, String)> = command
315 .arguments
316 .iter()
317 .filter_map(|argument| Some((argument, argument.rendered_about()?)))
318 .collect();
319 if !documented.is_empty() {
320 if model.shapes.len() > 1 {
321 out.push_str("Arguments across every shape above:\n\n");
325 }
326 out.push_str("| Argument | Meaning |\n|---|---|\n");
327 for (argument, about) in documented {
328 out.push_str(&format!(
331 "| `{}` | {about} |\n",
332 crate::cli_spec::argument_key(argument)
333 ));
334 }
335 out.push('\n');
336 }
337
338 if let Some(note) = &command.reference_note {
340 out.push_str(note.trim_end());
341 out.push_str("\n\n");
342 }
343 }
344
345 out.push_str("## Exit codes\n\n");
346 out.push_str(
347 "| Code | Meaning |\n|---|---|\n\
348 | 0 | The command ran and succeeded. |\n\
349 | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
350 | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
351 `cli_*` codes below. |\n",
352 );
353 let mut declared_exit_codes: Vec<(u8, &str)> = spec
356 .exit_codes
357 .iter()
358 .map(|exit| (exit.code, exit.meaning.as_str()))
359 .collect();
360 declared_exit_codes.push((
361 4,
362 "Output could not be written (`output_write_failed`); a closed reader is success.",
363 ));
364 declared_exit_codes.sort_by_key(|(code, _)| *code);
365 for (code, meaning) in declared_exit_codes {
366 out.push_str(&format!("| {code} | {meaning} |\n"));
367 }
368 out.push_str(
369 "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
370 retrying it unchanged cannot help, while exit 1 means it was.\n\n",
371 );
372
373 out.push_str("## CLI errors\n\n");
374 out.push_str(CLI_ERRORS_PROSE);
375 out
376}
377
378fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
381 use crate::cli_spec::OutputSpec;
382 match output {
383 OutputSpec::Raw { file_sinks } => format!(
384 "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
385 strict JSON on stderr",
386 render_file_sinks(file_sinks)
387 ),
388 OutputSpec::Protocol {
389 formats,
390 destinations,
391 default_format,
392 default_destination,
393 file_sinks,
394 ..
395 } => format!(
396 "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
397 (default `{default_destination}`){}",
398 formats.join("/"),
399 destinations.join("/"),
400 render_file_sinks(file_sinks),
401 ),
402 }
403}
404
405fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
407 let mut lines: Vec<String> = Vec::new();
408 for combination in combinations {
409 let line = describe_output(&combination.output);
410 if !lines.contains(&line) {
411 lines.push(line);
412 }
413 }
414 lines
415}
416
417fn baseline_output(
422 commands: &[&crate::cli_spec::CommandSpec],
423) -> Option<crate::cli_spec::OutputSpec> {
424 let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
425 std::collections::BTreeMap::new();
426 for command in commands {
427 let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
428 for combination in &command.combinations {
429 if !contracts.contains(&&combination.output) {
430 contracts.push(&combination.output);
431 }
432 }
433 if let [only] = contracts.as_slice() {
434 let entry = counts
435 .entry(describe_output(only))
436 .or_insert((0, (*only).clone()));
437 entry.0 += 1;
438 }
439 }
440 counts
441 .into_iter()
442 .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
443 .filter(|(_, (count, _))| *count > 1)
444 .map(|(_, (_, spec))| spec)
445}
446
447fn render_output(contracts: &[String]) -> String {
448 match contracts {
449 [] => String::new(),
450 [only] => format!("Output: {only}.\n\n"),
451 many => {
452 let mut out = String::from("Output differs by combination:\n\n");
453 for line in many {
454 out.push_str(&format!("- {line}\n"));
455 }
456 out.push('\n');
457 out
458 }
459 }
460}
461
462fn trim_output_arguments(usage: &str) -> &str {
469 let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
470 .iter()
471 .filter_map(|marker| usage.find(marker))
472 .min();
473 match cut {
474 Some(index) => usage[..index].trim_end(),
475 None => usage,
476 }
477}
478
479fn render_file_sinks(file_sinks: &[String]) -> String {
480 let mut names: Vec<&str> = Vec::new();
481 if file_sinks.iter().any(|sink| sink == "stdout") {
482 names.push("`--stdout-file`");
483 }
484 if file_sinks.iter().any(|sink| sink == "stderr") {
485 names.push("`--stderr-file`");
486 }
487 if names.is_empty() {
488 String::new()
489 } else {
490 format!("; redirect with {}", names.join(" or "))
491 }
492}
493
494pub fn cli_error_event(error: &CliError) -> Event {
504 let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
505 match builder.build() {
506 Ok(event) => event,
507 Err(_) => json_error("cli_error", "failed to build CLI error")
508 .build()
509 .unwrap_or_else(|_| {
510 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
513 }),
514 }
515}
516
517pub fn cli_invocation_invalid_event(detail: &str) -> Event {
535 let builder = json_error("cli_invocation_invalid", detail)
536 .hint("this is a defect in the program, not in the command; report it");
537 match builder.build() {
538 Ok(event) => event,
539 Err(_) => json_error("cli_invocation_invalid", "invocation cannot be dispatched")
540 .build()
541 .unwrap_or_else(|_| {
542 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
545 }),
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::cli_spec::SourceSet;
553 use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
554
555 fn output() -> OutputSpec {
556 OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
557 }
558
559 fn spec_with(argument: ArgSpec) -> CliSpec {
560 let id = argument.argument_id.clone();
561 CliSpec::new("demo", "1").command(
562 CommandSpec::root().arg(argument).combination(
563 Combination::new("only")
564 .action("only")
565 .required([id])
566 .output(output()),
567 ),
568 )
569 }
570
571 #[test]
572 fn a_cli_declares_exit_codes_beyond_afdatas_own() {
573 let spec = CliSpec::new("demo", "1.0.0")
574 .lifecycle_output(output())
575 .exit_code(5, "An application-specific failure.")
576 .exit_code(3, "The command ran and partly succeeded.")
577 .command(CommandSpec::root())
578 .build()
579 .unwrap();
580 let reference = render_cli_reference(&spec);
581 let table = reference
582 .split("## Exit codes")
583 .nth(1)
584 .expect("the reference documents exit codes");
585 let partial = table.find("| 3 | The command ran and partly succeeded. |");
587 let write_failed = table.find("| 5 | An application-specific failure. |");
588 assert!(partial.is_some() && write_failed.is_some(), "{table}");
589 assert!(partial < write_failed, "{table}");
590 }
591
592 #[test]
596 fn a_declared_source_set_renders_itself_into_help_and_docs() {
597 let built = build_afdata_cli(spec_with(
598 ArgSpec::option("--token-secret", "SOURCE")
599 .about("Token this host requires")
600 .sources(SourceSet::config().host_scheme("container", "container:NAME")),
601 ))
602 .expect("registry builds");
603
604 let reference = render_cli_reference(&built);
605 assert!(
606 reference.contains(
607 "Token this host requires (the value, or where to read it: env:NAME, \
608 file[+FORMAT]:PATH[#DOT_PATH], container:NAME, literal:VALUE)"
609 ),
610 "{reference}"
611 );
612
613 let CliOutcome::Help(help) = built
614 .resolve_from(vec!["demo", "--help"])
615 .expect("help resolves")
616 else {
617 panic!("--help must resolve to help");
618 };
619 let model = serde_json::to_value(help.model()).expect("help serializes");
620 let note = model["notes"]["--token-secret"]
621 .as_str()
622 .unwrap_or_default()
623 .to_string();
624 assert!(note.contains("file[+FORMAT]:PATH[#DOT_PATH]"), "{model}");
625 }
626
627 #[test]
630 fn an_unaccepted_scheme_is_an_argv_rejection() {
631 let built = build_afdata_cli(spec_with(
632 ArgSpec::option("--token-secret", "SOURCE").sources(SourceSet::config()),
633 ))
634 .expect("registry builds");
635 let error = built
636 .resolve_from(vec!["demo", "--token-secret", "prompt"])
637 .expect_err("prompt is not in config()");
638 assert_eq!(
639 error.rule,
640 crate::cli_spec::CliErrorRule::InvalidArgumentValue
641 );
642 assert!(error.message.contains("env:NAME"), "{}", error.message);
643
644 let outcome = built
646 .resolve_from(vec!["demo", "--token-secret", "env:NAME"])
647 .expect("env is accepted");
648 let CliOutcome::Run(invocation) = outcome else {
649 panic!("must resolve to a run");
650 };
651 assert_eq!(
652 invocation.required("token_secret").as_str(),
653 Some("env:NAME")
654 );
655 }
656
657 #[test]
660 fn the_prompt_source_is_refused_on_a_non_secret_argument() {
661 let error = build_afdata_cli(spec_with(
662 ArgSpec::option("--label", "LABEL").sources(SourceSet::stream()),
663 ))
664 .expect_err("prompt on a non-secret argument");
665 assert_eq!(error.rule, "prompt_source_without_secret");
666 assert!(
668 build_afdata_cli(spec_with(
669 ArgSpec::option("--token-secret", "SOURCE").sources(SourceSet::stream())
670 ))
671 .is_ok()
672 );
673 }
674
675 #[test]
676 fn secret_suffix_drives_the_sensitive_bit() {
677 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
678 let argument = &built.spec().commands[0].arguments[0];
679 assert!(argument.sensitive);
680 }
681
682 #[test]
683 fn sensitive_without_the_suffix_fails_the_build() {
684 let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
685 .unwrap_err();
686 assert_eq!(error.rule, "sensitive_without_secret_suffix");
687 }
688
689 #[test]
690 fn a_plain_argument_stays_insensitive() {
691 let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
692 assert!(!built.spec().commands[0].arguments[0].sensitive);
693 }
694
695 #[test]
699 fn version_events_carry_the_full_documented_payload() {
700 let built = CliSpec::new("demo", "1.2.3")
701 .display_name("Demo Tool")
702 .build_id("abc1234")
703 .command(CommandSpec::root())
704 .build()
705 .unwrap();
706 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
707 else {
708 panic!("expected a version outcome");
709 };
710 assert_eq!(
711 cli_version_event(&version).as_value(),
712 &serde_json::json!({
713 "kind": "result",
714 "result": {
715 "code": "version",
716 "name": "demo",
717 "display_name": "Demo Tool",
718 "version": "1.2.3",
719 "build": "abc1234",
720 },
721 "trace": {},
722 })
723 );
724 }
725
726 #[test]
727 fn version_events_omit_absent_metadata() {
728 let built = CliSpec::new("demo", "1.2.3")
729 .command(CommandSpec::root())
730 .build()
731 .unwrap();
732 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
733 else {
734 panic!("expected a version outcome");
735 };
736 let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
737 assert!(!payload.contains("display_name"), "{payload}");
738 assert!(!payload.contains("build"), "{payload}");
739 }
740
741 #[test]
742 fn cli_error_events_never_carry_a_secret_value() {
743 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
744 let error = built
745 .resolve_from([
746 "demo",
747 "--dsn-secret",
748 "postgres://user:password@example.test/db",
749 "--unknown",
750 ])
751 .unwrap_err();
752 let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
753 assert!(!serialized.contains("password"));
754 assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
756 assert!(serialized.contains("run `demo --help`"));
759 }
760
761 #[test]
766 fn invocation_invalid_event_is_a_program_defect_not_a_usage_error() {
767 let event = cli_invocation_invalid_event("no handler for this registry's invocation");
768 let serialized = serde_json::to_string(event.as_value()).unwrap();
769
770 assert!(serialized.contains("\"code\":\"cli_invocation_invalid\""));
771 assert!(serialized.contains("no handler for this registry's invocation"));
772 assert!(serialized.contains("defect in the program"));
775 assert!(
776 crate::validate_protocol_event(event.as_value(), true).is_ok(),
777 "the helper must emit a strict event: {serialized}"
778 );
779 }
780
781 #[test]
782 fn a_secret_named_flag_is_not_marked_sensitive() {
783 let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
787 let argument = built
788 .spec()
789 .commands
790 .iter()
791 .flat_map(|command| &command.arguments)
792 .find(|argument| argument.argument_id == "reveal_secret")
793 .expect("the flag is registered");
794 assert!(!argument.sensitive, "a flag has no value to redact");
795 }
796
797 #[test]
798 fn marking_a_flag_sensitive_is_a_contradiction() {
799 let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
800 .expect_err("a sensitive flag must not build");
801 assert_eq!(error.rule, "sensitive_flag");
802 }
803
804 #[test]
805 fn a_value_carrying_secret_argument_is_still_marked() {
806 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
807 let argument = built
808 .spec()
809 .commands
810 .iter()
811 .flat_map(|command| &command.arguments)
812 .find(|argument| argument.argument_id == "dsn_secret")
813 .expect("the option is registered");
814 assert!(argument.sensitive, "an option with a value still counts");
815 }
816}