Skip to main content

safe_chains/handlers/
mod.rs

1pub mod ai;
2pub mod android;
3pub mod containers;
4pub mod coreutils;
5pub mod dotnet;
6pub mod forges;
7pub mod fuzzy;
8pub mod go;
9pub mod jvm;
10pub mod magick;
11pub mod network;
12pub mod node;
13pub mod perl;
14pub mod php;
15pub mod python;
16pub mod r;
17pub mod ruby;
18pub mod rust;
19pub mod shell;
20pub mod swift;
21pub mod system;
22pub mod vcs;
23pub mod wrappers;
24pub mod xcode;
25
26use crate::parse::Token;
27use crate::verdict::Verdict;
28
29pub fn dispatch(tokens: &[Token]) -> Verdict {
30    let cmd = tokens[0].command_name();
31    None
32        .or_else(|| shell::dispatch(cmd, tokens))
33        .or_else(|| wrappers::dispatch(cmd, tokens))
34        .or_else(|| vcs::dispatch(cmd, tokens))
35        .or_else(|| forges::dispatch(cmd, tokens))
36        .or_else(|| node::dispatch(cmd, tokens))
37        .or_else(|| ruby::dispatch(cmd, tokens))
38        .or_else(|| python::dispatch(cmd, tokens))
39        .or_else(|| rust::dispatch(cmd, tokens))
40        .or_else(|| go::dispatch(cmd, tokens))
41        .or_else(|| jvm::dispatch(cmd, tokens))
42        .or_else(|| android::dispatch(cmd, tokens))
43        .or_else(|| php::dispatch(cmd, tokens))
44        .or_else(|| swift::dispatch(cmd, tokens))
45        .or_else(|| dotnet::dispatch(cmd, tokens))
46        .or_else(|| containers::dispatch(cmd, tokens))
47        .or_else(|| network::dispatch(cmd, tokens))
48        .or_else(|| ai::dispatch(cmd, tokens))
49        .or_else(|| system::dispatch(cmd, tokens))
50        .or_else(|| xcode::dispatch(cmd, tokens))
51        .or_else(|| perl::dispatch(cmd, tokens))
52        .or_else(|| r::dispatch(cmd, tokens))
53        .or_else(|| coreutils::dispatch(cmd, tokens))
54        .or_else(|| fuzzy::dispatch(cmd, tokens))
55        .or_else(|| magick::dispatch(cmd, tokens))
56        .or_else(|| crate::registry::toml_dispatch(tokens))
57        .unwrap_or(Verdict::Denied)
58}
59
60#[cfg(test)]
61const HANDLED_CMDS: &[&str] = &[
62    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
63    "git", "jj", "gh", "glab", "jjpr", "tea",
64    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta",
65    "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
66    "pip", "uv", "poetry", "pyenv", "conda",
67    "cargo", "rustup",
68    "go",
69    "gradle", "mvn", "mvnw", "ktlint", "detekt",
70    "javap", "jar", "keytool", "jarsigner",
71    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
72    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
73    "fastlane", "firebase",
74    "composer", "craft",
75    "swift",
76    "dotnet",
77    "curl",
78    "docker", "podman", "kubectl", "orbctl", "qemu-img",
79    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
80    "ddev", "dcli",
81    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
82    "terraform", "heroku", "vercel", "flyctl",
83    "overmind", "tailscale", "tmux", "wg",
84    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
85    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
86    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
87    "perl",
88    "R", "Rscript",
89    "grep", "rg", "ag", "ack", "zgrep", "locate",
90    "cat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
91    "diff", "comm", "paste", "tac", "rev", "nl",
92    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
93    "echo", "printf", "seq", "test", "expr", "bc", "factor", "bat",
94    "arch", "command", "hostname",
95    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
96    "magick",
97    "fd", "eza", "ls", "delta", "colordiff",
98    "dirname", "basename", "realpath", "readlink",
99    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
100    "true", "false",
101    "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
102    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
103    "who", "w", "last", "lastlog",
104    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
105    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
106    "base64", "xxd", "getconf", "uuidgen",
107    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
108    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
109    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
110    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
111    "xv",
112    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
113    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
114];
115
116pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
117    let mut docs = Vec::new();
118    docs.extend(vcs::command_docs());
119    docs.extend(forges::command_docs());
120    docs.extend(node::command_docs());
121    docs.extend(ruby::command_docs());
122    docs.extend(python::command_docs());
123    docs.extend(rust::command_docs());
124    docs.extend(go::command_docs());
125    docs.extend(jvm::command_docs());
126    docs.extend(android::command_docs());
127    docs.extend(php::command_docs());
128    docs.extend(swift::command_docs());
129    docs.extend(dotnet::command_docs());
130    docs.extend(containers::command_docs());
131    docs.extend(ai::command_docs());
132    docs.extend(network::command_docs());
133    docs.extend(system::command_docs());
134    docs.extend(xcode::command_docs());
135    docs.extend(perl::command_docs());
136    docs.extend(r::command_docs());
137    docs.extend(coreutils::command_docs());
138    docs.extend(fuzzy::command_docs());
139    docs.extend(shell::command_docs());
140    docs.extend(wrappers::command_docs());
141    docs.extend(magick::command_docs());
142    docs.extend(crate::registry::toml_command_docs());
143    docs
144}
145
146#[cfg(test)]
147#[derive(Debug)]
148pub(crate) enum CommandEntry {
149    Positional { cmd: &'static str },
150    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
151    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
152    Delegation { cmd: &'static str },
153}
154
155#[cfg(test)]
156#[derive(Debug)]
157pub(crate) enum SubEntry {
158    Policy { name: &'static str },
159    Nested { name: &'static str, subs: &'static [SubEntry] },
160    Custom { name: &'static str, valid_suffix: Option<&'static str> },
161    Positional,
162    Guarded { name: &'static str, valid_suffix: &'static str },
163}
164
165use crate::command::CommandDef;
166
167const COMMAND_DEFS: &[&CommandDef] = &[
168    &ai::CODEX, &ai::OLLAMA, &ai::OPENCODE, &ai::LLM, &ai::HF,
169    &containers::DOCKER, &containers::PODMAN, &containers::KUBECTL, &containers::ORBCTL, &containers::QEMU_IMG,
170    &dotnet::DOTNET,
171    &go::GO,
172    &android::APKANALYZER, &android::APKSIGNER, &android::BUNDLETOOL, &android::AAPT2,
173    &android::AVDMANAGER,
174    &jvm::GRADLE, &jvm::KEYTOOL,
175    &magick::MAGICK,
176    &node::NPM, &node::PNPM, &node::BUN, &node::DENO,
177    &node::NVM, &node::FNM, &node::VOLTA,
178    &php::COMPOSER, &php::CRAFT,
179    &python::PIP, &python::UV, &python::POETRY,
180    &python::PYENV, &python::CONDA,
181    &ruby::BUNDLE, &ruby::GEM, &ruby::IMPORTMAP, &ruby::RBENV,
182    &rust::CARGO, &rust::RUSTUP,
183    &vcs::GIT,
184    &swift::SWIFT,
185    &system::BREW, &system::MISE, &system::ASDF, &system::DDEV, &system::DCLI, &system::CMAKE,
186    &system::DEFAULTS, &system::TERRAFORM, &system::HEROKU, &system::VERCEL,
187    &system::FLYCTL, &system::FASTLANE, &system::FIREBASE,
188    &system::OVERMIND, &system::TAILSCALE, &system::WG,
189    &system::SECURITY, &system::CSRUTIL, &system::DISKUTIL,
190    &system::LAUNCHCTL, &system::LOG,
191    &xcode::XCODEBUILD, &xcode::PLUTIL, &xcode::XCODE_SELECT,
192    &xcode::XCODEGEN, &xcode::TUIST, &xcode::POD, &xcode::SWIFTLINT,
193    &xcode::PERIPHERY, &xcode::AGVTOOL, &xcode::SIMCTL,
194];
195
196pub fn all_opencode_patterns() -> Vec<String> {
197    let mut patterns = Vec::new();
198    for def in COMMAND_DEFS {
199        patterns.extend(def.opencode_patterns());
200    }
201    for def in coreutils::all_flat_defs() {
202        patterns.extend(def.opencode_patterns());
203    }
204    for def in jvm::jvm_flat_defs() {
205        patterns.extend(def.opencode_patterns());
206    }
207    for def in android::android_flat_defs() {
208        patterns.extend(def.opencode_patterns());
209    }
210    for def in ai::ai_flat_defs() {
211        patterns.extend(def.opencode_patterns());
212    }
213    for def in ruby::ruby_flat_defs() {
214        patterns.extend(def.opencode_patterns());
215    }
216    for def in system::system_flat_defs() {
217        patterns.extend(def.opencode_patterns());
218    }
219    for def in xcode::xcbeautify_flat_defs() {
220        patterns.extend(def.opencode_patterns());
221    }
222    for def in fuzzy::fuzzy_flat_defs() {
223        patterns.extend(def.opencode_patterns());
224    }
225    patterns.sort();
226    patterns.dedup();
227    patterns
228}
229
230#[cfg(test)]
231fn full_registry() -> Vec<&'static CommandEntry> {
232    let mut entries = Vec::new();
233    entries.extend(shell::REGISTRY);
234    entries.extend(wrappers::REGISTRY);
235    entries.extend(vcs::full_registry());
236    entries.extend(forges::full_registry());
237    entries.extend(node::full_registry());
238    entries.extend(jvm::full_registry());
239    entries.extend(android::full_registry());
240    entries.extend(network::REGISTRY);
241    entries.extend(system::full_registry());
242    entries.extend(xcode::full_registry());
243    entries.extend(perl::REGISTRY);
244    entries.extend(r::REGISTRY);
245    entries.extend(coreutils::full_registry());
246    entries.extend(fuzzy::full_registry());
247    entries
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::collections::HashSet;
254
255    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
256    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
257
258    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
259        match entry {
260            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
261            CommandEntry::Custom { cmd, valid_prefix } => {
262                let base = valid_prefix.unwrap_or(cmd);
263                let test = format!("{base} {UNKNOWN_FLAG}");
264                if crate::is_safe_command(&test) {
265                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
266                }
267            }
268            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
269                if !bare_ok && crate::is_safe_command(cmd) {
270                    failures.push(format!("{cmd}: accepted bare invocation"));
271                }
272                let test = format!("{cmd} {UNKNOWN_SUB}");
273                if crate::is_safe_command(&test) {
274                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
275                }
276                for sub in *subs {
277                    check_sub(cmd, sub, failures);
278                }
279            }
280        }
281    }
282
283    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
284        match entry {
285            SubEntry::Policy { name } => {
286                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
287                if crate::is_safe_command(&test) {
288                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
289                }
290            }
291            SubEntry::Nested { name, subs } => {
292                let path = format!("{prefix} {name}");
293                let test = format!("{path} {UNKNOWN_SUB}");
294                if crate::is_safe_command(&test) {
295                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
296                }
297                for sub in *subs {
298                    check_sub(&path, sub, failures);
299                }
300            }
301            SubEntry::Custom { name, valid_suffix } => {
302                let base = match valid_suffix {
303                    Some(s) => format!("{prefix} {name} {s}"),
304                    None => format!("{prefix} {name}"),
305                };
306                let test = format!("{base} {UNKNOWN_FLAG}");
307                if crate::is_safe_command(&test) {
308                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
309                }
310            }
311            SubEntry::Positional => {}
312            SubEntry::Guarded { name, valid_suffix } => {
313                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
314                if crate::is_safe_command(&test) {
315                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
316                }
317            }
318        }
319    }
320
321    #[test]
322    fn all_commands_reject_unknown() {
323        let registry = full_registry();
324        let mut failures = Vec::new();
325        for entry in &registry {
326            check_entry(entry, &mut failures);
327        }
328        assert!(
329            failures.is_empty(),
330            "unknown flags/subcommands accepted:\n{}",
331            failures.join("\n")
332        );
333    }
334
335    #[test]
336    fn command_defs_reject_unknown() {
337        for def in COMMAND_DEFS {
338            def.auto_test_reject_unknown();
339        }
340    }
341
342    #[test]
343    fn flat_defs_reject_unknown() {
344        for def in coreutils::all_flat_defs() {
345            def.auto_test_reject_unknown();
346        }
347        for def in xcode::xcbeautify_flat_defs() {
348            def.auto_test_reject_unknown();
349        }
350        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
351            def.auto_test_reject_unknown();
352        }
353    }
354
355
356    #[test]
357    fn bare_false_rejects_bare_invocation() {
358        let check_def = |def: &crate::command::FlatDef| {
359            if !def.policy.bare {
360                assert!(
361                    !crate::is_safe_command(def.name),
362                    "{}: bare=false but bare invocation accepted",
363                    def.name,
364                );
365            }
366        };
367        for def in coreutils::all_flat_defs()
368            .into_iter()
369            .chain(xcode::xcbeautify_flat_defs())
370        {
371            check_def(def);
372        }
373        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
374            check_def(def);
375        }
376    }
377
378    fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
379        for sub in subs {
380            visitor(prefix, sub);
381            if let crate::command::SubDef::Nested { name, subs: inner } = sub {
382                visit_subs(&format!("{prefix} {name}"), inner, visitor);
383            }
384        }
385    }
386
387    #[test]
388    fn guarded_subs_require_guard() {
389        let mut failures = Vec::new();
390        for def in COMMAND_DEFS {
391            visit_subs(def.name, def.subs, &mut |prefix, sub| {
392                if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
393                    let without = format!("{prefix} {name}");
394                    if crate::is_safe_command(&without) {
395                        failures.push(format!("{without}: accepted without guard {guard_long}"));
396                    }
397                    let with = format!("{prefix} {name} {guard_long}");
398                    if !crate::is_safe_command(&with) {
399                        failures.push(format!("{with}: rejected with guard {guard_long}"));
400                    }
401                }
402            });
403        }
404        assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
405    }
406
407    #[test]
408    fn guarded_subs_accept_guard_short() {
409        let mut failures = Vec::new();
410        for def in COMMAND_DEFS {
411            visit_subs(def.name, def.subs, &mut |prefix, sub| {
412                if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
413                    let with_short = format!("{prefix} {name} {short}");
414                    if !crate::is_safe_command(&with_short) {
415                        failures.push(format!("{with_short}: rejected with guard_short"));
416                    }
417                }
418            });
419        }
420        assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
421    }
422
423    #[test]
424    fn nested_subs_reject_bare() {
425        let mut failures = Vec::new();
426        for def in COMMAND_DEFS {
427            visit_subs(def.name, def.subs, &mut |prefix, sub| {
428                if let crate::command::SubDef::Nested { name, .. } = sub {
429                    let bare = format!("{prefix} {name}");
430                    if crate::is_safe_command(&bare) {
431                        failures.push(format!("{bare}: nested sub accepted bare invocation"));
432                    }
433                }
434            });
435        }
436        assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
437    }
438
439    #[test]
440    fn process_substitution_blocked() {
441        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
442        for cmd in &cmds {
443            assert!(
444                !crate::is_safe_command(cmd),
445                "process substitution not blocked: {cmd}",
446            );
447        }
448    }
449
450    #[test]
451    fn positional_style_accepts_unknown_args() {
452        use crate::policy::FlagStyle;
453        for def in coreutils::all_flat_defs() {
454            if def.policy.flag_style == FlagStyle::Positional {
455                let test = format!("{} --unknown-xyz", def.name);
456                assert!(
457                    crate::is_safe_command(&test),
458                    "{}: FlagStyle::Positional but rejected unknown arg",
459                    def.name,
460                );
461            }
462        }
463    }
464
465    fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
466        for sub in subs {
467            match sub {
468                crate::command::SubDef::Policy { name, policy, .. } => {
469                    visitor(&format!("{prefix} {name}"), policy);
470                }
471                crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
472                    visitor(&format!("{prefix} {name} {guard_long}"), policy);
473                }
474                crate::command::SubDef::Nested { name, subs: inner } => {
475                    visit_policies(&format!("{prefix} {name}"), inner, visitor);
476                }
477                _ => {}
478            }
479        }
480    }
481
482    #[test]
483    fn valued_flags_accept_eq_syntax() {
484        let mut failures = Vec::new();
485
486        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
487            for flag in def.policy.valued.iter() {
488                let cmd = format!("{} {flag}=test_value", def.name);
489                if !crate::is_safe_command(&cmd) {
490                    failures.push(format!("{cmd}: valued flag rejected with = syntax"));
491                }
492            }
493        };
494        for def in coreutils::all_flat_defs()
495            .into_iter()
496            .chain(xcode::xcbeautify_flat_defs())
497        {
498            check_flat(def, &mut failures);
499        }
500        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
501            check_flat(def, &mut failures);
502        }
503
504        for def in COMMAND_DEFS {
505            visit_policies(def.name, def.subs, &mut |prefix, policy| {
506                for flag in policy.valued.iter() {
507                    let cmd = format!("{prefix} {flag}=test_value");
508                    if !crate::is_safe_command(&cmd) {
509                        failures.push(format!("{cmd}: valued flag rejected with = syntax"));
510                    }
511                }
512            });
513        }
514
515        assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
516    }
517
518    #[test]
519    fn max_positional_enforced() {
520        let mut failures = Vec::new();
521
522        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
523            if let Some(max) = def.policy.max_positional {
524                let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
525                let cmd = format!("{} {}", def.name, args.join(" "));
526                if crate::is_safe_command(&cmd) {
527                    failures.push(format!(
528                        "{}: max_positional={max} but accepted {} positional args",
529                        def.name,
530                        max + 1,
531                    ));
532                }
533            }
534        };
535        for def in coreutils::all_flat_defs()
536            .into_iter()
537            .chain(xcode::xcbeautify_flat_defs())
538        {
539            check_flat(def, &mut failures);
540        }
541        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
542            check_flat(def, &mut failures);
543        }
544
545        for def in COMMAND_DEFS {
546            visit_policies(def.name, def.subs, &mut |prefix, policy| {
547                if let Some(max) = policy.max_positional {
548                    let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
549                    let cmd = format!("{prefix} {}", args.join(" "));
550                    if crate::is_safe_command(&cmd) {
551                        failures.push(format!(
552                            "{prefix}: max_positional={max} but accepted {} positional args",
553                            max + 1,
554                        ));
555                    }
556                }
557            });
558        }
559
560        assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
561    }
562
563    #[test]
564    fn doc_generation_non_empty() {
565        let mut failures = Vec::new();
566
567        for def in COMMAND_DEFS {
568            let doc = def.to_doc();
569            if doc.description.trim().is_empty() {
570                failures.push(format!("{}: CommandDef produced empty doc", def.name));
571            }
572            if doc.url.is_empty() {
573                failures.push(format!("{}: CommandDef has empty URL", def.name));
574            }
575        }
576
577        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
578            let doc = def.to_doc();
579            if doc.description.trim().is_empty() && !def.policy.bare {
580                failures.push(format!("{}: FlatDef produced empty doc", def.name));
581            }
582            if doc.url.is_empty() {
583                failures.push(format!("{}: FlatDef has empty URL", def.name));
584            }
585        };
586        for def in coreutils::all_flat_defs()
587            .into_iter()
588            .chain(xcode::xcbeautify_flat_defs())
589        {
590            check_flat(def, &mut failures);
591        }
592        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
593            check_flat(def, &mut failures);
594        }
595
596        assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
597    }
598
599    #[test]
600    fn registry_covers_handled_commands() {
601        let registry = full_registry();
602        let mut all_cmds: HashSet<&str> = registry
603            .iter()
604            .map(|e| match e {
605                CommandEntry::Positional { cmd }
606                | CommandEntry::Custom { cmd, .. }
607                | CommandEntry::Subcommand { cmd, .. }
608                | CommandEntry::Delegation { cmd } => *cmd,
609            })
610            .collect();
611        for def in COMMAND_DEFS {
612            all_cmds.insert(def.name);
613        }
614        for def in coreutils::all_flat_defs() {
615            all_cmds.insert(def.name);
616        }
617        for def in xcode::xcbeautify_flat_defs() {
618            all_cmds.insert(def.name);
619        }
620        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
621            all_cmds.insert(def.name);
622        }
623        for name in crate::registry::toml_command_names() {
624            all_cmds.insert(name);
625        }
626        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
627
628        let missing: Vec<_> = handled.difference(&all_cmds).collect();
629        assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
630
631        let extra: Vec<_> = all_cmds.difference(&handled).collect();
632        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
633    }
634
635}