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}