Skip to main content

lean_ctx_patterns/
flyway.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Flyway (database migrations) output compression.
4//!
5//! `flyway migrate` prints an edition banner, the JDBC URL and a validation
6//! line before the actual work. We keep the applied-migration summary (count +
7//! resulting version), the per-version names being migrated, the up-to-date
8//! signal and any error, dropping the banner/URL/validation noise.
9
10use crate::ansi::strip_ansi;
11
12pub fn compress(_cmd: &str, output: &str) -> Option<String> {
13    let trimmed = output.trim();
14    if trimmed.is_empty() {
15        return Some("flyway: ok".to_string());
16    }
17
18    let mut applied: Option<String> = None;
19    let mut migrating: Vec<String> = Vec::new();
20    let mut up_to_date = false;
21    let mut errors: Vec<String> = Vec::new();
22
23    for raw in trimmed.lines() {
24        let stripped = strip_ansi(raw);
25        let line = stripped.trim();
26        if line.is_empty() {
27            continue;
28        }
29
30        if line.contains("No migration necessary") || line.contains("is up to date") {
31            up_to_date = true;
32        } else if let Some(v) = extract_quoted_after(line, "to version ") {
33            migrating.push(format_version(&v));
34        } else if line.starts_with("Successfully applied") {
35            applied = Some(summarize_applied(line));
36        } else if is_error(line) {
37            errors.push(line.to_string());
38        }
39    }
40
41    if applied.is_none() && migrating.is_empty() && !up_to_date && errors.is_empty() {
42        return Some(fallback(trimmed));
43    }
44
45    let mut parts: Vec<String> = Vec::new();
46    if let Some(a) = applied {
47        parts.push(format!("flyway: {a}"));
48    } else if up_to_date {
49        parts.push("flyway: up to date".to_string());
50    } else if !errors.is_empty() {
51        parts.push("flyway: FAILED".to_string());
52    } else {
53        parts.push("flyway: migrating".to_string());
54    }
55    for m in &migrating {
56        parts.push(format!("  {m}"));
57    }
58    for e in errors.iter().take(5) {
59        parts.push(format!("  {e}"));
60    }
61    Some(parts.join("\n"))
62}
63
64/// Return the text inside the first `"..."` that appears after `marker`.
65fn extract_quoted_after(line: &str, marker: &str) -> Option<String> {
66    let after = line.split_once(marker)?.1;
67    let after = after.split_once('"')?.1;
68    let inner = after.split_once('"')?.0;
69    Some(inner.to_string())
70}
71
72/// `5 - add orders` -> `v5 add orders`.
73fn format_version(v: &str) -> String {
74    match v.split_once(" - ") {
75        Some((ver, name)) => format!("v{} {}", ver.trim(), name.trim()),
76        None => format!("v{}", v.trim()),
77    }
78}
79
80/// `Successfully applied 1 migration to schema "x", now at version v5 (..)` ->
81/// `applied 1 migration -> v5`.
82fn summarize_applied(line: &str) -> String {
83    let count = line
84        .strip_prefix("Successfully applied ")
85        .and_then(|r| r.split_whitespace().next())
86        .unwrap_or("?");
87    let noun = if count == "1" {
88        "migration"
89    } else {
90        "migrations"
91    };
92    let version = extract_after(line, "now at version ")
93        .map(|v| {
94            v.split_whitespace()
95                .next()
96                .unwrap_or("")
97                .trim_end_matches([',', '.'])
98                .to_string()
99        })
100        .filter(|v| !v.is_empty());
101    match version {
102        Some(v) => format!("applied {count} {noun} -> {v}"),
103        None => format!("applied {count} {noun}"),
104    }
105}
106
107fn extract_after(line: &str, marker: &str) -> Option<String> {
108    line.split_once(marker).map(|(_, r)| r.to_string())
109}
110
111fn is_error(line: &str) -> bool {
112    line.starts_with("ERROR")
113        || line.contains("Migration") && (line.contains("failed") || line.contains("FAILED"))
114        || line.contains("SQL State")
115        || line.contains("FlywayException")
116}
117
118fn fallback(text: &str) -> String {
119    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
120    let n = lines.len().min(8);
121    let mut s = lines[..n].join("\n");
122    if lines.len() > n {
123        s.push_str(&format!("\n... (+{} lines)", lines.len() - n));
124    }
125    s
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    const MIGRATE: &str = "Flyway Community Edition 9.22.0 by Redgate\nDatabase: jdbc:postgresql://localhost:5432/mydb (PostgreSQL 15.2)\nSuccessfully validated 5 migrations (execution time 00:00.123s)\nCurrent version of schema \"public\": 4\nMigrating schema \"public\" to version \"5 - add orders\"\nSuccessfully applied 1 migration to schema \"public\", now at version v5 (execution time 00:00.456s)\n";
133
134    #[test]
135    fn keeps_applied_summary_and_version() {
136        let r = compress("flyway migrate", MIGRATE).unwrap();
137        assert!(r.contains("applied 1 migration -> v5"), "{r}");
138        assert!(r.contains("v5 add orders"), "{r}");
139        assert!(!r.contains("Redgate"), "drops banner: {r}");
140        assert!(!r.contains("jdbc:"), "drops jdbc url: {r}");
141    }
142
143    #[test]
144    fn detects_up_to_date() {
145        let out = "Flyway Community Edition 9.22.0 by Redgate\nSchema \"public\" is up to date. No migration necessary.\n";
146        let r = compress("flyway migrate", out).unwrap();
147        assert_eq!(r, "flyway: up to date");
148    }
149
150    #[test]
151    fn shorter_than_input() {
152        let r = compress("flyway migrate", MIGRATE).unwrap();
153        assert!(r.len() < MIGRATE.len());
154    }
155
156    #[test]
157    fn empty_is_ok() {
158        assert_eq!(compress("flyway migrate", "").unwrap(), "flyway: ok");
159    }
160}