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}
11
12pub enum DocKind {
13    Handler,
14}
15
16impl CommandDoc {
17    pub fn handler(name: &'static str, url: &'static str, description: impl Into<String>) -> Self {
18        let raw = description.into();
19        let description = raw
20            .lines()
21            .map(|line| {
22                if line.is_empty() || line.starts_with("- ") {
23                    line.to_string()
24                } else {
25                    format!("- {line}")
26                }
27            })
28            .collect::<Vec<_>>()
29            .join("\n");
30        Self { name: name.to_string(), kind: DocKind::Handler, url, description, aliases: Vec::new() }
31    }
32
33    pub fn wordset(name: &'static str, url: &'static str, words: &WordSet) -> Self {
34        Self::handler(name, url, doc(words).build())
35    }
36
37    pub fn wordset_multi(name: &'static str, url: &'static str, words: &WordSet, multi: &[(&str, WordSet)]) -> Self {
38        Self::handler(name, url, doc_multi(words, multi).build())
39    }
40
41
42}
43
44#[derive(Default)]
45pub struct DocBuilder {
46    subcommands: Vec<String>,
47    flags: Vec<String>,
48    sections: Vec<String>,
49}
50
51impl DocBuilder {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn wordset(mut self, words: &WordSet) -> Self {
57        for item in words.iter() {
58            if item.starts_with('-') {
59                self.flags.push(item.to_string());
60            } else {
61                self.subcommands.push(item.to_string());
62            }
63        }
64        self
65    }
66
67    pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
68        for (prefix, actions) in multi {
69            for action in actions.iter() {
70                self.subcommands.push(format!("{prefix} {action}"));
71            }
72        }
73        self
74    }
75
76    pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
77        for (a, b, actions) in triples {
78            for action in actions.iter() {
79                self.subcommands.push(format!("{a} {b} {action}"));
80            }
81        }
82        self
83    }
84
85    pub fn subcommand(mut self, name: impl Into<String>) -> Self {
86        self.subcommands.push(name.into());
87        self
88    }
89
90    pub fn section(mut self, text: impl Into<String>) -> Self {
91        let s = text.into();
92        if !s.is_empty() {
93            self.sections.push(s);
94        }
95        self
96    }
97
98    pub fn build(self) -> String {
99        let mut lines = Vec::new();
100        if !self.subcommands.is_empty() {
101            let mut subs = self.subcommands;
102            subs.sort();
103            lines.push(format!("- Subcommands: {}", subs.join(", ")));
104        }
105        if !self.flags.is_empty() {
106            lines.push(format!("- Flags: {}", self.flags.join(", ")));
107        }
108        for s in self.sections {
109            if s.starts_with("- ") {
110                lines.push(s);
111            } else {
112                lines.push(format!("- {s}"));
113            }
114        }
115        lines.join("\n")
116    }
117}
118
119pub fn doc(words: &WordSet) -> DocBuilder {
120    DocBuilder::new().wordset(words)
121}
122
123pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
124    DocBuilder::new().wordset(words).multi_word(multi)
125}
126
127pub fn wordset_items(words: &WordSet) -> String {
128    let items: Vec<&str> = words.iter().collect();
129    items.join(", ")
130}
131
132
133pub fn all_command_docs() -> Vec<CommandDoc> {
134    let mut docs = handlers::handler_docs();
135    docs.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
136    docs
137}
138
139pub fn render_markdown(docs: &[CommandDoc]) -> String {
140    let mut out = String::from(
141        "# Supported Commands\n\
142         \n\
143         Auto-generated by `safe-chains --list-commands`. These commands, subcommands, and flags are read-only and safe to run individually or in combination.\n\n",
144    );
145
146    for doc in docs {
147        if doc.aliases.is_empty() {
148            out.push_str(&format!("### `{}` ({})\n\n", doc.name, doc.url));
149        } else {
150            let alias_str: Vec<String> = doc.aliases.iter().map(|a| format!("`{a}`")).collect();
151            out.push_str(&format!(
152                "### `{}` ({})\n\nAliases: {}\n\n",
153                doc.name, doc.url, alias_str.join(", ")
154            ));
155        }
156        out.push_str(&format!("{}\n\n", doc.description));
157    }
158
159    out
160}
161
162pub fn render_opencode_json(patterns: &[String]) -> String {
163    use serde_json::{Map, Value};
164    use std::fs;
165
166    let mut root: Map<String, Value> = fs::read_to_string("opencode.json")
167        .ok()
168        .and_then(|s| serde_json::from_str(&s).ok())
169        .and_then(|v: Value| v.as_object().cloned())
170        .unwrap_or_else(|| {
171            let mut m = Map::new();
172            m.insert(
173                "$schema".to_string(),
174                Value::String("https://opencode.ai/config.json".to_string()),
175            );
176            m
177        });
178
179    let mut bash = Map::new();
180    bash.insert("*".to_string(), Value::String("ask".to_string()));
181    for pat in patterns {
182        bash.insert(pat.clone(), Value::String("allow".to_string()));
183    }
184
185    let permission = root
186        .entry("permission")
187        .or_insert_with(|| Value::Object(Map::new()));
188    if !permission.is_object() {
189        *permission = Value::Object(Map::new());
190    }
191    if let Value::Object(perm_map) = permission {
192        perm_map.insert("bash".to_string(), Value::Object(bash));
193    }
194
195    let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
196    out.push('\n');
197    out
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn all_commands_have_url() {
206        for doc in all_command_docs() {
207            assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
208            assert!(
209                doc.url.starts_with("https://"),
210                "{} URL must use https: {}",
211                doc.name,
212                doc.url
213            );
214        }
215    }
216
217    #[test]
218    fn builder_two_sections() {
219        let ws = WordSet::new(&["--version", "list", "show"]);
220        assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
221    }
222
223    #[test]
224    fn builder_subcommands_only() {
225        let ws = WordSet::new(&["list", "show"]);
226        assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
227    }
228
229    #[test]
230    fn builder_flags_only() {
231        let ws = WordSet::new(&["--check", "--version"]);
232        assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
233    }
234
235    #[test]
236    fn builder_three_sections() {
237        let ws = WordSet::new(&["--version", "list", "show"]);
238        assert_eq!(
239            doc(&ws).section("Guarded: foo (bar only).").build(),
240            "- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
241        );
242    }
243
244    #[test]
245    fn builder_multi_word_merged() {
246        let ws = WordSet::new(&["--version", "info", "show"]);
247        let multi: &[(&str, WordSet)] =
248            &[("config", WordSet::new(&["get", "list"]))];
249        assert_eq!(
250            doc_multi(&ws, multi).build(),
251            "- Subcommands: config get, config list, info, show\n- Flags: --version"
252        );
253    }
254
255    #[test]
256    fn builder_multi_word_with_extra_section() {
257        let ws = WordSet::new(&["--version", "show"]);
258        let multi: &[(&str, WordSet)] =
259            &[("config", WordSet::new(&["get", "list"]))];
260        assert_eq!(
261            doc_multi(&ws, multi).section("Guarded: foo.").build(),
262            "- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
263        );
264    }
265
266    #[test]
267    fn builder_no_flags_with_extra() {
268        let ws = WordSet::new(&["list", "show"]);
269        assert_eq!(
270            doc(&ws).section("Also: foo.").build(),
271            "- Subcommands: list, show\n- Also: foo."
272        );
273    }
274
275    #[test]
276    fn builder_custom_sections_only() {
277        assert_eq!(
278            DocBuilder::new()
279                .section("Read-only: foo.")
280                .section("Always safe: bar.")
281                .section("Guarded: baz.")
282                .build(),
283            "- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
284        );
285    }
286
287    #[test]
288    fn builder_triple_word() {
289        let ws = WordSet::new(&["--version", "diff"]);
290        let triples: &[(&str, &str, WordSet)] =
291            &[("git", "remote", WordSet::new(&["list"]))];
292        assert_eq!(
293            doc(&ws).triple_word(triples).build(),
294            "- Subcommands: diff, git remote list\n- Flags: --version"
295        );
296    }
297
298    #[test]
299    fn builder_subcommand_method() {
300        let ws = WordSet::new(&["--version", "list"]);
301        assert_eq!(
302            doc(&ws).subcommand("plugin-list").build(),
303            "- Subcommands: list, plugin-list\n- Flags: --version"
304        );
305    }
306
307    #[test]
308    fn render_opencode_json_valid() {
309        let patterns = vec!["grep".to_string(), "grep *".to_string(), "ls".to_string()];
310        let json = render_opencode_json(&patterns);
311        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
312        let bash = &parsed["permission"]["bash"];
313        assert_eq!(bash["*"], "ask");
314        assert_eq!(bash["grep"], "allow");
315        assert_eq!(bash["grep *"], "allow");
316        assert_eq!(bash["ls"], "allow");
317        assert!(bash["rm"].is_null());
318    }
319
320    #[test]
321    fn render_opencode_json_has_schema() {
322        let json = render_opencode_json(&[]);
323        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
324        assert_eq!(parsed["$schema"], "https://opencode.ai/config.json");
325    }
326
327    #[test]
328    fn render_opencode_json_trailing_newline() {
329        let json = render_opencode_json(&[]);
330        assert!(json.ends_with('\n'));
331    }
332
333    #[test]
334    fn render_opencode_json_merges_existing() {
335        use std::fs;
336        let dir = tempfile::tempdir().expect("tmpdir");
337        let config_path = dir.path().join("opencode.json");
338        fs::write(
339            &config_path,
340            r#"{"$schema":"https://opencode.ai/config.json","model":"claude-sonnet-4-6","permission":{"bash":{"rm *":"deny"}}}"#,
341        )
342        .expect("write");
343
344        let prev = std::env::current_dir().expect("cwd");
345        std::env::set_current_dir(dir.path()).expect("cd");
346        let json = render_opencode_json(&["ls".to_string()]);
347        std::env::set_current_dir(prev).expect("cd back");
348
349        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
350        assert_eq!(parsed["model"], "claude-sonnet-4-6", "existing keys preserved");
351        assert_eq!(parsed["permission"]["bash"]["*"], "ask");
352        assert_eq!(parsed["permission"]["bash"]["ls"], "allow");
353        assert!(
354            parsed["permission"]["bash"]["rm *"].is_null(),
355            "old bash rules replaced, not merged"
356        );
357    }
358
359}