everruns_cli_contract/
render.rs1use clap::Command;
12
13pub 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 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
76pub 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 #[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}