1use 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
20pub 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
71type ConfigureDb = dyn Fn(&str, Db) -> Db + Send + Sync;
73
74type ApplyRouter = dyn Fn(Databases) -> Databases + Send + Sync;
76
77#[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 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 #[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 #[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 #[must_use]
135 pub fn models(mut self, models: &[&'static ModelMeta]) -> Self {
136 self.models = models.to_vec();
137 self
138 }
139
140 #[must_use]
142 pub fn settings(mut self, settings: CliSettings) -> Self {
143 self.settings = settings;
144 self
145 }
146
147 #[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 #[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 pub async fn run(self) -> ExitCode {
170 self.run_from(std::env::args().skip(1)).await
171 }
172
173 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 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 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 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 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 fn database_url(&self, global: &GlobalArgs, env: &Env) -> Result<String, CliError> {
310 Ok(self.select_database(global, env)?.url)
311 }
312
313 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 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 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 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 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 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 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 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
519fn 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 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 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}