Skip to main content

safe_chains/
docs.rs

1use crate::handlers;
2use crate::parse::WordSet;
3
4pub struct CommandDoc {
5    pub name: String,
6    pub kind: DocKind,
7    pub url: String,
8    pub description: String,
9    pub aliases: Vec<String>,
10    pub category: String,
11    pub examples: Vec<String>,
12}
13
14pub enum DocKind {
15    Handler,
16}
17
18impl CommandDoc {
19    pub fn handler(name: impl Into<String>, url: impl Into<String>, description: impl Into<String>, category: &str) -> Self {
20        let raw = description.into();
21        let description = raw
22            .lines()
23            .map(|line| {
24                // Leave already-bulleted lines (top-level OR indented
25                // sub-bullets like `  - **action**`) alone; prepend
26                // `- ` only to plain prose lines.
27                if line.is_empty() || line.trim_start().starts_with("- ") { line.to_string() } else { format!("- {line}") }
28            })
29            .collect::<Vec<_>>()
30            .join("\n");
31        Self {
32            name: name.into(),
33            kind: DocKind::Handler,
34            url: url.into(),
35            description,
36            aliases: Vec::new(),
37            category: category.to_string(),
38            examples: Vec::new(),
39        }
40    }
41
42    pub fn wordset(name: &'static str, url: &'static str, words: &WordSet, category: &str) -> Self {
43        Self::handler(name, url, doc(words).build(), category)
44    }
45
46    pub fn wordset_multi(name: &'static str, url: &'static str, words: &WordSet, multi: &[(&str, WordSet)], category: &str) -> Self {
47        Self::handler(name, url, doc_multi(words, multi).build(), category)
48    }
49}
50
51#[derive(Default)]
52pub struct DocBuilder {
53    subcommands: Vec<String>,
54    flags: Vec<String>,
55    sections: Vec<String>,
56}
57
58impl DocBuilder {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    pub fn wordset(mut self, words: &WordSet) -> Self {
64        for item in words.iter() {
65            if item.starts_with('-') {
66                self.flags.push(item.to_string());
67            } else {
68                self.subcommands.push(item.to_string());
69            }
70        }
71        self
72    }
73
74    pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
75        for (prefix, actions) in multi {
76            for action in actions.iter() {
77                self.subcommands.push(format!("{prefix} {action}"));
78            }
79        }
80        self
81    }
82
83    pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
84        for (a, b, actions) in triples {
85            for action in actions.iter() {
86                self.subcommands.push(format!("{a} {b} {action}"));
87            }
88        }
89        self
90    }
91
92    pub fn subcommand(mut self, name: impl Into<String>) -> Self {
93        self.subcommands.push(name.into());
94        self
95    }
96
97    pub fn section(mut self, text: impl Into<String>) -> Self {
98        let s = text.into();
99        if !s.is_empty() {
100            self.sections.push(s);
101        }
102        self
103    }
104
105    pub fn build(self) -> String {
106        let mut lines = Vec::new();
107        if !self.subcommands.is_empty() {
108            let mut subs = self.subcommands;
109            subs.sort();
110            lines.push(format!("- Subcommands: {}", subs.join(", ")));
111        }
112        if !self.flags.is_empty() {
113            lines.push(format!("- Flags: {}", self.flags.join(", ")));
114        }
115        for s in self.sections {
116            if s.starts_with("- ") {
117                lines.push(s);
118            } else {
119                lines.push(format!("- {s}"));
120            }
121        }
122        lines.join("\n")
123    }
124}
125
126pub fn doc(words: &WordSet) -> DocBuilder {
127    DocBuilder::new().wordset(words)
128}
129
130pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
131    DocBuilder::new().wordset(words).multi_word(multi)
132}
133
134pub fn wordset_items(words: &WordSet) -> String {
135    let items: Vec<&str> = words.iter().collect();
136    items.join(", ")
137}
138
139pub fn all_command_docs() -> Vec<CommandDoc> {
140    let mut docs = handlers::handler_docs();
141    docs.sort_by_key(|a| a.name.to_ascii_lowercase());
142    docs
143}
144
145const GLOSSARY: &str = "\
146| Term | Meaning |\n\
147|------|---------|\n\
148| **Allowed standalone flags** | Flags that take no value (`--verbose`, `-v`). Listed on flat commands. |\n\
149| **Flags** | Same as standalone flags, but in the shorter format used within subcommand entries. |\n\
150| **Allowed valued flags** | Flags that require a value (`--output file`, `-j 4`). |\n\
151| **Valued** | Same as valued flags, in shorter format within subcommand entries. |\n\
152| **Bare invocation allowed** | The command can be run with no arguments at all. |\n\
153| **Subcommands** | Named subcommands that are allowed (e.g. `git log`, `cargo test`). |\n\
154| **Positional arguments only** | No specific flags are listed; only positional arguments are accepted. |\n\
155| **(requires --flag)** | A guarded subcommand that is only allowed when a specific flag is present (e.g. `cargo fmt` requires `--check`). |\n\
156\n\
157Unlisted flags, subcommands, and commands are not allowed.\n";
158
159pub fn render_markdown(docs: &[CommandDoc]) -> String {
160    let mut out = format!(
161        "# Supported Commands\n\n\
162         Auto-generated by `safe-chains --list-commands`. These commands, subcommands, and flags are safe to run individually or in combination.\n\n\
163         ## Glossary\n\n{GLOSSARY}\n",
164    );
165
166    for doc in docs {
167        out.push_str(&render_command_entry(doc));
168    }
169
170    out
171}
172
173fn category_display_name(slug: &str) -> &'static str {
174    match slug {
175        "ai" => "AI Tools",
176        "android" => "Android",
177        "ansible" => "Ansible",
178        "api" => "API / Load Testing",
179        "archive" => "Compression / Archive",
180        "binary" => "Binary Analysis",
181        "blockchain" => "Blockchain / Crypto",
182        "build" => "Build Systems",
183        "builtins" => "Shell Builtins",
184        "clipboard" => "Clipboard",
185        "compile" => "Compilation Toolchains",
186        "configmgmt" => "Config Management",
187        "cppkg" => "C++ Package Managers",
188        "crypto" => "Cryptography",
189        "c" => "C / C++",
190        "cloud" => "Cloud Providers",
191        "containers" => "Containers",
192        "crystal" => "Crystal",
193        "d" => "D",
194        "dart" => "Dart / Flutter",
195        "data" => "Data Processing",
196        "db" => "Database Clients",
197        "editors" => "Editors",
198        "embedded" => "Embedded",
199        "dotnet" => ".NET",
200        "elixir" => "Elixir / Erlang",
201        "erlang" => "Erlang",
202        "game" => "Game Engines",
203        "gleam" => "Gleam",
204        "media" => "Media",
205        "migrations" => "Database Migrations",
206        "ml" => "ML / Observability",
207        "mobile" => "Mobile Frameworks",
208        "niche" => "Niche / Esoteric",
209        "nix" => "Nix",
210        "forges" => "Code Forges",
211        "fs" => "Filesystem",
212        "fuzzy" => "Fuzzy Finders",
213        "go" => "Go",
214        "hash" => "Hashing",
215        "haskell" => "Haskell",
216        "julia" => "Julia",
217        "jvm" => "JVM",
218        "kafka" => "Kafka",
219        "lisp" => "Common Lisp",
220        "lua" => "Lua",
221        "magick" => "ImageMagick",
222        "net" => "Networking",
223        "nim" => "Nim",
224        "node" => "Node.js",
225        "ocaml" => "OCaml",
226        "pdf" => "PDF / Document",
227        "perl" => "Perl",
228        "php" => "PHP",
229        "proof" => "Theorem Provers",
230        "scaffold" => "Project Scaffolders",
231        "serverless" => "Serverless / IaC",
232        "pm" => "Package Managers",
233        "python" => "Python",
234        "r" => "R",
235        "racket" => "Racket",
236        "roc" => "Roc",
237        "ruby" => "Ruby",
238        "rust" => "Rust",
239        "search" => "Search",
240        "swift" => "Swift",
241        "sysinfo" => "System Info",
242        "system" => "System",
243        "tex" => "TeX / LaTeX",
244        "text" => "Text Processing",
245        "tools" => "Developer Tools",
246        "vcs" => "Version Control",
247        "wasm" => "WebAssembly",
248        "wrappers" => "Shell Wrappers",
249        "xcode" => "Xcode",
250        other => panic!("unknown category '{other}' — add it to category_display_name() in src/docs.rs"),
251    }
252}
253
254fn render_command_entry(doc: &CommandDoc) -> String {
255    let mut out = String::new();
256    out.push_str(&format!("### `{}`\n", doc.name));
257    out.push_str(&format!("<p class=\"cmd-url\"><a href=\"{}\">{}</a></p>\n\n", doc.url, doc.url,));
258    if !doc.aliases.is_empty() {
259        let alias_str: Vec<String> = doc.aliases.iter().map(|a| format!("`{a}`")).collect();
260        out.push_str(&format!("Aliases: {}\n\n", alias_str.join(", ")));
261    }
262    out.push_str(&format!("{}\n\n", doc.description));
263    if !doc.examples.is_empty() {
264        out.push_str("**Examples:**\n\n");
265        for ex in &doc.examples {
266            out.push_str(&format!("- `{ex}`\n"));
267        }
268        out.push('\n');
269    }
270    out
271}
272
273pub fn render_book(docs: &[CommandDoc], output_dir: &std::path::Path) {
274    use std::collections::BTreeMap;
275    use std::fs;
276
277    let commands_dir = output_dir.join("src").join("commands");
278    fs::create_dir_all(&commands_dir).expect("failed to create commands dir");
279
280    let mut by_category: BTreeMap<&str, Vec<&CommandDoc>> = BTreeMap::new();
281    for doc in docs {
282        by_category.entry(&doc.category).or_default().push(doc);
283    }
284
285    let total: usize = by_category.values().map(|v| v.len()).sum();
286
287    let includes_dir = output_dir.join("src").join("includes");
288    fs::create_dir_all(&includes_dir).expect("failed to create includes dir");
289    fs::write(includes_dir.join("command-count.md"), format!("{total}\n")).expect("failed to write command-count.md");
290
291    let version = env!("CARGO_PKG_VERSION");
292    fs::write(
293        output_dir.join("src").join("version-footer.js"),
294        format!(
295            "document.addEventListener('DOMContentLoaded', function() {{\n\
296             \x20   var nav = document.querySelector('.nav-wide-wrapper') || document.querySelector('.nav-wrapper');\n\
297             \x20   if (nav) {{\n\
298             \x20       var footer = document.createElement('div');\n\
299             \x20       footer.className = 'version-footer';\n\
300             \x20       footer.textContent = 'safe-chains v{version} · {total} commands';\n\
301             \x20       nav.parentNode.insertBefore(footer, nav.nextSibling);\n\
302             \x20   }}\n\
303             }});\n"
304        ),
305    )
306    .expect("failed to write version-footer.js");
307
308    let mut readme = format!(
309        "# Command Reference\n\n\
310         safe-chains knows {total} commands across {} categories.\n\n\
311         ## Glossary\n\n{GLOSSARY}\n",
312        by_category.len(),
313    );
314    for (slug, cmds) in &by_category {
315        let name = category_display_name(slug);
316        readme.push_str(&format!("- [{}]({}.md) ({} commands)\n", name, slug, cmds.len(),));
317    }
318    readme.push('\n');
319    fs::write(commands_dir.join("README.md"), &readme).expect("failed to write commands/README.md");
320
321    for (slug, cmds) in &by_category {
322        let name = category_display_name(slug);
323        let mut page = format!("# {name}\n\n");
324        for doc in cmds {
325            page.push_str(&render_command_entry(doc));
326        }
327        fs::write(commands_dir.join(format!("{slug}.md")), &page).expect("failed to write category page");
328    }
329
330    eprintln!("Generated {} category pages:", by_category.len());
331    for slug in by_category.keys() {
332        eprintln!("  - [{}](commands/{}.md)", category_display_name(slug), slug);
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn all_commands_have_url() {
342        for doc in all_command_docs() {
343            assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
344            assert!(doc.url.starts_with("https://"), "{} URL must use https: {}", doc.name, doc.url);
345        }
346    }
347
348    #[test]
349    fn all_commands_have_valid_category() {
350        for doc in all_command_docs() {
351            assert!(!doc.category.is_empty(), "{} has no category", doc.name);
352            category_display_name(&doc.category);
353        }
354    }
355
356    #[test]
357    fn builder_two_sections() {
358        let ws = WordSet::new(&["--version", "list", "show"]);
359        assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
360    }
361
362    #[test]
363    fn builder_subcommands_only() {
364        let ws = WordSet::new(&["list", "show"]);
365        assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
366    }
367
368    #[test]
369    fn builder_flags_only() {
370        let ws = WordSet::new(&["--check", "--version"]);
371        assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
372    }
373
374    #[test]
375    fn builder_three_sections() {
376        let ws = WordSet::new(&["--version", "list", "show"]);
377        assert_eq!(
378            doc(&ws).section("Guarded: foo (bar only).").build(),
379            "- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
380        );
381    }
382
383    #[test]
384    fn builder_multi_word_merged() {
385        let ws = WordSet::new(&["--version", "info", "show"]);
386        let multi: &[(&str, WordSet)] = &[("config", WordSet::new(&["get", "list"]))];
387        assert_eq!(doc_multi(&ws, multi).build(), "- Subcommands: config get, config list, info, show\n- Flags: --version");
388    }
389
390    #[test]
391    fn builder_multi_word_with_extra_section() {
392        let ws = WordSet::new(&["--version", "show"]);
393        let multi: &[(&str, WordSet)] = &[("config", WordSet::new(&["get", "list"]))];
394        assert_eq!(
395            doc_multi(&ws, multi).section("Guarded: foo.").build(),
396            "- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
397        );
398    }
399
400    #[test]
401    fn builder_no_flags_with_extra() {
402        let ws = WordSet::new(&["list", "show"]);
403        assert_eq!(doc(&ws).section("Also: foo.").build(), "- Subcommands: list, show\n- Also: foo.");
404    }
405
406    #[test]
407    fn builder_custom_sections_only() {
408        assert_eq!(
409            DocBuilder::new()
410                .section("Read-only: foo.")
411                .section("Always safe: bar.")
412                .section("Guarded: baz.")
413                .build(),
414            "- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
415        );
416    }
417
418    #[test]
419    fn builder_triple_word() {
420        let ws = WordSet::new(&["--version", "diff"]);
421        let triples: &[(&str, &str, WordSet)] = &[("git", "remote", WordSet::new(&["list"]))];
422        assert_eq!(doc(&ws).triple_word(triples).build(), "- Subcommands: diff, git remote list\n- Flags: --version");
423    }
424
425    #[test]
426    fn builder_subcommand_method() {
427        let ws = WordSet::new(&["--version", "list"]);
428        assert_eq!(doc(&ws).subcommand("plugin-list").build(), "- Subcommands: list, plugin-list\n- Flags: --version");
429    }
430}