waypoint_core/commands/
validate.rs1use 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#[derive(Debug, Serialize)]
18pub struct ValidateReport {
19 pub valid: bool,
21 pub issues: Vec<String>,
23 pub warnings: Vec<String>,
25}
26
27#[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
41pub 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}