Skip to main content

waypoint_core/commands/
validate.rs

1//! Validate applied migrations against local files (checksum and ordering).
2
3use std::collections::HashMap;
4
5use serde::Serialize;
6use tokio_postgres::Client;
7
8use crate::config::WaypointConfig;
9use crate::error::{Result, WaypointError};
10use crate::history;
11use crate::migration::{scan_migrations, ResolvedMigration};
12
13/// Report returned after a validate operation.
14#[derive(Debug, Serialize)]
15pub struct ValidateReport {
16    /// Whether all validations passed without errors.
17    pub valid: bool,
18    /// Validation errors (e.g. checksum mismatches) that indicate corruption.
19    pub issues: Vec<String>,
20    /// Non-fatal warnings (e.g. missing files on disk).
21    pub warnings: Vec<String>,
22}
23
24/// Execute the validate command.
25///
26/// For each applied (success=TRUE) migration in history:
27/// - Find the corresponding file on disk
28/// - Recalculate the checksum
29/// - Report mismatches
30/// - Warn if file is missing
31pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<ValidateReport> {
32    let schema = &config.migrations.schema;
33    let table = &config.migrations.table;
34
35    if !history::history_table_exists(client, schema, table).await? {
36        return Ok(ValidateReport {
37            valid: true,
38            issues: Vec::new(),
39            warnings: vec!["No history table found — nothing to validate.".to_string()],
40        });
41    }
42
43    let applied = history::get_applied_migrations(client, schema, table).await?;
44    let resolved = scan_migrations(&config.migrations.locations)?;
45
46    // Build lookup maps
47    let resolved_by_version: HashMap<String, &ResolvedMigration> = resolved
48        .iter()
49        .filter(|m| m.is_versioned())
50        .filter_map(|m| m.version().map(|v| (v.raw.clone(), m)))
51        .collect();
52
53    let resolved_by_script: HashMap<String, &ResolvedMigration> = resolved
54        .iter()
55        .filter(|m| !m.is_versioned())
56        .map(|m| (m.script.clone(), m))
57        .collect();
58
59    let mut issues = Vec::new();
60    let mut warnings = Vec::new();
61
62    for am in &applied {
63        if !am.success {
64            continue;
65        }
66        if am.migration_type == "BASELINE" {
67            continue;
68        }
69        if am.migration_type == "UNDO_SQL" {
70            continue;
71        }
72
73        // Distinguish by version presence for Flyway compatibility
74        if am.version.is_some() {
75            // Versioned migration
76            if let Some(ref version) = am.version {
77                if let Some(resolved) = resolved_by_version.get(version) {
78                    if let Some(expected_checksum) = am.checksum {
79                        if resolved.checksum != expected_checksum {
80                            issues.push(format!(
81                                "Checksum mismatch for version {}: applied={}, resolved={}. \
82                                 Migration file '{}' has been modified after it was applied.",
83                                version, expected_checksum, resolved.checksum, resolved.script
84                            ));
85                        }
86                    }
87                } else {
88                    warnings.push(format!(
89                        "Applied migration version {} (script: {}) not found on disk.",
90                        version, am.script
91                    ));
92                }
93            }
94        } else {
95            // Repeatable (version is NULL) — we only warn on missing, don't fail on checksum diff
96            // (checksum diff is expected and triggers re-apply)
97            if !resolved_by_script.contains_key(&am.script) {
98                warnings.push(format!(
99                    "Applied repeatable migration '{}' not found on disk.",
100                    am.script
101                ));
102            }
103        }
104    }
105
106    let valid = issues.is_empty();
107
108    log::info!(
109        "Validation completed; valid={}, issue_count={}, warning_count={}",
110        valid,
111        issues.len(),
112        warnings.len()
113    );
114
115    if !valid {
116        return Err(WaypointError::ValidationFailed(issues.join("\n")));
117    }
118
119    Ok(ValidateReport {
120        valid,
121        issues,
122        warnings,
123    })
124}