use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use pgevolve_core::identifier::Identifier;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("i/o reading {0}: {1}")]
Io(PathBuf, #[source] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("managed.schemas[{idx}]: {message}")]
InvalidSchemaName {
idx: usize,
message: String,
},
#[error("planner.strategy: expected `atomic` or `online`, got `{0}`")]
InvalidStrategy(String),
#[error("unknown environment: `{0}`")]
UnknownEnvironment(String),
#[error("environment variable `{0}` is not set")]
EnvVarMissing(String),
#[error("environment `{0}`: must set either `url` or `url_env`")]
EnvMissingDsn(String),
#[error("environment `{0}`: set only one of `url` or `url_env`")]
EnvDoubleDsn(String),
#[error("path not found: {0}")]
PathNotFound(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigStrategy {
Atomic,
#[default]
Online,
}
impl ConfigStrategy {
pub const fn to_planner_strategy(self) -> pgevolve_core::plan::Strategy {
match self {
Self::Atomic => pgevolve_core::plan::Strategy::Atomic,
Self::Online => pgevolve_core::plan::Strategy::Online,
}
}
}
#[derive(Debug, Deserialize)]
pub struct PgevolveConfig {
pub project: ProjectConfig,
#[serde(default)]
pub managed: ManagedConfig,
#[serde(default)]
pub planner: PlannerConfig,
#[serde(default)]
pub environments: BTreeMap<String, EnvironmentConfig>,
#[serde(default)]
pub shadow: Option<ShadowConfig>,
#[serde(default)]
pub cluster: Option<ClusterLink>,
}
#[derive(Debug, Deserialize)]
pub struct ClusterLink {
pub project: String,
}
#[derive(Debug, Deserialize)]
pub struct ProjectConfig {
pub name: String,
#[serde(default = "default_schema_dir")]
pub schema_dir: PathBuf,
#[serde(default = "default_plan_dir")]
pub plan_dir: PathBuf,
#[serde(default = "default_layout_profile")]
pub layout_profile: String,
}
fn default_schema_dir() -> PathBuf {
PathBuf::from("schema")
}
fn default_plan_dir() -> PathBuf {
PathBuf::from("plans")
}
fn default_layout_profile() -> String {
"schema-mirror".into()
}
const fn default_min_pg_version() -> u32 {
14
}
#[derive(Debug, Default, Deserialize)]
pub struct ManagedConfig {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub ignore_objects: Vec<String>,
#[serde(default = "default_min_pg_version")]
pub min_pg_version: u32,
}
#[derive(Debug, Default, Deserialize)]
pub struct PlannerConfig {
#[serde(default = "default_strategy")]
pub strategy: ConfigStrategy,
#[serde(default)]
pub online_rewrites: PlannerOnlineRewrites,
}
const fn default_strategy() -> ConfigStrategy {
ConfigStrategy::Online
}
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Deserialize)]
pub struct PlannerOnlineRewrites {
#[serde(default = "default_true")]
pub create_index_concurrent: bool,
#[serde(default = "default_true")]
pub fk_not_valid_then_validate: bool,
#[serde(default = "default_true")]
pub check_not_valid_then_validate: bool,
#[serde(default = "default_true")]
pub not_null_via_check_pattern: bool,
#[serde(default = "default_true")]
pub refresh_mv_concurrently: bool,
#[serde(default = "default_true")]
pub view_drop_create_dependents: bool,
}
impl Default for PlannerOnlineRewrites {
fn default() -> Self {
Self {
create_index_concurrent: true,
fk_not_valid_then_validate: true,
check_not_valid_then_validate: true,
not_null_via_check_pattern: true,
refresh_mv_concurrently: true,
view_drop_create_dependents: true,
}
}
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize)]
pub struct EnvironmentConfig {
pub url: Option<String>,
pub url_env: Option<String>,
pub strategy: Option<ConfigStrategy>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ShadowConfig {
pub backend: Option<String>,
pub postgres_version: Option<String>,
pub url: Option<String>,
pub url_env: Option<String>,
pub reset: Option<String>,
#[serde(default)]
pub extensions: Vec<String>,
}
impl PgevolveConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
for (i, s) in self.managed.schemas.iter().enumerate() {
Identifier::from_unquoted(s).map_err(|e| ConfigError::InvalidSchemaName {
idx: i,
message: e.to_string(),
})?;
}
for (name, env) in &self.environments {
if env.url.is_some() && env.url_env.is_some() {
return Err(ConfigError::EnvDoubleDsn(name.clone()));
}
}
Ok(())
}
}
pub fn load(path: &Path) -> Result<PgevolveConfig, ConfigError> {
let bytes = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(path.into(), e))?;
let cfg: PgevolveConfig = toml::from_str(&bytes)?;
cfg.validate()?;
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_tmp(s: &str) -> tempfile::NamedTempFile {
use std::io::Write as _;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(s.as_bytes()).unwrap();
f
}
#[test]
fn loads_minimal_config() {
let f = write_tmp(
"[project]\nname = \"x\"\n[environments.dev]\nurl = \"postgres://localhost/x\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.project.name, "x");
assert_eq!(cfg.project.schema_dir, PathBuf::from("schema"));
assert_eq!(cfg.planner.strategy, ConfigStrategy::Online);
assert_eq!(
cfg.environments["dev"].url.as_deref(),
Some("postgres://localhost/x")
);
}
#[test]
fn rejects_invalid_strategy_via_toml() {
let f = write_tmp("[project]\nname=\"x\"\n[planner]\nstrategy=\"bogus\"\n");
assert!(matches!(load(f.path()), Err(ConfigError::Parse(_))));
}
#[test]
fn rejects_double_dsn() {
let f = write_tmp(
"[project]\nname=\"x\"\n\
[environments.dev]\nurl=\"a\"\nurl_env=\"B\"\n",
);
assert!(matches!(load(f.path()), Err(ConfigError::EnvDoubleDsn(_))));
}
#[test]
fn rejects_invalid_schema_name() {
let f = write_tmp(
"[project]\nname=\"x\"\n\
[managed]\nschemas=[\"ok\", \"contains space\"]\n",
);
match load(f.path()) {
Err(ConfigError::InvalidSchemaName { idx, .. }) => assert_eq!(idx, 1),
other => panic!("expected InvalidSchemaName, got {other:?}"),
}
}
#[test]
fn missing_dsn_is_allowed_for_libpq_fallback() {
let f = write_tmp(
"[project]\nname=\"x\"\n\
[environments.dev]\nstrategy=\"atomic\"\n",
);
let cfg = load(f.path()).unwrap();
let env = &cfg.environments["dev"];
assert!(env.url.is_none() && env.url_env.is_none());
assert_eq!(env.strategy, Some(ConfigStrategy::Atomic));
}
#[test]
fn parses_pgevolve_toml_with_cluster_block() {
let f = write_tmp(
"[project]\nname=\"x\"\n\
[cluster]\nproject = \"../my-cluster\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.cluster.unwrap().project, "../my-cluster");
}
#[test]
fn parses_pgevolve_toml_without_cluster_block() {
let f = write_tmp("[project]\nname=\"x\"\n");
let cfg = load(f.path()).unwrap();
assert!(cfg.cluster.is_none());
}
#[test]
fn min_pg_version_defaults_to_14() {
let f = write_tmp(
"[project]\nname=\"t\"\nschema_dir=\"schema\"\nplan_dir=\"plans\"\n\
[managed]\nschemas=[\"app\"]\n\
[environments.dev]\nurl=\"postgres://localhost\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.managed.min_pg_version, 14);
}
#[test]
fn min_pg_version_can_be_raised() {
let f = write_tmp(
"[project]\nname=\"t\"\nschema_dir=\"schema\"\nplan_dir=\"plans\"\n\
[managed]\nschemas=[\"app\"]\nmin_pg_version=16\n\
[environments.dev]\nurl=\"postgres://localhost\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.managed.min_pg_version, 16);
}
#[test]
fn min_pg_version_accepts_18() {
let f = write_tmp(
"[project]\nname=\"t\"\nschema_dir=\"schema\"\nplan_dir=\"plans\"\n\
[managed]\nschemas=[\"app\"]\nmin_pg_version=18\n\
[environments.dev]\nurl=\"postgres://localhost\"\n",
);
let cfg = load(f.path()).unwrap();
assert_eq!(cfg.managed.min_pg_version, 18);
}
}