1use crate::handlers;
2use crate::parse::{FlagCheck, WordSet};
3
4pub struct CommandDoc {
5 pub name: &'static str,
6 pub kind: DocKind,
7 pub description: String,
8}
9
10pub enum DocKind {
11 AlwaysSafe,
12 Handler,
13}
14
15impl CommandDoc {
16 pub fn handler(name: &'static str, description: impl Into<String>) -> Self {
17 Self { name, kind: DocKind::Handler, description: description.into() }
18 }
19
20 pub fn wordset(name: &'static str, words: &WordSet) -> Self {
21 Self::handler(name, doc(words).build())
22 }
23
24 pub fn wordset_multi(name: &'static str, words: &WordSet, multi: &[(&str, WordSet)]) -> Self {
25 Self::handler(name, doc_multi(words, multi).build())
26 }
27
28 pub fn flagcheck(name: &'static str, check: &FlagCheck) -> Self {
29 Self::handler(name, describe_flagcheck(check))
30 }
31
32 pub fn always_safe(name: &'static str, description: &str) -> Self {
33 Self { name, kind: DocKind::AlwaysSafe, description: description.into() }
34 }
35}
36
37#[derive(Default)]
38pub struct DocBuilder {
39 subcommands: Vec<String>,
40 flags: Vec<String>,
41 sections: Vec<String>,
42}
43
44impl DocBuilder {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn wordset(mut self, words: &WordSet) -> Self {
50 for item in words.iter() {
51 if item.starts_with('-') {
52 self.flags.push(item.to_string());
53 } else {
54 self.subcommands.push(item.to_string());
55 }
56 }
57 self
58 }
59
60 pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
61 for (prefix, actions) in multi {
62 for action in actions.iter() {
63 self.subcommands.push(format!("{prefix} {action}"));
64 }
65 }
66 self
67 }
68
69 pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
70 for (a, b, actions) in triples {
71 for action in actions.iter() {
72 self.subcommands.push(format!("{a} {b} {action}"));
73 }
74 }
75 self
76 }
77
78 pub fn subcommand(mut self, name: impl Into<String>) -> Self {
79 self.subcommands.push(name.into());
80 self
81 }
82
83 pub fn section(mut self, text: impl Into<String>) -> Self {
84 let s = text.into();
85 if !s.is_empty() {
86 self.sections.push(s);
87 }
88 self
89 }
90
91 pub fn build(self) -> String {
92 let mut all_sections = Vec::new();
93 if !self.subcommands.is_empty() {
94 let mut subs = self.subcommands;
95 subs.sort();
96 all_sections.push(format!("Subcommands: {}.", subs.join(", ")));
97 }
98 if !self.flags.is_empty() {
99 all_sections.push(format!("Flags: {}.", self.flags.join(", ")));
100 }
101 all_sections.extend(self.sections);
102 if all_sections.len() > 2 {
103 all_sections.join("\n\n")
104 } else {
105 all_sections.join(" ")
106 }
107 }
108}
109
110pub fn doc(words: &WordSet) -> DocBuilder {
111 DocBuilder::new().wordset(words)
112}
113
114pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
115 DocBuilder::new().wordset(words).multi_word(multi)
116}
117
118pub fn wordset_items(words: &WordSet) -> String {
119 let items: Vec<&str> = words.iter().collect();
120 items.join(", ")
121}
122
123pub fn describe_flagcheck(check: &FlagCheck) -> String {
124 let mut parts = Vec::new();
125 let req: Vec<&str> = check.required().iter().collect();
126 if !req.is_empty() {
127 parts.push(format!("Requires: {}", req.join(", ")));
128 }
129 let denied: Vec<&str> = check.denied().iter().collect();
130 if !denied.is_empty() {
131 parts.push(format!("Denied: {}", denied.join(", ")));
132 }
133 format!("{}.", parts.join(". "))
134}
135
136pub fn all_command_docs() -> Vec<CommandDoc> {
137 let mut docs = safe_cmd_docs();
138 docs.extend(handlers::handler_docs());
139 docs.sort_by_key(|d| d.name);
140 docs
141}
142
143pub fn render_markdown(docs: &[CommandDoc]) -> String {
144 let mut out = String::from(
145 "# Supported Commands\n\
146 \n\
147 Auto-generated by `safe-chains --list-commands`.\n\
148 \n\
149 Any command with only `--version` or `--help` as its sole argument is always allowed.\n\
150 \n\
151 ## Unconditionally Safe\n\
152 \n\
153 These commands are allowed with any arguments.\n\
154 \n\
155 | Command | Description |\n\
156 |---------|-------------|\n",
157 );
158
159 for doc in docs.iter().filter(|d| matches!(d.kind, DocKind::AlwaysSafe)) {
160 out.push_str(&format!("| `{}` | {} |\n", doc.name, doc.description));
161 }
162
163 out.push_str("\n## Handled Commands\n\nThese commands are allowed with specific subcommands or flags.\n\n");
164
165 for doc in docs.iter().filter(|d| matches!(d.kind, DocKind::Handler)) {
166 out.push_str(&format!("### `{}`\n\n{}\n\n", doc.name, doc.description));
167 }
168
169 out
170}
171
172fn safe_cmd_docs() -> Vec<CommandDoc> {
173 handlers::SAFE_CMD_ENTRIES
174 .iter()
175 .map(|&(name, description)| CommandDoc::always_safe(name, description))
176 .collect()
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn builder_two_sections_inline() {
185 let ws = WordSet::new(&["--version", "list", "show"]);
186 assert_eq!(doc(&ws).build(), "Subcommands: list, show. Flags: --version.");
187 }
188
189 #[test]
190 fn builder_subcommands_only() {
191 let ws = WordSet::new(&["list", "show"]);
192 assert_eq!(doc(&ws).build(), "Subcommands: list, show.");
193 }
194
195 #[test]
196 fn builder_flags_only() {
197 let ws = WordSet::new(&["--check", "--version"]);
198 assert_eq!(doc(&ws).build(), "Flags: --check, --version.");
199 }
200
201 #[test]
202 fn builder_three_sections_newlines() {
203 let ws = WordSet::new(&["--version", "list", "show"]);
204 assert_eq!(
205 doc(&ws).section("Guarded: foo (bar only).").build(),
206 "Subcommands: list, show.\n\nFlags: --version.\n\nGuarded: foo (bar only)."
207 );
208 }
209
210 #[test]
211 fn builder_multi_word_merged() {
212 let ws = WordSet::new(&["--version", "info", "show"]);
213 let multi: &[(&str, WordSet)] =
214 &[("config", WordSet::new(&["get", "list"]))];
215 assert_eq!(
216 doc_multi(&ws, multi).build(),
217 "Subcommands: config get, config list, info, show. Flags: --version."
218 );
219 }
220
221 #[test]
222 fn builder_multi_word_with_extra_section() {
223 let ws = WordSet::new(&["--version", "show"]);
224 let multi: &[(&str, WordSet)] =
225 &[("config", WordSet::new(&["get", "list"]))];
226 assert_eq!(
227 doc_multi(&ws, multi).section("Guarded: foo.").build(),
228 "Subcommands: config get, config list, show.\n\nFlags: --version.\n\nGuarded: foo."
229 );
230 }
231
232 #[test]
233 fn builder_no_flags_with_extra_stays_inline() {
234 let ws = WordSet::new(&["list", "show"]);
235 assert_eq!(
236 doc(&ws).section("Also: foo.").build(),
237 "Subcommands: list, show. Also: foo."
238 );
239 }
240
241 #[test]
242 fn builder_custom_sections_only() {
243 assert_eq!(
244 DocBuilder::new()
245 .section("Read-only: foo.")
246 .section("Always safe: bar.")
247 .section("Guarded: baz.")
248 .build(),
249 "Read-only: foo.\n\nAlways safe: bar.\n\nGuarded: baz."
250 );
251 }
252
253 #[test]
254 fn builder_triple_word() {
255 let ws = WordSet::new(&["--version", "diff"]);
256 let triples: &[(&str, &str, WordSet)] =
257 &[("git", "remote", WordSet::new(&["list"]))];
258 assert_eq!(
259 doc(&ws).triple_word(triples).build(),
260 "Subcommands: diff, git remote list. Flags: --version."
261 );
262 }
263
264 #[test]
265 fn builder_subcommand_method() {
266 let ws = WordSet::new(&["--version", "list"]);
267 assert_eq!(
268 doc(&ws).subcommand("plugin-list").build(),
269 "Subcommands: list, plugin-list. Flags: --version."
270 );
271 }
272
273 #[test]
274 fn flagcheck_description() {
275 let fc = FlagCheck::new(&["--check"], &["--force"]);
276 assert_eq!(
277 describe_flagcheck(&fc),
278 "Requires: --check. Denied: --force."
279 );
280 }
281
282 #[test]
283 fn flagcheck_required_only() {
284 let fc = FlagCheck::new(&["--check"], &[]);
285 assert_eq!(describe_flagcheck(&fc), "Requires: --check.");
286 }
287}