Skip to main content

vespertide_core/action/
display.rs

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