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