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
82pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
99 let spec = cli.spec();
100 let name = spec.name.as_str();
101 let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
102 .commands
103 .iter()
104 .filter(|command| !command.combinations.is_empty())
105 .collect();
106 commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
107
108 let path_of = |command: &crate::cli_spec::CommandSpec| {
109 if command.command_path.is_empty() {
110 name.to_string()
111 } else {
112 format!("{name} {}", command.command_path.join(" "))
113 }
114 };
115
116 let mut out = String::new();
117 out.push_str(&format!("# {name} CLI reference\n\n"));
118 out.push_str(&format!(
119 "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
120 ));
121 if let Some(about) = &spec.about {
122 out.push_str(&format!("{about}\n\n"));
123 }
124 out.push_str(&format!(
125 "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
126 typed invocation values, which parameter combinations are legal, output contracts, and \
127 help. An invocation runs only when it matches exactly one registered combination.\n\n"
128 ));
129
130 let baseline = baseline_output(&commands);
135 out.push_str("## Global arguments\n\n");
136 out.push_str(
140 "AFDATA registers these itself, so the syntax in [Commands](#commands) \
141 leaves them out.\n\n",
142 );
143 out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
144 out.push_str(
145 "| `--help` | every command | Every legal shape of that command, complete, plus its \
146 subcommands. JSON by default; `--output plain` for a terminal. |\n",
147 );
148 out.push_str(&format!(
149 "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
150 |\n"
151 ));
152 out.push_str(&format!(
153 "| `--docs` | {name} only | This document, rendered from the registry. |\n"
154 ));
155 if let Some(crate::cli_spec::OutputSpec::Protocol {
156 formats,
157 destinations,
158 default_format,
159 default_destination,
160 ..
161 }) = &baseline
162 {
163 out.push_str(&format!(
164 "| `--output <FORMAT>` | per output contract | Render as {} (default \
165 `{default_format}`). |\n",
166 formats.join(", ")
167 ));
168 out.push_str(&format!(
169 "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
170 to {} (default `{default_destination}`). |\n",
171 destinations.join(", ")
172 ));
173 }
174 out.push_str(
175 "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
176 stream to a file instead. |\n\n",
177 );
178 let baseline_line = baseline.as_ref().map(describe_output);
179 if baseline.is_some() {
182 out.push_str(
183 "Success output is protocol events, on those terms, unless a command's own \
184 **Output** line says otherwise.\n\n",
185 );
186 }
187 out.push_str(
188 "A **shape** is one legal set of arguments that may appear together, under a stable id. \
189 Where a command has more than one, each id is a heading below. `--help` returns them \
190 all at once, so discovering a command costs one call; there is no recursive mode across \
191 commands, and this document is that view.\n\n",
192 );
193
194 out.push_str("## Commands\n\n");
195 for command in &commands {
196 let path = path_of(command);
197 let anchor = path.replace(' ', "-");
198 let about = command.about.as_deref().unwrap_or("");
199 out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
200 }
201 out.push('\n');
202
203 for command in &commands {
204 let path = path_of(command);
205 out.push_str(&format!("### `{path}`\n\n"));
206 if let Some(about) = &command.about {
207 out.push_str(&format!("{about}\n\n"));
208 }
209
210 let Some(model) = cli.help(&command.command_path) else {
211 continue;
212 };
213 for shape in &model.shapes {
214 if model.shapes.len() > 1 {
215 let differs = shape.about.as_deref().unwrap_or_default();
216 out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
217 }
218 out.push_str(&format!(
219 "```\n{}\n```\n\n",
220 trim_output_arguments(&shape.usage)
221 ));
222 }
223
224 let combinations: Vec<&crate::cli_spec::Combination> =
225 command.combinations.iter().collect();
226 let contracts = output_contracts(&combinations);
227 let is_baseline =
228 matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
229 if !is_baseline {
230 out.push_str(&render_output(&contracts));
231 }
232
233 let documented: Vec<&crate::cli_spec::ArgSpec> = command
234 .arguments
235 .iter()
236 .filter(|argument| argument.about.is_some())
237 .collect();
238 if !documented.is_empty() {
239 if model.shapes.len() > 1 {
240 out.push_str("Arguments across every shape above:\n\n");
244 }
245 out.push_str("| Argument | Meaning |\n|---|---|\n");
246 for argument in documented {
247 let about = argument.about.as_deref().unwrap_or_default();
248 out.push_str(&format!(
251 "| `{}` | {about} |\n",
252 crate::cli_spec::argument_key(argument)
253 ));
254 }
255 out.push('\n');
256 }
257 }
258
259 out.push_str("## Exit codes\n\n");
260 out.push_str(
261 "| Code | Meaning |\n|---|---|\n\
262 | 0 | The command ran and succeeded. |\n\
263 | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
264 | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
265 `cli_*` codes below. |\n",
266 );
267 let mut declared_exit_codes: Vec<&crate::cli_spec::ExitCodeSpec> =
270 spec.exit_codes.iter().collect();
271 declared_exit_codes.sort_by_key(|exit| exit.code);
272 for exit in declared_exit_codes {
273 out.push_str(&format!("| {} | {} |\n", exit.code, exit.meaning));
274 }
275 out.push_str(
276 "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
277 retrying it unchanged cannot help, while exit 1 means it was.\n\n",
278 );
279
280 out.push_str("## CLI errors\n\n");
281 out.push_str(
282 "Every structural failure emits one strict JSON `kind:\"error\"` event on stderr, leaves \
283 stdout empty, and exits 2. The `code` names the failure — `cli_unknown_argument` for an \
284 unknown spelling, `cli_unregistered_combination` for registered arguments in a mixture \
285 that is not, and one each for `cli_unknown_command`, `cli_missing_argument_value`, \
286 `cli_invalid_argument_value`, `cli_duplicate_argument`, `cli_unexpected_positional`, and \
287 `cli_invalid_utf8`. `message` names the offending argument and `hint` gives the command \
288 to run next; neither ever quotes a raw value, including secrets. These are decided \
289 before any config, secret source, filesystem, network, or domain I/O.\n\n\
290 Domain failures (exit 1) carry their own stable `error.code` instead, drawn from \
291 whatever this tool defines rather than from the `cli_*` set. No error message quotes a \
292 raw value it was given — an error event is routinely logged, and the input may hold \
293 secrets.\n",
294 );
295 out
296}
297
298fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
301 use crate::cli_spec::OutputSpec;
302 match output {
303 OutputSpec::Raw { file_sinks } => format!(
304 "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
305 strict JSON on stderr",
306 render_file_sinks(file_sinks)
307 ),
308 OutputSpec::Protocol {
309 formats,
310 destinations,
311 default_format,
312 default_destination,
313 file_sinks,
314 ..
315 } => format!(
316 "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
317 (default `{default_destination}`){}",
318 formats.join("/"),
319 destinations.join("/"),
320 render_file_sinks(file_sinks),
321 ),
322 }
323}
324
325fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
327 let mut lines: Vec<String> = Vec::new();
328 for combination in combinations {
329 let line = describe_output(&combination.output);
330 if !lines.contains(&line) {
331 lines.push(line);
332 }
333 }
334 lines
335}
336
337fn baseline_output(
342 commands: &[&crate::cli_spec::CommandSpec],
343) -> Option<crate::cli_spec::OutputSpec> {
344 let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
345 std::collections::BTreeMap::new();
346 for command in commands {
347 let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
348 for combination in &command.combinations {
349 if !contracts.contains(&&combination.output) {
350 contracts.push(&combination.output);
351 }
352 }
353 if let [only] = contracts.as_slice() {
354 let entry = counts
355 .entry(describe_output(only))
356 .or_insert((0, (*only).clone()));
357 entry.0 += 1;
358 }
359 }
360 counts
361 .into_iter()
362 .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
363 .filter(|(_, (count, _))| *count > 1)
364 .map(|(_, (_, spec))| spec)
365}
366
367fn render_output(contracts: &[String]) -> String {
368 match contracts {
369 [] => String::new(),
370 [only] => format!("Output: {only}.\n\n"),
371 many => {
372 let mut out = String::from("Output differs by combination:\n\n");
373 for line in many {
374 out.push_str(&format!("- {line}\n"));
375 }
376 out.push('\n');
377 out
378 }
379 }
380}
381
382fn trim_output_arguments(usage: &str) -> &str {
389 let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
390 .iter()
391 .filter_map(|marker| usage.find(marker))
392 .min();
393 match cut {
394 Some(index) => usage[..index].trim_end(),
395 None => usage,
396 }
397}
398
399fn render_file_sinks(file_sinks: &[String]) -> String {
400 let mut names: Vec<&str> = Vec::new();
401 if file_sinks.iter().any(|sink| sink == "stdout") {
402 names.push("`--stdout-file`");
403 }
404 if file_sinks.iter().any(|sink| sink == "stderr") {
405 names.push("`--stderr-file`");
406 }
407 if names.is_empty() {
408 String::new()
409 } else {
410 format!("; redirect with {}", names.join(" or "))
411 }
412}
413
414pub fn cli_error_event(error: &CliError) -> Event {
424 let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
425 match builder.build() {
426 Ok(event) => event,
427 Err(_) => json_error("cli_error", "failed to build CLI error")
428 .build()
429 .unwrap_or_else(|_| {
430 json_result(serde_json::json!({"code":"internal_cli_error"})).build()
433 }),
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
441
442 fn output() -> OutputSpec {
443 OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
444 }
445
446 fn spec_with(argument: ArgSpec) -> CliSpec {
447 let id = argument.argument_id.clone();
448 CliSpec::new("demo", "1").command(
449 CommandSpec::root().arg(argument).combination(
450 Combination::new("only")
451 .action("only")
452 .required([id])
453 .output(output()),
454 ),
455 )
456 }
457
458 #[test]
459 fn a_cli_declares_exit_codes_beyond_afdatas_own() {
460 let spec = CliSpec::new("demo", "1.0.0")
461 .lifecycle_output(output())
462 .exit_code(4, "The output could not be written.")
463 .exit_code(3, "The command ran and partly succeeded.")
464 .command(CommandSpec::root())
465 .build()
466 .unwrap();
467 let reference = render_cli_reference(&spec);
468 let table = reference
469 .split("## Exit codes")
470 .nth(1)
471 .expect("the reference documents exit codes");
472 let partial = table.find("| 3 | The command ran and partly succeeded. |");
474 let write_failed = table.find("| 4 | The output could not be written. |");
475 assert!(partial.is_some() && write_failed.is_some(), "{table}");
476 assert!(partial < write_failed, "{table}");
477 }
478
479 #[test]
480 fn secret_suffix_drives_the_sensitive_bit() {
481 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
482 let argument = &built.spec().commands[0].arguments[0];
483 assert!(argument.sensitive);
484 }
485
486 #[test]
487 fn sensitive_without_the_suffix_fails_the_build() {
488 let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
489 .unwrap_err();
490 assert_eq!(error.rule, "sensitive_without_secret_suffix");
491 }
492
493 #[test]
494 fn a_plain_argument_stays_insensitive() {
495 let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
496 assert!(!built.spec().commands[0].arguments[0].sensitive);
497 }
498
499 #[test]
503 fn version_events_carry_the_full_documented_payload() {
504 let built = CliSpec::new("demo", "1.2.3")
505 .display_name("Demo Tool")
506 .build_id("abc1234")
507 .command(CommandSpec::root())
508 .build()
509 .unwrap();
510 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
511 else {
512 panic!("expected a version outcome");
513 };
514 assert_eq!(
515 cli_version_event(&version).as_value(),
516 &serde_json::json!({
517 "kind": "result",
518 "result": {
519 "code": "version",
520 "name": "demo",
521 "display_name": "Demo Tool",
522 "version": "1.2.3",
523 "build": "abc1234",
524 },
525 "trace": {},
526 })
527 );
528 }
529
530 #[test]
531 fn version_events_omit_absent_metadata() {
532 let built = CliSpec::new("demo", "1.2.3")
533 .command(CommandSpec::root())
534 .build()
535 .unwrap();
536 let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
537 else {
538 panic!("expected a version outcome");
539 };
540 let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
541 assert!(!payload.contains("display_name"), "{payload}");
542 assert!(!payload.contains("build"), "{payload}");
543 }
544
545 #[test]
546 fn cli_error_events_never_carry_a_secret_value() {
547 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
548 let error = built
549 .resolve_from([
550 "demo",
551 "--dsn-secret",
552 "postgres://user:password@example.test/db",
553 "--unknown",
554 ])
555 .unwrap_err();
556 let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
557 assert!(!serialized.contains("password"));
558 assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
560 assert!(serialized.contains("run `demo --help`"));
563 }
564
565 #[test]
566 fn a_secret_named_flag_is_not_marked_sensitive() {
567 let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
571 let argument = built
572 .spec()
573 .commands
574 .iter()
575 .flat_map(|command| &command.arguments)
576 .find(|argument| argument.argument_id == "reveal_secret")
577 .expect("the flag is registered");
578 assert!(!argument.sensitive, "a flag has no value to redact");
579 }
580
581 #[test]
582 fn marking_a_flag_sensitive_is_a_contradiction() {
583 let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
584 .expect_err("a sensitive flag must not build");
585 assert_eq!(error.rule, "sensitive_flag");
586 }
587
588 #[test]
589 fn a_value_carrying_secret_argument_is_still_marked() {
590 let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
591 let argument = built
592 .spec()
593 .commands
594 .iter()
595 .flat_map(|command| &command.arguments)
596 .find(|argument| argument.argument_id == "dsn_secret")
597 .expect("the option is registered");
598 assert!(argument.sensitive, "an option with a value still counts");
599 }
600}