Skip to main content

waypoint_core/commands/
info.rs

1//! Show migration status by merging resolved files with applied history.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::Serialize;
7
8#[cfg(feature = "postgres")]
9use tokio_postgres::Client;
10
11use crate::config::WaypointConfig;
12use crate::db::DbClient;
13use crate::error::Result;
14use crate::history::{self, AppliedMigration};
15use crate::migration::{MigrationKind, MigrationVersion, ResolvedMigration, scan_migrations};
16
17/// The state of a migration.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub enum MigrationState {
20    /// Migration file exists on disk but has not been applied yet.
21    Pending,
22    /// Migration has been successfully applied to the database.
23    Applied,
24    /// Migration execution failed (recorded in history as unsuccessful).
25    Failed,
26    /// Migration is recorded in history but its file is missing from disk.
27    Missing,
28    /// Repeatable migration whose checksum has changed since last application.
29    Outdated,
30    /// Versioned migration with a version lower than the highest applied version.
31    OutOfOrder,
32    /// Versioned migration with a version at or below the baseline.
33    BelowBaseline,
34    /// Migration was skipped (e.g. filtered by environment).
35    Ignored,
36    /// A baseline marker entry in the history table.
37    Baseline,
38    /// Migration was applied but subsequently reverted by an undo operation.
39    Undone,
40}
41
42impl std::fmt::Display for MigrationState {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            MigrationState::Pending => write!(f, "Pending"),
46            MigrationState::Applied => write!(f, "Applied"),
47            MigrationState::Failed => write!(f, "Failed"),
48            MigrationState::Missing => write!(f, "Missing"),
49            MigrationState::Outdated => write!(f, "Outdated"),
50            MigrationState::OutOfOrder => write!(f, "Out of Order"),
51            MigrationState::BelowBaseline => write!(f, "Below Baseline"),
52            MigrationState::Ignored => write!(f, "Ignored"),
53            MigrationState::Baseline => write!(f, "Baseline"),
54            MigrationState::Undone => write!(f, "Undone"),
55        }
56    }
57}
58
59/// Combined view of a migration (file + history).
60#[derive(Debug, Clone, Serialize)]
61pub struct MigrationInfo {
62    /// Version string, or None for repeatable migrations.
63    pub version: Option<String>,
64    /// Human-readable description from the migration filename.
65    pub description: String,
66    /// Type of migration (e.g. "SQL", "BASELINE", "UNDO_SQL").
67    pub migration_type: String,
68    /// Filename of the migration script.
69    pub script: String,
70    /// Current state of this migration.
71    pub state: MigrationState,
72    /// Timestamp when the migration was applied, if recorded in history.
73    pub installed_on: Option<DateTime<Utc>>,
74    /// Execution time in milliseconds, if recorded in history.
75    pub execution_time: Option<i32>,
76    /// CRC32 checksum of the migration SQL content.
77    pub checksum: Option<i32>,
78}
79
80/// Execute the info command (PostgreSQL legacy entry).
81#[cfg(feature = "postgres")]
82pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<Vec<MigrationInfo>> {
83    let schema = &config.migrations.schema;
84    let table = &config.migrations.table;
85
86    if !history::history_table_exists(client, schema, table).await? {
87        let resolved = scan_migrations(&config.migrations.locations)?;
88        return Ok(pending_only(resolved));
89    }
90    let applied = history::get_applied_migrations(client, schema, table).await?;
91    let resolved = scan_migrations(&config.migrations.locations)?;
92    Ok(merge(applied, resolved))
93}
94
95/// Execute the info command (dialect-aware entry).
96pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<Vec<MigrationInfo>> {
97    let schema = client.resolve_schema(&config.migrations.schema).await?;
98    let schema = schema.as_str();
99    let table = &config.migrations.table;
100
101    if !history::history_table_exists_db(client, schema, table).await? {
102        let resolved = scan_migrations(&config.migrations.locations)?;
103        return Ok(pending_only(resolved));
104    }
105    let applied = history::get_applied_migrations_db(client, schema, table).await?;
106    let resolved = scan_migrations(&config.migrations.locations)?;
107    Ok(merge(applied, resolved))
108}
109
110/// Build the "everything is pending" view used when the history table is absent.
111fn pending_only(resolved: Vec<ResolvedMigration>) -> Vec<MigrationInfo> {
112    resolved
113        .into_iter()
114        .filter(|m| !m.is_undo())
115        .map(|m| {
116            let version = m.version().map(|v| v.raw.clone());
117            let migration_type = m.migration_type().to_string();
118            MigrationInfo {
119                version,
120                description: m.description,
121                migration_type,
122                script: m.script,
123                state: MigrationState::Pending,
124                installed_on: None,
125                execution_time: None,
126                checksum: Some(m.checksum),
127            }
128        })
129        .collect()
130}
131
132/// Merge applied-migration rows with on-disk migrations into a unified status view.
133fn merge(applied: Vec<AppliedMigration>, resolved: Vec<ResolvedMigration>) -> Vec<MigrationInfo> {
134    let effective = history::effective_applied_versions(&applied);
135
136    let resolved_by_version: HashMap<String, &ResolvedMigration> = resolved
137        .iter()
138        .filter(|m| m.is_versioned())
139        .filter_map(|m| m.version().map(|v| (v.raw.clone(), m)))
140        .collect();
141
142    let resolved_by_script: HashMap<String, &ResolvedMigration> = resolved
143        .iter()
144        .filter(|m| !m.is_versioned() && !m.is_undo())
145        .map(|m| (m.script.clone(), m))
146        .collect();
147
148    let baseline_version = applied
149        .iter()
150        .find(|a| a.migration_type == "BASELINE")
151        .and_then(|a| a.version.as_ref())
152        .and_then(|v| MigrationVersion::parse(v).ok());
153
154    let highest_applied = effective
155        .iter()
156        .filter_map(|v| MigrationVersion::parse(v).ok())
157        .max();
158
159    let mut infos: Vec<MigrationInfo> = Vec::new();
160    let mut seen_versions: HashMap<String, bool> = HashMap::new();
161    let mut seen_scripts: HashMap<String, bool> = HashMap::new();
162
163    for am in &applied {
164        let is_versioned = am.version.is_some();
165        let is_repeatable = am.version.is_none() && am.migration_type != "BASELINE";
166
167        let state = if am.migration_type == "BASELINE" {
168            MigrationState::Baseline
169        } else if am.migration_type == "UNDO_SQL" {
170            MigrationState::Undone
171        } else if !am.success {
172            MigrationState::Failed
173        } else if is_versioned {
174            if let Some(ref version) = am.version {
175                if !effective.contains(version) {
176                    MigrationState::Undone
177                } else if resolved_by_version.contains_key(version) {
178                    MigrationState::Applied
179                } else {
180                    MigrationState::Missing
181                }
182            } else {
183                MigrationState::Applied
184            }
185        } else if is_repeatable {
186            if let Some(resolved) = resolved_by_script.get(&am.script) {
187                if Some(resolved.checksum) != am.checksum {
188                    MigrationState::Outdated
189                } else {
190                    MigrationState::Applied
191                }
192            } else {
193                MigrationState::Missing
194            }
195        } else {
196            MigrationState::Applied
197        };
198
199        if let Some(ref v) = am.version {
200            seen_versions.insert(v.clone(), true);
201        }
202        if am.version.is_none() {
203            seen_scripts.insert(am.script.clone(), true);
204        }
205
206        infos.push(MigrationInfo {
207            version: am.version.clone(),
208            description: am.description.clone(),
209            migration_type: am.migration_type.clone(),
210            script: am.script.clone(),
211            state,
212            installed_on: Some(am.installed_on),
213            execution_time: Some(am.execution_time),
214            checksum: am.checksum,
215        });
216    }
217
218    for m in &resolved {
219        if m.is_undo() {
220            continue;
221        }
222        match &m.kind {
223            MigrationKind::Versioned(version) => {
224                if seen_versions.contains_key(&version.raw) {
225                    continue;
226                }
227                let state = if let Some(ref bv) = baseline_version {
228                    if version <= bv {
229                        MigrationState::BelowBaseline
230                    } else if let Some(ref highest) = highest_applied {
231                        if version < highest {
232                            MigrationState::OutOfOrder
233                        } else {
234                            MigrationState::Pending
235                        }
236                    } else {
237                        MigrationState::Pending
238                    }
239                } else if let Some(ref highest) = highest_applied {
240                    if version < highest {
241                        MigrationState::OutOfOrder
242                    } else {
243                        MigrationState::Pending
244                    }
245                } else {
246                    MigrationState::Pending
247                };
248
249                infos.push(MigrationInfo {
250                    version: Some(version.raw.clone()),
251                    description: m.description.clone(),
252                    migration_type: m.migration_type().to_string(),
253                    script: m.script.clone(),
254                    state,
255                    installed_on: None,
256                    execution_time: None,
257                    checksum: Some(m.checksum),
258                });
259            }
260            MigrationKind::Repeatable => {
261                if seen_scripts.contains_key(&m.script) {
262                    continue;
263                }
264                infos.push(MigrationInfo {
265                    version: None,
266                    description: m.description.clone(),
267                    migration_type: m.migration_type().to_string(),
268                    script: m.script.clone(),
269                    state: MigrationState::Pending,
270                    installed_on: None,
271                    execution_time: None,
272                    checksum: Some(m.checksum),
273                });
274            }
275            MigrationKind::Undo(_) => unreachable!("undo files are skipped above"),
276        }
277    }
278
279    infos.sort_by(|a, b| match (&a.version, &b.version) {
280        (Some(av), Some(bv)) => {
281            let pa = MigrationVersion::parse(av);
282            let pb = MigrationVersion::parse(bv);
283            match (pa, pb) {
284                (Ok(pa), Ok(pb)) => pa.cmp(&pb),
285                _ => av.cmp(bv),
286            }
287        }
288        (Some(_), None) => std::cmp::Ordering::Less,
289        (None, Some(_)) => std::cmp::Ordering::Greater,
290        (None, None) => a.description.cmp(&b.description),
291    });
292
293    infos
294}