Skip to main content

siderite_cli/
dispatch.rs

1//! Top-level `siderite` binary: `new`, cargo wrap, or standalone migrations.
2
3use crate::args::{check_json_args, json_requested};
4use crate::commands::{self, Runner};
5use crate::doctor;
6use crate::error::CliError;
7use crate::openapi;
8use crate::project::{self, Output, passthrough, passthrough_of};
9use crate::report::{Envelope, channel};
10use crate::scaffold;
11use crate::standalone;
12use crate::target::{self, ProjectArgs, Selected, split_project};
13
14type Code = Result<u8, CliError>;
15use std::env;
16use std::path::Path;
17
18/// Entry point of the `siderite` binary.
19///
20/// # Errors
21/// Usage, IO, connection, or migration errors.
22pub async fn run() -> Result<u8, CliError> {
23    let raw: Vec<String> = env::args().skip(1).collect();
24    let cwd = env::current_dir().map_err(|err| CliError::Io(format!("cannot read cwd: {err}")))?;
25    let outcome = dispatch(&raw, &cwd).await;
26    if let Err(err) = &outcome
27        && let Err(write) = report_json_error(&raw, err)
28    {
29        // The original error decides the exit code, not this write.
30        eprintln!("{write}");
31    }
32    outcome
33}
34
35/// In JSON mode, write a failure of the `siderite` binary itself as an
36/// envelope on stdout (cargo passthroughs keep their own output).
37fn report_json_error(raw: &[String], err: &CliError) -> Result<(), CliError> {
38    let Ok((_, raw)) = split_project(raw) else {
39        return Ok(());
40    };
41    let command = first_command(&raw).unwrap_or("siderite");
42    if !json_requested(&raw) || passthrough(command).is_some() {
43        return Ok(());
44    }
45    channel::emit_json(&channel::error_envelope(command, err), None)
46}
47
48async fn dispatch(raw: &[String], cwd: &std::path::Path) -> Result<u8, CliError> {
49    let (project, raw) = split_project(raw)?;
50    let raw = raw.as_slice();
51    let command = first_command(raw);
52    // The command contract is consulted once per run.
53    let spec = command.and_then(commands::find);
54    let cargo = spec.and_then(passthrough_of);
55    if cargo.is_none() && json_requested(raw) {
56        check_json_args(raw, command)?;
57    }
58    let help = raw.iter().any(|a| a == "--help" || a == "-h");
59    if help && command.is_none_or(|c| c == "help") {
60        print_help();
61        return Ok(0);
62    }
63    let command = command.unwrap_or("");
64    if command.is_empty() || command == "help" {
65        print_help();
66        return Ok(if command == "help" { 0 } else { 2 });
67    }
68    if command == "new" {
69        if project != ProjectArgs::default() {
70            return Err(CliError::usage(
71                "--manifest-path, --package and --bin do not apply to `new`",
72            ));
73        }
74        return scaffold::run(raw);
75    }
76    if command == "completions" {
77        return crate::completions::run(raw);
78    }
79    if command == "commands" {
80        return list_commands(raw);
81    }
82    if command == "dev" {
83        return crate::dev::run(raw, cwd, &project).await;
84    }
85    if command == "generate" {
86        return crate::generate::command::run(raw, cwd, &project);
87    }
88    if command == "add" || command == "remove" {
89        return crate::deps::run(raw, cwd, &project);
90    }
91    let diff = || commands::resolve(raw).map(|c| c.name);
92    if command == "openapi" && diff() == Some("openapi diff") {
93        return crate::openapi::diff::run(raw, cwd);
94    }
95    if command == "explain" {
96        return crate::explain::run(raw);
97    }
98    if command == "mcp" {
99        return mcp(raw, cwd, &project).await;
100    }
101    if command == "upgrade" {
102        return crate::upgrade::run(raw, cwd, &project);
103    }
104    if command == "package" {
105        return crate::package::run(raw, cwd, &project).await;
106    }
107    if command == "services" {
108        return crate::services::run(raw, cwd, &project);
109    }
110    if command == "config" {
111        return crate::config::run(raw, cwd, &project);
112    }
113    if command == "docs" {
114        return crate::docs::run(raw, cwd);
115    }
116    if command == "setup" {
117        return doctor::setup::run(raw, cwd);
118    }
119    if command == "doctor" {
120        return doctor::run(raw, cwd.to_path_buf(), project).await;
121    }
122    let validate = command == "ai" && raw.iter().any(|a| a == "validate");
123    if command == "verify" || validate {
124        let app = target::locate("cargo", cwd, &project, true)?;
125        let missing = || CliError::usage(NEEDS_APP);
126        let app = app.ok_or_else(missing)?;
127        let (dir, bin) = (&app.dir, app.bin.as_deref());
128        if validate {
129            return crate::ai::validate::run(raw, dir, bin);
130        }
131        return crate::verify::run(raw, dir, bin);
132    }
133    if let Some(pass) = &cargo {
134        pass.check_bin(project.bin.as_deref())?;
135        let found = target::locate("cargo", cwd, &project, false)?;
136        let Selected { dir, bin, .. } = found.ok_or_else(|| {
137            CliError::usage(format!(
138                "{command} needs a Cargo package (cd into a siderite app, or run siderite new)"
139            ))
140        })?;
141        return project::cargo_passthrough(&dir, pass, bin.as_deref(), raw);
142    }
143    let runner = spec.map(|s| s.runner);
144    if matches!(runner, Some(Runner::App | Runner::AppOrStandalone)) {
145        if let Some(app) = target::locate("cargo", cwd, &project, true)? {
146            let bin = app.bin.as_deref();
147            return run_app(&app.dir, bin, raw, command, cwd);
148        }
149        if runner == Some(Runner::App) {
150            return Err(CliError::usage(format!(
151                "`{command}` needs an application. cd into a project created with `siderite new`, or an example directory"
152            )));
153        }
154    }
155    let env_url = env::var("DATABASE_URL").ok();
156    if json_requested(raw) {
157        return standalone::run_json(raw, env_url).await;
158    }
159    let code = standalone::run_with(raw, env_url).await?;
160    Ok(code.0)
161}
162
163/// `verify` or `ai validate` outside an application.
164const NEEDS_APP: &str = "this command needs an application; cd into one";
165
166/// `siderite commands [--json]`.
167fn list_commands(raw: &[String]) -> Result<u8, CliError> {
168    let extra = raw.iter().find(|a| *a != "commands" && *a != "--json");
169    if let Some(extra) = extra {
170        let msg = format!("unexpected argument `{extra}` for `commands`");
171        return Err(CliError::usage(msg));
172    }
173    if json_requested(raw) {
174        let report = Envelope::new("commands", commands::list(), Vec::new());
175        channel::emit_json(&report, None)?;
176    } else {
177        print!("{}", commands::table());
178    }
179    Ok(0)
180}
181
182/// `cargo run` the application with `raw`. Output that must stay clean on
183/// stdout (`--json` reports, the OpenAPI document) is captured.
184fn run_app(
185    dir: &Path,
186    bin: Option<&str>,
187    raw: &[String],
188    command: &str,
189    cwd: &Path,
190) -> Result<u8, CliError> {
191    let ai_context = command == "ai" && raw.iter().any(|a| a == "context");
192    let absolute;
193    let raw = if ai_context {
194        absolute = crate::args::absolutize(raw, cwd, &["--include"]);
195        absolute.as_slice()
196    } else {
197        raw
198    };
199    if json_requested(raw) {
200        let missing = Output::Envelope(command);
201        return project::cargo_run_captured(dir, bin, raw, missing);
202    }
203    let ai_output = ["dump-context", "context"];
204    if command == "ai" && raw.iter().any(|a| ai_output.contains(&a.as_str())) {
205        // The context is the output: keep factory prints off stdout.
206        let output = if format_json(raw) {
207            Output::Json
208        } else {
209            Output::Text
210        };
211        return project::cargo_run_captured(dir, bin, raw, output);
212    }
213    if command != "openapi" {
214        return project::cargo_run(dir, bin, raw);
215    }
216    let raw = openapi::absolute_output(raw, cwd);
217    if openapi::writes_file(&raw) {
218        return project::cargo_run(dir, bin, &raw);
219    }
220    project::cargo_run_captured(dir, bin, &raw, Output::Json)
221}
222
223/// Whether `--format json` (or `=json`) was asked for.
224fn format_json(raw: &[String]) -> bool {
225    let joined = raw.windows(2).any(|w| w[0] == "--format" && w[1] == "json");
226    joined || raw.iter().any(|a| a == "--format=json")
227}
228
229/// First positional token, skipping flags and the values of known flags.
230fn first_command(args: &[String]) -> Option<&str> {
231    let mut i = 0;
232    while i < args.len() {
233        let arg = &args[i];
234        i += 1;
235        if arg == "--help" || arg == "-h" {
236            continue;
237        }
238        if let Some((flag, inline)) = flag_parts(arg) {
239            if inline.is_none()
240                && matches!(
241                    flag,
242                    "--database-url" | "--database" | "--addr" | "--migrations-dir" | "--path"
243                )
244            {
245                i += 1;
246            }
247            continue;
248        }
249        if arg.starts_with('-') {
250            continue;
251        }
252        return Some(arg);
253    }
254    None
255}
256
257fn flag_parts(arg: &str) -> Option<(&str, Option<&str>)> {
258    if !arg.starts_with("--") {
259        return None;
260    }
261    Some(match arg.split_once('=') {
262        Some((flag, value)) => (flag, Some(value)),
263        None => (arg, None),
264    })
265}
266
267fn print_help() {
268    let version = env!("CARGO_PKG_VERSION");
269    let commands = commands::help(|_| true);
270    println!(
271        "\
272siderite {version} — FastAPI-style Rust web framework
273{commands}
274Options (before the command for the project flags):
275  --manifest-path PATH            Cargo.toml of the app
276  --package, -p NAME              Workspace package of the app
277  --bin NAME                      Binary of the app package
278  --database ALIAS                Database alias (migrations, dbshell)
279  --database-url URL              Database URL
280  --migrations-dir DIR            Migration JSON directory
281  --addr ADDR                     Listen address (run)
282  --json                          One JSON report on stdout
283  --help                          Show this help
284
285App commands run in your binary through `cargo run`; `siderite commands
286--json` lists every command with what it needs and may change.
287"
288    );
289}
290
291#[cfg(feature = "mcp")]
292async fn mcp(raw: &[String], cwd: &Path, p: &ProjectArgs) -> Code {
293    crate::mcp::run(raw, cwd, p).await
294}
295
296#[cfg(not(feature = "mcp"))]
297async fn mcp(_: &[String], _: &Path, _: &ProjectArgs) -> Code {
298    Err(CliError::usage(
299        "this siderite was built without MCP support; install it with \
300         `cargo install siderite-cli --features mcp`",
301    ))
302}
303
304#[cfg(test)]
305mod tests {
306    #![allow(clippy::unwrap_used)]
307
308    use super::*;
309
310    fn args(list: &[&str]) -> Vec<String> {
311        list.iter().map(|a| (*a).to_owned()).collect()
312    }
313
314    #[test]
315    fn first_command_skips_flags() {
316        assert_eq!(
317            first_command(&args(&["--addr", "127.0.0.1:1", "run"])),
318            Some("run")
319        );
320        assert_eq!(first_command(&args(&["new", "demo"])), Some("new"));
321        assert_eq!(first_command(&args(&["--help"])), None);
322    }
323
324    #[tokio::test]
325    async fn help_and_missing_command() {
326        let cwd = std::env::temp_dir();
327        assert_eq!(dispatch(&args(&["--help"]), &cwd).await.unwrap(), 0);
328        assert_eq!(dispatch(&[], &cwd).await.unwrap(), 2);
329        assert_eq!(dispatch(&args(&["help"]), &cwd).await.unwrap(), 0);
330    }
331
332    #[tokio::test]
333    async fn run_without_a_package_is_usage() {
334        let dir = std::env::temp_dir().join(format!("siderite-dispatch-{}", std::process::id()));
335        std::fs::create_dir_all(&dir).unwrap();
336        let err = dispatch(&args(&["run"]), &dir).await.unwrap_err();
337        assert_eq!(err.exit_code(), 2);
338        assert!(err.to_string().contains("siderite new"));
339    }
340
341    #[tokio::test]
342    async fn build_without_a_package_is_usage() {
343        let dir = std::env::temp_dir().join(format!("siderite-build-{}", std::process::id()));
344        std::fs::create_dir_all(&dir).unwrap();
345        let err = dispatch(&args(&["build", "--release"]), &dir)
346            .await
347            .unwrap_err();
348        assert_eq!(err.exit_code(), 2);
349        assert!(err.to_string().contains("build needs a Cargo package"));
350    }
351}