use std::path::Path;
use crate::config::DatabaseConfig;
use crate::db::RuntimeConnection;
use crate::db::create_pool;
use diesel_async::pooled_connection::deadpool::{Object, Pool};
use futures::future::BoxFuture;
#[derive(Debug, thiserror::Error)]
pub enum SeedContextError {
#[error(
"no primary database URL configured; set AUTUMN_DATABASE__PRIMARY_URL, AUTUMN_DATABASE__URL, or `database.primary_url` in autumn.toml"
)]
NoDatabaseUrl,
#[error("failed to build connection pool: {0}")]
PoolBuild(#[from] crate::db::PoolError),
#[error("failed to acquire database connection: {0}")]
Connection(String),
}
pub struct SeedContext {
pool: Pool<RuntimeConnection>,
profile: String,
}
impl SeedContext {
pub fn build() -> Result<Self, SeedContextError> {
let profile = resolve_profile();
let db_url = resolve_database_url(&profile).ok_or(SeedContextError::NoDatabaseUrl)?;
let config = DatabaseConfig {
primary_url: Some(db_url),
..DatabaseConfig::default()
};
let pool = create_pool(&config)?.ok_or(SeedContextError::NoDatabaseUrl)?;
Ok(Self { pool, profile })
}
#[must_use]
pub fn profile(&self) -> &str {
&self.profile
}
#[must_use]
pub const fn pool(&self) -> &Pool<RuntimeConnection> {
&self.pool
}
pub async fn conn(&self) -> Result<Object<RuntimeConnection>, SeedContextError> {
self.pool
.get()
.await
.map_err(|e| SeedContextError::Connection(e.to_string()))
}
}
fn resolve_profile() -> String {
std::env::var("AUTUMN_ENV")
.or_else(|_| std::env::var("AUTUMN_PROFILE"))
.unwrap_or_else(|_| "dev".to_string())
}
fn resolve_database_url(profile: &str) -> Option<String> {
if let Ok(url) = std::env::var("AUTUMN_DATABASE__PRIMARY_URL")
&& !url.is_empty()
{
return Some(url);
}
if let Ok(url) = std::env::var("AUTUMN_DATABASE__URL")
&& !url.is_empty()
{
return Some(url);
}
if let Ok(url) = std::env::var("DATABASE_URL")
&& !url.is_empty()
{
return Some(url);
}
resolve_database_url_from_toml(profile, Path::new("autumn.toml"))
}
fn resolve_database_url_from_toml(profile: &str, config_path: &Path) -> Option<String> {
if config_path.exists()
&& let Ok(contents) = std::fs::read_to_string(config_path)
&& let Ok(table) = toml::from_str::<toml::Table>(&contents)
{
let value = toml::Value::Table(table);
if let Some(url) = first_database_url(
value
.get("profile")
.and_then(|p| p.get(profile))
.and_then(|p| p.get("database")),
) {
return Some(url);
}
if let Some(url) = first_database_url(value.get("database")) {
return Some(url);
}
}
None
}
fn first_database_url(database: Option<&toml::Value>) -> Option<String> {
let database = database?;
for key in ["primary_url", "url"] {
if let Some(url) = database
.get(key)
.and_then(toml::Value::as_str)
.filter(|u| !u.is_empty())
{
return Some(url.to_string());
}
}
None
}
pub struct FakeSeeder {
pub model: &'static str,
pub run: fn(&Pool<RuntimeConnection>, usize) -> BoxFuture<'_, usize>,
}
inventory::collect!(FakeSeeder);
#[derive(Debug, thiserror::Error)]
pub enum FakeSeedError {
#[error("unknown model {requested:?}; available faked models: {available}")]
UnknownModel {
requested: String,
available: String,
},
#[error("invalid AUTUMN_SEED_COUNT {value:?}: expected a non-negative integer")]
InvalidCount {
value: String,
},
}
#[must_use]
pub fn registered_fake_models() -> Vec<&'static str> {
let mut names: Vec<&'static str> = inventory::iter::<FakeSeeder>
.into_iter()
.map(|s| s.model)
.collect();
names.sort_unstable();
names.dedup();
names
}
fn find_fake_seeder(name: &str) -> Option<&'static FakeSeeder> {
inventory::iter::<FakeSeeder>
.into_iter()
.find(|s| s.model.eq_ignore_ascii_case(name))
}
pub async fn fake_seed_model(
name: &str,
count: usize,
pool: &Pool<RuntimeConnection>,
) -> Result<usize, FakeSeedError> {
if let Some(seeder) = find_fake_seeder(name) {
return Ok((seeder.run)(pool, count).await);
}
let available = registered_fake_models();
let available = if available.is_empty() {
"(none registered)".to_string()
} else {
available.join(", ")
};
Err(FakeSeedError::UnknownModel {
requested: name.to_string(),
available,
})
}
pub async fn maybe_fake_seed(pool: &Pool<RuntimeConnection>) -> Result<bool, FakeSeedError> {
let (Ok(model), Ok(count)) = (
std::env::var("AUTUMN_SEED_MODEL"),
std::env::var("AUTUMN_SEED_COUNT"),
) else {
return Ok(false);
};
let count: usize = count
.trim()
.parse()
.map_err(|_| FakeSeedError::InvalidCount { value: count })?;
let inserted = fake_seed_model(&model, count, pool).await?;
println!("Inserted {inserted} faked `{model}` row(s).");
Ok(true)
}
#[cfg(test)]
inventory::submit! {
FakeSeeder {
model: "DummyFakeSeederModel",
run: |_pool, count| ::std::boxed::Box::pin(async move { count }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_collects_submitted_seeder() {
assert!(
registered_fake_models().contains(&"DummyFakeSeederModel"),
"expected the submitted dummy seeder to be collected via inventory, \
got: {:?}",
registered_fake_models()
);
}
#[test]
fn find_fake_seeder_is_case_insensitive() {
assert!(find_fake_seeder("DummyFakeSeederModel").is_some());
assert!(
find_fake_seeder("dummyfakeseedermodel").is_some(),
"lookup should be case-insensitive"
);
assert_eq!(
find_fake_seeder("DummyFakeSeederModel").unwrap().model,
"DummyFakeSeederModel"
);
}
#[test]
fn find_fake_seeder_returns_none_for_unknown() {
assert!(find_fake_seeder("NoSuchModelXYZ").is_none());
}
#[test]
fn unknown_model_error_lists_available_models() {
let available = registered_fake_models().join(", ");
let err = FakeSeedError::UnknownModel {
requested: "NoSuchModelXYZ".to_string(),
available,
};
let msg = err.to_string();
assert!(
msg.contains("NoSuchModelXYZ"),
"error should name the requested model, got: {msg}"
);
assert!(
msg.contains("DummyFakeSeederModel"),
"error should list available registered models, got: {msg}"
);
}
fn lazy_pool() -> Pool<RuntimeConnection> {
crate::db::create_pool(&DatabaseConfig {
primary_url: Some("postgres://localhost/unused".to_string()),
..DatabaseConfig::default()
})
.expect("pool builds")
.expect("url present => Some(pool)")
}
#[test]
fn maybe_fake_seed_returns_false_when_env_unset() {
let pool = lazy_pool();
temp_env::with_vars(
[
("AUTUMN_SEED_MODEL", None::<&str>),
("AUTUMN_SEED_COUNT", None::<&str>),
],
|| {
let handled = futures::executor::block_on(maybe_fake_seed(&pool))
.expect("no request is not an error");
assert!(
!handled,
"with no fake request the caller must run its own seed body"
);
},
);
}
#[test]
fn maybe_fake_seed_errors_on_non_integer_count() {
let pool = lazy_pool();
temp_env::with_vars(
[
("AUTUMN_SEED_MODEL", Some("DummyFakeSeederModel")),
("AUTUMN_SEED_COUNT", Some("not-a-number")),
],
|| {
let err = futures::executor::block_on(maybe_fake_seed(&pool))
.expect_err("a non-integer count must be an error, not a silent no-op");
assert!(
matches!(err, FakeSeedError::InvalidCount { .. }),
"expected InvalidCount, got: {err:?}"
);
},
);
}
#[test]
fn resolve_profile_defaults_to_dev() {
temp_env::with_vars(
[
("AUTUMN_ENV", None::<&str>),
("AUTUMN_PROFILE", None::<&str>),
],
|| {
assert_eq!(resolve_profile(), "dev");
},
);
}
#[test]
fn resolve_profile_prefers_autumn_env() {
temp_env::with_vars(
[
("AUTUMN_ENV", Some("demo")),
("AUTUMN_PROFILE", Some("test")),
],
|| {
assert_eq!(resolve_profile(), "demo");
},
);
}
#[test]
fn resolve_profile_falls_back_to_autumn_profile() {
temp_env::with_vars(
[
("AUTUMN_ENV", None::<&str>),
("AUTUMN_PROFILE", Some("staging")),
],
|| {
assert_eq!(resolve_profile(), "staging");
},
);
}
#[test]
fn resolve_database_url_prefers_autumn_database_primary_url() {
temp_env::with_vars(
[
(
"AUTUMN_DATABASE__PRIMARY_URL",
Some("postgres://primary:5432/db"),
),
("AUTUMN_DATABASE__URL", Some("postgres://legacy:5432/db")),
("DATABASE_URL", Some("postgres://fallback:5432/db")),
],
|| {
assert_eq!(
resolve_database_url("dev").as_deref(),
Some("postgres://primary:5432/db")
);
},
);
}
#[test]
fn resolve_database_url_falls_back_to_database_url() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", Some("postgres://fallback:5432/db")),
],
|| {
assert_eq!(
resolve_database_url("dev").as_deref(),
Some("postgres://fallback:5432/db")
);
},
);
}
#[test]
fn resolve_database_url_returns_none_when_nothing_configured() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let url = resolve_database_url("dev");
let _ = url;
},
);
}
#[test]
fn resolve_database_url_ignores_empty_autumn_database_url() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", Some("")),
("DATABASE_URL", Some("postgres://real:5432/db")),
],
|| {
assert_eq!(
resolve_database_url("dev").as_deref(),
Some("postgres://real:5432/db")
);
},
);
}
#[test]
fn resolve_database_url_uses_profile_specific_section_from_toml() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let toml_content = r#"
[database]
url = "postgres://default:5432/db"
[profile.demo.database]
primary_url = "postgres://demo:5432/demo_db"
"#;
std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
let result = temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
);
assert_eq!(result.as_deref(), Some("postgres://demo:5432/demo_db"));
}
#[test]
fn resolve_database_url_falls_back_to_top_level_when_profile_section_absent() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let toml_content = r#"
[database]
primary_url = "postgres://default:5432/db"
"#;
std::fs::write(tmp.path().join("autumn.toml"), toml_content).unwrap();
let result = temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| resolve_database_url_from_toml("demo", &tmp.path().join("autumn.toml")),
);
assert_eq!(result.as_deref(), Some("postgres://default:5432/db"));
}
#[test]
fn no_database_url_error_message_is_actionable() {
let msg = SeedContextError::NoDatabaseUrl.to_string();
assert!(
msg.contains("AUTUMN_DATABASE__PRIMARY_URL") || msg.contains("autumn.toml"),
"error should be actionable, got: {msg}"
);
}
}