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    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}