Skip to main content

pgmt/config/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Raw configuration input - all fields Optional for merging
5#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
6pub struct ConfigInput {
7    #[serde(skip_serializing_if = "Option::is_none")]
8    pub databases: Option<DatabasesInput>,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub directories: Option<DirectoriesInput>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub objects: Option<ObjectsInput>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub migration: Option<MigrationInput>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub schema: Option<SchemaInput>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub docker: Option<DockerInput>,
19}
20
21/// Resolved configuration with all defaults applied.
22///
23/// Deliberately carries NO database connection values — those are typed
24/// values (`DevUrl`, `ShadowDatabase`, `TargetUrl`) resolved at the command
25/// boundary from CLI args + `PGMT_*` env + pgmt.yaml; see
26/// `config::connections`.
27#[derive(Debug, Clone, Default)]
28pub struct Config {
29    pub directories: Directories,
30    pub objects: Objects,
31    pub migration: Migration,
32    pub schema: Schema,
33    pub docker: Docker,
34}
35
36// Database configuration
37#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
38pub struct DatabasesInput {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub dev_url: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub shadow_url: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub target_url: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub shadow: Option<ShadowDatabaseInput>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
50pub struct ShadowDatabaseInput {
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub auto: Option<bool>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub url: Option<String>,
55    /// How an external `url` shadow is brought back to its baseline between
56    /// runs. Ignored for Docker-managed shadows (always branched).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub reset: Option<ShadowResetMode>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub docker: Option<ShadowDockerInput>,
61}
62
63/// Reset strategy for an external `shadow.url` database.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ShadowResetMode {
67    /// Drop the schemas pgmt manages; never create or drop databases. Safe
68    /// when the server is shared or its lifecycle belongs to something else.
69    #[default]
70    Clean,
71    /// Treat the named database as a read-only baseline: work on an ephemeral
72    /// `CREATE DATABASE ... TEMPLATE` copy, dropped again at process exit.
73    /// The named database is never written to. Requires CREATEDB. Set this
74    /// only when the database exists solely for pgmt (e.g. a CI service
75    /// container).
76    Branch,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
80pub struct ShadowDockerInput {
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub version: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub image: Option<String>,
85    /// Platform to request when pulling/running the image (e.g. "linux/amd64").
86    /// Needed for images only published for one architecture (e.g. postgis/postgis
87    /// has no arm64 build) so they can run under emulation on other hosts.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub platform: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub environment: Option<HashMap<String, String>>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub container_name: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub auto_cleanup: Option<bool>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub volumes: Option<Vec<String>>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub network: Option<String>,
100}
101
102#[derive(Debug, Clone)]
103pub enum ShadowDatabase {
104    Auto, // Docker mode with default configuration
105    Url { url: String, reset: ShadowResetMode },
106    Docker(ShadowDockerConfig),
107}
108
109impl ShadowDatabase {
110    /// Get a connection string for the shadow database
111    pub async fn get_connection_string(&self) -> anyhow::Result<String> {
112        match self {
113            ShadowDatabase::Auto => {
114                // Auto mode is just Docker with default configuration
115                let default_config = ShadowDockerConfig::default();
116                Self::generate_docker_shadow_url(&default_config).await
117            }
118            ShadowDatabase::Url { url, reset } => match reset {
119                ShadowResetMode::Branch => crate::db::branch::branch_url(url).await,
120                ShadowResetMode::Clean => Ok(url.clone()),
121            },
122            ShadowDatabase::Docker(config) => Self::generate_docker_shadow_url(config).await,
123        }
124    }
125
126    /// Connect to a fresh, pristine shadow database scoped to a single apply.
127    ///
128    /// Branch/Docker shadows return a brand-new branch of the untouched source;
129    /// a `reset: clean` URL returns the configured database (which the apply
130    /// scoped-cleans itself). Every pristine-start phase MUST take its own
131    /// connection: `clean_shadow_db` is a no-op on branches, so a branch reused
132    /// across phases still holds the previous phase's objects and the next
133    /// apply fails with "already exists". Pair with [`crate::db::branch::drop_branch`]
134    /// to reclaim the branch when the phase is done.
135    pub async fn connect_fresh(&self) -> anyhow::Result<sqlx::PgPool> {
136        let url = self.get_connection_string().await?;
137        crate::db::connection::connect_with_retry(&url).await
138    }
139
140    /// Generate a shadow database URL for Docker mode
141    async fn generate_docker_shadow_url(config: &ShadowDockerConfig) -> anyhow::Result<String> {
142        use crate::docker::DockerManager;
143
144        let docker_manager = DockerManager::new().await?;
145        let shadow_db = docker_manager.start_shadow_database(config).await?;
146        // Convert to connection string, keeping container alive via global registry
147        Ok(shadow_db.into_connection_string())
148    }
149}
150
151#[derive(Debug, Clone)]
152pub struct ShadowDockerConfig {
153    pub version: Option<String>,
154    pub image: String,
155    /// Platform to request when pulling/running the image (e.g. "linux/amd64").
156    pub platform: Option<String>,
157    pub environment: HashMap<String, String>,
158    pub container_name: Option<String>,
159    pub auto_cleanup: bool,
160    #[allow(dead_code)] // Future feature: Docker volume mounting
161    pub volumes: Option<Vec<String>>,
162    #[allow(dead_code)] // Future feature: Docker network configuration
163    pub network: Option<String>,
164}
165
166impl ShadowDockerConfig {
167    /// Resolve the Docker image from version or use the image directly
168    /// Precedence: explicit image > version > default
169    pub fn resolved_image(&self) -> String {
170        // If an explicit image is set, use it directly
171        if !self.image.is_empty() && self.image != Self::default_image() {
172            return self.image.clone();
173        }
174
175        // If a version is specified, construct the image
176        if let Some(version) = &self.version {
177            return format!("postgres:{}-alpine", version);
178        }
179
180        // Fall back to the configured image (which may be default)
181        self.image.clone()
182    }
183
184    /// Get the default PostgreSQL image
185    fn default_image() -> String {
186        "postgres:18-alpine".to_string()
187    }
188}
189
190// Directory configuration
191#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
192pub struct DirectoriesInput {
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub schema_dir: Option<String>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub migrations_dir: Option<String>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub baselines_dir: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub roles_file: Option<String>,
201}
202
203#[derive(Debug, Clone)]
204pub struct Directories {
205    pub schema: String,
206    pub migrations: String,
207    pub baselines: String,
208    pub roles: String,
209}
210
211// Object filtering configuration
212// Note: Boolean toggles (comments, grants, triggers, extensions) have been removed.
213// Schema files are now the source of truth - what's in your files is what gets managed.
214// Use exclude patterns to filter objects during init import.
215#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
216pub struct ObjectsInput {
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub include: Option<ObjectIncludeInput>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub exclude: Option<ObjectExcludeInput>,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
224pub struct ObjectIncludeInput {
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub schemas: Option<Vec<String>>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub tables: Option<Vec<String>>,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
232pub struct ObjectExcludeInput {
233    /// Accepts the legacy `exclude_schemas` key for configs written before
234    /// the rename to match `include.schemas`.
235    #[serde(alias = "exclude_schemas", skip_serializing_if = "Option::is_none")]
236    pub schemas: Option<Vec<String>>,
237    #[serde(alias = "exclude_tables", skip_serializing_if = "Option::is_none")]
238    pub tables: Option<Vec<String>>,
239}
240
241#[derive(Debug, Clone, Default)]
242pub struct Objects {
243    pub include: ObjectInclude,
244    pub exclude: ObjectExclude,
245}
246
247#[derive(Debug, Clone, Default)]
248pub struct ObjectInclude {
249    pub schemas: Vec<String>,
250    pub tables: Vec<String>,
251}
252
253#[derive(Debug, Clone)]
254pub struct ObjectExclude {
255    pub schemas: Vec<String>,
256    pub tables: Vec<String>,
257}
258
259// Migration configuration
260#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
261pub struct MigrationInput {
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub default_mode: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub validate_baseline_consistency: Option<bool>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub create_baselines_by_default: Option<bool>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub tracking_table: Option<TrackingTableInput>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub column_order: Option<ColumnOrderMode>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub filename_prefix: Option<String>,
274}
275
276#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
277pub struct TrackingTableInput {
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub schema: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub name: Option<String>,
282}
283
284#[derive(Debug, Clone)]
285pub struct Migration {
286    pub default_mode: String,
287    pub validate_baseline_consistency: bool,
288    pub create_baselines_by_default: bool,
289    pub tracking_table: TrackingTable,
290    pub column_order: ColumnOrderMode,
291    pub filename_prefix: String,
292}
293
294#[derive(Debug, Clone)]
295pub struct TrackingTable {
296    pub schema: String,
297    pub name: String,
298}
299
300/// Column order validation mode for migration generation
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
302#[serde(rename_all = "snake_case")]
303pub enum ColumnOrderMode {
304    /// Error if new columns aren't at the end of the table
305    #[default]
306    Strict,
307    /// Warn but generate migration anyway
308    Warn,
309    /// No validation, allow any ordering
310    Relaxed,
311}
312
313// Docker configuration
314#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
315pub struct DockerInput {
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub auto_cleanup: Option<bool>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub check_system_identifier: Option<bool>,
320}
321
322#[derive(Debug, Clone)]
323pub struct Docker {
324    pub auto_cleanup: bool,
325    pub check_system_identifier: bool,
326}
327
328// Schema configuration
329#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
330pub struct SchemaInput {
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub augment_dependencies_from_files: Option<bool>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub validate_file_dependencies: Option<bool>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub verbose_file_processing: Option<bool>,
337}
338
339#[derive(Debug, Clone)]
340pub struct Schema {
341    pub augment_dependencies_from_files: bool,
342    pub validate_file_dependencies: bool,
343    pub verbose_file_processing: bool,
344}