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