use std::path::Path;
use crate::config::DatabaseConfig;
use crate::db::create_pool;
use diesel_async::AsyncPgConnection;
use diesel_async::pooled_connection::deadpool::{Object, Pool};
#[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<AsyncPgConnection>,
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
}
pub async fn conn(&self) -> Result<Object<AsyncPgConnection>, 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
}
#[cfg(test)]
mod tests {
use super::*;
#[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}"
);
}
}