Skip to main content

waypoint_core/commands/
validate.rs

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