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).max(6);
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 max_title_chars = width.saturating_sub(4);
469        let title_text = self.truncate_plain_to_width(title, max_title_chars);
470        let title_display = format!(" {title_text} ");
471        let title_len = title_display.chars().count();
472        let border_space = width.saturating_sub(2);
473        let total_pad = border_space.saturating_sub(title_len);
474        let left_pad = total_pad / 2;
475        let right_pad = total_pad.saturating_sub(left_pad);
476
477        lines.push(format!(
478            "{}╭{}{}{}╮{}",
479            self.border_color(),
480            "─".repeat(left_pad),
481            title_display,
482            "─".repeat(right_pad),
483            reset
484        ));
485
486        // Summary line
487        let summary = format!(
488            " Applied: {}{}{}  Pending: {}{}{}  Failed: {}{}{}",
489            self.theme.success.color_code(),
490            self.applied_count(),
491            reset,
492            self.theme.warning.color_code(),
493            self.pending_count(),
494            reset,
495            self.theme.error.color_code(),
496            self.failed_count(),
497            reset,
498        );
499        lines.push(self.wrap_line(&summary, width));
500
501        // Separator
502        lines.push(format!(
503            "{}├{}┤{}",
504            self.border_color(),
505            "─".repeat(width.saturating_sub(2)),
506            reset
507        ));
508
509        if self.records.is_empty() {
510            let empty_msg = format!(" {}No migrations found.{}", dim, reset);
511            lines.push(self.wrap_line(&empty_msg, width));
512        } else {
513            // Column headers
514            let header = format!(
515                " {dim}Status   Version   Name{:width$}Applied At          Duration{reset}",
516                "",
517                width = width.saturating_sub(70),
518                dim = dim,
519                reset = reset
520            );
521            lines.push(self.wrap_line(&header, width));
522            lines.push(format!(
523                "{}│{}{}│{}",
524                self.border_color(),
525                dim,
526                "─".repeat(width.saturating_sub(2)),
527                reset
528            ));
529
530            // Migration rows
531            for record in &self.records {
532                let state_color = record.state.color_code();
533                let icon = record.state.icon();
534
535                // Format version and name (truncate if needed)
536                let version_name = format!("{}_{}", record.version, record.name);
537                let version_name_display = self.truncate_plain_to_width(&version_name, 30);
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.saturating_sub(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.saturating_sub(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    fn truncate_plain_to_width(&self, s: &str, max_visible: usize) -> String {
653        if max_visible == 0 {
654            return String::new();
655        }
656
657        let char_count = s.chars().count();
658        if char_count <= max_visible {
659            return s.to_string();
660        }
661
662        if max_visible <= 3 {
663            return ".".repeat(max_visible);
664        }
665
666        let truncated: String = s.chars().take(max_visible - 3).collect();
667        format!("{truncated}...")
668    }
669
670    /// Calculate visible length of a string (excluding ANSI codes).
671    fn visible_length(&self, s: &str) -> usize {
672        let mut len = 0;
673        let mut in_escape = false;
674
675        for c in s.chars() {
676            if c == '\x1b' {
677                in_escape = true;
678            } else if in_escape {
679                if c == 'm' {
680                    in_escape = false;
681                }
682            } else {
683                len += 1;
684            }
685        }
686        len
687    }
688}
689
690impl Default for MigrationStatus {
691    fn default() -> Self {
692        Self::new(Vec::new())
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    // === Test 1: test_migration_status_creation ===
701    #[test]
702    fn test_migration_status_creation() {
703        let status = MigrationStatus::new(vec![
704            MigrationRecord::new("001", "create_users"),
705            MigrationRecord::new("002", "add_posts"),
706        ]);
707
708        assert_eq!(status.total_count(), 2);
709        assert_eq!(status.records.len(), 2);
710    }
711
712    // === Test 2: test_migration_state_applied ===
713    #[test]
714    fn test_migration_state_applied() {
715        let state = MigrationState::Applied;
716
717        assert_eq!(state.as_str(), "APPLIED");
718        assert_eq!(state.indicator(), "[OK]");
719        assert_eq!(state.icon(), "✓");
720        assert!(state.color_code().contains("32")); // Green
721    }
722
723    // === Test 3: test_migration_state_pending ===
724    #[test]
725    fn test_migration_state_pending() {
726        let state = MigrationState::Pending;
727
728        assert_eq!(state.as_str(), "PENDING");
729        assert_eq!(state.indicator(), "[PENDING]");
730        assert_eq!(state.icon(), "○");
731        assert!(state.color_code().contains("33")); // Yellow
732    }
733
734    // === Test 4: test_migration_state_failed ===
735    #[test]
736    fn test_migration_state_failed() {
737        let state = MigrationState::Failed;
738
739        assert_eq!(state.as_str(), "FAILED");
740        assert_eq!(state.indicator(), "[FAILED]");
741        assert_eq!(state.icon(), "✗");
742        assert!(state.color_code().contains("31")); // Red
743    }
744
745    // === Test 5: test_migration_render_plain ===
746    #[test]
747    fn test_migration_render_plain() {
748        let status = MigrationStatus::new(vec![
749            MigrationRecord::new("001", "create_users")
750                .state(MigrationState::Applied)
751                .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
752                .duration_ms(Some(45)),
753            MigrationRecord::new("002", "add_posts").state(MigrationState::Pending),
754        ]);
755
756        let plain = status.render_plain();
757
758        // Should contain header
759        assert!(plain.contains("MIGRATION STATUS"));
760
761        // Should contain summary
762        assert!(plain.contains("Applied: 1"));
763        assert!(plain.contains("Pending: 1"));
764
765        // Should contain records
766        assert!(plain.contains("[OK] 001_create_users"));
767        assert!(plain.contains("[PENDING] 002_add_posts"));
768
769        // Should contain timestamp
770        assert!(plain.contains("2024-01-15"));
771
772        // Should contain duration
773        assert!(plain.contains("45ms"));
774    }
775
776    // === Test 6: test_migration_render_rich ===
777    #[test]
778    fn test_migration_render_rich() {
779        let status = MigrationStatus::new(vec![
780            MigrationRecord::new("001", "create_users").state(MigrationState::Applied),
781        ])
782        .width(80);
783
784        let styled = status.render_styled();
785
786        // Should contain box drawing characters
787        assert!(styled.contains("╭"));
788        assert!(styled.contains("╯"));
789        assert!(styled.contains("│"));
790
791        // Should contain status icon
792        assert!(styled.contains("✓"));
793    }
794
795    #[test]
796    fn test_migration_render_styled_tiny_width_does_not_panic() {
797        let status = MigrationStatus::new(vec![
798            MigrationRecord::new("001", "create_users").state(MigrationState::Applied),
799        ])
800        .width(1);
801
802        let styled = status.render_styled();
803
804        assert!(!styled.is_empty());
805        assert!(styled.contains('╭'));
806        assert!(styled.contains('╯'));
807    }
808
809    #[test]
810    fn test_migration_render_styled_unicode_name_truncation() {
811        let status = MigrationStatus::new(vec![
812            MigrationRecord::new(
813                "001",
814                "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥",
815            )
816            .state(MigrationState::Applied),
817        ])
818        .width(80);
819
820        let styled = status.render_styled();
821
822        assert!(styled.contains("..."));
823        assert!(styled.contains("001_"));
824    }
825
826    // === Test 7: test_migration_timestamps ===
827    #[test]
828    fn test_migration_timestamps() {
829        let record = MigrationRecord::new("001", "test")
830            .applied_at(Some("2024-01-15T10:30:00Z".to_string()));
831
832        let formatted = record.format_timestamp();
833
834        assert!(formatted.is_some());
835        let ts = formatted.unwrap();
836        assert!(ts.contains("2024-01-15"));
837        assert!(ts.contains("10:30:00"));
838        assert!(!ts.contains('T')); // Should be replaced with space
839        assert!(!ts.contains('Z')); // Should be stripped
840    }
841
842    // === Test 8: test_migration_checksums ===
843    #[test]
844    fn test_migration_checksums() {
845        let status = MigrationStatus::new(vec![
846            MigrationRecord::new("001", "test")
847                .state(MigrationState::Applied)
848                .checksum(Some("abc123def456".to_string())),
849        ])
850        .show_checksums(true);
851
852        let plain = status.render_plain();
853
854        assert!(plain.contains("Checksum: abc123def456"));
855    }
856
857    // === Test 9: test_migration_duration ===
858    #[test]
859    fn test_migration_duration() {
860        // Test milliseconds
861        let record_ms = MigrationRecord::new("001", "test").duration_ms(Some(45));
862        assert_eq!(record_ms.format_duration(), Some("45ms".to_string()));
863
864        // Test seconds
865        let record_sec = MigrationRecord::new("002", "test").duration_ms(Some(2500));
866        assert_eq!(record_sec.format_duration(), Some("2.5s".to_string()));
867
868        // Test minutes
869        let record_m = MigrationRecord::new("003", "test").duration_ms(Some(125_000));
870        assert_eq!(record_m.format_duration(), Some("2m 5s".to_string()));
871    }
872
873    // === Test 10: test_migration_empty_list ===
874    #[test]
875    fn test_migration_empty_list() {
876        let status = MigrationStatus::new(vec![]);
877
878        assert_eq!(status.total_count(), 0);
879        assert_eq!(status.applied_count(), 0);
880        assert_eq!(status.pending_count(), 0);
881        assert!(status.is_up_to_date());
882
883        let plain = status.render_plain();
884        assert!(plain.contains("No migrations found"));
885    }
886
887    // === Additional tests ===
888
889    #[test]
890    fn test_migration_state_display() {
891        assert_eq!(format!("{}", MigrationState::Applied), "APPLIED");
892        assert_eq!(format!("{}", MigrationState::Pending), "PENDING");
893        assert_eq!(format!("{}", MigrationState::Failed), "FAILED");
894        assert_eq!(format!("{}", MigrationState::Skipped), "SKIPPED");
895    }
896
897    #[test]
898    fn test_migration_state_skipped() {
899        let state = MigrationState::Skipped;
900
901        assert_eq!(state.as_str(), "SKIPPED");
902        assert_eq!(state.indicator(), "[SKIPPED]");
903        assert_eq!(state.icon(), "⊘");
904        assert!(state.color_code().contains("90")); // Gray
905    }
906
907    #[test]
908    fn test_migration_record_builder() {
909        let record = MigrationRecord::new("001", "create_users")
910            .state(MigrationState::Applied)
911            .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
912            .checksum(Some("abc123".to_string()))
913            .duration_ms(Some(100))
914            .error_message(None)
915            .up_sql(Some("CREATE TABLE users".to_string()))
916            .down_sql(Some("DROP TABLE users".to_string()));
917
918        assert_eq!(record.version, "001");
919        assert_eq!(record.name, "create_users");
920        assert_eq!(record.state, MigrationState::Applied);
921        assert!(record.applied_at.is_some());
922        assert!(record.checksum.is_some());
923        assert_eq!(record.duration_ms, Some(100));
924        assert!(record.up_sql.is_some());
925        assert!(record.down_sql.is_some());
926    }
927
928    #[test]
929    fn test_migration_status_counts() {
930        let status = MigrationStatus::new(vec![
931            MigrationRecord::new("001", "a").state(MigrationState::Applied),
932            MigrationRecord::new("002", "b").state(MigrationState::Applied),
933            MigrationRecord::new("003", "c").state(MigrationState::Pending),
934            MigrationRecord::new("004", "d").state(MigrationState::Failed),
935            MigrationRecord::new("005", "e").state(MigrationState::Skipped),
936        ]);
937
938        assert_eq!(status.applied_count(), 2);
939        assert_eq!(status.pending_count(), 1);
940        assert_eq!(status.failed_count(), 1);
941        assert_eq!(status.skipped_count(), 1);
942        assert_eq!(status.total_count(), 5);
943        assert!(!status.is_up_to_date()); // Has pending and failed
944    }
945
946    #[test]
947    fn test_migration_is_up_to_date() {
948        // All applied
949        let status1 = MigrationStatus::new(vec![
950            MigrationRecord::new("001", "a").state(MigrationState::Applied),
951            MigrationRecord::new("002", "b").state(MigrationState::Applied),
952        ]);
953        assert!(status1.is_up_to_date());
954
955        // Has pending
956        let status2 = MigrationStatus::new(vec![
957            MigrationRecord::new("001", "a").state(MigrationState::Applied),
958            MigrationRecord::new("002", "b").state(MigrationState::Pending),
959        ]);
960        assert!(!status2.is_up_to_date());
961
962        // Has failed
963        let status3 = MigrationStatus::new(vec![
964            MigrationRecord::new("001", "a").state(MigrationState::Failed),
965        ]);
966        assert!(!status3.is_up_to_date());
967    }
968
969    #[test]
970    fn test_migration_status_builder_pattern() {
971        let status = MigrationStatus::new(vec![])
972            .theme(Theme::light())
973            .show_checksums(true)
974            .show_duration(false)
975            .show_sql(true)
976            .width(100)
977            .title("Custom Title");
978
979        assert!(status.show_checksums);
980        assert!(!status.show_duration);
981        assert!(status.show_sql);
982        assert_eq!(status.width, Some(100));
983        assert_eq!(status.title, Some("Custom Title".to_string()));
984    }
985
986    #[test]
987    fn test_migration_to_json() {
988        let status = MigrationStatus::new(vec![
989            MigrationRecord::new("001", "create_users")
990                .state(MigrationState::Applied)
991                .applied_at(Some("2024-01-15T10:30:00Z".to_string()))
992                .duration_ms(Some(45)),
993            MigrationRecord::new("002", "add_posts").state(MigrationState::Pending),
994        ]);
995
996        let json = status.to_json();
997
998        // Check summary
999        assert_eq!(json["summary"]["applied"], 1);
1000        assert_eq!(json["summary"]["pending"], 1);
1001        assert_eq!(json["summary"]["total"], 2);
1002        assert!(!json["summary"]["up_to_date"].as_bool().unwrap());
1003
1004        // Check migrations array
1005        let migrations = json["migrations"].as_array().unwrap();
1006        assert_eq!(migrations.len(), 2);
1007        assert_eq!(migrations[0]["state"], "APPLIED");
1008        assert_eq!(migrations[1]["state"], "PENDING");
1009    }
1010
1011    #[test]
1012    fn test_migration_failed_with_error() {
1013        let status = MigrationStatus::new(vec![
1014            MigrationRecord::new("001", "broken")
1015                .state(MigrationState::Failed)
1016                .error_message(Some("Duplicate column 'id'".to_string())),
1017        ]);
1018
1019        let plain = status.render_plain();
1020
1021        assert!(plain.contains("[FAILED]"));
1022        assert!(plain.contains("Error: Duplicate column 'id'"));
1023    }
1024
1025    #[test]
1026    fn test_migration_render_plain_with_sql() {
1027        let status = MigrationStatus::new(vec![
1028            MigrationRecord::new("001", "create_users")
1029                .state(MigrationState::Pending)
1030                .up_sql(Some(
1031                    "CREATE TABLE users (\n  id SERIAL,\n  name TEXT\n);".to_string(),
1032                )),
1033        ])
1034        .show_sql(true);
1035
1036        let plain = status.render_plain();
1037
1038        assert!(plain.contains("Up SQL:"));
1039        assert!(plain.contains("CREATE TABLE users"));
1040    }
1041
1042    #[test]
1043    fn test_migration_default() {
1044        let status = MigrationStatus::default();
1045        assert_eq!(status.total_count(), 0);
1046        assert!(status.records.is_empty());
1047    }
1048
1049    #[test]
1050    fn test_migration_record_default() {
1051        let record = MigrationRecord::default();
1052        assert_eq!(record.version, "");
1053        assert_eq!(record.name, "");
1054        assert_eq!(record.state, MigrationState::Pending);
1055    }
1056
1057    #[test]
1058    fn test_migration_state_default() {
1059        let state = MigrationState::default();
1060        assert_eq!(state, MigrationState::Pending);
1061    }
1062}