1use serde::Serialize;
10
11pub const COMMANDS_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case", tag = "kind")]
17pub enum Runner {
18 Tool,
20 App,
22 AppOrStandalone,
25 Cargo {
28 cargo: &'static str,
30 takes_bin: bool,
32 component: Option<&'static str>,
34 },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Needs {
41 Nothing,
43 Package,
45 App,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Mutation {
53 ProjectFiles,
55 OutputFile,
57 Database,
59 BuildArtifacts,
61 RunsCode,
63 Toolchain,
65 Containers,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Live {
73 Never,
75 OptIn,
77 Always,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Format {
85 Text,
87 Json,
89 Markdown,
91 Document,
93 Mcp,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Group {
101 App,
103 Migrations,
105 Ai,
107 Cargo,
109 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127pub struct Command {
128 pub name: &'static str,
130 pub group: Group,
132 pub usage: &'static str,
134 pub summary: &'static str,
136 pub runner: Runner,
138 pub needs: Needs,
140 pub mutates: &'static [Mutation],
142 pub live: Live,
144 pub formats: &'static [Format],
146}
147
148impl Command {
149 pub fn word(&self) -> &'static str {
151 self.name.split(' ').next().unwrap_or(self.name)
152 }
153
154 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
216pub 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 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
704pub fn find(word: &str) -> Option<&'static Command> {
706 COMMANDS.iter().find(|c| c.word() == word)
707}
708
709pub 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
719fn 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#[derive(Debug, Clone, Serialize)]
744pub struct CommandList {
745 pub version: u32,
747 pub siderite: &'static str,
749 pub commands: &'static [Command],
751}
752
753pub fn list() -> CommandList {
755 CommandList {
756 version: COMMANDS_VERSION,
757 siderite: env!("CARGO_PKG_VERSION"),
758 commands: COMMANDS,
759 }
760}
761
762pub 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
781pub 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
807fn 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}