Skip to main content

everruns_cli_contract/
render.rs

1//! A stable text rendering of a clap tree.
2//!
3//! Two guards read this. A golden snapshot in `crates/cli` pins the contract
4//! humans already type, so a refactor that re-spells a flag fails rather than
5//! ships. A drift check pins the agent-facing tree to the same rendering, so
6//! the two surfaces cannot diverge without a test saying so.
7//!
8//! The format is for diffing, not for reading: one line per argument, fields
9//! in a fixed order, sorted, so a change shows up as the line that changed.
10
11use clap::Command;
12
13/// Render a whole command tree, one line per leaf argument.
14pub fn tree(command: &Command) -> String {
15    let mut lines = Vec::new();
16    walk(command, "", &mut lines);
17    lines.sort();
18    lines.join("\n")
19}
20
21fn walk(command: &Command, path: &str, lines: &mut Vec<String>) {
22    let mut children = command.get_subcommands().peekable();
23    if children.peek().is_none() {
24        if !path.is_empty() {
25            lines.push(leaf(command, path));
26        }
27        return;
28    }
29    for child in command.get_subcommands() {
30        let child_path = if path.is_empty() {
31            child.get_name().to_string()
32        } else {
33            format!("{path} {}", child.get_name())
34        };
35        walk(child, &child_path, lines);
36    }
37}
38
39fn leaf(command: &Command, path: &str) -> String {
40    let mut parts: Vec<String> = command
41        .get_arguments()
42        .filter(|arg| arg.get_id() != "help" && arg.get_id() != "version")
43        .map(argument)
44        .collect();
45    parts.sort();
46    format!("{path}\t{}", parts.join(" "))
47}
48
49fn argument(arg: &clap::Arg) -> String {
50    let mut spelling = String::new();
51    if let Some(long) = arg.get_long() {
52        spelling.push_str(&format!("--{long}"));
53    }
54    if let Some(short) = arg.get_short() {
55        spelling.push_str(&format!("/-{short}"));
56    }
57    if arg.is_positional() {
58        // The argument id, minus the suffix that keeps a bare-word spelling
59        // distinct from its own flag. Clap's value name would do, but it
60        // defaults differently for derive and builder arguments, so rendering
61        // it would make this snapshot report a format change as a contract
62        // change.
63        let id = arg.get_id().as_str();
64        let name = id.split('\u{1}').next().unwrap_or(id);
65        spelling.push_str(&format!("<{name}>"));
66    }
67    if arg.is_required_set() {
68        spelling.push('!');
69    }
70    if matches!(arg.get_action(), clap::ArgAction::Append) {
71        spelling.push_str("...");
72    }
73    spelling
74}
75
76/// A line-level difference between two renderings.
77///
78/// Comparing the whole rendering with `assert_eq!` reports two multi-line
79/// strings as two opaque blobs, which is exactly the moment a guard stops
80/// being read and starts being overridden. Report the lines that moved.
81pub fn diff(expected: &str, actual: &str) -> Option<String> {
82    let expected: Vec<&str> = expected.trim().lines().collect();
83    let actual: Vec<&str> = actual.trim().lines().collect();
84    if expected == actual {
85        return None;
86    }
87
88    let mut report = String::new();
89    for line in &expected {
90        if !actual.contains(line) {
91            report.push_str(&format!("- {line}\n"));
92        }
93    }
94    for line in &actual {
95        if !expected.contains(line) {
96            report.push_str(&format!("+ {line}\n"));
97        }
98    }
99    Some(report)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn command() -> clap::Command {
107        clap::Command::new("root").subcommand(
108            clap::Command::new("widgets").subcommand(
109                clap::Command::new("list")
110                    .arg(clap::Arg::new("limit").long("limit").short('l'))
111                    .arg(clap::Arg::new("name").long("name").required(true)),
112            ),
113        )
114    }
115
116    #[test]
117    fn a_leaf_renders_its_spelling_shorts_and_requirement() {
118        assert_eq!(tree(&command()), "widgets list\t--limit/-l --name!");
119    }
120
121    /// The guard is only worth having if it fails, so prove it does.
122    #[test]
123    fn a_respelled_flag_shows_up_as_the_line_that_moved() {
124        let before = tree(&command());
125        let after = tree(
126            &clap::Command::new("root").subcommand(
127                clap::Command::new("widgets").subcommand(
128                    clap::Command::new("list")
129                        .arg(clap::Arg::new("limit").long("max").short('l'))
130                        .arg(clap::Arg::new("name").long("name").required(true)),
131                ),
132            ),
133        );
134
135        let report = diff(&before, &after).expect("a re-spelled flag is a difference");
136        assert!(report.contains("- widgets list\t--limit/-l"), "{report}");
137        assert!(report.contains("+ widgets list\t--max/-l"), "{report}");
138    }
139
140    #[test]
141    fn an_unchanged_tree_reports_nothing() {
142        assert!(diff(&tree(&command()), &tree(&command())).is_none());
143    }
144}