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    /// # Errors
124    ///
125    /// Returns [`SeedContextError::NoDatabaseUrl`] if no database URL is
126    /// configured, or [`SeedContextError::PoolBuild`] if the pool cannot be
127    /// constructed.
128    pub fn build() -> Result<Self, SeedContextError> {
129        let profile = resolve_profile();
130        let db_url = resolve_database_url(&profile).ok_or(SeedContextError::NoDatabaseUrl)?;
131
132        let config = DatabaseConfig {
133            primary_url: Some(db_url),
134            ..DatabaseConfig::default()
135        };
136
137        let pool = create_pool(&config)?.ok_or(SeedContextError::NoDatabaseUrl)?;
138
139        Ok(Self { pool, profile })
140    }
141
142    /// Returns the active profile name (e.g. `"dev"`, `"demo"`, `"test"`).
143    #[must_use]
144    pub fn profile(&self) -> &str {
145        &self.profile
146    }
147
148    /// Returns the underlying connection pool.
149    ///
150    /// Useful for APIs that take a `&Pool<AsyncPgConnection>` directly, such as
151    /// factory `create_many(count, pool)` and
152    /// [`fake_seed_model`].
153    #[must_use]
154    pub const fn pool(&self) -> &Pool<RuntimeConnection> {
155        &self.pool
156    }
157
158    /// Acquires a pooled database connection.
159    ///
160    /// Returns a [`Object<AsyncPgConnection>`] that implements `DerefMut` to
161    /// `AsyncPgConnection`, so it can be passed directly to diesel-async
162    /// query methods as `&mut *conn`.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`SeedContextError::Connection`] if the pool is exhausted or
167    /// the connection cannot be established.
168    pub async fn conn(&self) -> Result<Object<RuntimeConnection>, SeedContextError> {
169        self.pool
170            .get()
171            .await
172            .map_err(|e| SeedContextError::Connection(e.to_string()))
173    }
174}
175
176/// Resolve the active profile from environment variables.
177fn resolve_profile() -> String {
178    std::env::var("AUTUMN_ENV")
179        .or_else(|_| std::env::var("AUTUMN_PROFILE"))
180        .unwrap_or_else(|_| "dev".to_string())
181}
182
183/// Resolve the database URL from environment variables and `autumn.toml`.
184///
185/// Resolution order (first non-empty value wins):
186/// 1. `AUTUMN_DATABASE__PRIMARY_URL` env var
187/// 2. `AUTUMN_DATABASE__URL` env var
188/// 3. `DATABASE_URL` env var
189/// 4. `[profile.<profile>.database.primary_url]` in `autumn.toml`
190/// 5. `[profile.<profile>.database.url]` in `autumn.toml`
191/// 6. `[database.primary_url]` in `autumn.toml`
192/// 7. `[database.url]` in `autumn.toml`
193fn resolve_database_url(profile: &str) -> Option<String> {
194    if let Ok(url) = std::env::var("AUTUMN_DATABASE__PRIMARY_URL")
195        && !url.is_empty()
196    {
197        return Some(url);
198    }
199    if let Ok(url) = std::env::var("AUTUMN_DATABASE__URL")
200        && !url.is_empty()
201    {
202        return Some(url);
203    }
204    if let Ok(url) = std::env::var("DATABASE_URL")
205        && !url.is_empty()
206    {
207        return Some(url);
208    }
209
210    resolve_database_url_from_toml(profile, Path::new("autumn.toml"))
211}
212
213fn resolve_database_url_from_toml(profile: &str, config_path: &Path) -> Option<String> {
214    if config_path.exists()
215        && let Ok(contents) = std::fs::read_to_string(config_path)
216        && let Ok(table) = toml::from_str::<toml::Table>(&contents)
217    {
218        let value = toml::Value::Table(table);
219
220        // Profile-specific override: [profile.<name>.database.primary_url/url]
221        if let Some(url) = first_database_url(
222            value
223                .get("profile")
224                .and_then(|p| p.get(profile))
225                .and_then(|p| p.get("database")),
226        ) {
227            return Some(url);
228        }
229
230        // Top-level fallback: [database.primary_url/url]
231        if let Some(url) = first_database_url(value.get("database")) {
232            return Some(url);
233        }
234    }
235
236    None
237}
238
239fn first_database_url(database: Option<&toml::Value>) -> Option<String> {
240    let database = database?;
241    for key in ["primary_url", "url"] {
242        if let Some(url) = database
243            .get(key)
244            .and_then(toml::Value::as_str)
245            .filter(|u| !u.is_empty())
246        {
247            return Some(url.to_string());
248        }
249    }
250    None
251}
252
253// ── #1343 AC4: model-name → factory fake-seeder registry ────────────────────
254//
255// The `autumn` CLI cannot name a project's generated model types directly, so
256// each `#[model]` registers a `FakeSeeder` via `inventory` (see the
257// `__autumn_register_fake_seeder!` forwarding macro in `lib.rs`). A seed binary
258// (or `autumn seed --count N --model M`) then looks a model up by name and runs
259// its factory's `.fake().create_many(count, pool)`, all without editing
260// `src/bin/seed.rs`.
261
262/// A registered model factory that `fake_seed_model` can drive by name.
263///
264/// One is submitted per `#[model]` (through the internal
265/// `__autumn_register_fake_seeder!` macro) when autumn-web is built with the
266/// `seed` feature. Collected at link time via [`inventory`].
267pub struct FakeSeeder {
268    /// The model's type name, e.g. `"Post"`. Matched case-insensitively.
269    pub model: &'static str,
270    /// Insert `count` faked rows via the model's factory and return how many
271    /// were inserted.
272    pub run: fn(&Pool<RuntimeConnection>, usize) -> BoxFuture<'_, usize>,
273}
274
275inventory::collect!(FakeSeeder);
276
277/// Error returned by [`fake_seed_model`] when the requested model is not a
278/// registered faked model.
279#[derive(Debug, thiserror::Error)]
280pub enum FakeSeedError {
281    /// No registered `#[model]` matched the requested name.
282    #[error("unknown model {requested:?}; available faked models: {available}")]
283    UnknownModel {
284        /// The `--model` value that did not match any registered model.
285        requested: String,
286        /// Comma-separated list of registered model names (or a placeholder
287        /// when none are registered).
288        available: String,
289    },
290
291    /// The `AUTUMN_SEED_COUNT` value forwarded by `autumn seed --count` did not
292    /// parse as a non-negative integer.
293    #[error("invalid AUTUMN_SEED_COUNT {value:?}: expected a non-negative integer")]
294    InvalidCount {
295        /// The raw `AUTUMN_SEED_COUNT` value that failed to parse.
296        value: String,
297    },
298}
299
300/// The names of every registered faked model, sorted, for diagnostics.
301#[must_use]
302pub fn registered_fake_models() -> Vec<&'static str> {
303    let mut names: Vec<&'static str> = inventory::iter::<FakeSeeder>
304        .into_iter()
305        .map(|s| s.model)
306        .collect();
307    names.sort_unstable();
308    names.dedup();
309    names
310}
311
312/// Find the registered [`FakeSeeder`] whose model name matches `name`
313/// (case-insensitive), if any. Pool-free so the lookup can be unit-tested
314/// without a live database.
315fn find_fake_seeder(name: &str) -> Option<&'static FakeSeeder> {
316    inventory::iter::<FakeSeeder>
317        .into_iter()
318        .find(|s| s.model.eq_ignore_ascii_case(name))
319}
320
321/// Generate and insert `count` faked rows for the model named `name`
322/// (case-insensitive), using that model's factory `.fake().create_many(...)`.
323///
324/// Returns the number of rows inserted.
325///
326/// # Errors
327///
328/// Returns [`FakeSeedError::UnknownModel`] when no registered `#[model]`
329/// matches `name`. The error lists the available model names.
330pub async fn fake_seed_model(
331    name: &str,
332    count: usize,
333    pool: &Pool<RuntimeConnection>,
334) -> Result<usize, FakeSeedError> {
335    if let Some(seeder) = find_fake_seeder(name) {
336        return Ok((seeder.run)(pool, count).await);
337    }
338    let available = registered_fake_models();
339    let available = if available.is_empty() {
340        "(none registered)".to_string()
341    } else {
342        available.join(", ")
343    };
344    Err(FakeSeedError::UnknownModel {
345        requested: name.to_string(),
346        available,
347    })
348}
349
350/// Handle a fake-seed request forwarded by `autumn seed --count N --model M`, if
351/// one is present.
352///
353/// `autumn seed --count/--model` forwards the request as the
354/// `AUTUMN_SEED_COUNT` and `AUTUMN_SEED_MODEL` environment variables. This
355/// helper reads them and, when **both** are set, generates that many faked rows
356/// for the named model via [`fake_seed_model`] and prints a one-line summary.
357///
358/// The dispatch lives here — in versioned framework code — rather than being
359/// copied into every generated `src/bin/seed.rs`, so the scaffolded seed binary
360/// is a single call to this function. Fixes/extensions to the flag handling
361/// ship with autumn-web instead of requiring every project to re-scaffold.
362///
363/// Returns:
364/// - `Ok(true)` — a request was present and handled; the caller should return
365///   from its seed `main` without running its hand-written body.
366/// - `Ok(false)` — no request (neither var set); the caller runs its own body.
367///
368/// # Errors
369///
370/// Returns [`FakeSeedError::InvalidCount`] when `AUTUMN_SEED_COUNT` is set but
371/// does not parse as a non-negative integer, or [`FakeSeedError::UnknownModel`]
372/// when `AUTUMN_SEED_MODEL` names no registered `#[model]`.
373pub async fn maybe_fake_seed(pool: &Pool<RuntimeConnection>) -> Result<bool, FakeSeedError> {
374    let (Ok(model), Ok(count)) = (
375        std::env::var("AUTUMN_SEED_MODEL"),
376        std::env::var("AUTUMN_SEED_COUNT"),
377    ) else {
378        return Ok(false);
379    };
380    let count: usize = count
381        .trim()
382        .parse()
383        .map_err(|_| FakeSeedError::InvalidCount { value: count })?;
384    let inserted = fake_seed_model(&model, count, pool).await?;
385    println!("Inserted {inserted} faked `{model}` row(s).");
386    Ok(true)
387}
388
389// ── #1343 AC4: fake-seeder registry test fixture ────────────────────────────
390//
391// Register a dummy model so the registry-lookup tests below have a known entry
392// to find without depending on any other crate's `#[model]`s. The `run` closure
393// never touches the pool (the lookup tests never invoke it), so it is safe to
394// leave a placeholder body.
395#[cfg(test)]
396inventory::submit! {
397    FakeSeeder {
398        model: "DummyFakeSeederModel",
399        run: |_pool, count| ::std::boxed::Box::pin(async move { count }),
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    // ── fake-seeder registry (#1343 AC4) ───────────────────────────────────
408
409    #[test]
410    fn registry_collects_submitted_seeder() {
411        assert!(
412            registered_fake_models().contains(&"DummyFakeSeederModel"),
413            "expected the submitted dummy seeder to be collected via inventory, \
414             got: {:?}",
415            registered_fake_models()
416        );
417    }
418
419    #[test]
420    fn find_fake_seeder_is_case_insensitive() {
421        assert!(find_fake_seeder("DummyFakeSeederModel").is_some());
422        assert!(
423            find_fake_seeder("dummyfakeseedermodel").is_some(),
424            "lookup should be case-insensitive"
425        );
426        assert_eq!(
427            find_fake_seeder("DummyFakeSeederModel").unwrap().model,
428            "DummyFakeSeederModel"
429        );
430    }
431
432    #[test]
433    fn find_fake_seeder_returns_none_for_unknown() {
434        assert!(find_fake_seeder("NoSuchModelXYZ").is_none());
435    }
436
437    #[test]
438    fn unknown_model_error_lists_available_models() {
439        // Build the error the way `fake_seed_model` does for an unknown name,
440        // without needing a live pool.
441        let available = registered_fake_models().join(", ");
442        let err = FakeSeedError::UnknownModel {
443            requested: "NoSuchModelXYZ".to_string(),
444            available,
445        };
446        let msg = err.to_string();
447        assert!(
448            msg.contains("NoSuchModelXYZ"),
449            "error should name the requested model, got: {msg}"
450        );
451        assert!(
452            msg.contains("DummyFakeSeederModel"),
453            "error should list available registered models, got: {msg}"
454        );
455    }
456
457    // ── maybe_fake_seed dispatch (#1343 AC4 centralization) ────────────────
458
459    /// A lazily-built pool (deadpool does not connect until first use), so these
460    /// tests exercise `maybe_fake_seed`'s pre-connection branches without a live
461    /// database.
462    fn lazy_pool() -> Pool<RuntimeConnection> {
463        crate::db::create_pool(&DatabaseConfig {
464            primary_url: Some("postgres://localhost/unused".to_string()),
465            ..DatabaseConfig::default()
466        })
467        .expect("pool builds")
468        .expect("url present => Some(pool)")
469    }
470
471    #[test]
472    fn maybe_fake_seed_returns_false_when_env_unset() {
473        let pool = lazy_pool();
474        temp_env::with_vars(
475            [
476                ("AUTUMN_SEED_MODEL", None::<&str>),
477                ("AUTUMN_SEED_COUNT", None::<&str>),
478            ],
479            || {
480                let handled = futures::executor::block_on(maybe_fake_seed(&pool))
481                    .expect("no request is not an error");
482                assert!(
483                    !handled,
484                    "with no fake request the caller must run its own seed body"
485                );
486            },
487        );
488    }
489
490    #[test]
491    fn maybe_fake_seed_errors_on_non_integer_count() {
492        let pool = lazy_pool();
493        temp_env::with_vars(
494            [
495                ("AUTUMN_SEED_MODEL", Some("DummyFakeSeederModel")),
496                ("AUTUMN_SEED_COUNT", Some("not-a-number")),
497            ],
498            || {
499                let err = futures::executor::block_on(maybe_fake_seed(&pool))
500                    .expect_err("a non-integer count must be an error, not a silent no-op");
501                assert!(
502                    matches!(err, FakeSeedError::InvalidCount { .. }),
503                    "expected InvalidCount, got: {err:?}"
504                );
505            },
506        );
507    }
508
509    // ── resolve_profile ────────────────────────────────────────────────────
510
511    #[test]
512    fn resolve_profile_defaults_to_dev() {
513        // Isolate from the real environment using temp_env.
514        temp_env::with_vars(
515            [
516                ("AUTUMN_ENV", None::<&str>),
517                ("AUTUMN_PROFILE", None::<&str>),
518            ],
519            || {
520                assert_eq!(resolve_profile(), "dev");
521            },
522        );
523    }
524
525    #[test]
526    fn resolve_profile_prefers_autumn_env() {
527        temp_env::with_vars(
528            [
529                ("AUTUMN_ENV", Some("demo")),
530                ("AUTUMN_PROFILE", Some("test")),
531            ],
532            || {
533                assert_eq!(resolve_profile(), "demo");
534            },
535        );
536    }
537
538    #[test]
539    fn resolve_profile_falls_back_to_autumn_profile() {
540        temp_env::with_vars(
541            [
542                ("AUTUMN_ENV", None::<&str>),
543                ("AUTUMN_PROFILE", Some("staging")),
544            ],
545            || {
546                assert_eq!(resolve_profile(), "staging");
547            },
548        );
549    }
550
551    // ── resolve_database_url ───────────────────────────────────────────────
552
553    #[test]
554    fn resolve_database_url_prefers_autumn_database_primary_url() {
555        temp_env::with_vars(
556            [
557                (
558                    "AUTUMN_DATABASE__PRIMARY_URL",
559                    Some("postgres://primary:5432/db"),
560                ),
561                ("AUTUMN_DATABASE__URL", Some("postgres://legacy:5432/db")),
562                ("DATABASE_URL", Some("postgres://fallback:5432/db")),
563            ],
564            || {
565                assert_eq!(
566                    resolve_database_url("dev").as_deref(),
567                    Some("postgres://primary:5432/db")
568                );
569            },
570        );
571    }
572
573    #[test]
574    fn resolve_database_url_falls_back_to_database_url() {
575        temp_env::with_vars(
576            [
577                ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
578                ("AUTUMN_DATABASE__URL", None::<&str>),
579                ("DATABASE_URL", Some("postgres://fallback:5432/db")),
580            ],
581            || {
582                assert_eq!(
583                    resolve_database_url("dev").as_deref(),
584                    Some("postgres://fallback:5432/db")
585                );
586            },
587        );
588    }
589
590    #[test]
591    fn resolve_database_url_returns_none_when_nothing_configured() {
592        temp_env::with_vars(
593            [
594                ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
595                ("AUTUMN_DATABASE__URL", None::<&str>),
596                ("DATABASE_URL", None::<&str>),
597            ],
598            || {
599                // No autumn.toml in the test runner's cwd (we rely on that
600                // directory not having one; if it does, this test is a no-op).
601                let url = resolve_database_url("dev");
602                // Either None (no autumn.toml) or Some (if autumn.toml exists
603                // with a database.url in the test runner cwd). We can't assert
604                // None unconditionally, so we just assert the function returns
605                // without panicking.
606                let _ = url;
607            },
608        );
609    }
610
611    #[test]
612    fn resolve_database_url_ignores_empty_autumn_database_url() {
613        temp_env::with_vars(
614            [
615                ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
616                ("AUTUMN_DATABASE__URL", Some("")),
617                ("DATABASE_URL", Some("postgres://real:5432/db")),
618            ],
619            || {
620                assert_eq!(
621                    resolve_database_url("dev").as_deref(),
622                    Some("postgres://real:5432/db")
623                );
624            },
625        );
626    }
627
628    #[test]
629    fn resolve_database_url_uses_profile_specific_section_from_toml() {
630        use tempfile::TempDir;
631        let tmp = TempDir::new().unwrap();
632        let toml_content = r#"
633[database]
634url = "postgres://default:5432/db"
635
636[profile.demo.database]
637primary_url = "postgres://demo:5432/demo_db"
638"#;
639        std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
640
641        let result = temp_env::with_vars(
642            [
643                ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
644                ("AUTUMN_DATABASE__URL", None::<&str>),
645                ("DATABASE_URL", None::<&str>),
646            ],
647            || resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
648        );
649
650        assert_eq!(result.as_deref(), Some("postgres://demo:5432/demo_db"));
651    }
652
653    #[test]
654    fn resolve_database_url_falls_back_to_top_level_when_profile_section_absent() {
655        use tempfile::TempDir;
656        let tmp = TempDir::new().unwrap();
657        let toml_content = r#"
658[database]
659primary_url = "postgres://default:5432/db"
660"#;
661        std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
662
663        let result = temp_env::with_vars(
664            [
665                ("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
666                ("AUTUMN_DATABASE__URL", None::<&str>),
667                ("DATABASE_URL", None::<&str>),
668            ],
669            || resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
670        );
671
672        assert_eq!(result.as_deref(), Some("postgres://default:5432/db"));
673    }
674
675    // ── SeedContextError messages ──────────────────────────────────────────
676
677    #[test]
678    fn no_database_url_error_message_is_actionable() {
679        let msg = SeedContextError::NoDatabaseUrl.to_string();
680        assert!(
681            msg.contains("AUTUMN_DATABASE__PRIMARY_URL") || msg.contains("autumn.toml"),
682            "error should be actionable, got: {msg}"
683        );
684    }
685}