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 use super::*;
169 use crate::action::MigrationAction;
170
171 #[test]
172 fn modify_column_default_some_format() {
173 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 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 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 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 #[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 #[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 #[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 #[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 #[test]
305 fn raw_sql_display_50_char_boundary_not_truncated() {
306 let sql50: String = "0123456789".repeat(5); 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"); 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 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}