Skip to main content

safe_chains/
docs.rs

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}