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}