Skip to main content

vespertide_core/action/
display.rs

1use super::MigrationAction;
2use crate::schema::TableConstraint;
3use std::fmt;
4
5impl fmt::Display for MigrationAction {
6    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7        write_migration_action(f, self)
8    }
9}
10
11fn write_migration_action(f: &mut fmt::Formatter<'_>, action: &MigrationAction) -> fmt::Result {
12    match action {
13        MigrationAction::CreateTable { table, .. } => write!(f, "CreateTable: {table}"),
14        MigrationAction::DeleteTable { table } => write!(f, "DeleteTable: {table}"),
15        MigrationAction::AddColumn { table, column, .. } => {
16            write!(f, "AddColumn: {}.{}", table, column.name)
17        }
18        MigrationAction::RenameColumn { table, from, to } => {
19            write!(f, "RenameColumn: {table}.{from} -> {to}")
20        }
21        MigrationAction::DeleteColumn { table, column } => {
22            write!(f, "DeleteColumn: {table}.{column}")
23        }
24        MigrationAction::ModifyColumnType { table, column, .. } => {
25            write!(f, "ModifyColumnType: {table}.{column}")
26        }
27        MigrationAction::ModifyColumnNullable {
28            table,
29            column,
30            nullable,
31            ..
32        } => write_nullable_action(f, table, column, *nullable),
33        MigrationAction::ModifyColumnDefault {
34            table,
35            column,
36            new_default,
37            ..
38        } => write_default_action(f, table, column, new_default.as_deref()),
39        MigrationAction::ModifyColumnComment {
40            table,
41            column,
42            new_comment,
43        } => write_comment_action(f, table, column, new_comment.as_deref()),
44        MigrationAction::AddConstraint { table, constraint } => {
45            write_constraint_action(f, "AddConstraint", table, constraint)
46        }
47        MigrationAction::RemoveConstraint { table, constraint } => {
48            write_constraint_action(f, "RemoveConstraint", table, constraint)
49        }
50        MigrationAction::ReplaceConstraint { table, to, .. } => {
51            write_constraint_action(f, "ReplaceConstraint", table, to)
52        }
53        MigrationAction::RenameTable { from, to } => write!(f, "RenameTable: {from} -> {to}"),
54        MigrationAction::RawSql { sql } => write_raw_sql_action(f, sql),
55        MigrationAction::RemapEnumValues {
56            table,
57            column,
58            mapping,
59        } => {
60            let summary = mapping
61                .iter()
62                .map(|(old, new)| format!("{old}->{new}"))
63                .collect::<Vec<_>>()
64                .join(", ");
65            write!(f, "RemapEnumValues: {table}.{column} [{summary}]")
66        }
67    }
68}
69
70fn write_nullable_action(
71    f: &mut fmt::Formatter<'_>,
72    table: &str,
73    column: &str,
74    nullable: bool,
75) -> fmt::Result {
76    let nullability = if nullable { "NULL" } else { "NOT NULL" };
77    write!(f, "ModifyColumnNullable: {table}.{column} -> {nullability}")
78}
79
80fn write_default_action(
81    f: &mut fmt::Formatter<'_>,
82    table: &str,
83    column: &str,
84    default: Option<&str>,
85) -> fmt::Result {
86    if let Some(default) = default {
87        write!(f, "ModifyColumnDefault: {table}.{column} -> {default}")
88    } else {
89        write!(f, "ModifyColumnDefault: {table}.{column} -> (none)")
90    }
91}
92
93fn write_comment_action(
94    f: &mut fmt::Formatter<'_>,
95    table: &str,
96    column: &str,
97    comment: Option<&str>,
98) -> fmt::Result {
99    if let Some(comment) = comment {
100        let display = truncate_comment(comment);
101        write!(f, "ModifyColumnComment: {table}.{column} -> '{display}'")
102    } else {
103        write!(f, "ModifyColumnComment: {table}.{column} -> (none)")
104    }
105}
106
107fn truncate_comment(comment: &str) -> String {
108    if comment.chars().count() > 30 {
109        format!("{}...", truncate_chars(comment, 27))
110    } else {
111        comment.to_string()
112    }
113}
114
115fn truncate_chars(s: &str, max_chars: usize) -> String {
116    s.chars().take(max_chars).collect()
117}
118
119fn write_raw_sql_action(f: &mut fmt::Formatter<'_>, sql: &str) -> fmt::Result {
120    let display_sql = if sql.chars().count() > 50 {
121        format!("{}...", truncate_chars(sql, 47))
122    } else {
123        sql.to_string()
124    };
125    write!(f, "RawSql: {display_sql}")
126}
127
128fn write_constraint_action(
129    f: &mut fmt::Formatter<'_>,
130    action: &str,
131    table: &str,
132    constraint: &TableConstraint,
133) -> fmt::Result {
134    match constraint {
135        TableConstraint::PrimaryKey { .. } => write!(f, "{action}: {table}.PRIMARY KEY"),
136        TableConstraint::Unique { name, .. } => {
137            write_named_constraint(f, action, table, name.as_ref(), "UNIQUE")
138        }
139        TableConstraint::ForeignKey { name, .. } => {
140            write_named_constraint(f, action, table, name.as_ref(), "FOREIGN KEY")
141        }
142        TableConstraint::Check { name, .. } => write!(f, "{action}: {table}.{name} (CHECK)"),
143        TableConstraint::Index { name, .. } => {
144            write_named_constraint(f, action, table, name.as_ref(), "INDEX")
145        }
146    }
147}
148
149fn write_named_constraint(
150    f: &mut fmt::Formatter<'_>,
151    action: &str,
152    table: &str,
153    name: Option<&String>,
154    fallback: &str,
155) -> fmt::Result {
156    if let Some(name) = name {
157        write!(f, "{action}: {table}.{name} ({fallback})")
158    } else {
159        write!(f, "{action}: {table}.{fallback}")
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    //! Coverage-closure tests for the `ModifyColumnDefault` Display match arm.
166    //! Targets `uncovered-detail.json` lines 35, 36, 37 (the field bindings
167    //! `column`, `new_default`, `..` inside the `ModifyColumnDefault` pattern).
168    use super::*;
169    use crate::action::MigrationAction;
170
171    #[test]
172    fn modify_column_default_some_format() {
173        // Hits lines 33-38 (ModifyColumnDefault pattern with new_default = Some).
174        let action = MigrationAction::ModifyColumnDefault {
175            table: "user".into(),
176            column: "status".into(),
177            new_default: Some("'active'".to_string()),
178            backfill: None,
179        };
180        assert_eq!(
181            format!("{action}"),
182            "ModifyColumnDefault: user.status -> 'active'"
183        );
184    }
185
186    #[test]
187    fn modify_column_default_none_format() {
188        // Re-exercises the ModifyColumnDefault arm with new_default = None.
189        let action = MigrationAction::ModifyColumnDefault {
190            table: "user".into(),
191            column: "status".into(),
192            new_default: None,
193            backfill: None,
194        };
195        assert_eq!(
196            format!("{action}"),
197            "ModifyColumnDefault: user.status -> (none)"
198        );
199    }
200
201    #[test]
202    fn remap_enum_values_format_single_mapping() {
203        // Drives lines 35, 36, 37 — the RemapEnumValues match arm: the
204        // pattern binding (`table`, `column`, `mapping`), the iterator that
205        // joins `old->new` pairs into the summary, and the `write!` call
206        // that emits the bracketed summary.
207        let action = MigrationAction::RemapEnumValues {
208            table: "user".into(),
209            column: "status".into(),
210            mapping: vec![(1_i64, 2_i64)].into_iter().collect(),
211        };
212        assert_eq!(format!("{action}"), "RemapEnumValues: user.status [1->2]");
213    }
214
215    #[test]
216    fn remap_enum_values_format_multiple_mappings_joined() {
217        // Re-exercises the same arm with a multi-entry BTreeMap so the
218        // `.join(", ")` in line 36 produces a non-trivial summary. BTreeMap
219        // iteration is sorted, so the resulting order is deterministic.
220        let action = MigrationAction::RemapEnumValues {
221            table: "order".into(),
222            column: "state".into(),
223            mapping: vec![(1_i64, 10_i64), (2_i64, 20_i64)].into_iter().collect(),
224        };
225        assert_eq!(
226            format!("{action}"),
227            "RemapEnumValues: order.state [1->10, 2->20]"
228        );
229    }
230
231    // ── truncate_comment / truncate_chars unit tests ─────────────────────
232
233    /// Kills the `> 30` → `>= 30` boundary mutant.
234    ///
235    /// A 30-char string must be returned unchanged; a 31-char string must be
236    /// truncated to 27 chars + "...".
237    #[test]
238    fn truncate_comment_30_char_boundary() {
239        let s30 = "a".repeat(30);
240        assert_eq!(
241            truncate_comment(&s30),
242            s30,
243            "30-char string must be returned unchanged"
244        );
245
246        let s31 = "a".repeat(31);
247        let expected = format!("{}...", "a".repeat(27));
248        assert_eq!(
249            truncate_comment(&s31),
250            expected,
251            "31-char string must be truncated to 27 chars + '...'"
252        );
253    }
254
255    /// Kills the `.chars().count()` → `.len()` mutant.
256    ///
257    /// "é" is 2 bytes but 1 char.  30 × "é" = 30 chars / 60 bytes → unchanged.
258    /// 31 × "é" = 31 chars / 62 bytes → truncated by char count, not byte count.
259    #[test]
260    fn truncate_comment_multibyte_char_count() {
261        let s30 = "é".repeat(30);
262        assert_eq!(
263            truncate_comment(&s30),
264            s30,
265            "30 multibyte chars must be returned unchanged"
266        );
267
268        let s31 = "é".repeat(31);
269        let expected = format!("{}...", "é".repeat(27));
270        assert_eq!(
271            truncate_comment(&s31),
272            expected,
273            "31 multibyte chars must be truncated by char count"
274        );
275    }
276
277    /// Kills the `take(n)` → `take(MAX)` mutant.
278    ///
279    /// `truncate_chars("hi", 0)` must return an empty string.
280    #[test]
281    fn truncate_chars_zero_returns_empty() {
282        assert_eq!(
283            truncate_chars("hi", 0),
284            "",
285            "truncate_chars with max_chars=0 must return empty string"
286        );
287    }
288
289    /// Kills the `.chars()` → `.bytes()` mutant.
290    ///
291    /// "héllo" has 5 chars; taking 3 must yield "hél" (not a byte-split panic).
292    #[test]
293    fn truncate_chars_no_grapheme_panic() {
294        assert_eq!(
295            truncate_chars("héllo", 3),
296            "hél",
297            "truncate_chars must split by chars, not bytes"
298        );
299    }
300
301    // write_raw_sql_action truncates the RawSql preview only when
302    // chars().count() > 50. Pins the `> 50` boundary so a mutation to
303    // `>= 50` (which would truncate an exactly-50-char SQL) is caught.
304    #[test]
305    fn raw_sql_display_50_char_boundary_not_truncated() {
306        let sql50: String = "0123456789".repeat(5); // exactly 50 chars
307        let out = format!("{}", crate::MigrationAction::RawSql { sql: sql50.clone() });
308        assert_eq!(out, format!("RawSql: {sql50}"));
309        assert!(
310            !out.contains("..."),
311            "50-char SQL must NOT be truncated: {out}"
312        );
313
314        let sql51 = format!("{sql50}X"); // 51 chars → truncated to 47 + "..."
315        let out51 = format!("{}", crate::MigrationAction::RawSql { sql: sql51.clone() });
316        let head: String = sql51.chars().take(47).collect();
317        assert_eq!(out51, format!("RawSql: {head}..."));
318    }
319
320    /// Migrated INLINE from `crates/vespertide-core/tests/utf8_safety.rs`:
321    /// the `MigrationAction::RawSql` Display+Debug impls must never panic on
322    /// arbitrary Unicode input. This is a single-module proptest of the
323    /// Display impl that lives in this file — it belongs inline.
324    mod utf8 {
325        use crate::MigrationAction;
326        use proptest::prelude::*;
327
328        proptest! {
329            #[test]
330            fn action_display_does_not_panic_on_unicode(
331                s in proptest::collection::vec(any::<char>(), 0..100)
332                    .prop_map(|v| v.into_iter().collect::<String>())
333            ) {
334                let action = MigrationAction::RawSql { sql: s };
335                let _ = format!("{action:?}");
336                let _ = format!("{action}");
337            }
338        }
339    }
340}