Skip to main content

siderite_cli/
app_cli.rs

1//! [`AppCli`]: the command line of an application binary.
2
3use crate::args::{GlobalArgs, check_json_args, json_requested, split_global};
4use crate::connect::{backend_kind, connect_url, scratch_db};
5use crate::dbshell::ShellCommand;
6use crate::error::CliError;
7use crate::openapi;
8use crate::report::{UrlSource, build, channel, text};
9use crate::seed::{self, Dataset, SeedError, Seeding};
10use crate::settings::{CliSettings, DEFAULT_DATABASE};
11use crate::status::{STATUS_TIMEOUT, Selection, status};
12use siderite_core::App;
13use siderite_orm::router::DatabaseRouter;
14use siderite_orm::{BackendKind, Databases, Db, ModelMeta};
15use std::future::Future;
16use std::path::PathBuf;
17use std::process::ExitCode;
18use std::sync::Arc;
19
20/// The commands that need the application's own [`App`] and models, so they
21/// run from the application binary rather than the standalone `siderite` tool.
22///
23/// | Command | Does |
24/// |---|---|
25/// | `run [--addr ADDR]` | connects the configured databases and serves the app |
26/// | `routes [--json]` | prints `METHOD PATH operation_id` for every route |
27/// | `check [--json]` | runs [`check`](crate::check()); exits `1` on errors |
28/// | `openapi export [-o PATH] [--force]` | writes the OpenAPI document |
29/// | `dbshell` | starts `sqlite3`, `psql` or `mysql` on the database |
30/// | `seed --dataset NAME`, `--list` | loads an [`AppCli::seed`] dataset |
31/// | `makemigrations`, `migrate`, `rollback`, `showmigrations`, `squashmigrations` | delegate to [`siderite_migrations::cli::run`] |
32///
33/// Global flags: `--database ALIAS` (default `default`), `--database-url URL`,
34/// `--migrations-dir DIR` and `--json`. With `--json`, `check` and `routes`
35/// write one [`Envelope`](crate::report::Envelope) to stdout (or to the file
36/// named by [`REPORT_PATH_ENV`](crate::report::channel::REPORT_PATH_ENV)),
37/// and any failure, usage errors included, is written as an envelope too.
38/// The listen address is, in order, `--addr`, the `ADDR` environment
39/// variable, the configured address, `127.0.0.1:8000`.
40/// The database of `migrate`, `rollback`, `showmigrations` and `dbshell` is
41/// `--database-url`, then the configured URL of the alias, then (for
42/// `default`) the `DATABASE_URL` environment variable.
43///
44/// ```no_run
45/// use siderite_cli::{AppCli, CliSettings};
46/// use siderite_core::App;
47///
48/// #[tokio::main]
49/// async fn main() -> std::process::ExitCode {
50///     AppCli::new(|| App::new().title("demo"))
51///         .settings(CliSettings::new().database("default", "sqlite://demo.db?mode=rwc"))
52///         .migrations_dir("migrations")
53///         .run()
54///         .await
55/// }
56/// ```
57pub struct AppCli {
58    factory: Box<dyn Fn() -> App + Send + Sync>,
59    models: Vec<&'static ModelMeta>,
60    settings: CliSettings,
61    migrations_dir: PathBuf,
62    configure_db: Box<ConfigureDb>,
63    router: Option<Arc<ApplyRouter>>,
64    datasets: Vec<Dataset>,
65}
66
67type CliResult = Result<u8, CliError>;
68type Selected = Result<Selection, CliError>;
69type SeedTarget = Result<seed::Target, CliError>;
70
71/// Hook applied to each connected database handle.
72type ConfigureDb = dyn Fn(&str, Db) -> Db + Send + Sync;
73
74/// Installs the database router on the registry.
75type ApplyRouter = dyn Fn(Databases) -> Databases + Send + Sync;
76
77/// Process environment read once per run, so tests can pass their own.
78#[derive(Debug, Clone, Default)]
79pub(crate) struct Env {
80    pub(crate) addr: Option<String>,
81    pub(crate) database_url: Option<String>,
82    pub(crate) report_path: Option<PathBuf>,
83}
84
85impl Env {
86    pub(crate) fn from_process() -> Self {
87        Self {
88            addr: std::env::var("ADDR").ok(),
89            database_url: std::env::var("DATABASE_URL").ok(),
90            report_path: std::env::var_os(channel::REPORT_PATH_ENV)
91                .filter(|p| !p.is_empty())
92                .map(PathBuf::from),
93        }
94    }
95}
96
97impl AppCli {
98    /// Create the command line for the app that `app` builds.
99    ///
100    /// `app` is a factory because some commands build the app more than once
101    /// (`check` builds a fresh one to test that it can be served).
102    pub fn new(app: impl Fn() -> App + Send + Sync + 'static) -> Self {
103        Self {
104            factory: Box::new(app),
105            models: Vec::new(),
106            settings: CliSettings::new(),
107            migrations_dir: PathBuf::from("migrations"),
108            configure_db: Box::new(|_, db| db),
109            router: None,
110            datasets: Vec::new(),
111        }
112    }
113
114    /// Adjust every database handle `run` connects, before it is
115    /// registered with the app: attach [`Signals`](siderite_orm::signals::Signals)
116    /// with `db.with_signals(..)`, for example. Receives the alias.
117    #[must_use]
118    pub fn configure_db(mut self, f: impl Fn(&str, Db) -> Db + Send + Sync + 'static) -> Self {
119        self.configure_db = Box::new(f);
120        self
121    }
122
123    /// Route models to databases with `router` when `run` builds the
124    /// app's [`Databases`] registry.
125    #[must_use]
126    pub fn database_router<R: DatabaseRouter + Clone>(mut self, router: R) -> Self {
127        self.router = Some(Arc::new(move |dbs: Databases| {
128            dbs.with_router(router.clone())
129        }));
130        self
131    }
132
133    /// Register the compiled model metadata (`User::META`, ...).
134    #[must_use]
135    pub fn models(mut self, models: &[&'static ModelMeta]) -> Self {
136        self.models = models.to_vec();
137        self
138    }
139
140    /// Set the listen address and database URLs.
141    #[must_use]
142    pub fn settings(mut self, settings: CliSettings) -> Self {
143        self.settings = settings;
144        self
145    }
146
147    /// Register the seed dataset `name`, loaded only by
148    /// `seed --dataset NAME` into the selected database (in one
149    /// transaction where the backend has them). See [`crate::seed`].
150    #[must_use]
151    pub fn seed<F, Fut>(mut self, name: &str, summary: &str, run: F) -> Self
152    where
153        F: Fn(Seeding) -> Fut + Send + Sync + 'static,
154        Fut: Future<Output = Result<(), SeedError>> + Send + 'static,
155    {
156        self.datasets.push(Dataset::new(name, summary, run));
157        self
158    }
159
160    /// Directory of the JSON migrations (default `migrations`).
161    #[must_use]
162    pub fn migrations_dir(mut self, dir: impl Into<PathBuf>) -> Self {
163        self.migrations_dir = dir.into();
164        self
165    }
166
167    /// Run the command in `std::env::args`, print any error to stderr and
168    /// return the process exit code (`0` success, `1` failure, `2` usage).
169    pub async fn run(self) -> ExitCode {
170        self.run_from(std::env::args().skip(1)).await
171    }
172
173    /// Like [`run`](Self::run) with explicit arguments (no program name).
174    pub async fn run_from(self, args: impl IntoIterator<Item = String>) -> ExitCode {
175        let args: Vec<String> = args.into_iter().collect();
176        match self.execute(&args, &Env::from_process()).await {
177            Ok(code) => ExitCode::from(code),
178            Err(err) => {
179                eprintln!("error: {err}");
180                ExitCode::from(err.exit_code())
181            }
182        }
183    }
184
185    /// Run `args`; in JSON mode a failure is also written as an envelope.
186    pub(crate) async fn execute(&self, args: &[String], env: &Env) -> Result<u8, CliError> {
187        let outcome = self.dispatch(args, env).await;
188        if let Err(err) = &outcome
189            && json_requested(args)
190        {
191            let command = split_global(args)
192                .ok()
193                .and_then(|(_, rest)| rest.into_iter().next())
194                .or_else(|| args.iter().find(|a| !a.starts_with('-')).cloned())
195                .unwrap_or_else(|| "siderite".to_owned());
196            // The original error decides the exit code, not this write.
197            let envelope = channel::error_envelope(&command, err);
198            if let Err(write) = emit(&envelope, env) {
199                eprintln!("error: {write}");
200            }
201        }
202        outcome
203    }
204
205    async fn dispatch(&self, args: &[String], env: &Env) -> CliResult {
206        let (global, rest) = split_global(args)?;
207        let command = rest.first().map(String::as_str);
208        if global.json {
209            check_json_args(args, command)?;
210        }
211        let Some(command) = command else {
212            print_help();
213            return Ok(if global.help { 0 } else { 2 });
214        };
215        if global.help || command == "help" {
216            print_help();
217            return Ok(0);
218        }
219        match command {
220            "run" | "routes" | "check" | "dbshell" => {
221                if rest.iter().any(|a| a == "--help" || a == "-h") {
222                    print_help();
223                    return Ok(0);
224                }
225                if let Some(extra) = rest.get(1) {
226                    return Err(CliError::usage(format!(
227                        "unexpected argument `{extra}` for `{command}`"
228                    )));
229                }
230                let json = global.json.then_some(env);
231                match command {
232                    "run" => self.serve(&global, env).await,
233                    "routes" => self.routes(json),
234                    "check" => self.check(json),
235                    _ => self.dbshell(&global, env),
236                }
237            }
238            "seed" => self.seeds(&global, &rest, env).await,
239            "ai" => {
240                if rest.iter().any(|a| a == "--help" || a == "-h") {
241                    print_help();
242                    return Ok(0);
243                }
244                self.ai(&global, &rest, env).await
245            }
246            "openapi" => {
247                if rest.iter().any(|a| a == "--help" || a == "-h") {
248                    print_help();
249                    return Ok(0);
250                }
251                let export = openapi::parse(&rest)?;
252                let path = env.report_path.as_deref();
253                openapi::export(&(self.factory)(), &export, path)
254            }
255            "makemigrations" | "squashmigrations" => {
256                // These never touch a database, but the runner wants a handle.
257                let db = scratch_db().await?;
258                self.migrations(&global, &db, rest).await
259            }
260            "showmigrations" if global.json => {
261                if let Some(extra) = rest.get(1) {
262                    return Err(CliError::usage(format!(
263                        "unexpected argument `{extra}` for `showmigrations`"
264                    )));
265                }
266                let selection = self.select_database(&global, env)?;
267                let dir = self.migrations_dir_for(&global);
268                let report = status(&selection, &dir, STATUS_TIMEOUT).await?;
269                emit(&report, env)?;
270                Ok(report.exit_code())
271            }
272            "migrate" | "rollback" | "showmigrations" | "inspectmigrations" => {
273                let url = self.database_url(&global, env)?;
274                let alias = global.database.as_deref().unwrap_or(DEFAULT_DATABASE);
275                let db = connect_url(&url)
276                    .await
277                    .map_err(|source| CliError::Connect {
278                        alias: alias.to_owned(),
279                        source,
280                    })?;
281                self.migrations(&global, &db, rest).await
282            }
283            other => Err(CliError::usage(format!(
284                "unknown command `{other}` (try --help)"
285            ))),
286        }
287    }
288
289    async fn migrations(
290        &self,
291        global: &GlobalArgs,
292        db: &siderite_orm::Db,
293        rest: Vec<String>,
294    ) -> Result<u8, CliError> {
295        let dir = self.migrations_dir_for(global);
296        let code = siderite_migrations::cli::run(&self.models, db, &dir, rest).await?;
297        Ok(code.0)
298    }
299
300    /// `--migrations-dir`, else the configured directory.
301    fn migrations_dir_for(&self, global: &GlobalArgs) -> PathBuf {
302        global
303            .migrations_dir
304            .clone()
305            .unwrap_or_else(|| self.migrations_dir.clone())
306    }
307
308    /// URL for the database selected by the global flags.
309    fn database_url(&self, global: &GlobalArgs, env: &Env) -> Result<String, CliError> {
310        Ok(self.select_database(global, env)?.url)
311    }
312
313    /// The database selected by the global flags: `--database-url`, then
314    /// the URL configured for the alias, then (for `default` only) the
315    /// `DATABASE_URL` environment variable.
316    fn select_database(&self, global: &GlobalArgs, env: &Env) -> Selected {
317        let alias = global.database.as_deref().unwrap_or(DEFAULT_DATABASE);
318        let chosen = |url: &str, source| Selection {
319            url: url.to_owned(),
320            alias: Some(alias.to_owned()),
321            source,
322        };
323        if let Some(url) = &global.database_url {
324            return Ok(chosen(url, UrlSource::Flag));
325        }
326        if let Some(url) = self.settings.database_url(alias) {
327            return Ok(chosen(url, UrlSource::Settings));
328        }
329        if alias == DEFAULT_DATABASE
330            && let Some(url) = env.database_url.as_deref().filter(|u| !u.is_empty())
331        {
332            return Ok(chosen(url, UrlSource::Environment));
333        }
334        Err(CliError::NoDatabase {
335            alias: alias.to_owned(),
336        })
337    }
338
339    /// Build the app with every configured SQL database registered under its
340    /// alias. Aliases for other kinds of store (Redis, MongoDB) are left to the
341    /// application.
342    pub(crate) async fn build_app(&self) -> Result<App, CliError> {
343        let app = (self.factory)();
344        if self.settings.database_urls().is_empty() && self.router.is_none() {
345            return Ok(app);
346        }
347        let mut databases = app.database_registry().cloned().unwrap_or_default();
348        for (alias, url) in self.settings.database_urls() {
349            if matches!(
350                backend_kind(url),
351                Some(BackendKind::Redis | BackendKind::MongoDb)
352            ) {
353                continue;
354            }
355            let db = connect_url(url).await.map_err(|source| CliError::Connect {
356                alias: alias.clone(),
357                source,
358            })?;
359            databases = databases.with(alias.clone(), (self.configure_db)(alias, db));
360        }
361        if let Some(router) = &self.router {
362            databases = router(databases);
363        }
364        Ok(app.databases(databases))
365    }
366
367    async fn serve(&self, global: &GlobalArgs, env: &Env) -> Result<u8, CliError> {
368        let addr = self
369            .settings
370            .resolve_addr(global.addr.as_deref(), env.addr.as_deref());
371        let app = self.build_app().await?;
372        println!("Starting server at http://{addr}");
373        app.run(&addr).await?;
374        Ok(0)
375    }
376
377    /// `seed --list` or `seed --dataset NAME`.
378    async fn seeds(&self, g: &GlobalArgs, r: &[String], e: &Env) -> CliResult {
379        let report = match seed::parse(r)? {
380            seed::Request::List => seed::list(&self.datasets),
381            seed::Request::Load(name) => {
382                let dataset = seed::find(&self.datasets, &name)?;
383                let target = self.seed_target(g, e).await?;
384                seed::load(&self.datasets, dataset, target).await
385            }
386        };
387        if g.json {
388            emit(&report, e)?;
389        } else {
390            print!("{}", seed::render(&report));
391        }
392        Ok(report.exit_code())
393    }
394
395    /// The database the global flags select, connected and configured.
396    async fn seed_target(&self, g: &GlobalArgs, e: &Env) -> SeedTarget {
397        let Selection { url, alias, source } = self.select_database(g, e)?;
398        let alias = alias.unwrap_or_else(|| DEFAULT_DATABASE.to_owned());
399        let connected = connect_url(&url).await;
400        let db = connected.map_err(|source| CliError::Connect {
401            alias: alias.clone(),
402            source,
403        })?;
404        let db = (self.configure_db)(&alias, db);
405        Ok(seed::Target {
406            db,
407            alias,
408            source,
409            url,
410        })
411    }
412
413    /// `ai dump-context`: the snapshot as Markdown or JSON, migration
414    /// status only with `--live`.
415    async fn ai(&self, g: &GlobalArgs, rest: &[String], e: &Env) -> CliResult {
416        match rest.get(1).map(String::as_str) {
417            Some("init") => return self.ai_init(rest),
418            Some("context") => return self.ai_task(g, rest, e),
419            _ => {}
420        }
421        let options = crate::ai::parse(rest, g.json)?;
422        let app = (self.factory)();
423        let dir = self.migrations_dir_for(g);
424        let inputs = crate::ai::Inputs {
425            app: &app,
426            models: &self.models,
427            settings: &self.settings,
428            database: self.select_database(g, e).ok(),
429            migrations_dir: &dir,
430        };
431        let report = crate::ai::context(inputs, &options).await;
432        if options.format == crate::ai::Format::Json {
433            emit(&report, e)?;
434        } else {
435            let text = crate::ai::render_markdown(&report);
436            channel::emit(&text, e.report_path.as_deref())?;
437        }
438        Ok(report.exit_code())
439    }
440
441    /// `ai context --task TEXT`: context for one task within a budget.
442    fn ai_task(&self, g: &GlobalArgs, rest: &[String], e: &Env) -> CliResult {
443        use crate::ai::{Format, task};
444        use crate::report::Envelope;
445        use crate::report::snapshot;
446        let options = task::parse(rest, g.json)?;
447        let app = (self.factory)();
448        let limits = snapshot::Limits::default();
449        let models = &self.models;
450        let snap = snapshot::snapshot(&app, models, &self.settings, limits);
451        let index = crate::docs::index::packaged().map_err(CliError::Io)?;
452        let root = std::env::current_dir()
453            .map_err(|err| CliError::Io(format!("cannot read cwd: {err}")))?;
454        let notes = snap.diagnostics.iter().map(|d| d.message().to_owned());
455        let sources = task::Sources {
456            snapshot: &snap.data,
457            notes: notes.collect(),
458            index: &index,
459            root: &root,
460        };
461        let context = task::build(&sources, &options);
462        if options.format == Format::Json {
463            let report = Envelope::new("ai context", context, Vec::new());
464            emit(&report, e)?;
465        } else {
466            channel::emit(&context.markdown, e.report_path.as_deref())?;
467        }
468        Ok(0)
469    }
470
471    /// `ai init`: refresh `.siderite/context.md` in the package directory
472    /// (the working directory under `siderite`).
473    fn ai_init(&self, rest: &[String]) -> CliResult {
474        use crate::ai::init;
475        let options = init::parse(rest)?;
476        use crate::report::snapshot;
477        let limits = snapshot::Limits {
478            max_bytes: 256 << 10,
479            ..Default::default()
480        };
481        let app = (self.factory)();
482        let models = &self.models;
483        let snap = snapshot::snapshot(&app, models, &self.settings, limits);
484        let dir = std::env::current_dir()
485            .map_err(|err| CliError::Io(format!("cannot read cwd: {err}")))?;
486        init::run(&dir, &snap.data, &options)
487    }
488
489    /// `json` is the environment of a `--json` run, `None` for text.
490    fn routes(&self, json: Option<&Env>) -> Result<u8, CliError> {
491        let report = build::routes(&(self.factory)())?;
492        match json {
493            Some(env) => emit(&report, env)?,
494            None => print!("{}", text::render_routes(&report.data.routes)),
495        }
496        Ok(report.exit_code())
497    }
498
499    fn check(&self, json: Option<&Env>) -> Result<u8, CliError> {
500        let report = build::check_report(
501            &*self.factory,
502            &self.models,
503            &self.settings,
504            Some(&self.migrations_dir),
505        );
506        match json {
507            Some(env) => emit(&report, env)?,
508            None => print!("{}", text::render_check(&report)),
509        }
510        Ok(report.exit_code())
511    }
512
513    fn dbshell(&self, global: &GlobalArgs, env: &Env) -> Result<u8, CliError> {
514        let url = self.database_url(global, env)?;
515        ShellCommand::from_url(&url)?.run()
516    }
517}
518
519/// Write a `--json` report through the report channel of `env`.
520fn emit<T: serde::Serialize>(report: &T, env: &Env) -> Result<(), CliError> {
521    channel::emit_json(report, env.report_path.as_deref())
522}
523
524fn print_help() {
525    use crate::commands::{Command, Runner, help};
526    use Runner::AppOrStandalone;
527    let ours = |c: &Command| matches!(c.runner, Runner::App | AppOrStandalone);
528    println!(
529        "\
530Commands:{}
531Options:
532  --addr ADDR             Listen address (else ADDR, settings, 127.0.0.1:8000)
533  --database ALIAS        Database alias (migrations, dbshell, --live)
534  --database-url URL      Database URL, overriding the settings
535  --migrations-dir DIR    Migration JSON directory
536  --json                  One JSON report on stdout
537  --help                  Show this help
538",
539        help(ours)
540    );
541}
542
543#[cfg(test)]
544mod tests {
545    #![allow(clippy::unwrap_used)]
546
547    use super::*;
548    use crate::fixtures::{author, book, no_pk};
549    use siderite_core::get;
550
551    fn cli() -> AppCli {
552        AppCli::new(|| App::new().route("/ping", get(|| async { "pong" }).operation_id("ping")))
553    }
554
555    fn args(list: &[&str]) -> Vec<String> {
556        list.iter().map(|a| (*a).to_owned()).collect()
557    }
558
559    async fn run(cli: &AppCli, list: &[&str], env: &Env) -> Result<u8, CliError> {
560        cli.execute(&args(list), env).await
561    }
562
563    fn temp_dir(tag: &str) -> PathBuf {
564        let dir = std::env::temp_dir().join(format!(
565            "siderite-cli-{tag}-{}-{}",
566            std::process::id(),
567            std::time::SystemTime::now()
568                .duration_since(std::time::UNIX_EPOCH)
569                .map_or(0, |d| d.as_nanos())
570        ));
571        std::fs::create_dir_all(&dir).unwrap();
572        dir
573    }
574
575    fn sqlite_url(dir: &std::path::Path) -> String {
576        format!("sqlite://{}?mode=rwc", dir.join("app.db").display())
577    }
578
579    #[tokio::test]
580    async fn no_command_and_help() {
581        let env = Env::default();
582        assert_eq!(run(&cli(), &[], &env).await.unwrap(), 2);
583        assert_eq!(run(&cli(), &["--help"], &env).await.unwrap(), 0);
584        assert_eq!(run(&cli(), &["help"], &env).await.unwrap(), 0);
585        assert_eq!(run(&cli(), &["routes", "--help"], &env).await.unwrap(), 0);
586    }
587
588    #[tokio::test]
589    async fn unknown_commands_and_extra_arguments_are_usage_errors() {
590        let env = Env::default();
591        let err = run(&cli(), &["frobnicate"], &env).await.unwrap_err();
592        assert_eq!(err.exit_code(), 2);
593        assert!(err.to_string().contains("frobnicate"));
594        let err = run(&cli(), &["routes", "extra"], &env).await.unwrap_err();
595        assert_eq!(err.exit_code(), 2);
596        let err = run(&cli(), &["check", "--nope"], &env).await.unwrap_err();
597        assert_eq!(err.exit_code(), 2);
598    }
599
600    #[tokio::test]
601    async fn routes_and_a_clean_check_succeed() {
602        let env = Env::default();
603        assert_eq!(run(&cli(), &["routes"], &env).await.unwrap(), 0);
604        let dir = temp_dir("check-clean");
605        let cli = cli().migrations_dir(dir);
606        assert_eq!(run(&cli, &["check"], &env).await.unwrap(), 0);
607    }
608
609    #[tokio::test]
610    async fn check_fails_on_errors_but_not_on_warnings() {
611        let env = Env::default();
612        let dir = temp_dir("check-errors");
613        let broken = cli().models(&[no_pk()]).migrations_dir(dir.clone());
614        assert_eq!(run(&broken, &["check"], &env).await.unwrap(), 1);
615
616        // Registered models and no migrations yet: only a warning, so it passes.
617        let warn_only = cli()
618            .models(&[author()])
619            .settings(CliSettings::new().database("default", "sqlite::memory:"))
620            .migrations_dir(dir);
621        assert_eq!(run(&warn_only, &["check"], &env).await.unwrap(), 0);
622    }
623
624    #[tokio::test]
625    async fn check_reports_duplicate_routes() {
626        let env = Env::default();
627        let dup = AppCli::new(|| {
628            App::new()
629                .route("/a", get(|| async { "1" }))
630                .route("/a", get(|| async { "2" }))
631        })
632        .migrations_dir(temp_dir("dup"));
633        assert_eq!(run(&dup, &["check"], &env).await.unwrap(), 1);
634    }
635
636    #[tokio::test]
637    async fn migration_commands_round_trip_on_sqlite() {
638        let env = Env::default();
639        let dir = temp_dir("migrate");
640        let url = sqlite_url(&dir);
641        let migrations = dir.join("migrations");
642        let cli = cli()
643            .models(&[author(), book()])
644            .settings(CliSettings::new().database("default", url))
645            .migrations_dir(migrations.clone());
646
647        assert_eq!(run(&cli, &["makemigrations"], &env).await.unwrap(), 0);
648        let files: Vec<_> = std::fs::read_dir(&migrations).unwrap().collect();
649        assert_eq!(files.len(), 1);
650        assert_eq!(run(&cli, &["migrate"], &env).await.unwrap(), 0);
651        assert_eq!(run(&cli, &["showmigrations"], &env).await.unwrap(), 0);
652        assert_eq!(run(&cli, &["check"], &env).await.unwrap(), 0);
653        assert_eq!(
654            run(&cli, &["rollback", "--steps", "1"], &env)
655                .await
656                .unwrap(),
657            0
658        );
659    }
660
661    #[tokio::test]
662    async fn migrations_dir_flag_overrides_the_builder() {
663        let env = Env::default();
664        let dir = temp_dir("dirflag");
665        let custom = dir.join("custom");
666        let cli = cli()
667            .models(&[author()])
668            .migrations_dir(dir.join("ignored"));
669        let flag = format!("--migrations-dir={}", custom.display());
670        assert_eq!(
671            run(&cli, &["makemigrations", &flag], &env).await.unwrap(),
672            0
673        );
674        assert!(custom.exists());
675        assert!(!dir.join("ignored").exists());
676    }
677
678    #[tokio::test]
679    async fn database_selection_precedence() {
680        let cli = cli().settings(
681            CliSettings::new()
682                .database("default", "sqlite::memory:")
683                .database("replica", "sqlite://replica.db"),
684        );
685        let env = Env {
686            database_url: Some("sqlite://from-env.db".into()),
687            ..Env::default()
688        };
689        let none = GlobalArgs::default();
690        assert_eq!(cli.database_url(&none, &env).unwrap(), "sqlite::memory:");
691        let replica = GlobalArgs {
692            database: Some("replica".into()),
693            ..GlobalArgs::default()
694        };
695        assert_eq!(
696            cli.database_url(&replica, &env).unwrap(),
697            "sqlite://replica.db"
698        );
699        let flag = GlobalArgs {
700            database_url: Some("sqlite://flag.db".into()),
701            ..GlobalArgs::default()
702        };
703        assert_eq!(cli.database_url(&flag, &env).unwrap(), "sqlite://flag.db");
704
705        let bare = self::cli();
706        assert_eq!(
707            bare.database_url(&none, &env).unwrap(),
708            "sqlite://from-env.db"
709        );
710        // The environment only stands in for the `default` alias.
711        let err = bare.database_url(&replica, &env).unwrap_err();
712        assert!(matches!(err, CliError::NoDatabase { ref alias } if alias == "replica"));
713        let err = bare.database_url(&none, &Env::default()).unwrap_err();
714        assert!(matches!(err, CliError::NoDatabase { .. }));
715    }
716
717    #[tokio::test]
718    async fn migrate_without_a_database_is_an_error() {
719        let err = run(&cli(), &["migrate"], &Env::default())
720            .await
721            .unwrap_err();
722        assert!(matches!(err, CliError::NoDatabase { .. }));
723    }
724
725    #[tokio::test]
726    async fn connect_failures_name_the_alias_not_the_url() {
727        let cli = cli().settings(CliSettings::new().database("default", "ftp://u:hunter2@h/db"));
728        let err = run(&cli, &["migrate"], &Env::default()).await.unwrap_err();
729        let text = err.to_string();
730        assert!(text.contains("`default`"), "{text}");
731        assert!(!text.contains("hunter2"), "{text}");
732    }
733
734    #[tokio::test]
735    async fn dbshell_rejects_in_memory_sqlite_without_running_a_client() {
736        let cli = cli().settings(CliSettings::new().database("default", "sqlite::memory:"));
737        let err = run(&cli, &["dbshell"], &Env::default()).await.unwrap_err();
738        assert_eq!(err.exit_code(), 2);
739    }
740
741    #[tokio::test]
742    async fn build_app_registers_sql_databases_and_skips_other_stores() {
743        let cli = cli().settings(
744            CliSettings::new()
745                .database("default", "sqlite::memory:")
746                .database("replica", "sqlite::memory:")
747                .database("cache", "redis://localhost:6379"),
748        );
749        let app = cli.build_app().await.unwrap();
750        let registry = app.database_registry().unwrap();
751        assert!(registry.get("default").is_some());
752        assert!(registry.get("replica").is_some());
753        assert!(registry.get("cache").is_none());
754    }
755
756    #[tokio::test]
757    async fn build_app_applies_db_configuration_and_router() {
758        use std::sync::atomic::{AtomicUsize, Ordering};
759
760        #[derive(Clone)]
761        struct DefaultOnly;
762        impl DatabaseRouter for DefaultOnly {
763            fn allow_migrate(&self, alias: &str, _: &ModelMeta) -> bool {
764                alias == "default"
765            }
766        }
767
768        let configured = Arc::new(AtomicUsize::new(0));
769        let seen = Arc::clone(&configured);
770        let cli = cli()
771            .settings(
772                CliSettings::new()
773                    .database("default", "sqlite::memory:")
774                    .database("replica", "sqlite::memory:"),
775            )
776            .configure_db(move |_, db| {
777                seen.fetch_add(1, Ordering::SeqCst);
778                db
779            })
780            .database_router(DefaultOnly);
781        let app = cli.build_app().await.unwrap();
782        let registry = app.database_registry().unwrap();
783        assert_eq!(configured.load(Ordering::SeqCst), 2);
784        let author = crate::fixtures::author();
785        assert!(registry.allow_migrate("default", author));
786        assert!(!registry.allow_migrate("replica", author));
787    }
788
789    #[tokio::test]
790    async fn build_app_without_databases_leaves_the_app_alone() {
791        let app = cli().build_app().await.unwrap();
792        assert!(app.database_registry().is_none());
793    }
794
795    #[tokio::test]
796    async fn run_with_a_bad_database_fails_before_binding() {
797        let cli = cli().settings(CliSettings::new().database("default", "ftp://h/db"));
798        let err = run(&cli, &["run", "--addr", "127.0.0.1:0"], &Env::default())
799            .await
800            .unwrap_err();
801        assert!(matches!(err, CliError::Connect { .. }));
802    }
803}