autumn_web/seed.rs
1//! Seed context for populating databases with representative data.
2//!
3//! Enabled with the `seed` cargo feature (off by default). Include in your
4//! project's `Cargo.toml` to use it in a seed binary:
5//!
6//! ```toml
7//! autumn-web = { version = "...", features = ["seed"] }
8//! ```
9//!
10//! # Example (`src/bin/seed.rs`)
11//!
12//! ```no_run
13//! use autumn_web::seed::SeedContext;
14//!
15//! #[tokio::main]
16//! async fn main() {
17//! let ctx = SeedContext::build().expect("seed context");
18//! let mut db = ctx.conn().await.expect("db connection");
19//! // use db with Diesel queries ...
20//! println!("Seed complete (profile: {})", ctx.profile());
21//! }
22//! ```
23//!
24//! # Faking data (issue #1343)
25//!
26//! Any `#[autumn_web::model]` gets a factory whose `.fake()` fills unset fields
27//! with realistic data. Populate 100+ rows in a single line to exercise
28//! pagination and search:
29//!
30//! ```no_run
31//! use autumn_web::seed::SeedContext;
32//!
33//! autumn_web::reexports::diesel::table! {
34//! posts (id) { id -> Int8, title -> Text, body -> Text }
35//! }
36//!
37//! #[autumn_web::model(table = "posts")]
38//! struct Post {
39//! #[id]
40//! id: i64,
41//! title: String,
42//! body: String,
43//! }
44//!
45//! #[tokio::main]
46//! async fn main() {
47//! let ctx = SeedContext::build().expect("seed context");
48//!
49//! // 200 faked posts, each with distinct fake title/body:
50//! Post::factory().fake().create_many(200, ctx.pool()).await;
51//! }
52//! ```
53//!
54//! The same registry powers `autumn seed --count 200 --model Post`, which drives
55//! a model's factory by name (via [`fake_seed_model`]) without editing the seed
56//! binary at all.
57
58use std::path::Path;
59
60use crate::config::DatabaseConfig;
61use crate::db::RuntimeConnection;
62use crate::db::create_pool;
63use diesel_async::pooled_connection::deadpool::{Object, Pool};
64use futures::future::BoxFuture;
65
66/// Error type returned by [`SeedContext`] operations.
67#[derive(Debug, thiserror::Error)]
68pub enum SeedContextError {
69 /// No database URL was found in the environment or `autumn.toml`.
70 #[error(
71 "no primary database URL configured; set AUTUMN_DATABASE__PRIMARY_URL, AUTUMN_DATABASE__URL, or `database.primary_url` in autumn.toml"
72 )]
73 NoDatabaseUrl,
74
75 /// The connection pool could not be built.
76 #[error("failed to build connection pool: {0}")]
77 PoolBuild(#[from] crate::db::PoolError),
78
79 /// A pooled connection could not be acquired.
80 #[error("failed to acquire database connection: {0}")]
81 Connection(String),
82}
83
84/// Context provided to a seed binary.
85///
86/// Holds the database connection pool and the active profile, both resolved
87/// from the project's `autumn.toml` and environment variables — the same
88/// sources the main application uses.
89///
90/// # Usage
91///
92/// ```no_run
93/// # use autumn_web::seed::SeedContext;
94/// # #[tokio::main]
95/// # async fn main() {
96/// let ctx = SeedContext::build().expect("seed context");
97/// println!("profile: {}", ctx.profile());
98/// let mut db = ctx.conn().await.expect("connection");
99/// // use &mut *db as &mut AsyncPgConnection with diesel_async queries
100/// # }
101/// ```
102pub struct SeedContext {
103 pool: Pool<RuntimeConnection>,
104 profile: String,
105}
106
107impl SeedContext {
108 /// Build a `SeedContext` by reading the database URL and profile from the
109 /// environment and `autumn.toml` in the current working directory.
110 ///
111 /// Profile resolution order (first wins):
112 /// 1. `AUTUMN_ENV` env var
113 /// 2. `AUTUMN_PROFILE` env var
114 /// 3. Defaults to `"dev"`
115 ///
116 /// Database URL resolution order (first wins):
117 /// 1. `AUTUMN_DATABASE__PRIMARY_URL` env var
118 /// 2. `AUTUMN_DATABASE__URL` env var
119 /// 3. `DATABASE_URL` env var
120 /// 4. `database.primary_url` in `autumn.toml`
121 /// 5. `database.url` in `autumn.toml`
122 ///
123 /// With the `repl` feature and `AUTUMN_CONSOLE_REPL=1` (set by
124 /// `autumn console --repl`), this opens the REPL and ends the process.
125 ///
126 /// # Errors
127 ///
128 /// Returns [`SeedContextError::NoDatabaseUrl`] if no database URL is
129 /// configured, or [`SeedContextError::PoolBuild`] if the pool cannot be
130 /// constructed.
131 pub fn build() -> Result<Self, SeedContextError> {
132 let profile = resolve_profile();
133 let db_url = resolve_database_url(&profile).ok_or(SeedContextError::NoDatabaseUrl)?;
134
135 let config = DatabaseConfig {
136 primary_url: Some(db_url),
137 ..DatabaseConfig::default()
138 };
139
140 let pool = create_pool(&config)?.ok_or(SeedContextError::NoDatabaseUrl)?;
141
142 // `autumn console --repl` (issue #2148): open the prompt here and end
143 // the process, so no playground code after `build()` runs.
144 #[cfg(feature = "repl")]
145 if crate::repl::requested() {
146 crate::repl::run_in_place(&pool);
147 }
148
149 Ok(Self { pool, profile })
150 }
151
152 /// Returns the active profile name (e.g. `"dev"`, `"demo"`, `"test"`).
153 #[must_use]
154 pub fn profile(&self) -> &str {
155 &self.profile
156 }
157
158 /// Returns the underlying connection pool.
159 ///
160 /// Useful for APIs that take a `&Pool<AsyncPgConnection>` directly, such as
161 /// factory `create_many(count, pool)` and
162 /// [`fake_seed_model`].
163 #[must_use]
164 pub const fn pool(&self) -> &Pool<RuntimeConnection> {
165 &self.pool
166 }
167
168 /// Acquires a pooled database connection.
169 ///
170 /// Returns a [`Object<AsyncPgConnection>`] that implements `DerefMut` to
171 /// `AsyncPgConnection`, so it can be passed directly to diesel-async
172 /// query methods as `&mut *conn`.
173 ///
174 /// # Errors
175 ///
176 /// Returns [`SeedContextError::Connection`] if the pool is exhausted or
177 /// the connection cannot be established.
178 pub async fn conn(&self) -> Result<Object<RuntimeConnection>, SeedContextError> {
179 self.pool
180 .get()
181 .await
182 .map_err(|e| SeedContextError::Connection(e.to_string()))
183 }
184}
185
186/// Resolve the active profile from environment variables.
187fn resolve_profile() -> String {
188 std::env::var("AUTUMN_ENV")
189 .or_else(|_| std::env::var("AUTUMN_PROFILE"))
190 .unwrap_or_else(|_| "dev".to_string())
191}
192
193/// Resolve the database URL from environment variables and `autumn.toml`.
194///
195/// Resolution order (first non-empty value wins):
196/// 1. `AUTUMN_DATABASE__PRIMARY_URL` env var
197/// 2. `AUTUMN_DATABASE__URL` env var
198/// 3. `DATABASE_URL` env var
199/// 4. `[profile.<profile>.database.primary_url]` in `autumn.toml`
200/// 5. `[profile.<profile>.database.url]` in `autumn.toml`
201/// 6. `[database.primary_url]` in `autumn.toml`
202/// 7. `[database.url]` in `autumn.toml`
203fn resolve_database_url(profile: &str) -> Option<String> {
204 if let Ok(url) = std::env::var("AUTUMN_DATABASE__PRIMARY_URL")
205 && !url.is_empty()
206 {
207 return Some(url);
208 }
209 if let Ok(url) = std::env::var("AUTUMN_DATABASE__URL")
210 && !url.is_empty()
211 {
212 return Some(url);
213 }
214 if let Ok(url) = std::env::var("DATABASE_URL")
215 && !url.is_empty()
216 {
217 return Some(url);
218 }
219
220 resolve_database_url_from_toml(profile, Path::new("autumn.toml"))
221}
222
223fn resolve_database_url_from_toml(profile: &str, config_path: &Path) -> Option<String> {
224 if config_path.exists()
225 && let Ok(contents) = std::fs::read_to_string(config_path)
226 && let Ok(table) = toml::from_str::<toml::Table>(&contents)
227 {
228 let value = toml::Value::Table(table);
229
230 // Profile-specific override: [profile.<name>.database.primary_url/url]
231 if let Some(url) = first_database_url(
232 value
233 .get("profile")
234 .and_then(|p| p.get(profile))
235 .and_then(|p| p.get("database")),
236 ) {
237 return Some(url);
238 }
239
240 // Top-level fallback: [database.primary_url/url]
241 if let Some(url) = first_database_url(value.get("database")) {
242 return Some(url);
243 }
244 }
245
246 None
247}
248
249fn first_database_url(database: Option<&toml::Value>) -> Option<String> {
250 let database = database?;
251 for key in ["primary_url", "url"] {
252 if let Some(url) = database
253 .get(key)
254 .and_then(toml::Value::as_str)
255 .filter(|u| !u.is_empty())
256 {
257 return Some(url.to_string());
258 }
259 }
260 None
261}
262
263// ── #1343 AC4: model-name → factory fake-seeder registry ────────────────────
264//
265// The `autumn` CLI cannot name a project's generated model types directly, so
266// each `#[model]` registers a `FakeSeeder` via `inventory` (see the
267// `__autumn_register_fake_seeder!` forwarding macro in `lib.rs`). A seed binary
268// (or `autumn seed --count N --model M`) then looks a model up by name and runs
269// its factory's `.fake().create_many(count, pool)`, all without editing
270// `src/bin/seed.rs`.
271
272/// A registered model factory that `fake_seed_model` can drive by name.
273///
274/// One is submitted per `#[model]` (through the internal
275/// `__autumn_register_fake_seeder!` macro) when autumn-web is built with the
276/// `seed` feature. Collected at link time via [`inventory`].
277pub struct FakeSeeder {
278 /// The model's type name, e.g. `"Post"`. Matched case-insensitively.
279 pub model: &'static str,
280 /// Insert `count` faked rows via the model's factory and return how many
281 /// were inserted.
282 pub run: fn(&Pool<RuntimeConnection>, usize) -> BoxFuture<'_, usize>,
283}
284
285inventory::collect!(FakeSeeder);
286
287/// Error returned by [`fake_seed_model`] when the requested model is not a
288/// registered faked model.
289#[derive(Debug, thiserror::Error)]
290pub enum FakeSeedError {
291 /// No registered `#[model]` matched the requested name.
292 #[error("unknown model {requested:?}; available faked models: {available}")]
293 UnknownModel {
294 /// The `--model` value that did not match any registered model.
295 requested: String,
296 /// Comma-separated list of registered model names (or a placeholder
297 /// when none are registered).
298 available: String,
299 },
300
301 /// The `AUTUMN_SEED_COUNT` value forwarded by `autumn seed --count` did not
302 /// parse as a non-negative integer.
303 #[error("invalid AUTUMN_SEED_COUNT {value:?}: expected a non-negative integer")]
304 InvalidCount {
305 /// The raw `AUTUMN_SEED_COUNT` value that failed to parse.
306 value: String,
307 },
308
309 /// The `AUTUMN_SEED_COUNT` value exceeds [`MAX_FAKE_SEED_COUNT`], the
310 /// sanity ceiling on a single fake-seed request.
311 #[error("AUTUMN_SEED_COUNT {value} exceeds the maximum of {max} rows per fake-seed request")]
312 CountTooLarge {
313 /// The requested count.
314 value: usize,
315 /// The ceiling it exceeded ([`MAX_FAKE_SEED_COUNT`]).
316 max: usize,
317 },
318}
319
320/// Sanity ceiling on a single `--count`/`AUTUMN_SEED_COUNT` fake-seed request.
321///
322/// Generously above any realistic dev-seeding volume, this exists solely to
323/// turn a mistyped or pasted-wrong count (e.g. an extra digit, or a stray
324/// `usize::MAX`) into a clear [`FakeSeedError::CountTooLarge`] instead of a
325/// `Vec::with_capacity` allocation panic deep inside the generated factory's
326/// `create_many`/`build_many`.
327pub const MAX_FAKE_SEED_COUNT: usize = 1_000_000;
328
329/// The names of every registered faked model, sorted, for diagnostics.
330#[must_use]
331pub fn registered_fake_models() -> Vec<&'static str> {
332 let mut names: Vec<&'static str> = inventory::iter::<FakeSeeder>
333 .into_iter()
334 .map(|s| s.model)
335 .collect();
336 names.sort_unstable();
337 names.dedup();
338 names
339}
340
341/// Find the registered [`FakeSeeder`] whose model name matches `name`
342/// (case-insensitive), if any. Pool-free so the lookup can be unit-tested
343/// without a live database.
344fn find_fake_seeder(name: &str) -> Option<&'static FakeSeeder> {
345 inventory::iter::<FakeSeeder>
346 .into_iter()
347 .find(|s| s.model.eq_ignore_ascii_case(name))
348}
349
350/// Generate and insert `count` faked rows for the model named `name`
351/// (case-insensitive), using that model's factory `.fake().create_many(...)`.
352///
353/// Returns the number of rows inserted.
354///
355/// # Errors
356///
357/// Returns [`FakeSeedError::UnknownModel`] when no registered `#[model]`
358/// matches `name`. The error lists the available model names.
359pub async fn fake_seed_model(
360 name: &str,
361 count: usize,
362 pool: &Pool<RuntimeConnection>,
363) -> Result<usize, FakeSeedError> {
364 if let Some(seeder) = find_fake_seeder(name) {
365 return Ok((seeder.run)(pool, count).await);
366 }
367 let available = registered_fake_models();
368 let available = if available.is_empty() {
369 "(none registered)".to_string()
370 } else {
371 available.join(", ")
372 };
373 Err(FakeSeedError::UnknownModel {
374 requested: name.to_string(),
375 available,
376 })
377}
378
379/// Handle a fake-seed request forwarded by `autumn seed --count N --model M`, if
380/// one is present.
381///
382/// `autumn seed --count/--model` forwards the request as the
383/// `AUTUMN_SEED_COUNT` and `AUTUMN_SEED_MODEL` environment variables. This
384/// helper reads them and, when **both** are set, generates that many faked rows
385/// for the named model via [`fake_seed_model`] and prints a one-line summary.
386///
387/// The dispatch lives here — in versioned framework code — rather than being
388/// copied into every generated `src/bin/seed.rs`, so the scaffolded seed binary
389/// is a single call to this function. Fixes/extensions to the flag handling
390/// ship with autumn-web instead of requiring every project to re-scaffold.
391///
392/// Returns:
393/// - `Ok(true)` — a request was present and handled; the caller should return
394/// from its seed `main` without running its hand-written body.
395/// - `Ok(false)` — no request (neither var set); the caller runs its own body.
396///
397/// # Errors
398///
399/// Returns [`FakeSeedError::InvalidCount`] when `AUTUMN_SEED_COUNT` is set but
400/// does not parse as a non-negative integer, or [`FakeSeedError::UnknownModel`]
401/// when `AUTUMN_SEED_MODEL` names no registered `#[model]`.
402pub async fn maybe_fake_seed(pool: &Pool<RuntimeConnection>) -> Result<bool, FakeSeedError> {
403 let (Ok(model), Ok(count)) = (
404 std::env::var("AUTUMN_SEED_MODEL"),
405 std::env::var("AUTUMN_SEED_COUNT"),
406 ) else {
407 return Ok(false);
408 };
409 let count: usize = count
410 .trim()
411 .parse()
412 .map_err(|_| FakeSeedError::InvalidCount { value: count })?;
413 if count > MAX_FAKE_SEED_COUNT {
414 return Err(FakeSeedError::CountTooLarge {
415 value: count,
416 max: MAX_FAKE_SEED_COUNT,
417 });
418 }
419 let inserted = fake_seed_model(&model, count, pool).await?;
420 println!("Inserted {inserted} faked `{model}` row(s).");
421 Ok(true)
422}
423
424// ── #1343 AC4: fake-seeder registry test fixture ────────────────────────────
425//
426// Register a dummy model so the registry-lookup tests below have a known entry
427// to find without depending on any other crate's `#[model]`s. The `run` closure
428// never touches the pool (the lookup tests never invoke it), so it is safe to
429// leave a placeholder body.
430#[cfg(test)]
431inventory::submit! {
432 FakeSeeder {
433 model: "DummyFakeSeederModel",
434 run: |_pool, count| ::std::boxed::Box::pin(async move { count }),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 // ── fake-seeder registry (#1343 AC4) ───────────────────────────────────
443
444 #[test]
445 fn registry_collects_submitted_seeder() {
446 assert!(
447 registered_fake_models().contains(&"DummyFakeSeederModel"),
448 "expected the submitted dummy seeder to be collected via inventory, \
449 got: {:?}",
450 registered_fake_models()
451 );
452 }
453
454 #[test]
455 fn find_fake_seeder_is_case_insensitive() {
456 assert!(find_fake_seeder("DummyFakeSeederModel").is_some());
457 assert!(
458 find_fake_seeder("dummyfakeseedermodel").is_some(),
459 "lookup should be case-insensitive"
460 );
461 assert_eq!(
462 find_fake_seeder("DummyFakeSeederModel").unwrap().model,
463 "DummyFakeSeederModel"
464 );
465 }
466
467 #[test]
468 fn find_fake_seeder_returns_none_for_unknown() {
469 assert!(find_fake_seeder("NoSuchModelXYZ").is_none());
470 }
471
472 #[test]
473 fn unknown_model_error_lists_available_models() {
474 // Build the error the way `fake_seed_model` does for an unknown name,
475 // without needing a live pool.
476 let available = registered_fake_models().join(", ");
477 let err = FakeSeedError::UnknownModel {
478 requested: "NoSuchModelXYZ".to_string(),
479 available,
480 };
481 let msg = err.to_string();
482 assert!(
483 msg.contains("NoSuchModelXYZ"),
484 "error should name the requested model, got: {msg}"
485 );
486 assert!(
487 msg.contains("DummyFakeSeederModel"),
488 "error should list available registered models, got: {msg}"
489 );
490 }
491
492 // ── maybe_fake_seed dispatch (#1343 AC4 centralization) ────────────────
493
494 /// A lazily-built pool (deadpool does not connect until first use), so these
495 /// tests exercise `maybe_fake_seed`'s pre-connection branches without a live
496 /// database.
497 fn lazy_pool() -> Pool<RuntimeConnection> {
498 crate::db::create_pool(&DatabaseConfig {
499 // Backend-parametric: `build_sqlite_pool` refuses a `postgres://`
500 // target, so a literal here panics under `--features "sqlite,seed"`.
501 primary_url: Some(crate::test_urls::primary("unused")),
502 ..DatabaseConfig::default()
503 })
504 .expect("pool builds")
505 .expect("url present => Some(pool)")
506 }
507
508 #[test]
509 fn maybe_fake_seed_returns_false_when_env_unset() {
510 let pool = lazy_pool();
511 temp_env::with_vars(
512 [
513 ("AUTUMN_SEED_MODEL", None::<&str>),
514 ("AUTUMN_SEED_COUNT", None::<&str>),
515 ],
516 || {
517 let handled = futures::executor::block_on(maybe_fake_seed(&pool))
518 .expect("no request is not an error");
519 assert!(
520 !handled,
521 "with no fake request the caller must run its own seed body"
522 );
523 },
524 );
525 }
526
527 #[test]
528 fn maybe_fake_seed_errors_on_non_integer_count() {
529 let pool = lazy_pool();
530 temp_env::with_vars(
531 [
532 ("AUTUMN_SEED_MODEL", Some("DummyFakeSeederModel")),
533 ("AUTUMN_SEED_COUNT", Some("not-a-number")),
534 ],
535 || {
536 let err = futures::executor::block_on(maybe_fake_seed(&pool))
537 .expect_err("a non-integer count must be an error, not a silent no-op");
538 assert!(
539 matches!(err, FakeSeedError::InvalidCount { .. }),
540 "expected InvalidCount, got: {err:?}"
541 );
542 },
543 );
544 }
545
546 #[test]
547 fn maybe_fake_seed_errors_on_count_above_max() {
548 let pool = lazy_pool();
549 let too_large = (MAX_FAKE_SEED_COUNT + 1).to_string();
550 temp_env::with_vars(
551 [
552 ("AUTUMN_SEED_MODEL", Some("DummyFakeSeederModel")),
553 ("AUTUMN_SEED_COUNT", Some(too_large.as_str())),
554 ],
555 || {
556 let err = futures::executor::block_on(maybe_fake_seed(&pool)).expect_err(
557 "a count above the sanity ceiling must be a clean error, not a later \
558 capacity-overflow panic inside create_many/build_many",
559 );
560 assert!(
561 matches!(err, FakeSeedError::CountTooLarge { .. }),
562 "expected CountTooLarge, got: {err:?}"
563 );
564 },
565 );
566 }
567
568 #[test]
569 fn maybe_fake_seed_allows_count_at_max() {
570 // The boundary itself (MAX_FAKE_SEED_COUNT exactly) must not be
571 // rejected by the ceiling check — only values strictly above it.
572 // DummyFakeSeederModel's `run` just echoes `count` back without
573 // allocating, so this exercises the guard without a live database.
574 let pool = lazy_pool();
575 let at_max = MAX_FAKE_SEED_COUNT.to_string();
576 temp_env::with_vars(
577 [
578 ("AUTUMN_SEED_MODEL", Some("DummyFakeSeederModel")),
579 ("AUTUMN_SEED_COUNT", Some(at_max.as_str())),
580 ],
581 || {
582 let handled = futures::executor::block_on(maybe_fake_seed(&pool))
583 .expect("count exactly at the ceiling must be accepted");
584 assert!(handled);
585 },
586 );
587 }
588
589 #[test]
590 fn count_too_large_error_message_reports_value_and_max() {
591 let msg = FakeSeedError::CountTooLarge {
592 value: 5_000_000,
593 max: MAX_FAKE_SEED_COUNT,
594 }
595 .to_string();
596 assert!(msg.contains("5000000"), "got: {msg}");
597 assert!(msg.contains(&MAX_FAKE_SEED_COUNT.to_string()), "got: {msg}");
598 }
599
600 // ── resolve_profile ────────────────────────────────────────────────────
601
602 #[test]
603 fn resolve_profile_defaults_to_dev() {
604 // Isolate from the real environment using temp_env.
605 temp_env::with_vars(
606 [
607 ("AUTUMN_ENV", None::<&str>),
608 ("AUTUMN_PROFILE", None::<&str>),
609 ],
610 || {
611 assert_eq!(resolve_profile(), "dev");
612 },
613 );
614 }
615
616 #[test]
617 fn resolve_profile_prefers_autumn_env() {
618 temp_env::with_vars(
619 [
620 ("AUTUMN_ENV", Some("demo")),
621 ("AUTUMN_PROFILE", Some("test")),
622 ],
623 || {
624 assert_eq!(resolve_profile(), "demo");
625 },
626 );
627 }
628
629 #[test]
630 fn resolve_profile_falls_back_to_autumn_profile() {
631 temp_env::with_vars(
632 [
633 ("AUTUMN_ENV", None::<&str>),
634 ("AUTUMN_PROFILE", Some("staging")),
635 ],
636 || {
637 assert_eq!(resolve_profile(), "staging");
638 },
639 );
640 }
641
642 // ── resolve_database_url ───────────────────────────────────────────────
643
644 #[test]
645 fn resolve_database_url_prefers_autumn_database_primary_url() {
646 temp_env::with_vars(
647 [
648 (
649 "AUTUMN_DATABASE__PRIMARY_URL",
650 Some("postgres://primary:5432/db"),
651 ),
652 ("AUTUMN_DATABASE__URL", Some("postgres://legacy:5432/db")),
653 ("DATABASE_URL", Some("postgres://fallback:5432/db")),
654 ],
655 || {
656 assert_eq!(
657 resolve_database_url("dev").as_deref(),
658 Some("postgres://primary:5432/db")
659 );
660 },
661 );
662 }
663
664 #[test]
665 fn resolve_database_url_falls_back_to_database_url() {
666 temp_env::with_vars(
667 [
668 ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
669 ("AUTUMN_DATABASE__URL", None::<&str>),
670 ("DATABASE_URL", Some("postgres://fallback:5432/db")),
671 ],
672 || {
673 assert_eq!(
674 resolve_database_url("dev").as_deref(),
675 Some("postgres://fallback:5432/db")
676 );
677 },
678 );
679 }
680
681 #[test]
682 fn resolve_database_url_returns_none_when_nothing_configured() {
683 temp_env::with_vars(
684 [
685 ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
686 ("AUTUMN_DATABASE__URL", None::<&str>),
687 ("DATABASE_URL", None::<&str>),
688 ],
689 || {
690 // No autumn.toml in the test runner's cwd (we rely on that
691 // directory not having one; if it does, this test is a no-op).
692 let url = resolve_database_url("dev");
693 // Either None (no autumn.toml) or Some (if autumn.toml exists
694 // with a database.url in the test runner cwd). We can't assert
695 // None unconditionally, so we just assert the function returns
696 // without panicking.
697 let _ = url;
698 },
699 );
700 }
701
702 #[test]
703 fn resolve_database_url_ignores_empty_autumn_database_url() {
704 temp_env::with_vars(
705 [
706 ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
707 ("AUTUMN_DATABASE__URL", Some("")),
708 ("DATABASE_URL", Some("postgres://real:5432/db")),
709 ],
710 || {
711 assert_eq!(
712 resolve_database_url("dev").as_deref(),
713 Some("postgres://real:5432/db")
714 );
715 },
716 );
717 }
718
719 #[test]
720 fn resolve_database_url_uses_profile_specific_section_from_toml() {
721 use tempfile::TempDir;
722 let tmp = TempDir::new().unwrap();
723 let toml_content = r#"
724[database]
725url = "postgres://default:5432/db"
726
727[profile.demo.database]
728primary_url = "postgres://demo:5432/demo_db"
729"#;
730 std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
731
732 let result = temp_env::with_vars(
733 [
734 ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
735 ("AUTUMN_DATABASE__URL", None::<&str>),
736 ("DATABASE_URL", None::<&str>),
737 ],
738 || resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
739 );
740
741 assert_eq!(result.as_deref(), Some("postgres://demo:5432/demo_db"));
742 }
743
744 #[test]
745 fn resolve_database_url_falls_back_to_top_level_when_profile_section_absent() {
746 use tempfile::TempDir;
747 let tmp = TempDir::new().unwrap();
748 let toml_content = r#"
749[database]
750primary_url = "postgres://default:5432/db"
751"#;
752 std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
753
754 let result = temp_env::with_vars(
755 [
756 ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
757 ("AUTUMN_DATABASE__URL", None::<&str>),
758 ("DATABASE_URL", None::<&str>),
759 ],
760 || resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
761 );
762
763 assert_eq!(result.as_deref(), Some("postgres://default:5432/db"));
764 }
765
766 // ── SeedContextError messages ──────────────────────────────────────────
767
768 #[test]
769 fn no_database_url_error_message_is_actionable() {
770 let msg = SeedContextError::NoDatabaseUrl.to_string();
771 assert!(
772 msg.contains("AUTUMN_DATABASE__PRIMARY_URL") || msg.contains("autumn.toml"),
773 "error should be actionable, got: {msg}"
774 );
775 }
776}