Skip to main content

sqlmodel_console/renderables/
migration_status.rs

1//! Migration status renderable for tracking database migrations.
2//!
3//! Provides a visual display of migration status, showing applied vs pending
4//! migrations with timestamps, checksums, and visual indicators.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::{MigrationStatus, MigrationRecord, MigrationState};
10//!
11//! let status = MigrationStatus::new(vec![
12//!     MigrationRecord::new("001", "create_users")
13//!         .state(MigrationState::Applied)
14//!         .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
15//!         .duration_ms(Some(45)),
16//!     MigrationRecord::new("002", "add_email_index")
17//!         .state(MigrationState::Applied)
18//!         .applied_at(Some("2024-01-15T10:30:01Z".to_string()))
19//!         .duration_ms(Some(12)),
20//!     MigrationRecord::new("003", "add_posts_table")
21//!         .state(MigrationState::Pending),
22//! ]);
23//!
24//! // Plain mode output for agents
25//! println!("{}", status.render_plain());
26//! ```
27
28use crate::theme::Theme;
29
30/// Migration state enum indicating the status of a migration.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum MigrationState {
33    /// Migration has been successfully applied to the database.
34    Applied,
35    /// Migration is pending and has not yet been applied.
36    #[default]
37    Pending,
38    /// Migration failed during execution.
39    Failed,
40    /// Migration was skipped (e.g., manually marked as complete).
41    Skipped,
42}
43
44impl MigrationState {
45    /// Get a human-readable status string.
46    #[must_use]
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            Self::Applied => "APPLIED",
50            Self::Pending => "PENDING",
51            Self::Failed => "FAILED",
52            Self::Skipped => "SKIPPED",
53        }
54    }
55
56    /// Get the short status indicator for plain mode.
57    #[must_use]
58    pub fn indicator(&self) -> &'static str {
59        match self {
60            Self::Applied => "[OK]",
61            Self::Pending => "[PENDING]",
62            Self::Failed => "[FAILED]",
63            Self::Skipped => "[SKIPPED]",
64        }
65    }
66
67    /// Get the status icon for rich mode.
68    #[must_use]
69    pub fn icon(&self) -> &'static str {
70        match self {
71            Self::Applied => "✓",
72            Self::Pending => "○",
73            Self::Failed => "✗",
74            Self::Skipped => "⊘",
75        }
76    }
77
78    /// Get the ANSI color code for this migration state.
79    #[must_use]
80    pub fn color_code(&self) -> &'static str {
81        match self {
82            Self::Applied => "\x1b[32m", // Green
83            Self::Pending => "\x1b[33m", // Yellow
84            Self::Failed => "\x1b[31m",  // Red
85            Self::Skipped => "\x1b[90m", // Gray
86        }
87    }
88
89    /// Get the ANSI reset code.
90    #[must_use]
91    pub fn reset_code() -> &'static str {
92        "\x1b[0m"
93    }
94}
95
96impl std::fmt::Display for MigrationState {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(f, "{}", self.as_str())
99    }
100}
101
102/// A single migration record with metadata.
103#[derive(Debug, Clone, Default)]
104pub struct MigrationRecord {
105    /// Version identifier (e.g., "001", "20240115_1030").
106    pub version: String,
107    /// Human-readable migration name.
108    pub name: String,
109    /// Current state of this migration.
110    pub state: MigrationState,
111    /// ISO-8601 timestamp when migration was applied.
112    pub applied_at: Option<String>,
113    /// Checksum for migration file verification.
114    pub checksum: Option<String>,
115    /// Execution duration in milliseconds.
116    pub duration_ms: Option<u64>,
117    /// Error message if migration failed.
118    pub error_message: Option<String>,
119    /// Up SQL preview (for pending migrations).
120    pub up_sql: Option<String>,
121    /// Down SQL preview (for applied migrations).
122    pub down_sql: Option<String>,
123}
124
125impl MigrationRecord {
126    /// Create a new migration record with version and name.
127    ///
128    /// # Example
129    ///
130    /// ```rust
131    /// use sqlmodel_console::renderables::MigrationRecord;
132    ///
133    /// let record = MigrationRecord::new("001", "create_users_table");
134    /// ```
135    #[must_use]
136    pub fn new(version: impl Into<String>, name: impl Into<String>) -> Self {
137        Self {
138            version: version.into(),
139            name: name.into(),
140            state: MigrationState::default(),
141            applied_at: None,
142            checksum: None,
143            duration_ms: None,
144            error_message: None,
145            up_sql: None,
146            down_sql: None,
147        }
148    }
149
150    /// Set the migration state.
151    #[must_use]
152    pub fn state(mut self, state: MigrationState) -> Self {
153        self.state = state;
154        self
155    }
156
157    /// Set the applied timestamp.
158    #[must_use]
159    pub fn applied_at(mut self, timestamp: Option<String>) -> Self {
160        self.applied_at = timestamp;
161        self
162    }
163
164    /// Set the checksum.
165    #[must_use]
166    pub fn checksum(mut self, checksum: Option<String>) -> Self {
167        self.checksum = checksum;
168        self
169    }
170
171    /// Set the execution duration in milliseconds.
172    #[must_use]
173    pub fn duration_ms(mut self, duration: Option<u64>) -> Self {
174        self.duration_ms = duration;
175        self
176    }
177
178    /// Set an error message (for failed migrations).
179    #[must_use]
180    pub fn error_message(mut self, message: Option<String>) -> Self {
181        self.error_message = message;
182        self
183    }
184
185    /// Set the up SQL preview.
186    #[must_use]
187    pub fn up_sql(mut self, sql: Option<String>) -> Self {
188        self.up_sql = sql;
189        self
190    }
191
192    /// Set the down SQL preview.
193    #[must_use]
194    pub fn down_sql(mut self, sql: Option<String>) -> Self {
195        self.down_sql = sql;
196        self
197    }
198
199    /// Format the duration for display.
200    fn format_duration(&self) -> Option<String> {
201        self.duration_ms.map(|ms| {
202            if ms < 1000 {
203                format!("{}ms", ms)
204            } else if ms < 60_000 {
205                let secs = ms as f64 / 1000.0;
206                format!("{:.1}s", secs)
207            } else {
208                let mins = ms / 60_000;
209                let secs = (ms % 60_000) / 1000;
210                format!("{}m {}s", mins, secs)
211            }
212        })
213    }
214
215    /// Format the timestamp for display (simplified).
216    fn format_timestamp(&self) -> Option<String> {
217        self.applied_at.as_ref().map(|ts| {
218            // Try to extract just the date and time portion
219            // Input: "2024-01-15T10:30:00Z" or "2024-01-15 10:30:00"
220            // Output: "2024-01-15 10:30:00"
221            ts.replace('T', " ")
222                .trim_end_matches('Z')
223                .trim_end_matches("+00:00")
224                .to_string()
225        })
226    }
227}
228
229/// Display options for migration status.
230///
231/// Shows a list of migrations with their states, timestamps, and durations.
232#[derive(Debug, Clone)]
233pub struct MigrationStatus {
234    /// List of migration records to display.
235    records: Vec<MigrationRecord>,
236    /// Theme for styled output.
237    theme: Theme,
238    /// Whether to show checksums.
239    show_checksums: bool,
240    /// Whether to show durations.
241    show_duration: bool,
242    /// Whether to show SQL previews.
243    show_sql: bool,
244    /// Optional width constraint.
245    width: Option<usize>,
246    /// Title for the status display.
247    title: Option<String>,
248}
249
250impl MigrationStatus {
251    /// Create a new migration status display from a list of records.
252    ///
253    /// # Example
254    ///
255    /// ```rust
256    /// use sqlmodel_console::renderables::{MigrationStatus, MigrationRecord, MigrationState};
257    ///
258    /// let status = MigrationStatus::new(vec![
259    ///     MigrationRecord::new("001", "create_users").state(MigrationState::Applied),
260    ///     MigrationRecord::new("002", "add_posts").state(MigrationState::Pending),
261    /// ]);
262    /// ```
263    #[must_use]
264    pub fn new(records: Vec<MigrationRecord>) -> Self {
265        Self {
266            records,
267            theme: Theme::default(),
268            show_checksums: false,
269            show_duration: true,
270            show_sql: false,
271            width: None,
272            title: None,
273        }
274    }
275
276    /// Set the theme for styled output.
277    #[must_use]
278    pub fn theme(mut self, theme: Theme) -> Self {
279        self.theme = theme;
280        self
281    }
282
283    /// Set whether to show checksums.
284    #[must_use]
285    pub fn show_checksums(mut self, show: bool) -> Self {
286        self.show_checksums = show;
287        self
288    }
289
290    /// Set whether to show durations.
291    #[must_use]
292    pub fn show_duration(mut self, show: bool) -> Self {
293        self.show_duration = show;
294        self
295    }
296
297    /// Set whether to show SQL previews.
298    #[must_use]
299    pub fn show_sql(mut self, show: bool) -> Self {
300        self.show_sql = show;
301        self
302    }
303
304    /// Set the display width.
305    #[must_use]
306    pub fn width(mut self, width: usize) -> Self {
307        self.width = Some(width);
308        self
309    }
310
311    /// Set a custom title.
312    #[must_use]
313    pub fn title(mut self, title: impl Into<String>) -> Self {
314        self.title = Some(title.into());
315        self
316    }
317
318    /// Get the count of applied migrations.
319    #[must_use]
320    pub fn applied_count(&self) -> usize {
321        self.records
322            .iter()
323            .filter(|r| r.state == MigrationState::Applied)
324            .count()
325    }
326
327    /// Get the count of pending migrations.
328    #[must_use]
329    pub fn pending_count(&self) -> usize {
330        self.records
331            .iter()
332            .filter(|r| r.state == MigrationState::Pending)
333            .count()
334    }
335
336    /// Get the count of failed migrations.
337    #[must_use]
338    pub fn failed_count(&self) -> usize {
339        self.records
340            .iter()
341            .filter(|r| r.state == MigrationState::Failed)
342            .count()
343    }
344
345    /// Get the count of skipped migrations.
346    #[must_use]
347    pub fn skipped_count(&self) -> usize {
348        self.records
349            .iter()
350            .filter(|r| r.state == MigrationState::Skipped)
351            .count()
352    }
353
354    /// Get the total count of migrations.
355    #[must_use]
356    pub fn total_count(&self) -> usize {
357        self.records.len()
358    }
359
360    /// Check if all migrations are applied.
361    #[must_use]
362    pub fn is_up_to_date(&self) -> bool {
363        self.pending_count() == 0 && self.failed_count() == 0
364    }
365
366    /// Render as plain text for agent consumption.
367    ///
368    /// Returns a structured plain text representation suitable for
369    /// non-TTY environments or agent parsing.
370    #[must_use]
371    pub fn render_plain(&self) -> String {
372        let mut lines = Vec::new();
373
374        // Header
375        let title = self.title.as_deref().unwrap_or("MIGRATION STATUS");
376        lines.push(title.to_string());
377        lines.push("=".repeat(title.len()));
378
379        // Summary line
380        lines.push(format!(
381            "Applied: {}, Pending: {}, Failed: {}, Total: {}",
382            self.applied_count(),
383            self.pending_count(),
384            self.failed_count(),
385            self.total_count()
386        ));
387        lines.push(String::new());
388
389        if self.records.is_empty() {
390            lines.push("No migrations found.".to_string());
391            return lines.join("\n");
392        }
393
394        // Migration records
395        for record in &self.records {
396            let mut parts = vec![
397                record.state.indicator().to_string(),
398                format!("{}_{}", record.version, record.name),
399            ];
400
401            // Add timestamp for applied migrations
402            if let Some(ts) = record.format_timestamp() {
403                parts.push(format!("- Applied {}", ts));
404            }
405
406            // Add duration if showing and available
407            if self.show_duration {
408                if let Some(dur) = record.format_duration() {
409                    parts.push(format!("({})", dur));
410                }
411            }
412
413            lines.push(parts.join(" "));
414
415            // Add error message for failed migrations
416            if record.state == MigrationState::Failed {
417                if let Some(ref err) = record.error_message {
418                    lines.push(format!("    Error: {}", err));
419                }
420            }
421
422            // Add checksum if showing
423            if self.show_checksums {
424                if let Some(ref checksum) = record.checksum {
425                    lines.push(format!("    Checksum: {}", checksum));
426                }
427            }
428
429            // Add SQL preview if showing
430            if self.show_sql {
431                if record.state == MigrationState::Pending {
432                    if let Some(ref sql) = record.up_sql {
433                        lines.push("    Up SQL:".to_string());
434                        for sql_line in sql.lines().take(3) {
435                            lines.push(format!("      {}", sql_line));
436                        }
437                    }
438                } else if record.state == MigrationState::Applied {
439                    if let Some(ref sql) = record.down_sql {
440                        lines.push("    Down SQL:".to_string());
441                        for sql_line in sql.lines().take(3) {
442                            lines.push(format!("      {}", sql_line));
443                        }
444                    }
445                }
446            }
447        }
448
449        lines.join("\n")
450    }
451
452    /// Render with ANSI colors for terminal display.
453    ///
454    /// Returns a rich panel representation with colored status indicators
455    /// and formatted content.
456    #[must_use]
457    pub fn render_styled(&self) -> String {
458        let width = self.width.unwrap_or(80);
459        let reset = MigrationState::reset_code();
460        let dim = "\x1b[2m";
461
462        let mut lines = Vec::new();
463
464        // Title
465        let title = self.title.as_deref().unwrap_or("Migration Status");
466
467        // Top border with title
468        let title_display = format!(" {} ", title);
469        let title_len = title_display.chars().count();
470        let left_pad = (width - 2 - title_len) / 2;
471        let right_pad = width - 2 - title_len - left_pad;
472
473        lines.push(format!(
474            "{}╭{}{}{}╮{}",
475            self.border_color(),
476            "─".repeat(left_pad),
477            title_display,
478            "─".repeat(right_pad),
479            reset
480        ));
481
482        // Summary line
483        let summary = format!(
484            " Applied: {}{}{}  Pending: {}{}{}  Failed: {}{}{}",
485            self.theme.success.color_code(),
486            self.applied_count(),
487            reset,
488            self.theme.warning.color_code(),
489            self.pending_count(),
490            reset,
491            self.theme.error.color_code(),
492            self.failed_count(),
493            reset,
494        );
495        lines.push(self.wrap_line(&summary, width));
496
497        // Separator
498        lines.push(format!(
499            "{}├{}┤{}",
500            self.border_color(),
501            "─".repeat(width - 2),
502            reset
503        ));
504
505        if self.records.is_empty() {
506            let empty_msg = format!(" {}No migrations found.{}", dim, reset);
507            lines.push(self.wrap_line(&empty_msg, width));
508        } else {
509            // Column headers
510            let header = format!(
511                " {dim}Status   Version   Name{:width$}Applied At          Duration{reset}",
512                "",
513                width = width.saturating_sub(70),
514                dim = dim,
515                reset = reset
516            );
517            lines.push(self.wrap_line(&header, width));
518            lines.push(format!(
519                "{}│{}{}│{}",
520                self.border_color(),
521                dim,
522                "─".repeat(width - 2),
523                reset
524            ));
525
526            // Migration rows
527            for record in &self.records {
528                let state_color = record.state.color_code();
529                let icon = record.state.icon();
530
531                // Format version and name (truncate if needed)
532                let version_name = format!("{}_{}", record.version, record.name);
533                let version_name_display = if version_name.len() > 30 {
534                    format!("{}...", &version_name[..27])
535                } else {
536                    version_name
537                };
538
539                // Format timestamp
540                let timestamp = record.format_timestamp().unwrap_or_else(|| "-".to_string());
541
542                // Format duration
543                let duration = if self.show_duration {
544                    record.format_duration().unwrap_or_else(|| "-".to_string())
545                } else {
546                    String::new()
547                };
548
549                let row = format!(
550                    " {}{} {:7}{} {:30} {:19} {:>8}",
551                    state_color,
552                    icon,
553                    record.state.as_str(),
554                    reset,
555                    version_name_display,
556                    timestamp,
557                    duration,
558                );
559                lines.push(self.wrap_line(&row, width));
560
561                // Show error for failed migrations
562                if record.state == MigrationState::Failed {
563                    if let Some(ref err) = record.error_message {
564                        let err_line = format!(
565                            "   {}Error: {}{}",
566                            self.theme.error.color_code(),
567                            err,
568                            reset
569                        );
570                        lines.push(self.wrap_line(&err_line, width));
571                    }
572                }
573
574                // Show checksum if enabled
575                if self.show_checksums {
576                    if let Some(ref checksum) = record.checksum {
577                        let checksum_line = format!("   {}Checksum: {}{}", dim, checksum, reset);
578                        lines.push(self.wrap_line(&checksum_line, width));
579                    }
580                }
581            }
582        }
583
584        // Bottom border
585        lines.push(format!(
586            "{}╰{}╯{}",
587            self.border_color(),
588            "─".repeat(width - 2),
589            reset
590        ));
591
592        lines.join("\n")
593    }
594
595    /// Render as JSON-serializable structure.
596    ///
597    /// Returns a JSON value suitable for structured logging or API responses.
598    #[must_use]
599    pub fn to_json(&self) -> serde_json::Value {
600        let records: Vec<serde_json::Value> = self
601            .records
602            .iter()
603            .map(|r| {
604                serde_json::json!({
605                    "version": r.version,
606                    "name": r.name,
607                    "state": r.state.as_str(),
608                    "applied_at": r.applied_at,
609                    "checksum": r.checksum,
610                    "duration_ms": r.duration_ms,
611                    "error_message": r.error_message,
612                })
613            })
614            .collect();
615
616        serde_json::json!({
617            "title": self.title,
618            "summary": {
619                "applied": self.applied_count(),
620                "pending": self.pending_count(),
621                "failed": self.failed_count(),
622                "skipped": self.skipped_count(),
623                "total": self.total_count(),
624                "up_to_date": self.is_up_to_date(),
625            },
626            "migrations": records,
627        })
628    }
629
630    /// Get the border color code.
631    fn border_color(&self) -> String {
632        self.theme.border.color_code()
633    }
634
635    /// Wrap a line to fit within the panel width.
636    fn wrap_line(&self, content: &str, width: usize) -> String {
637        let visible_len = self.visible_length(content);
638        let padding = (width - 2).saturating_sub(visible_len);
639        let reset = MigrationState::reset_code();
640
641        format!(
642            "{}│{}{content}{:padding$}{}│{}",
643            self.border_color(),
644            reset,
645            "",
646            self.border_color(),
647            reset,
648            padding = padding
649        )
650    }
651
652    /// Calculate visible length of a string (excluding ANSI codes).
653    fn visible_length(&self, s: &str) -> usize {
654        let mut len = 0;
655        let mut in_escape = false;
656
657        for c in s.chars() {
658            if c == '\x1b' {
659                in_escape = true;
660            } else if in_escape {
661                if c == 'm' {
662                    in_escape = false;
663                }
664            } else {
665                len += 1;
666            }
667        }
668        len
669    }
670}
671
672impl Default for MigrationStatus {
673    fn default() -> Self {
674        Self::new(Vec::new())
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    // === Test 1: test_migration_status_creation ===
683    #[test]
684    fn test_migration_status_creation() {
685        let status = MigrationStatus::new(vec![
686            MigrationRecord::new("001", "create_users"),
687            MigrationRecord::new("002", "add_posts"),
688        ]);
689
690        assert_eq!(status.total_count(), 2);
691        assert_eq!(status.records.len(), 2);
692    }
693
694    // === Test 2: test_migration_state_applied ===
695    #[test]
696    fn test_migration_state_applied() {
697        let state = MigrationState::Applied;
698
699        assert_eq!(state.as_str(), "APPLIED");
700        assert_eq!(state.indicator(), "[OK]");
701        assert_eq!(state.icon(), "✓");
702        assert!(state.color_code().contains("32")); // Green
703    }
704
705    // === Test 3: test_migration_state_pending ===
706    #[test]
707    fn test_migration_state_pending() {
708        let state = MigrationState::Pending;
709
710        assert_eq!(state.as_str(), "PENDING");
711        assert_eq!(state.indicator(), "[PENDING]");
712        assert_eq!(state.icon(), "○");
713        assert!(state.color_code().contains("33")); // Yellow
714    }
715
716    // === Test 4: test_migration_state_failed ===
717    #[test]
718    fn test_migration_state_failed() {
719        let state = MigrationState::Failed;
720
721        assert_eq!(state.as_str(), "FAILED");
722        assert_eq!(state.indicator(), "[FAILED]");
723        assert_eq!(state.icon(), "✗");
724        assert!(state.color_code().contains("31")); // Red
725    }
726
727    // === Test 5: test_migration_render_plain ===
728    #[test]
729    fn test_migration_render_plain() {
730        let status = MigrationStatus::new(vec![
731            MigrationRecord::new("001", "create_users")
732                .state(MigrationState::Applied)
733                .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
734                .duration_ms(Some(45)),
735            MigrationRecord::new("002", "add_posts").state(MigrationState::Pending),
736        ]);
737
738        let plain = status.render_plain();
739
740        // Should contain header
741        assert!(plain.contains("MIGRATION STATUS"));
742
743        // Should contain summary
744        assert!(plain.contains("Applied: 1"));
745        assert!(plain.contains("Pending: 1"));
746
747        // Should contain records
748        assert!(plain.contains("[OK] 001_create_users"));
749        assert!(plain.contains("[PENDING] 002_add_posts"));
750
751        // Should contain timestamp
752        assert!(plain.contains("2024-01-15"));
753
754        // Should contain duration
755        assert!(plain.contains("45ms"));
756    }
757
758    // === Test 6: test_migration_render_rich ===
759    #[test]
760    fn test_migration_render_rich() {
761        let status = MigrationStatus::new(vec![
762            MigrationRecord::new("001", "create_users").state(MigrationState::Applied),
763        ])
764        .width(80);
765
766        let styled = status.render_styled();
767
768        // Should contain box drawing characters
769        assert!(styled.contains("╭"));
770        assert!(styled.contains("╯"));
771        assert!(styled.contains("│"));
772
773        // Should contain status icon
774        assert!(styled.contains("✓"));
775    }
776
777    // === Test 7: test_migration_timestamps ===
778    #[test]
779    fn test_migration_timestamps() {
780        let record = MigrationRecord::new("001", "test")
781            .applied_at(Some("2024-01-15T10:30:00Z".to_string()));
782
783        let formatted = record.format_timestamp();
784
785        assert!(formatted.is_some());
786        let ts = formatted.unwrap();
787        assert!(ts.contains("2024-01-15"));
788        assert!(ts.contains("10:30:00"));
789        assert!(!ts.contains('T')); // Should be replaced with space
790        assert!(!ts.contains('Z')); // Should be stripped
791    }
792
793    // === Test 8: test_migration_checksums ===
794    #[test]
795    fn test_migration_checksums() {
796        let status = MigrationStatus::new(vec![
797            MigrationRecord::new("001", "test")
798                .state(MigrationState::Applied)
799                .checksum(Some("abc123def456".to_string())),
800        ])
801        .show_checksums(true);
802
803        let plain = status.render_plain();
804
805        assert!(plain.contains("Checksum: abc123def456"));
806    }
807
808    // === Test 9: test_migration_duration ===
809    #[test]
810    fn test_migration_duration() {
811        // Test milliseconds
812        let record_ms = MigrationRecord::new("001", "test").duration_ms(Some(45));
813        assert_eq!(record_ms.format_duration(), Some("45ms".to_string()));
814
815        // Test seconds
816        let record_sec = MigrationRecord::new("002", "test").duration_ms(Some(2500));
817        assert_eq!(record_sec.format_duration(), Some("2.5s".to_string()));
818
819        // Test minutes
820        let record_m = MigrationRecord::new("003", "test").duration_ms(Some(125_000));
821        assert_eq!(record_m.format_duration(), Some("2m 5s".to_string()));
822    }
823
824    // === Test 10: test_migration_empty_list ===
825    #[test]
826    fn test_migration_empty_list() {
827        let status = MigrationStatus::new(vec![]);
828
829        assert_eq!(status.total_count(), 0);
830        assert_eq!(status.applied_count(), 0);
831        assert_eq!(status.pending_count(), 0);
832        assert!(status.is_up_to_date());
833
834        let plain = status.render_plain();
835        assert!(plain.contains("No migrations found"));
836    }
837
838    // === Additional tests ===
839
840    #[test]
841    fn test_migration_state_display() {
842        assert_eq!(format!("{}", MigrationState::Applied), "APPLIED");
843        assert_eq!(format!("{}", MigrationState::Pending), "PENDING");
844        assert_eq!(format!("{}", MigrationState::Failed), "FAILED");
845        assert_eq!(format!("{}", MigrationState::Skipped), "SKIPPED");
846    }
847
848    #[test]
849    fn test_migration_state_skipped() {
850        let state = MigrationState::Skipped;
851
852        assert_eq!(state.as_str(), "SKIPPED");
853        assert_eq!(state.indicator(), "[SKIPPED]");
854        assert_eq!(state.icon(), "⊘");
855        assert!(state.color_code().contains("90")); // Gray
856    }
857
858    #[test]
859    fn test_migration_record_builder() {
860        let record = MigrationRecord::new("001", "create_users")
861            .state(MigrationState::Applied)
862            .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
863            .checksum(Some("abc123".to_string()))
864            .duration_ms(Some(100))
865            .error_message(None)
866            .up_sql(Some("CREATE TABLE users".to_string()))
867            .down_sql(Some("DROP TABLE users".to_string()));
868
869        assert_eq!(record.version, "001");
870        assert_eq!(record.name, "create_users");
871        assert_eq!(record.state, MigrationState::Applied);
872        assert!(record.applied_at.is_some());
873        assert!(record.checksum.is_some());
874        assert_eq!(record.duration_ms, Some(100));
875        assert!(record.up_sql.is_some());
876        assert!(record.down_sql.is_some());
877    }
878
879    #[test]
880    fn test_migration_status_counts() {
881        let status = MigrationStatus::new(vec![
882            MigrationRecord::new("001", "a").state(MigrationState::Applied),
883            MigrationRecord::new("002", "b").state(MigrationState::Applied),
884            MigrationRecord::new("003", "c").state(MigrationState::Pending),
885            MigrationRecord::new("004", "d").state(MigrationState::Failed),
886            MigrationRecord::new("005", "e").state(MigrationState::Skipped),
887        ]);
888
889        assert_eq!(status.applied_count(), 2);
890        assert_eq!(status.pending_count(), 1);
891        assert_eq!(status.failed_count(), 1);
892        assert_eq!(status.skipped_count(), 1);
893        assert_eq!(status.total_count(), 5);
894        assert!(!status.is_up_to_date()); // Has pending and failed
895    }
896
897    #[test]
898    fn test_migration_is_up_to_date() {
899        // All applied
900        let status1 = MigrationStatus::new(vec![
901            MigrationRecord::new("001", "a").state(MigrationState::Applied),
902            MigrationRecord::new("002", "b").state(MigrationState::Applied),
903        ]);
904        assert!(status1.is_up_to_date());
905
906        // Has pending
907        let status2 = MigrationStatus::new(vec![
908            MigrationRecord::new("001", "a").state(MigrationState::Applied),
909            MigrationRecord::new("002", "b").state(MigrationState::Pending),
910        ]);
911        assert!(!status2.is_up_to_date());
912
913        // Has failed
914        let status3 = MigrationStatus::new(vec![
915            MigrationRecord::new("001", "a").state(MigrationState::Failed),
916        ]);
917        assert!(!status3.is_up_to_date());
918    }
919
920    #[test]
921    fn test_migration_status_builder_pattern() {
922        let status = MigrationStatus::new(vec![])
923            .theme(Theme::light())
924            .show_checksums(true)
925            .show_duration(false)
926            .show_sql(true)
927            .width(100)
928            .title("Custom Title");
929
930        assert!(status.show_checksums);
931        assert!(!status.show_duration);
932        assert!(status.show_sql);
933        assert_eq!(status.width, Some(100));
934        assert_eq!(status.title, Some("Custom Title".to_string()));
935    }
936
937    #[test]
938    fn test_migration_to_json() {
939        let status = MigrationStatus::new(vec![
940            MigrationRecord::new("001", "create_users")
941                .state(MigrationState::Applied)
942                .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
943                .duration_ms(Some(45)),
944            MigrationRecord::new("002", "add_posts").state(MigrationState::Pending),
945        ]);
946
947        let json = status.to_json();
948
949        // Check summary
950        assert_eq!(json["summary"]["applied"], 1);
951        assert_eq!(json["summary"]["pending"], 1);
952        assert_eq!(json["summary"]["total"], 2);
953        assert!(!json["summary"]["up_to_date"].as_bool().unwrap());
954
955        // Check migrations array
956        let migrations = json["migrations"].as_array().unwrap();
957        assert_eq!(migrations.len(), 2);
958        assert_eq!(migrations[0]["state"], "APPLIED");
959        assert_eq!(migrations[1]["state"], "PENDING");
960    }
961
962    #[test]
963    fn test_migration_failed_with_error() {
964        let status = MigrationStatus::new(vec![
965            MigrationRecord::new("001", "broken")
966                .state(MigrationState::Failed)
967                .error_message(Some("Duplicate column 'id'".to_string())),
968        ]);
969
970        let plain = status.render_plain();
971
972        assert!(plain.contains("[FAILED]"));
973        assert!(plain.contains("Error: Duplicate column 'id'"));
974    }
975
976    #[test]
977    fn test_migration_render_plain_with_sql() {
978        let status = MigrationStatus::new(vec![
979            MigrationRecord::new("001", "create_users")
980                .state(MigrationState::Pending)
981                .up_sql(Some(
982                    "CREATE TABLE users (\n  id SERIAL,\n  name TEXT\n);".to_string(),
983                )),
984        ])
985        .show_sql(true);
986
987        let plain = status.render_plain();
988
989        assert!(plain.contains("Up SQL:"));
990        assert!(plain.contains("CREATE TABLE users"));
991    }
992
993    #[test]
994    fn test_migration_default() {
995        let status = MigrationStatus::default();
996        assert_eq!(status.total_count(), 0);
997        assert!(status.records.is_empty());
998    }
999
1000    #[test]
1001    fn test_migration_record_default() {
1002        let record = MigrationRecord::default();
1003        assert_eq!(record.version, "");
1004        assert_eq!(record.name, "");
1005        assert_eq!(record.state, MigrationState::Pending);
1006    }
1007
1008    #[test]
1009    fn test_migration_state_default() {
1010        let state = MigrationState::default();
1011        assert_eq!(state, MigrationState::Pending);
1012    }
1013}