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;
6
7#[cfg(feature = "postgres")]
8use tokio_postgres::Client;
9
10use crate::config::WaypointConfig;
11use crate::db::DbClient;
12use crate::error::{Result, WaypointError};
13use crate::history::{self, AppliedMigration};
14use crate::migration::{scan_migrations, ResolvedMigration};
15
16/// Report returned after a validate operation.
17#[derive(Debug, Serialize)]
18pub struct ValidateReport {
19    /// Whether all validations passed without errors.
20    pub valid: bool,
21    /// Validation errors (e.g. checksum mismatches) that indicate corruption.
22    pub issues: Vec<String>,
23    /// Non-fatal warnings (e.g. missing files on disk).
24    pub warnings: Vec<String>,
25}
26
27/// Execute the validate command (PostgreSQL legacy entry).
28#[cfg(feature = "postgres")]
29pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<ValidateReport> {
30    let schema = &config.migrations.schema;
31    let table = &config.migrations.table;
32
33    if !history::history_table_exists(client, schema, table).await? {
34        return Ok(empty_report());
35    }
36    let applied = history::get_applied_migrations(client, schema, table).await?;
37    let resolved = scan_migrations(&config.migrations.locations)?;
38    finalise(check(applied, resolved))
39}
40
41/// Execute the validate command (dialect-aware entry).
42pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<ValidateReport> {
43    let schema = client.resolve_schema(&config.migrations.schema).await?;
44    let schema = schema.as_str();
45    let table = &config.migrations.table;
46
47    if !history::history_table_exists_db(client, schema, table).await? {
48        return Ok(empty_report());
49    }
50    let applied = history::get_applied_migrations_db(client, schema, table).await?;
51    let resolved = scan_migrations(&config.migrations.locations)?;
52    finalise(check(applied, resolved))
53}
54
55fn empty_report() -> ValidateReport {
56    ValidateReport {
57        valid: true,
58        issues: Vec::new(),
59        warnings: vec!["No history table found — nothing to validate.".to_string()],
60    }
61}
62
63fn finalise(report: ValidateReport) -> Result<ValidateReport> {
64    log::info!(
65        "Validation completed; valid={}, issue_count={}, warning_count={}",
66        report.valid,
67        report.issues.len(),
68        report.warnings.len()
69    );
70    if !report.valid {
71        return Err(WaypointError::ValidationFailed(report.issues.join("\n")));
72    }
73    Ok(report)
74}
75
76fn check(applied: Vec<AppliedMigration>, resolved: Vec<ResolvedMigration>) -> ValidateReport {
77    let resolved_by_version: HashMap<String, &ResolvedMigration> = resolved
78        .iter()
79        .filter(|m| m.is_versioned())
80        .filter_map(|m| m.version().map(|v| (v.raw.clone(), m)))
81        .collect();
82
83    let resolved_by_script: HashMap<String, &ResolvedMigration> = resolved
84        .iter()
85        .filter(|m| !m.is_versioned())
86        .map(|m| (m.script.clone(), m))
87        .collect();
88
89    let mut issues = Vec::new();
90    let mut warnings = Vec::new();
91
92    for am in &applied {
93        if !am.success {
94            continue;
95        }
96        if am.migration_type == "BASELINE" || am.migration_type == "UNDO_SQL" {
97            continue;
98        }
99
100        if am.version.is_some() {
101            if let Some(ref version) = am.version {
102                if let Some(resolved) = resolved_by_version.get(version) {
103                    if let Some(expected_checksum) = am.checksum {
104                        if resolved.checksum != expected_checksum {
105                            issues.push(format!(
106                                "Checksum mismatch for version {}: applied={}, resolved={}. \
107                                 Migration file '{}' has been modified after it was applied.",
108                                version, expected_checksum, resolved.checksum, resolved.script
109                            ));
110                        }
111                    }
112                } else {
113                    warnings.push(format!(
114                        "Applied migration version {} (script: {}) not found on disk.",
115                        version, am.script
116                    ));
117                }
118            }
119        } else if !resolved_by_script.contains_key(&am.script) {
120            warnings.push(format!(
121                "Applied repeatable migration '{}' not found on disk.",
122                am.script
123            ));
124        }
125    }
126
127    let valid = issues.is_empty();
128    ValidateReport {
129        valid,
130        issues,
131        warnings,
132    }
133}