Skip to main content

safe_chains/
docs.rs

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