use anyhow::Result;
use std::fs;
use std::path::Path;
use crate::constants::{
CONFIG_FILENAME, FUNCTIONS_SUBDIR, SCHEMAS_SUBDIR, TABLES_SUBDIR, TYPES_SUBDIR, VIEWS_SUBDIR,
};
pub fn create_project_structure(options: &super::InitOptions) -> Result<()> {
fs::create_dir_all(&options.project_dir)?;
let migrations_dir = options.project_dir.join(&options.migrations_dir);
let baselines_dir = options.project_dir.join(&options.baselines_dir);
let full_schema_dir = options.project_dir.join(&options.schema_dir);
fs::create_dir_all(&migrations_dir)?;
fs::create_dir_all(&baselines_dir)?;
fs::create_dir_all(&full_schema_dir)?;
fs::create_dir_all(full_schema_dir.join(SCHEMAS_SUBDIR))?;
fs::create_dir_all(full_schema_dir.join(TYPES_SUBDIR))?;
fs::create_dir_all(full_schema_dir.join(TABLES_SUBDIR))?;
fs::create_dir_all(full_schema_dir.join(VIEWS_SUBDIR))?;
fs::create_dir_all(full_schema_dir.join(FUNCTIONS_SUBDIR))?;
Ok(())
}
const CONFIG_HEADER: &str = r#"# pgmt Configuration File
# Generated by pgmt init — reference: https://docs.pgmt.dev/docs/reference/configuration
#
# Common options to add:
# databases.shadow.docker.image custom shadow image (e.g. PostGIS, Supabase)
# databases.shadow.docker.platform e.g. linux/amd64 for single-arch images
# objects.include.schemas limit pgmt to the schemas you manage
"#;
pub fn generate_config_file(
options: &super::InitOptions,
existing: Option<&crate::config::types::ConfigInput>,
project_dir: &Path,
) -> Result<()> {
use crate::config::merge::Merge;
let gathered = gathered_config_input(options);
let mut merged = match existing {
Some(base) => base.clone().merge(gathered),
None => gathered,
};
if !options.substrate_exclusions.is_empty() {
let objects = merged.objects.get_or_insert_with(Default::default);
let exclude = objects.exclude.get_or_insert_with(Default::default);
let schemas = exclude.schemas.get_or_insert_with(Vec::new);
for schema in &options.substrate_exclusions {
if !schemas.contains(schema) {
schemas.push(schema.clone());
}
}
}
let yaml = serde_yaml::to_string(&merged)?;
let config_path = project_dir.join(CONFIG_FILENAME);
std::fs::write(config_path, format!("{CONFIG_HEADER}\n{yaml}"))?;
Ok(())
}
fn gathered_config_input(options: &super::InitOptions) -> crate::config::types::ConfigInput {
use crate::config::types::{ConfigInput, DatabasesInput, DirectoriesInput, ShadowDockerInput};
let effective_version = options
.shadow_pg_version
.as_ref()
.or(options.detected_pg_version.as_ref())
.map(|v| crate::prompts::extract_major_version(v));
let shadow = match &options.shadow_config {
crate::prompts::ShadowDatabaseInput::Auto => match effective_version {
Some(version) => crate::config::types::ShadowDatabaseInput {
docker: Some(ShadowDockerInput {
version: Some(version),
..Default::default()
}),
..Default::default()
},
None => crate::config::types::ShadowDatabaseInput {
auto: Some(true),
..Default::default()
},
},
crate::prompts::ShadowDatabaseInput::Docker { image, platform } => {
crate::config::types::ShadowDatabaseInput {
docker: Some(ShadowDockerInput {
image: Some(image.clone()),
platform: platform.clone(),
..Default::default()
}),
..Default::default()
}
}
crate::prompts::ShadowDatabaseInput::Manual(url) => {
crate::config::types::ShadowDatabaseInput {
auto: Some(false),
url: Some(url.clone()),
..Default::default()
}
}
};
ConfigInput {
databases: Some(DatabasesInput {
dev_url: Some(options.dev_database_url.clone()),
shadow: Some(shadow),
..Default::default()
}),
directories: Some(DirectoriesInput {
schema_dir: Some(options.schema_dir.display().to_string()),
migrations_dir: Some(options.migrations_dir.clone()),
baselines_dir: Some(options.baselines_dir.clone()),
roles_file: options.roles_file.clone(),
}),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::init::{BaselineCreationConfig, InitOptions, ObjectManagementConfig};
use crate::config::types::Directories;
use std::env;
use std::path::{Path, PathBuf};
fn test_options(temp_dir: &Path) -> InitOptions {
let dir_defaults = Directories::default();
InitOptions {
project_dir: temp_dir.to_path_buf(),
dev_database_url: "postgres://localhost/test_db".to_string(),
shadow_config: crate::prompts::ShadowDatabaseInput::Auto,
shadow_pg_version: None,
detected_pg_version: None,
schema_dir: PathBuf::from("schema"),
migrations_dir: dir_defaults.migrations,
baselines_dir: dir_defaults.baselines,
import_source: None,
object_config: ObjectManagementConfig::default(),
baseline_config: BaselineCreationConfig::default(),
tracking_table: crate::config::types::TrackingTable::default(),
roles_file: None,
objects: Default::default(),
substrate_exclusions: Vec::new(),
}
}
#[test]
fn test_create_project_structure() {
let temp_dir = env::temp_dir().join("pgmt_test_project_structure");
let _ = std::fs::remove_dir_all(&temp_dir);
let options = test_options(&temp_dir);
create_project_structure(&options).unwrap();
let dir_defaults = Directories::default();
assert!(temp_dir.join(&dir_defaults.migrations).exists());
assert!(temp_dir.join(&dir_defaults.baselines).exists());
assert!(temp_dir.join("schema").exists());
assert!(temp_dir.join("schema/tables").exists());
assert!(temp_dir.join("schema/views").exists());
assert!(temp_dir.join("schema/functions").exists());
assert!(temp_dir.join("schema/types").exists());
assert!(temp_dir.join("schema/schemas").exists());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_create_project_structure_custom_directories() {
let temp_dir = env::temp_dir().join("pgmt_test_project_structure_custom");
let _ = std::fs::remove_dir_all(&temp_dir);
let mut options = test_options(&temp_dir);
options.migrations_dir = "db/migrations".to_string();
options.baselines_dir = "db/baselines".to_string();
create_project_structure(&options).unwrap();
assert!(temp_dir.join("db/migrations").exists());
assert!(temp_dir.join("db/baselines").exists());
assert!(temp_dir.join("schema").exists());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file() {
let temp_dir = env::temp_dir().join("pgmt_test_config");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.schema_dir = PathBuf::from("custom_schema");
generate_config_file(&options, None, &temp_dir).unwrap();
let config_path = temp_dir.join("pgmt.yaml");
assert!(config_path.exists());
let content = std::fs::read_to_string(&config_path).unwrap();
let dir_defaults = Directories::default();
assert!(content.contains("postgres://localhost/test_db"));
assert!(content.contains("custom_schema"));
assert!(content.contains("auto: true"));
assert!(content.contains(&format!("migrations_dir: {}", dir_defaults.migrations)));
assert!(content.contains(&format!("baselines_dir: {}", dir_defaults.baselines)));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_custom_directories() {
let temp_dir = env::temp_dir().join("pgmt_test_config_custom_dirs");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.migrations_dir = "db/migrations".to_string();
options.baselines_dir = "db/baselines".to_string();
generate_config_file(&options, None, &temp_dir).unwrap();
let config_path = temp_dir.join("pgmt.yaml");
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("migrations_dir: db/migrations"),
"Expected custom migrations_dir, got:\n{}",
content
);
assert!(
content.contains("baselines_dir: db/baselines"),
"Expected custom baselines_dir, got:\n{}",
content
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_with_detected_version() {
let temp_dir = env::temp_dir().join("pgmt_test_config_detected_version");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.detected_pg_version = Some("15.4".to_string());
generate_config_file(&options, None, &temp_dir).unwrap();
let config_path = temp_dir.join("pgmt.yaml");
let content = std::fs::read_to_string(&config_path).unwrap();
let parsed: crate::config::types::ConfigInput =
serde_yaml::from_str(&content).expect("generated pgmt.yaml should parse");
let docker = parsed
.databases
.and_then(|d| d.shadow)
.and_then(|s| s.docker)
.expect("shadow.docker should be present");
assert_eq!(
docker.version.as_deref(),
Some("15"),
"Expected detected version to be persisted, got:\n{}",
content
);
assert!(
!content.contains("auto: true"),
"Should not have auto: true when version is detected"
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_explicit_version_takes_precedence() {
let temp_dir = env::temp_dir().join("pgmt_test_config_explicit_version");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.shadow_pg_version = Some("16".to_string()); options.detected_pg_version = Some("15.4".to_string());
generate_config_file(&options, None, &temp_dir).unwrap();
let config_path = temp_dir.join("pgmt.yaml");
let content = std::fs::read_to_string(&config_path).unwrap();
let parsed: crate::config::types::ConfigInput =
serde_yaml::from_str(&content).expect("generated pgmt.yaml should parse");
let docker = parsed
.databases
.and_then(|d| d.shadow)
.and_then(|s| s.docker)
.expect("shadow.docker should be present");
assert_eq!(
docker.version.as_deref(),
Some("16"),
"Expected explicit version to take precedence, got:\n{}",
content
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_docker_image_and_platform() {
let temp_dir = env::temp_dir().join("pgmt_test_config_docker_image");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.shadow_config = crate::prompts::ShadowDatabaseInput::Docker {
image: "postgis/postgis:16-3.5".to_string(),
platform: Some("linux/amd64".to_string()),
};
generate_config_file(&options, None, &temp_dir).unwrap();
let config_path = temp_dir.join("pgmt.yaml");
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(
!content.contains("auto: true"),
"Should not emit auto mode for a custom image"
);
let parsed: crate::config::types::ConfigInput =
serde_yaml::from_str(&content).expect("generated pgmt.yaml should parse");
let docker = parsed
.databases
.and_then(|d| d.shadow)
.and_then(|s| s.docker)
.expect("shadow.docker should be present");
assert_eq!(docker.image.as_deref(), Some("postgis/postgis:16-3.5"));
assert_eq!(docker.platform.as_deref(), Some("linux/amd64"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_round_trips_exactly() {
let temp_dir = env::temp_dir().join("pgmt_test_config_round_trip");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut options = test_options(&temp_dir);
options.shadow_config = crate::prompts::ShadowDatabaseInput::Docker {
image: "postgis/postgis:16-3.5".to_string(),
platform: Some("linux/amd64".to_string()),
};
options.roles_file = Some("roles.sql".to_string());
generate_config_file(&options, None, &temp_dir).unwrap();
let content = std::fs::read_to_string(temp_dir.join("pgmt.yaml")).unwrap();
let parsed: crate::config::types::ConfigInput =
serde_yaml::from_str(&content).expect("generated pgmt.yaml should parse");
assert_eq!(
parsed,
gathered_config_input(&options),
"parse(serialize(x)) must equal x, got:\n{}",
content
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_generate_config_file_preserves_hand_edits_on_reinit() {
let temp_dir = env::temp_dir().join("pgmt_test_config_reinit_preserve");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let existing: crate::config::types::ConfigInput = serde_yaml::from_str(
r#"
databases:
dev_url: postgres://localhost/old_db
shadow:
docker:
image: postgis/postgis:16-3.4
container_name: pgmt_shadow_bluebox
auto_cleanup: false
environment:
POSTGRES_PASSWORD: hand-edited
objects:
include:
schemas: [public, bluebox]
migration:
filename_prefix: "V"
"#,
)
.unwrap();
let mut options = test_options(&temp_dir);
options.dev_database_url = "postgres://localhost/new_db".to_string();
options.shadow_config = crate::prompts::ShadowDatabaseInput::Docker {
image: "postgis/postgis:17-3.5".to_string(),
platform: None,
};
generate_config_file(&options, Some(&existing), &temp_dir).unwrap();
let content = std::fs::read_to_string(temp_dir.join("pgmt.yaml")).unwrap();
let parsed: crate::config::types::ConfigInput = serde_yaml::from_str(&content).unwrap();
let databases = parsed.databases.expect("databases");
assert_eq!(
databases.dev_url.as_deref(),
Some("postgres://localhost/new_db")
);
let docker = databases
.shadow
.and_then(|s| s.docker)
.expect("shadow.docker");
assert_eq!(docker.image.as_deref(), Some("postgis/postgis:17-3.5"));
assert_eq!(
docker.container_name.as_deref(),
Some("pgmt_shadow_bluebox"),
"hand-added container_name must survive re-init, got:\n{}",
content
);
assert_eq!(docker.auto_cleanup, Some(false));
assert_eq!(
docker
.environment
.as_ref()
.and_then(|e| e.get("POSTGRES_PASSWORD"))
.map(String::as_str),
Some("hand-edited")
);
let objects = parsed.objects.expect("objects section must survive");
assert_eq!(
objects.include.and_then(|i| i.schemas),
Some(vec!["public".to_string(), "bluebox".to_string()])
);
assert_eq!(
parsed.migration.and_then(|m| m.filename_prefix).as_deref(),
Some("V"),
"migration section must survive re-init"
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
#[cfg(test)]
mod substrate_tests {
use super::*;
use std::env;
use std::path::Path;
fn options_with_exclusions(
temp_dir: &Path,
exclusions: Vec<String>,
) -> crate::commands::init::InitOptions {
let dir_defaults = crate::config::types::Directories::default();
crate::commands::init::InitOptions {
project_dir: temp_dir.to_path_buf(),
dev_database_url: "postgres://localhost/test_db".to_string(),
shadow_config: crate::prompts::ShadowDatabaseInput::Auto,
shadow_pg_version: None,
detected_pg_version: None,
schema_dir: std::path::PathBuf::from("schema"),
migrations_dir: dir_defaults.migrations,
baselines_dir: dir_defaults.baselines,
import_source: None,
object_config: crate::commands::init::ObjectManagementConfig::default(),
baseline_config: crate::commands::init::BaselineCreationConfig::default(),
tracking_table: crate::config::types::TrackingTable::default(),
roles_file: None,
objects: Default::default(),
substrate_exclusions: exclusions,
}
}
#[test]
fn test_substrate_exclusions_written_to_config() {
let temp_dir = env::temp_dir().join("pgmt_test_substrate_exclusions");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let existing: crate::config::types::ConfigInput = serde_yaml::from_str(
"objects:\n exclude:\n schemas: [flyway_schema_history, tiger]\n",
)
.unwrap();
let options = options_with_exclusions(
&temp_dir,
vec![
"tiger".to_string(),
"tiger_data".to_string(),
"topology".to_string(),
],
);
generate_config_file(&options, Some(&existing), &temp_dir).unwrap();
let content = std::fs::read_to_string(temp_dir.join("pgmt.yaml")).unwrap();
let parsed: crate::config::types::ConfigInput = serde_yaml::from_str(&content).unwrap();
let schemas = parsed
.objects
.and_then(|o| o.exclude)
.and_then(|e| e.schemas)
.expect("exclude.schemas should be present");
assert_eq!(
schemas,
vec!["flyway_schema_history", "tiger", "tiger_data", "topology"],
"hand-written exclusions kept, substrate appended, no duplicates"
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}