1use 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
18pub 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 eprintln!("{write}");
31 }
32 outcome
33}
34
35fn 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 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
163const NEEDS_APP: &str = "this command needs an application; cd into one";
165
166fn 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
182fn 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 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
223fn 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
229fn 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}