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 version = env!("CARGO_PKG_VERSION");
243 fs::write(
244 output_dir.join("src").join("version-footer.js"),
245 format!(
246 "document.addEventListener('DOMContentLoaded', function() {{\n\
247 \x20 var nav = document.querySelector('.nav-wide-wrapper') || document.querySelector('.nav-wrapper');\n\
248 \x20 if (nav) {{\n\
249 \x20 var footer = document.createElement('div');\n\
250 \x20 footer.className = 'version-footer';\n\
251 \x20 footer.textContent = 'safe-chains v{version} · {total} commands';\n\
252 \x20 nav.parentNode.insertBefore(footer, nav.nextSibling);\n\
253 \x20 }}\n\
254 }});\n"
255 ),
256 )
257 .expect("failed to write version-footer.js");
258
259 let mut readme = format!(
260 "# Command Reference\n\n\
261 safe-chains knows {total} commands across {} categories.\n\n\
262 ## Glossary\n\n{GLOSSARY}\n",
263 by_category.len(),
264 );
265 for (slug, cmds) in &by_category {
266 let name = category_display_name(slug);
267 readme.push_str(&format!(
268 "- [{}]({}.md) ({} commands)\n",
269 name, slug, cmds.len(),
270 ));
271 }
272 readme.push('\n');
273 fs::write(commands_dir.join("README.md"), &readme)
274 .expect("failed to write commands/README.md");
275
276 for (slug, cmds) in &by_category {
277 let name = category_display_name(slug);
278 let mut page = format!("# {name}\n\n");
279 for doc in cmds {
280 page.push_str(&render_command_entry(doc));
281 }
282 fs::write(commands_dir.join(format!("{slug}.md")), &page)
283 .expect("failed to write category page");
284 }
285
286 eprintln!("Generated {} category pages:", by_category.len());
287 for slug in by_category.keys() {
288 eprintln!(" - [{}](commands/{}.md)", category_display_name(slug), slug);
289 }
290}
291
292pub fn render_opencode_json(patterns: &[String]) -> String {
293 use serde_json::{Map, Value};
294 use std::fs;
295
296 let mut root: Map<String, Value> = fs::read_to_string("opencode.json")
297 .ok()
298 .and_then(|s| serde_json::from_str(&s).ok())
299 .and_then(|v: Value| v.as_object().cloned())
300 .unwrap_or_else(|| {
301 let mut m = Map::new();
302 m.insert(
303 "$schema".to_string(),
304 Value::String("https://opencode.ai/config.json".to_string()),
305 );
306 m
307 });
308
309 let mut bash = Map::new();
310 bash.insert("*".to_string(), Value::String("ask".to_string()));
311 for pat in patterns {
312 bash.insert(pat.clone(), Value::String("allow".to_string()));
313 }
314
315 let permission = root
316 .entry("permission")
317 .or_insert_with(|| Value::Object(Map::new()));
318 if !permission.is_object() {
319 *permission = Value::Object(Map::new());
320 }
321 if let Value::Object(perm_map) = permission {
322 perm_map.insert("bash".to_string(), Value::Object(bash));
323 }
324
325 let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
326 out.push('\n');
327 out
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn all_commands_have_url() {
336 for doc in all_command_docs() {
337 assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
338 assert!(
339 doc.url.starts_with("https://"),
340 "{} URL must use https: {}",
341 doc.name,
342 doc.url
343 );
344 }
345 }
346
347 #[test]
348 fn all_commands_have_valid_category() {
349 for doc in all_command_docs() {
350 assert!(!doc.category.is_empty(), "{} has no category", doc.name);
351 category_display_name(&doc.category);
352 }
353 }
354
355 #[test]
356 fn builder_two_sections() {
357 let ws = WordSet::new(&["--version", "list", "show"]);
358 assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
359 }
360
361 #[test]
362 fn builder_subcommands_only() {
363 let ws = WordSet::new(&["list", "show"]);
364 assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
365 }
366
367 #[test]
368 fn builder_flags_only() {
369 let ws = WordSet::new(&["--check", "--version"]);
370 assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
371 }
372
373 #[test]
374 fn builder_three_sections() {
375 let ws = WordSet::new(&["--version", "list", "show"]);
376 assert_eq!(
377 doc(&ws).section("Guarded: foo (bar only).").build(),
378 "- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
379 );
380 }
381
382 #[test]
383 fn builder_multi_word_merged() {
384 let ws = WordSet::new(&["--version", "info", "show"]);
385 let multi: &[(&str, WordSet)] =
386 &[("config", WordSet::new(&["get", "list"]))];
387 assert_eq!(
388 doc_multi(&ws, multi).build(),
389 "- Subcommands: config get, config list, info, show\n- Flags: --version"
390 );
391 }
392
393 #[test]
394 fn builder_multi_word_with_extra_section() {
395 let ws = WordSet::new(&["--version", "show"]);
396 let multi: &[(&str, WordSet)] =
397 &[("config", WordSet::new(&["get", "list"]))];
398 assert_eq!(
399 doc_multi(&ws, multi).section("Guarded: foo.").build(),
400 "- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
401 );
402 }
403
404 #[test]
405 fn builder_no_flags_with_extra() {
406 let ws = WordSet::new(&["list", "show"]);
407 assert_eq!(
408 doc(&ws).section("Also: foo.").build(),
409 "- Subcommands: list, show\n- Also: foo."
410 );
411 }
412
413 #[test]
414 fn builder_custom_sections_only() {
415 assert_eq!(
416 DocBuilder::new()
417 .section("Read-only: foo.")
418 .section("Always safe: bar.")
419 .section("Guarded: baz.")
420 .build(),
421 "- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
422 );
423 }
424
425 #[test]
426 fn builder_triple_word() {
427 let ws = WordSet::new(&["--version", "diff"]);
428 let triples: &[(&str, &str, WordSet)] =
429 &[("git", "remote", WordSet::new(&["list"]))];
430 assert_eq!(
431 doc(&ws).triple_word(triples).build(),
432 "- Subcommands: diff, git remote list\n- Flags: --version"
433 );
434 }
435
436 #[test]
437 fn builder_subcommand_method() {
438 let ws = WordSet::new(&["--version", "list"]);
439 assert_eq!(
440 doc(&ws).subcommand("plugin-list").build(),
441 "- Subcommands: list, plugin-list\n- Flags: --version"
442 );
443 }
444
445 #[test]
446 fn render_opencode_json_valid() {
447 let patterns = vec!["grep".to_string(), "grep *".to_string(), "ls".to_string()];
448 let json = render_opencode_json(&patterns);
449 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
450 let bash = &parsed["permission"]["bash"];
451 assert_eq!(bash["*"], "ask");
452 assert_eq!(bash["grep"], "allow");
453 assert_eq!(bash["grep *"], "allow");
454 assert_eq!(bash["ls"], "allow");
455 assert!(bash["rm"].is_null());
456 }
457
458 #[test]
459 fn render_opencode_json_has_schema() {
460 let json = render_opencode_json(&[]);
461 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
462 assert_eq!(parsed["$schema"], "https://opencode.ai/config.json");
463 }
464
465 #[test]
466 fn render_opencode_json_trailing_newline() {
467 let json = render_opencode_json(&[]);
468 assert!(json.ends_with('\n'));
469 }
470
471 #[test]
472 fn render_opencode_json_merges_existing() {
473 use std::fs;
474 let dir = tempfile::tempdir().expect("tmpdir");
475 let config_path = dir.path().join("opencode.json");
476 fs::write(
477 &config_path,
478 r#"{"$schema":"https://opencode.ai/config.json","model":"claude-sonnet-4-6","permission":{"bash":{"rm *":"deny"}}}"#,
479 )
480 .expect("write");
481
482 let prev = std::env::current_dir().expect("cwd");
483 std::env::set_current_dir(dir.path()).expect("cd");
484 let json = render_opencode_json(&["ls".to_string()]);
485 std::env::set_current_dir(prev).expect("cd back");
486
487 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
488 assert_eq!(parsed["model"], "claude-sonnet-4-6", "existing keys preserved");
489 assert_eq!(parsed["permission"]["bash"]["*"], "ask");
490 assert_eq!(parsed["permission"]["bash"]["ls"], "allow");
491 assert!(
492 parsed["permission"]["bash"]["rm *"].is_null(),
493 "old bash rules replaced, not merged"
494 );
495 }
496
497}