1use crate::cli_spec::{
10 ArgValueType, BuiltCliSpec, CliError, CliSpec, CliSpecError, ResolvedHelp, ResolvedVersion,
11};
12use crate::protocol::{Event, json_error, json_result};
13
14pub fn build_afdata_cli(mut spec: CliSpec) -> Result<BuiltCliSpec, CliSpecError> {
24 for command in &mut spec.commands {
25 for argument in &mut command.arguments {
26 let suffixed = argument.argument_id.ends_with("_secret");
27 let is_flag = matches!(argument.value_type, ArgValueType::Flag);
28 if argument.sensitive && !suffixed {
29 return Err(CliSpecError {
30 rule: "sensitive_without_secret_suffix",
31 message: format!(
32 "argument `{}` is marked sensitive; rename it to `{}_secret` so AFDATA \
33 redaction covers it too",
34 argument.argument_id, argument.argument_id
35 ),
36 });
37 }
38 if argument.sensitive && is_flag {
39 return Err(CliSpecError {
40 rule: "sensitive_flag",
41 message: format!(
42 "flag `{}` is marked sensitive, but a flag carries no value to redact",
43 argument.argument_id
44 ),
45 });
46 }
47 argument.sensitive = suffixed && !is_flag;
53 }
54 }
55 spec.build()
56}
57
58pub fn cli_help_event(help: &ResolvedHelp) -> Event {
60 json_result(serde_json::json!({
61 "code": "help",
62 "help": help.model(),
63 }))
64 .build()
65}
66
67pub fn cli_version_event(version: &ResolvedVersion) -> Event {
74 crate::cli::build_cli_version(
75 version.name(),
76 version.display_name(),
77 version.version(),
78 version.build(),
79 )
80}
81
82const SHAPES_PROSE: &str = include_str!("cli_reference/shapes.md");
94const CLI_ERRORS_PROSE: &str = include_str!("cli_reference/cli-errors.md");
95
96pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
113 let spec = cli.spec();
114 let name = spec.name.as_str();
115 let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
116 .commands
117 .iter()
118 .filter(|command| !command.combinations.is_empty())
119 .collect();
120 commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
121
122 let path_of = |command: &crate::cli_spec::CommandSpec| {
123 if command.command_path.is_empty() {
124 name.to_string()
125 } else {
126 format!("{name} {}", command.command_path.join(" "))
127 }
128 };
129
130 let mut out = String::new();
131 out.push_str(&format!("# {name} CLI reference\n\n"));
132 out.push_str(&format!(
133 "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
134 ));
135 if let Some(about) = &spec.about {
136 out.push_str(&format!("{about}\n\n"));
137 }
138 out.push_str(&format!(
139 "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
140 typed invocation values, which parameter combinations are legal, output contracts, and \
141 help. An invocation runs only when it matches exactly one registered combination.\n\n"
142 ));
143
144 let baseline = baseline_output(&commands);
149 out.push_str("## Global arguments\n\n");
150 out.push_str(
154 "AFDATA registers these itself, so the syntax in [Commands](#commands) \
155 leaves them out.\n\n",
156 );
157 out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
158 out.push_str(
159 "| `--help` | every command | Every legal shape of that command, complete, plus its \
160 subcommands. JSON by default; `--output plain` for a terminal. |\n",
161 );
162 out.push_str(&format!(
163 "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
164 |\n"
165 ));
166 out.push_str(&format!(
167 "| `--docs` | {name} only | This document, rendered from the registry. |\n"
168 ));
169 if let Some(crate::cli_spec::OutputSpec::Protocol {
170 formats,
171 destinations,
172 default_format,
173 default_destination,
174 ..
175 }) = &baseline
176 {
177 out.push_str(&format!(
178 "| `--output <FORMAT>` | per output contract | Render as {} (default \
179 `{default_format}`). |\n",
180 formats.join(", ")
181 ));
182 out.push_str(&format!(
183 "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
184 to {} (default `{default_destination}`). |\n",
185 destinations.join(", ")
186 ));
187 }
188 out.push_str(
189 "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
190 stream to a file instead. |\n\n",
191 );
192 let baseline_line = baseline.as_ref().map(describe_output);
193 if baseline.is_some() {
196 out.push_str(
197 "Success output is protocol events, on those terms, unless a command's own \
198 **Output** line says otherwise.\n\n",
199 );
200 }
201 out.push_str(SHAPES_PROSE);
202 out.push('\n');
203
204 out.push_str("## Commands\n\n");
205 for command in &commands {
206 let path = path_of(command);
207 let anchor = path.replace(' ', "-");
208 let about = command.about.as_deref().unwrap_or("");
209 out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
210 }
211 out.push('\n');
212
213 for command in &commands {
214 let path = path_of(command);
215 out.push_str(&format!("### `{path}`\n\n"));
216 if let Some(about) = &command.about {
217 out.push_str(&format!("{about}\n\n"));
218 }
219
220 let Some(model) = cli.help(&command.command_path) else {
221 continue;
222 };
223 for shape in &model.shapes {
224 if model.shapes.len() > 1 {
225 let differs = shape.about.as_deref().unwrap_or_default();
226 out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
227 }
228 out.push_str(&format!(
229 "```\n{}\n```\n\n",
230 trim_output_arguments(&shape.usage)
231 ));
232 }
233
234 let combinations: Vec<&crate::cli_spec::Combination> =
235 command.combinations.iter().collect();
236 let contracts = output_contracts(&combinations);
237 let is_baseline =
238 matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
239 if !is_baseline {
240 out.push_str(&render_output(&contracts));
241 }
242
243 let documented: Vec<&crate::cli_spec::ArgSpec> = command
244 .arguments
245 .iter()
246 .filter(|argument| argument.about.is_some())
247 .collect();
248 if !documented.is_empty() {
249 if model.shapes.len() > 1 {
250 out.push_str("Arguments across every shape above:\n\n");
254 }
255 out.push_str("| Argument | Meaning |\n|---|---|\n");
256 for argument in documented {
257 let about = argument.about.as_deref().unwrap_or_default();
258 out.push_str(&format!(
261 "| `{}` | {about} |\n",
262 crate::cli_spec::argument_key(argument)
263 ));
264 }
265 out.push('\n');
266 }
267 }
268
269 out.push_str("## Exit codes\n\n");
270 out.push_str(
271 "| Code | Meaning |\n|---|---|\n\
272 | 0 | The command ran and succeeded. |\n\
273 | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
274 | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
275 `cli_*` codes below. |\n",
276 );
277 let mut declared_exit_codes: Vec<&crate::cli_spec::ExitCodeSpec> =
280 spec.exit_codes.iter().collect();
281 declared_exit_codes.sort_by_key(|exit| exit.code);
282 for exit in declared_exit_codes {
283 out.push_str(&format!("| {} | {} |\n", exit.code, exit.meaning));
284 }
285 out.push_str(
286 "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
287 retrying it unchanged cannot help, while exit 1 means it was.\n\n",
288 );
289
290 out.push_str("## CLI errors\n\n");
291 out.push_str(CLI_ERRORS_PROSE);
292 out
293}
294
295fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
298 use crate::cli_spec::OutputSpec;
299 match output {
300 OutputSpec::Raw { file_sinks } => format!(
301 "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
302 strict JSON on stderr",
303 render_file_sinks(file_sinks)
304 ),
305 OutputSpec::Protocol {
306 formats,
307 destinations,
308 default_format,
309 default_destination,
310 file_sinks,
311 ..
312 } => format!(
313 "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
314 (default `{default_destination}`){}",
315 formats.join("/"),
316 destinations.join("/"),
317 render_file_sinks(file_sinks),
318 ),
319 }
320}
321
322fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
324 let mut lines: Vec<String> = Vec::new();
325 for combination in combinations {
326 let line = describe_output(&combination.output);
327 if !lines.contains(&line) {
328 lines.push(line);
329 }
330 }
331 lines
332}
333
334fn baseline_output(
339 commands: &[&crate::cli_spec::CommandSpec],
340) -> Option<crate::cli_spec::OutputSpec> {
341 let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
342 std::collections::BTreeMap::new();
343 for command in commands {
344 let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
345 for combination in &command.combinations {
346 if !contracts.contains(&&combination.output) {
347 contracts.push(&combination.output);
348 }
349 }
350 if let [only] = contracts.as_slice() {
351 let entry = counts
352 .entry(describe_output(only))
353 .or_insert((0, (*only).clone()));
354 entry.0 += 1;
355 }
356 }
357 counts
358 .into_iter()
359 .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
360 .filter(|(_, (count, _))| *count > 1)
361 .map(|(_, (_, spec))| spec)
362}
363
364fn render_output(contracts: &[String]) -> String {
365 match contracts {
366 [] => String::new(),
367 [only] => format!("Output: {only}.\n\n"),
368 many => {
369 let mut out = String::from("Output differs by combination:\n\n");
370 for line in many {
371 out.push_str(&format!("- {line}\n"));
372 }
373 out.push('\n');
374 out
375 }
376 }
377}
378
379fn trim_output_arguments(usage: &str) -> &str {
386 let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
387 .iter()
388 .filter_map(|marker| usage.find(marker))
389 .min();
390 match cut {
391 Some(index) => usage[..index].trim_end(),
392 None => usage,
393 }
394}
395
396fn render_file_sinks(file_sinks: &[String]) -> String {
397 let mut names: Vec<&str> = Vec::new();
398 if file_sinks.iter().any(|sink| sink == "stdout") {
399 names.push("`--stdout-file`");
400 }
401 if file_sinks.iter().any(|sink| sink == "stderr") {
402 names.push("`--stderr-file`");
403 }
404 if names.is_empty() {
405 String::new()
406 } else {
407 format!("; redirect with {}", names.join(" or "))
408 }
409}
410
411pub fn cli_error_event(error: &CliError) -> Event {
421 let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
422 match builder.build() {
423 Ok(event) => event,
424 Err(_) => json_error("cli_error", "failed to build CLI error")
425 .build()
426 .unwrap_or_else(|_| {
427 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
430 }),
431 }
432}
433
434pub fn cli_invocation_invalid_event(detail: &str) -> Event {
450 let builder = json_error("cli_invocation_invalid", detail)
451 .hint("this is a defect in the program, not in the command; report it");
452 match builder.build() {
453 Ok(event) => event,
454 Err(_) => json_error("cli_invocation_invalid", "invocation cannot be dispatched")
455 .build()
456 .unwrap_or_else(|_| {
457 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
460 }),
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
468
469 fn output() -> OutputSpec {
470 OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
471 }
472
473 fn spec_with(argument: ArgSpec) -> CliSpec {
474 let id = argument.argument_id.clone();
475 CliSpec::new("demo", "1").command(
476 CommandSpec::root().arg(argument).combination(
477 Combination::new("only")
478 .action("only")
479 .required([id])
480 .output(output()),
481 ),
482 )
483 }
484
485 #[test]
486 fn a_cli_declares_exit_codes_beyond_afdatas_own() {
487 let spec = CliSpec::new("demo", "1.0.0")
488 .lifecycle_output(output())
489 .exit_code(4, "The output could not be written.")
490 .exit_code(3, "The command ran and partly succeeded.")
491 .command(CommandSpec::root())
492 .build()
493 .unwrap();
494 let reference = render_cli_reference(&spec);
495 let table = reference
496 .split("## Exit codes")
497 .nth(1)
498 .expect("the reference documents exit codes");
499 let partial = table.find("| 3 | The command ran and partly succeeded. |");
501 let write_failed = table.find("| 4 | The output could not be written. |");
502 assert!(partial.is_some() && write_failed.is_some(), "{table}");
503 assert!(partial < write_failed, "{table}");
504 }
505
506 #[test]
507 fn secret_suffix_drives_the_sensitive_bit() {
508 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
509 let argument = &built.spec().commands[0].arguments[0];
510 assert!(argument.sensitive);
511 }
512
513 #[test]
514 fn sensitive_without_the_suffix_fails_the_build() {
515 let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
516 .unwrap_err();
517 assert_eq!(error.rule, "sensitive_without_secret_suffix");
518 }
519
520 #[test]
521 fn a_plain_argument_stays_insensitive() {
522 let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
523 assert!(!built.spec().commands[0].arguments[0].sensitive);
524 }
525
526 #[test]
530 fn version_events_carry_the_full_documented_payload() {
531 let built = CliSpec::new("demo", "1.2.3")
532 .display_name("Demo Tool")
533 .build_id("abc1234")
534 .command(CommandSpec::root())
535 .build()
536 .unwrap();
537 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
538 else {
539 panic!("expected a version outcome");
540 };
541 assert_eq!(
542 cli_version_event(&version).as_value(),
543 &serde_json::json!({
544 "kind": "result",
545 "result": {
546 "code": "version",
547 "name": "demo",
548 "display_name": "Demo Tool",
549 "version": "1.2.3",
550 "build": "abc1234",
551 },
552 "trace": {},
553 })
554 );
555 }
556
557 #[test]
558 fn version_events_omit_absent_metadata() {
559 let built = CliSpec::new("demo", "1.2.3")
560 .command(CommandSpec::root())
561 .build()
562 .unwrap();
563 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
564 else {
565 panic!("expected a version outcome");
566 };
567 let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
568 assert!(!payload.contains("display_name"), "{payload}");
569 assert!(!payload.contains("build"), "{payload}");
570 }
571
572 #[test]
573 fn cli_error_events_never_carry_a_secret_value() {
574 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
575 let error = built
576 .resolve_from([
577 "demo",
578 "--dsn-secret",
579 "postgres://user:password@example.test/db",
580 "--unknown",
581 ])
582 .unwrap_err();
583 let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
584 assert!(!serialized.contains("password"));
585 assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
587 assert!(serialized.contains("run `demo --help`"));
590 }
591
592 #[test]
597 fn invocation_invalid_event_is_a_program_defect_not_a_usage_error() {
598 let event = cli_invocation_invalid_event("no handler for this registry's invocation");
599 let serialized = serde_json::to_string(event.as_value()).unwrap();
600
601 assert!(serialized.contains("\"code\":\"cli_invocation_invalid\""));
602 assert!(serialized.contains("no handler for this registry's invocation"));
603 assert!(serialized.contains("defect in the program"));
606 assert!(
607 crate::validate_protocol_event(event.as_value(), true).is_ok(),
608 "the helper must emit a strict event: {serialized}"
609 );
610 }
611
612 #[test]
613 fn a_secret_named_flag_is_not_marked_sensitive() {
614 let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
618 let argument = built
619 .spec()
620 .commands
621 .iter()
622 .flat_map(|command| &command.arguments)
623 .find(|argument| argument.argument_id == "reveal_secret")
624 .expect("the flag is registered");
625 assert!(!argument.sensitive, "a flag has no value to redact");
626 }
627
628 #[test]
629 fn marking_a_flag_sensitive_is_a_contradiction() {
630 let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
631 .expect_err("a sensitive flag must not build");
632 assert_eq!(error.rule, "sensitive_flag");
633 }
634
635 #[test]
636 fn a_value_carrying_secret_argument_is_still_marked() {
637 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
638 let argument = built
639 .spec()
640 .commands
641 .iter()
642 .flat_map(|command| &command.arguments)
643 .find(|argument| argument.argument_id == "dsn_secret")
644 .expect("the option is registered");
645 assert!(argument.sensitive, "an option with a value still counts");
646 }
647}