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: &'static str,
8    pub description: String,
9    pub aliases: Vec<String>,
10    pub category: String,
11}
12
13pub enum DocKind {
14    Handler,
15}
16
17impl CommandDoc {
18    pub fn handler(name: &'static str, url: &'static str, description: impl Into<String>, category: &str) -> Self {
19        let raw = description.into();
20        let description = raw
21            .lines()
22            .map(|line| {
23                if line.is_empty() || line.starts_with("- ") {
24                    line.to_string()
25                } else {
26                    format!("- {line}")
27                }
28            })
29            .collect::<Vec<_>>()
30            .join("\n");
31        Self { name: name.to_string(), kind: DocKind::Handler, url, description, aliases: Vec::new(), category: category.to_string() }
32    }
33
34    pub fn wordset(name: &'static str, url: &'static str, words: &WordSet, category: &str) -> Self {
35        Self::handler(name, url, doc(words).build(), category)
36    }
37
38    pub fn wordset_multi(name: &'static str, url: &'static str, words: &WordSet, multi: &[(&str, WordSet)], category: &str) -> Self {
39        Self::handler(name, url, doc_multi(words, multi).build(), category)
40    }
41
42
43}
44
45#[derive(Default)]
46pub struct DocBuilder {
47    subcommands: Vec<String>,
48    flags: Vec<String>,
49    sections: Vec<String>,
50}
51
52impl DocBuilder {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn wordset(mut self, words: &WordSet) -> Self {
58        for item in words.iter() {
59            if item.starts_with('-') {
60                self.flags.push(item.to_string());
61            } else {
62                self.subcommands.push(item.to_string());
63            }
64        }
65        self
66    }
67
68    pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
69        for (prefix, actions) in multi {
70            for action in actions.iter() {
71                self.subcommands.push(format!("{prefix} {action}"));
72            }
73        }
74        self
75    }
76
77    pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
78        for (a, b, actions) in triples {
79            for action in actions.iter() {
80                self.subcommands.push(format!("{a} {b} {action}"));
81            }
82        }
83        self
84    }
85
86    pub fn subcommand(mut self, name: impl Into<String>) -> Self {
87        self.subcommands.push(name.into());
88        self
89    }
90
91    pub fn section(mut self, text: impl Into<String>) -> Self {
92        let s = text.into();
93        if !s.is_empty() {
94            self.sections.push(s);
95        }
96        self
97    }
98
99    pub fn build(self) -> String {
100        let mut lines = Vec::new();
101        if !self.subcommands.is_empty() {
102            let mut subs = self.subcommands;
103            subs.sort();
104            lines.push(format!("- Subcommands: {}", subs.join(", ")));
105        }
106        if !self.flags.is_empty() {
107            lines.push(format!("- Flags: {}", self.flags.join(", ")));
108        }
109        for s in self.sections {
110            if s.starts_with("- ") {
111                lines.push(s);
112            } else {
113                lines.push(format!("- {s}"));
114            }
115        }
116        lines.join("\n")
117    }
118}
119
120pub fn doc(words: &WordSet) -> DocBuilder {
121    DocBuilder::new().wordset(words)
122}
123
124pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
125    DocBuilder::new().wordset(words).multi_word(multi)
126}
127
128pub fn wordset_items(words: &WordSet) -> String {
129    let items: Vec<&str> = words.iter().collect();
130    items.join(", ")
131}
132
133
134pub fn all_command_docs() -> Vec<CommandDoc> {
135    let mut docs = handlers::handler_docs();
136    docs.sort_by_key(|a| a.name.to_ascii_lowercase());
137    docs
138}
139
140const GLOSSARY: &str = "\
141| Term | Meaning |\n\
142|------|---------|\n\
143| **Allowed standalone flags** | Flags that take no value (`--verbose`, `-v`). Listed on flat commands. |\n\
144| **Flags** | Same as standalone flags, but in the shorter format used within subcommand entries. |\n\
145| **Allowed valued flags** | Flags that require a value (`--output file`, `-j 4`). |\n\
146| **Valued** | Same as valued flags, in shorter format within subcommand entries. |\n\
147| **Bare invocation allowed** | The command can be run with no arguments at all. |\n\
148| **Subcommands** | Named subcommands that are allowed (e.g. `git log`, `cargo test`). |\n\
149| **Positional arguments only** | No specific flags are listed; only positional arguments are accepted. |\n\
150| **(requires --flag)** | A guarded subcommand that is only allowed when a specific flag is present (e.g. `cargo fmt` requires `--check`). |\n\
151\n\
152Unlisted flags, subcommands, and commands are not allowed.\n";
153
154pub fn render_markdown(docs: &[CommandDoc]) -> String {
155    let mut out = format!(
156        "# Supported Commands\n\n\
157         Auto-generated by `safe-chains --list-commands`. These commands, subcommands, and flags are safe to run individually or in combination.\n\n\
158         ## Glossary\n\n{GLOSSARY}\n",
159    );
160
161    for doc in docs {
162        out.push_str(&render_command_entry(doc));
163    }
164
165    out
166}
167
168fn category_display_name(slug: &str) -> &'static str {
169    match slug {
170        "ai" => "AI Tools",
171        "android" => "Android",
172        "ansible" => "Ansible",
173        "binary" => "Binary Analysis",
174        "builtins" => "Shell Builtins",
175        "cloud" => "Cloud Providers",
176        "containers" => "Containers",
177        "data" => "Data Processing",
178        "dotnet" => ".NET",
179        "forges" => "Code Forges",
180        "fs" => "Filesystem",
181        "fuzzy" => "Fuzzy Finders",
182        "go" => "Go",
183        "hash" => "Hashing",
184        "jvm" => "JVM",
185        "kafka" => "Kafka",
186        "magick" => "ImageMagick",
187        "net" => "Networking",
188        "node" => "Node.js",
189        "php" => "PHP",
190        "pm" => "Package Managers",
191        "python" => "Python",
192        "r" => "R",
193        "ruby" => "Ruby",
194        "rust" => "Rust",
195        "search" => "Search",
196        "swift" => "Swift",
197        "sysinfo" => "System Info",
198        "system" => "System",
199        "text" => "Text Processing",
200        "tools" => "Developer Tools",
201        "vcs" => "Version Control",
202        "wrappers" => "Shell Wrappers",
203        "xcode" => "Xcode",
204        other => panic!("unknown category '{other}' — add it to category_display_name() in src/docs.rs")
205    }
206}
207
208fn render_command_entry(doc: &CommandDoc) -> String {
209    let mut out = String::new();
210    out.push_str(&format!("### `{}`\n", doc.name));
211    out.push_str(&format!(
212        "<p class=\"cmd-url\"><a href=\"{}\">{}</a></p>\n\n",
213        doc.url, doc.url,
214    ));
215    if !doc.aliases.is_empty() {
216        let alias_str: Vec<String> = doc.aliases.iter().map(|a| format!("`{a}`")).collect();
217        out.push_str(&format!("Aliases: {}\n\n", alias_str.join(", ")));
218    }
219    out.push_str(&format!("{}\n\n", doc.description));
220    out
221}
222
223pub fn render_book(docs: &[CommandDoc], output_dir: &std::path::Path) {
224    use std::collections::BTreeMap;
225    use std::fs;
226
227    let commands_dir = output_dir.join("src").join("commands");
228    fs::create_dir_all(&commands_dir).expect("failed to create commands dir");
229
230    let mut by_category: BTreeMap<&str, Vec<&CommandDoc>> = BTreeMap::new();
231    for doc in docs {
232        by_category.entry(&doc.category).or_default().push(doc);
233    }
234
235    let total: usize = by_category.values().map(|v| v.len()).sum();
236
237    let includes_dir = output_dir.join("src").join("includes");
238    fs::create_dir_all(&includes_dir).expect("failed to create includes dir");
239    fs::write(includes_dir.join("command-count.md"), format!("{total}\n"))
240        .expect("failed to write command-count.md");
241
242    let mut readme = format!(
243        "# Command Reference\n\n\
244         safe-chains knows {total} commands across {} categories.\n\n\
245         ## Glossary\n\n{GLOSSARY}\n",
246        by_category.len(),
247    );
248    for (slug, cmds) in &by_category {
249        let name = category_display_name(slug);
250        readme.push_str(&format!(
251            "- [{}]({}.md) ({} commands)\n",
252            name, slug, cmds.len(),
253        ));
254    }
255    readme.push('\n');
256    fs::write(commands_dir.join("README.md"), &readme)
257        .expect("failed to write commands/README.md");
258
259    for (slug, cmds) in &by_category {
260        let name = category_display_name(slug);
261        let mut page = format!("# {name}\n\n");
262        for doc in cmds {
263            page.push_str(&render_command_entry(doc));
264        }
265        fs::write(commands_dir.join(format!("{slug}.md")), &page)
266            .expect("failed to write category page");
267    }
268
269    eprintln!("Generated {} category pages:", by_category.len());
270    for slug in by_category.keys() {
271        eprintln!("  - [{}](commands/{}.md)", category_display_name(slug), slug);
272    }
273}
274
275pub fn render_opencode_json(patterns: &[String]) -> String {
276    use serde_json::{Map, Value};
277    use std::fs;
278
279    let mut root: Map<String, Value> = fs::read_to_string("opencode.json")
280        .ok()
281        .and_then(|s| serde_json::from_str(&s).ok())
282        .and_then(|v: Value| v.as_object().cloned())
283        .unwrap_or_else(|| {
284            let mut m = Map::new();
285            m.insert(
286                "$schema".to_string(),
287                Value::String("https://opencode.ai/config.json".to_string()),
288            );
289            m
290        });
291
292    let mut bash = Map::new();
293    bash.insert("*".to_string(), Value::String("ask".to_string()));
294    for pat in patterns {
295        bash.insert(pat.clone(), Value::String("allow".to_string()));
296    }
297
298    let permission = root
299        .entry("permission")
300        .or_insert_with(|| Value::Object(Map::new()));
301    if !permission.is_object() {
302        *permission = Value::Object(Map::new());
303    }
304    if let Value::Object(perm_map) = permission {
305        perm_map.insert("bash".to_string(), Value::Object(bash));
306    }
307
308    let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
309    out.push('\n');
310    out
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn all_commands_have_url() {
319        for doc in all_command_docs() {
320            assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
321            assert!(
322                doc.url.starts_with("https://"),
323                "{} URL must use https: {}",
324                doc.name,
325                doc.url
326            );
327        }
328    }
329
330    #[test]
331    fn all_commands_have_valid_category() {
332        for doc in all_command_docs() {
333            assert!(!doc.category.is_empty(), "{} has no category", doc.name);
334            category_display_name(&doc.category);
335        }
336    }
337
338    #[test]
339    fn builder_two_sections() {
340        let ws = WordSet::new(&["--version", "list", "show"]);
341        assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
342    }
343
344    #[test]
345    fn builder_subcommands_only() {
346        let ws = WordSet::new(&["list", "show"]);
347        assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
348    }
349
350    #[test]
351    fn builder_flags_only() {
352        let ws = WordSet::new(&["--check", "--version"]);
353        assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
354    }
355
356    #[test]
357    fn builder_three_sections() {
358        let ws = WordSet::new(&["--version", "list", "show"]);
359        assert_eq!(
360            doc(&ws).section("Guarded: foo (bar only).").build(),
361            "- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
362        );
363    }
364
365    #[test]
366    fn builder_multi_word_merged() {
367        let ws = WordSet::new(&["--version", "info", "show"]);
368        let multi: &[(&str, WordSet)] =
369            &[("config", WordSet::new(&["get", "list"]))];
370        assert_eq!(
371            doc_multi(&ws, multi).build(),
372            "- Subcommands: config get, config list, info, show\n- Flags: --version"
373        );
374    }
375
376    #[test]
377    fn builder_multi_word_with_extra_section() {
378        let ws = WordSet::new(&["--version", "show"]);
379        let multi: &[(&str, WordSet)] =
380            &[("config", WordSet::new(&["get", "list"]))];
381        assert_eq!(
382            doc_multi(&ws, multi).section("Guarded: foo.").build(),
383            "- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
384        );
385    }
386
387    #[test]
388    fn builder_no_flags_with_extra() {
389        let ws = WordSet::new(&["list", "show"]);
390        assert_eq!(
391            doc(&ws).section("Also: foo.").build(),
392            "- Subcommands: list, show\n- Also: foo."
393        );
394    }
395
396    #[test]
397    fn builder_custom_sections_only() {
398        assert_eq!(
399            DocBuilder::new()
400                .section("Read-only: foo.")
401                .section("Always safe: bar.")
402                .section("Guarded: baz.")
403                .build(),
404            "- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
405        );
406    }
407
408    #[test]
409    fn builder_triple_word() {
410        let ws = WordSet::new(&["--version", "diff"]);
411        let triples: &[(&str, &str, WordSet)] =
412            &[("git", "remote", WordSet::new(&["list"]))];
413        assert_eq!(
414            doc(&ws).triple_word(triples).build(),
415            "- Subcommands: diff, git remote list\n- Flags: --version"
416        );
417    }
418
419    #[test]
420    fn builder_subcommand_method() {
421        let ws = WordSet::new(&["--version", "list"]);
422        assert_eq!(
423            doc(&ws).subcommand("plugin-list").build(),
424            "- Subcommands: list, plugin-list\n- Flags: --version"
425        );
426    }
427
428    #[test]
429    fn render_opencode_json_valid() {
430        let patterns = vec!["grep".to_string(), "grep *".to_string(), "ls".to_string()];
431        let json = render_opencode_json(&patterns);
432        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
433        let bash = &parsed["permission"]["bash"];
434        assert_eq!(bash["*"], "ask");
435        assert_eq!(bash["grep"], "allow");
436        assert_eq!(bash["grep *"], "allow");
437        assert_eq!(bash["ls"], "allow");
438        assert!(bash["rm"].is_null());
439    }
440
441    #[test]
442    fn render_opencode_json_has_schema() {
443        let json = render_opencode_json(&[]);
444        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
445        assert_eq!(parsed["$schema"], "https://opencode.ai/config.json");
446    }
447
448    #[test]
449    fn render_opencode_json_trailing_newline() {
450        let json = render_opencode_json(&[]);
451        assert!(json.ends_with('\n'));
452    }
453
454    #[test]
455    fn render_opencode_json_merges_existing() {
456        use std::fs;
457        let dir = tempfile::tempdir().expect("tmpdir");
458        let config_path = dir.path().join("opencode.json");
459        fs::write(
460            &config_path,
461            r#"{"$schema":"https://opencode.ai/config.json","model":"claude-sonnet-4-6","permission":{"bash":{"rm *":"deny"}}}"#,
462        )
463        .expect("write");
464
465        let prev = std::env::current_dir().expect("cwd");
466        std::env::set_current_dir(dir.path()).expect("cd");
467        let json = render_opencode_json(&["ls".to_string()]);
468        std::env::set_current_dir(prev).expect("cd back");
469
470        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
471        assert_eq!(parsed["model"], "claude-sonnet-4-6", "existing keys preserved");
472        assert_eq!(parsed["permission"]["bash"]["*"], "ask");
473        assert_eq!(parsed["permission"]["bash"]["ls"], "allow");
474        assert!(
475            parsed["permission"]["bash"]["rm *"].is_null(),
476            "old bash rules replaced, not merged"
477        );
478    }
479
480}