Skip to main content

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}