Skip to main content

siderite_cli/
commands.rs

1//! The command contract: one table describing every `siderite` command.
2//!
3//! Dispatch, help text and `siderite commands --json` all read
4//! [`COMMANDS`], so what a command needs (a package, the compiled app),
5//! what it may change (project files, the database, build output),
6//! whether it touches live services and which output formats it offers
7//! are declared once. The JSON list is versioned for AI clients.
8
9use serde::Serialize;
10
11/// Version of the `commands` report, bumped for breaking changes.
12pub const COMMANDS_VERSION: u32 = 1;
13
14/// Where a command runs.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case", tag = "kind")]
17pub enum Runner {
18    /// In the `siderite` binary itself.
19    Tool,
20    /// In the application binary (`AppCli`), through `cargo run`.
21    App,
22    /// In the application binary when there is a package, else in the
23    /// standalone binary on JSON migration files.
24    AppOrStandalone,
25    /// As a Cargo subcommand in the package; arguments after the command
26    /// are forwarded unchanged.
27    Cargo {
28        /// The Cargo subcommand.
29        cargo: &'static str,
30        /// Whether `--bin` applies.
31        takes_bin: bool,
32        /// The rustup component providing the subcommand, if any.
33        component: Option<&'static str>,
34    },
35}
36
37/// What a command needs to find.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Needs {
41    /// Nothing; it works anywhere.
42    Nothing,
43    /// A Cargo package.
44    Package,
45    /// An application package (one that depends on `siderite`).
46    App,
47}
48
49/// What a command may change.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Mutation {
53    /// Files in the project (never overwriting user content).
54    ProjectFiles,
55    /// A file named by an `--output` flag.
56    OutputFile,
57    /// Database schema or data.
58    Database,
59    /// Cargo's build output.
60    BuildArtifacts,
61    /// Runs the application or its tests, which may do anything.
62    RunsCode,
63    /// Installs Rust toolchains or components (only when asked).
64    Toolchain,
65    /// Containers and volumes of the local container runtime.
66    Containers,
67}
68
69/// Whether a command connects to live services.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Live {
73    /// Never.
74    Never,
75    /// Only when a flag asks for it (`--live`).
76    OptIn,
77    /// Always.
78    Always,
79}
80
81/// An output format a command offers.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Format {
85    /// Human-readable text.
86    Text,
87    /// A versioned JSON envelope (`--json`).
88    Json,
89    /// Markdown.
90    Markdown,
91    /// A raw document (the OpenAPI JSON).
92    Document,
93    /// Model Context Protocol messages on stdin and stdout.
94    Mcp,
95}
96
97/// Help section of a command.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Group {
101    /// Create, run and inspect an app.
102    App,
103    /// Schema migrations.
104    Migrations,
105    /// Context for AI tools.
106    Ai,
107    /// Cargo passthroughs.
108    Cargo,
109    /// The tool itself.
110    Tool,
111}
112
113impl Group {
114    const fn title(self) -> &'static str {
115        match self {
116            Self::App => "Create, run and inspect an app",
117            Self::Migrations => "Migrations",
118            Self::Ai => "AI tools",
119            Self::Cargo => "Cargo (arguments are forwarded unchanged)",
120            Self::Tool => "This tool",
121        }
122    }
123}
124
125/// One command.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127pub struct Command {
128    /// The name, with its subcommand (`openapi export`).
129    pub name: &'static str,
130    /// Help section.
131    pub group: Group,
132    /// Usage after `siderite`.
133    pub usage: &'static str,
134    /// One-line description.
135    pub summary: &'static str,
136    /// Where it runs.
137    pub runner: Runner,
138    /// What it needs.
139    pub needs: Needs,
140    /// What it may change; empty for read-only commands.
141    pub mutates: &'static [Mutation],
142    /// Live service access.
143    pub live: Live,
144    /// Output formats.
145    pub formats: &'static [Format],
146}
147
148impl Command {
149    /// The first word of the name (what dispatch matches).
150    pub fn word(&self) -> &'static str {
151        self.name.split(' ').next().unwrap_or(self.name)
152    }
153
154    /// Whether `--json` applies.
155    pub fn json(&self) -> bool {
156        self.formats.contains(&Format::Json)
157    }
158}
159
160type Text = &'static str;
161
162const TEXT: &[Format] = &[Format::Text];
163const TEXT_JSON: &[Format] = &[Format::Text, Format::Json];
164const NONE: &[Mutation] = &[];
165const CONTAINERS: Mutation = Mutation::Containers;
166const CONTAINER_FILES: &[Mutation] = &[CONTAINERS, Mutation::ProjectFiles];
167
168const fn cmd(
169    name: &'static str,
170    group: Group,
171    usage: &'static str,
172    summary: &'static str,
173) -> Command {
174    Command {
175        name,
176        group,
177        usage,
178        summary,
179        runner: Runner::App,
180        needs: Needs::App,
181        mutates: NONE,
182        live: Live::Never,
183        formats: TEXT,
184    }
185}
186
187const fn cargo(
188    name: &'static str,
189    usage: &'static str,
190    summary: &'static str,
191    sub: (&'static str, bool, Option<&'static str>),
192) -> Command {
193    let (cargo, takes_bin, component) = sub;
194    Command {
195        runner: Runner::Cargo {
196            cargo,
197            takes_bin,
198            component,
199        },
200        needs: Needs::Package,
201        mutates: &[Mutation::BuildArtifacts],
202        ..cmd(name, Group::Cargo, usage, summary)
203    }
204}
205
206const fn migration(name: Text, usage: Text, summary: Text) -> Command {
207    Command {
208        runner: Runner::AppOrStandalone,
209        needs: Needs::Nothing,
210        mutates: &[Mutation::Database],
211        live: Live::Always,
212        ..cmd(name, Group::Migrations, usage, summary)
213    }
214}
215
216/// Every command, in help order.
217pub const COMMANDS: &[Command] = &[
218    Command {
219        runner: Runner::Tool,
220        needs: Needs::Nothing,
221        mutates: &[Mutation::ProjectFiles],
222        ..cmd(
223            "new",
224            Group::App,
225            "new NAME [--database E] [--template T] [--path DIR] [--dry-run]",
226            "Write a new API crate",
227        )
228    },
229    Command {
230        runner: Runner::Tool,
231        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
232        ..cmd(
233            "dev",
234            Group::App,
235            "dev [--grace-ms N] [-- ARGS]",
236            "Rebuild and restart the app on changes",
237        )
238    },
239    Command {
240        runner: Runner::Tool,
241        needs: Needs::Package,
242        mutates: &[Mutation::ProjectFiles],
243        ..cmd(
244            "generate resource",
245            Group::App,
246            "generate resource NAME [FIELD:TYPE]... [--table T]",
247            "Write a model and its CRUD routes",
248        )
249    },
250    Command {
251        runner: Runner::Tool,
252        needs: Needs::Package,
253        mutates: &[Mutation::ProjectFiles],
254        ..cmd(
255            "generate test",
256            Group::App,
257            "generate test route|resource NAME ... [--dry-run]",
258            "Write a request/response test (tests/NAME.rs)",
259        )
260    },
261    Command {
262        runner: Runner::Tool,
263        needs: Needs::Package,
264        mutates: &[Mutation::ProjectFiles],
265        ..cmd(
266            "generate ci",
267            Group::App,
268            "generate ci [--update] [--dry-run]",
269            "Write a GitHub Actions verify workflow",
270        )
271    },
272    Command {
273        runner: Runner::Tool,
274        needs: Needs::Package,
275        mutates: &[Mutation::ProjectFiles],
276        ..cmd(
277            "generate model",
278            Group::App,
279            "generate model NAME [FIELD:TYPE]... [--table T] [--dry-run]",
280            "Write a model (src/models/NAME.rs)",
281        )
282    },
283    Command {
284        runner: Runner::Tool,
285        needs: Needs::Package,
286        mutates: &[Mutation::ProjectFiles],
287        ..cmd(
288            "generate route",
289            Group::App,
290            "generate route NAME [--path /URL] [--dry-run]",
291            "Write a route module (src/routes/NAME.rs)",
292        )
293    },
294    Command {
295        runner: Runner::Tool,
296        needs: Needs::Package,
297        mutates: &[Mutation::ProjectFiles],
298        ..cmd(
299            "add",
300            Group::App,
301            "add CRATE... | add --preset postgres|mysql [--dry-run]",
302            "cargo add for the app package, or a preset",
303        )
304    },
305    Command {
306        runner: Runner::Tool,
307        needs: Needs::Package,
308        mutates: &[Mutation::ProjectFiles],
309        ..cmd(
310            "remove",
311            Group::App,
312            "remove CRATE... [--dry-run]",
313            "cargo remove for the app package",
314        )
315    },
316    Command {
317        runner: Runner::Tool,
318        needs: Needs::Nothing,
319        mutates: &[Mutation::Toolchain],
320        formats: TEXT_JSON,
321        ..cmd(
322            "setup",
323            Group::App,
324            "setup [--json] [--install]",
325            "Check build prerequisites; say how to fix",
326        )
327    },
328    Command {
329        runner: Runner::Tool,
330        needs: Needs::Nothing,
331        live: Live::OptIn,
332        formats: TEXT_JSON,
333        ..cmd(
334            "doctor",
335            Group::App,
336            "doctor [--json] [--live [--database ALIAS]...]",
337            "Check the toolchain, project and configuration",
338        )
339    },
340    Command {
341        runner: Runner::Tool,
342        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
343        live: Live::OptIn,
344        formats: TEXT_JSON,
345        ..cmd(
346            "verify",
347            Group::App,
348            "verify [--json] [--live]",
349            "fmt, lint, build, test and check in one report",
350        )
351    },
352    Command {
353        mutates: &[Mutation::RunsCode],
354        live: Live::Always,
355        ..cmd("run", Group::App, "run [--addr ADDR]", "Serve the app")
356    },
357    Command {
358        formats: TEXT_JSON,
359        ..cmd(
360            "routes",
361            Group::App,
362            "routes [--json]",
363            "List the documented routes",
364        )
365    },
366    Command {
367        formats: TEXT_JSON,
368        ..cmd(
369            "check",
370            Group::App,
371            "check [--json]",
372            "Validate config, models, migrations and routes",
373        )
374    },
375    Command {
376        mutates: &[Mutation::OutputFile],
377        formats: &[Format::Document],
378        ..cmd(
379            "openapi export",
380            Group::App,
381            "openapi export [--output PATH] [--force]",
382            "Write the OpenAPI document (stdout or PATH)",
383        )
384    },
385    Command {
386        runner: Runner::Tool,
387        needs: Needs::Nothing,
388        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
389        live: Live::OptIn,
390        formats: &[Format::Mcp],
391        ..cmd(
392            "mcp",
393            Group::Ai,
394            "mcp [--app] [--live --database ALIAS]",
395            "Read-only MCP tools on stdio (`mcp` feature)",
396        )
397    },
398    Command {
399        runner: Runner::Tool,
400        needs: Needs::Package,
401        formats: TEXT_JSON,
402        ..cmd(
403            "upgrade",
404            Group::Tool,
405            "upgrade --to VERSION --dry-run [--json]",
406            "Preview a framework upgrade (writes nothing)",
407        )
408    },
409    Command {
410        runner: Runner::Tool,
411        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
412        formats: TEXT_JSON,
413        ..cmd(
414            "package",
415            Group::App,
416            "package --release [--output DIR] [--force] [--json]",
417            "Build a release directory with checksums",
418        )
419    },
420    Command {
421        runner: Runner::Tool,
422        needs: Needs::Nothing,
423        formats: TEXT_JSON,
424        ..cmd(
425            "openapi diff",
426            Group::App,
427            "openapi diff BASE CURRENT [--json]",
428            "Compare two OpenAPI documents for clients",
429        )
430    },
431    Command {
432        live: Live::Always,
433        ..cmd(
434            "dbshell",
435            Group::App,
436            "dbshell",
437            "Open the database's native client",
438        )
439    },
440    // Needs the compiled models, so only the app binary can run it.
441    Command {
442        runner: Runner::App,
443        needs: Needs::App,
444        mutates: &[Mutation::ProjectFiles],
445        live: Live::Never,
446        ..migration(
447            "makemigrations",
448            "makemigrations [--name SLUG] [--empty] [--dry-run]",
449            "Write a migration for model changes",
450        )
451    },
452    migration(
453        "migrate",
454        "migrate [TARGET] [--dry-run]",
455        "Apply migrations",
456    ),
457    migration(
458        "rollback",
459        "rollback [--steps N | TARGET] [--dry-run]",
460        "Unapply migrations",
461    ),
462    Command {
463        mutates: NONE,
464        formats: TEXT_JSON,
465        ..migration(
466            "showmigrations",
467            "showmigrations [--json]",
468            "List migrations and whether they are applied",
469        )
470    },
471    Command {
472        mutates: NONE,
473        ..migration(
474            "inspectmigrations",
475            "inspectmigrations",
476            "Read-only report of interrupted migrations",
477        )
478    },
479    Command {
480        mutates: &[Mutation::ProjectFiles],
481        live: Live::Never,
482        ..migration(
483            "squashmigrations",
484            "squashmigrations FROM TO [--name SLUG]",
485            "Collapse a range of migrations into one",
486        )
487    },
488    Command {
489        live: Live::OptIn,
490        formats: &[Format::Markdown, Format::Json],
491        ..cmd(
492            "ai dump-context",
493            Group::Ai,
494            "ai dump-context [--format markdown|json] [--live]",
495            "Application context for AI tools",
496        )
497    },
498    Command {
499        formats: &[Format::Markdown, Format::Json],
500        ..cmd(
501            "ai context",
502            Group::Ai,
503            "ai context --task TEXT [--budget N] [--include PATH]... \
504             [--json]",
505            "Context for one task, within a byte budget",
506        )
507    },
508    Command {
509        runner: Runner::Tool,
510        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
511        live: Live::OptIn,
512        formats: TEXT_JSON,
513        ..cmd(
514            "ai validate",
515            Group::Ai,
516            "ai validate [--json] [--live]",
517            "verify, as findings with fixes and docs",
518        )
519    },
520    Command {
521        mutates: &[Mutation::ProjectFiles],
522        ..cmd(
523            "ai init",
524            Group::Ai,
525            "ai init [--check] [--dry-run] [--force] [--link FILE]",
526            "Write or refresh .siderite/context.md",
527        )
528    },
529    cargo(
530        "build",
531        "build [ARGS]",
532        "cargo build in the app package",
533        ("build", true, None),
534    ),
535    Command {
536        mutates: &[Mutation::BuildArtifacts, Mutation::RunsCode],
537        ..cargo(
538            "test",
539            "test [ARGS]",
540            "cargo test in the app package",
541            ("test", true, None),
542        )
543    },
544    Command {
545        mutates: &[Mutation::ProjectFiles],
546        ..cargo(
547            "fmt",
548            "fmt [--check ...]",
549            "cargo fmt (rustfmt) in the app package",
550            ("fmt", false, Some("rustfmt")),
551        )
552    },
553    cargo(
554        "lint",
555        "lint [-- -D warnings ...]",
556        "cargo clippy (Rust lints, not `check`)",
557        ("clippy", true, Some("clippy")),
558    ),
559    cargo(
560        "clean",
561        "clean [ARGS]",
562        "cargo clean (only when you run it)",
563        ("clean", false, None),
564    ),
565    Command {
566        mutates: &[Mutation::Database, Mutation::RunsCode],
567        live: Live::Always,
568        formats: TEXT_JSON,
569        ..cmd(
570            "seed",
571            Group::Migrations,
572            "seed --dataset NAME | seed --list [--json]",
573            "Load a seed dataset the app registers",
574        )
575    },
576    Command {
577        runner: Runner::Tool,
578        needs: Needs::Nothing,
579        mutates: CONTAINER_FILES,
580        live: Live::Always,
581        formats: TEXT_JSON,
582        ..cmd(
583            "services up",
584            Group::App,
585            "services up [postgres] [--port N] [--image I] [--json]",
586            "Start local PostgreSQL in a container",
587        )
588    },
589    Command {
590        runner: Runner::Tool,
591        needs: Needs::Nothing,
592        mutates: &[CONTAINERS],
593        live: Live::Always,
594        formats: TEXT_JSON,
595        ..cmd(
596            "services down",
597            Group::App,
598            "services down [postgres] [--json]",
599            "Stop local services, keeping their data",
600        )
601    },
602    Command {
603        runner: Runner::Tool,
604        needs: Needs::Nothing,
605        live: Live::Always,
606        formats: TEXT_JSON,
607        ..cmd(
608            "services status",
609            Group::App,
610            "services status [--json]",
611            "Show local services",
612        )
613    },
614    Command {
615        runner: Runner::Tool,
616        needs: Needs::Nothing,
617        ..cmd(
618            "services env",
619            Group::App,
620            "services env",
621            "Print DATABASE_URL of local services",
622        )
623    },
624    Command {
625        runner: Runner::Tool,
626        needs: Needs::Nothing,
627        mutates: CONTAINER_FILES,
628        live: Live::Always,
629        formats: TEXT_JSON,
630        ..cmd(
631            "services destroy",
632            Group::App,
633            "services destroy postgres --yes [--json]",
634            "Delete a local service and its data",
635        )
636    },
637    Command {
638        runner: Runner::Tool,
639        needs: Needs::Nothing,
640        formats: TEXT_JSON,
641        ..cmd(
642            "config show",
643            Group::Tool,
644            "config show [--redacted] [--json]",
645            "Show config values and their sources",
646        )
647    },
648    Command {
649        runner: Runner::Tool,
650        needs: Needs::Nothing,
651        formats: TEXT_JSON,
652        ..cmd(
653            "config validate",
654            Group::Tool,
655            "config validate [--json]",
656            "Load config, check database URLs",
657        )
658    },
659    Command {
660        runner: Runner::Tool,
661        needs: Needs::Nothing,
662        formats: TEXT_JSON,
663        ..cmd(
664            "docs search",
665            Group::Tool,
666            "docs search QUERY... [--json] [--limit N]",
667            "Search this version's framework docs offline",
668        )
669    },
670    Command {
671        runner: Runner::Tool,
672        needs: Needs::Nothing,
673        formats: TEXT_JSON,
674        ..cmd(
675            "explain",
676            Group::Tool,
677            "explain CODE [--json] | explain --list [--json]",
678            "Explain a diagnostic code and how to fix it",
679        )
680    },
681    Command {
682        runner: Runner::Tool,
683        needs: Needs::Nothing,
684        formats: TEXT_JSON,
685        ..cmd(
686            "commands",
687            Group::Tool,
688            "commands [--json]",
689            "List commands, their needs and changes",
690        )
691    },
692    Command {
693        runner: Runner::Tool,
694        needs: Needs::Nothing,
695        ..cmd(
696            "completions",
697            Group::Tool,
698            "completions bash|zsh|fish",
699            "Print a shell completion script",
700        )
701    },
702];
703
704/// The command for `word` (the first word, as typed).
705pub fn find(word: &str) -> Option<&'static Command> {
706    COMMANDS.iter().find(|c| c.word() == word)
707}
708
709/// The command named by the positional words of `args` (`ai init` before
710/// `ai`), ignoring flags and the values of global flags.
711pub fn resolve(args: &[String]) -> Option<&'static Command> {
712    let words = positional(args);
713    let first = words.first()?;
714    let two = words.get(1).map(|second| format!("{first} {second}"));
715    let exact = two.and_then(|two| COMMANDS.iter().find(|c| c.name == two));
716    exact.or_else(|| find(first))
717}
718
719/// Positional words, skipping flags and the values of value flags.
720fn positional(args: &[String]) -> Vec<&str> {
721    const VALUED: [&str; 6] = [
722        "--database-url",
723        "--database",
724        "--addr",
725        "--migrations-dir",
726        "--manifest-path",
727        "--bin",
728    ];
729    let mut words = Vec::new();
730    let mut args = args.iter();
731    while let Some(arg) = args.next() {
732        let package = arg == "-p" || arg == "--package";
733        if VALUED.contains(&arg.as_str()) || package {
734            args.next();
735        } else if !arg.starts_with('-') {
736            words.push(arg.as_str());
737        }
738    }
739    words
740}
741
742/// The data of the `commands` envelope.
743#[derive(Debug, Clone, Serialize)]
744pub struct CommandList {
745    /// [`COMMANDS_VERSION`].
746    pub version: u32,
747    /// The framework version.
748    pub siderite: &'static str,
749    /// The commands.
750    pub commands: &'static [Command],
751}
752
753/// The command list for `siderite commands --json`.
754pub fn list() -> CommandList {
755    CommandList {
756        version: COMMANDS_VERSION,
757        siderite: env!("CARGO_PKG_VERSION"),
758        commands: COMMANDS,
759    }
760}
761
762/// Help lines for the commands `filter` keeps, grouped and aligned.
763pub fn help(filter: impl Fn(&Command) -> bool) -> String {
764    let mut out = String::new();
765    let mut group = None;
766    for command in COMMANDS.iter().filter(|c| filter(c)) {
767        if group != Some(command.group) {
768            group = Some(command.group);
769            out.push_str(&format!("\n{}:\n", command.group.title()));
770        }
771        let (usage, summary) = (command.usage, command.summary);
772        if usage.len() <= 30 {
773            out.push_str(&format!("  {usage:<30}  {summary}\n"));
774        } else {
775            out.push_str(&format!("  {usage}\n  {:<30}  {summary}\n", ""));
776        }
777    }
778    out
779}
780
781/// The text form of `siderite commands`: what each command needs, may
782/// change and touches.
783pub fn table() -> String {
784    let mut out = format!(
785        "{:<18} {:<8} {:<26} {:<8} {}\n",
786        "COMMAND", "NEEDS", "MAY CHANGE", "LIVE", "FORMATS"
787    );
788    for c in COMMANDS {
789        let needs = serde_name(&c.needs);
790        let mutates: Vec<String> = c.mutates.iter().map(serde_name).collect();
791        let mutates = if mutates.is_empty() {
792            "-".to_owned()
793        } else {
794            mutates.join(",")
795        };
796        let formats: Vec<String> = c.formats.iter().map(serde_name).collect();
797        out.push_str(&format!(
798            "{:<18} {needs:<8} {mutates:<26} {:<8} {}\n",
799            c.name,
800            serde_name(&c.live),
801            formats.join(",")
802        ));
803    }
804    out
805}
806
807/// The serialized (`snake_case`) name of a unit variant.
808fn serde_name<T: Serialize>(value: &T) -> String {
809    match serde_json::to_value(value) {
810        Ok(serde_json::Value::String(name)) => name,
811        _ => String::new(),
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn names_resolve_and_help_fits_eighty_columns() {
821        let args = |list: &[&str]| -> Vec<String> {
822            let owned = list.iter().map(|a| (*a).to_owned());
823            owned.collect()
824        };
825        let init = resolve(&args(&["--json", "ai", "init"])).map(|c| c.name);
826        assert_eq!(init, Some("ai init"));
827        let dump = resolve(&args(&["ai", "--live", "dump-context"]));
828        assert_eq!(dump.map(|c| c.name), Some("ai dump-context"));
829        let check = resolve(&args(&["--database", "x", "check"]));
830        assert_eq!(check.map(|c| c.name), Some("check"));
831        assert!(resolve(&args(&["frobnicate"])).is_none());
832        for line in help(|_| true).lines() {
833            assert!(line.chars().count() <= 80, "{line}");
834        }
835        let mut names: Vec<_> = COMMANDS.iter().map(|c| c.name).collect();
836        names.sort_unstable();
837        names.dedup();
838        assert_eq!(names.len(), COMMANDS.len(), "duplicate command names");
839    }
840
841    #[test]
842    fn declarations_are_consistent() {
843        for c in COMMANDS {
844            let cargo = matches!(c.runner, Runner::Cargo { .. });
845            assert_eq!(cargo, c.group == Group::Cargo, "{}", c.name);
846            if c.live == Live::OptIn {
847                assert!(c.usage.contains("--live"), "{}", c.name);
848            }
849            if c.json() {
850                assert!(c.usage.contains("json"), "{}", c.name);
851            }
852        }
853    }
854}