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