Skip to main content

sqlmodel_console/renderables/
table_info.rs

1//! Table info panel for single-table detail display.
2//!
3//! Displays comprehensive information about a single database table, including
4//! columns, indexes, foreign keys, and optional statistics. Complementary to
5//! [`SchemaTree`](super::schema_tree::SchemaTree) - while SchemaTree shows the
6//! overview, TableInfo shows detailed information for one table.
7//!
8//! # Example
9//!
10//! ```rust
11//! use sqlmodel_console::renderables::{TableInfo, TableStats, ColumnData, IndexData, ForeignKeyData};
12//!
13//! let columns = vec![
14//!     ColumnData {
15//!         name: "id".to_string(),
16//!         sql_type: "BIGINT".to_string(),
17//!         nullable: false,
18//!         default: None,
19//!         primary_key: true,
20//!         auto_increment: true,
21//!     },
22//!     ColumnData {
23//!         name: "name".to_string(),
24//!         sql_type: "VARCHAR(255)".to_string(),
25//!         nullable: false,
26//!         default: None,
27//!         primary_key: false,
28//!         auto_increment: false,
29//!     },
30//! ];
31//!
32//! let table_info = TableInfo::new("heroes", columns)
33//!     .with_primary_key(vec!["id".to_string()])
34//!     .with_stats(TableStats {
35//!         row_count: Some(10_000),
36//!         size_bytes: Some(2_500_000),
37//!         ..Default::default()
38//!     })
39//!     .width(80);
40//!
41//! println!("{}", table_info.render_plain());
42//! ```
43
44use crate::theme::Theme;
45
46// Re-use data types from schema_tree
47pub use super::schema_tree::{ColumnData, ForeignKeyData, IndexData};
48
49/// Optional runtime statistics for a table.
50#[derive(Debug, Clone, Default)]
51pub struct TableStats {
52    /// Number of rows in the table.
53    pub row_count: Option<u64>,
54    /// Size of the table in bytes (data + indexes).
55    pub size_bytes: Option<u64>,
56    /// Index size in bytes.
57    pub index_size_bytes: Option<u64>,
58    /// Last analyzed timestamp.
59    pub last_analyzed: Option<String>,
60    /// Last modified timestamp.
61    pub last_modified: Option<String>,
62}
63
64impl TableStats {
65    /// Create empty stats.
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Set the row count.
72    #[must_use]
73    pub fn row_count(mut self, count: u64) -> Self {
74        self.row_count = Some(count);
75        self
76    }
77
78    /// Set the table size in bytes.
79    #[must_use]
80    pub fn size_bytes(mut self, bytes: u64) -> Self {
81        self.size_bytes = Some(bytes);
82        self
83    }
84
85    /// Set the index size in bytes.
86    #[must_use]
87    pub fn index_size_bytes(mut self, bytes: u64) -> Self {
88        self.index_size_bytes = Some(bytes);
89        self
90    }
91
92    /// Set the last analyzed timestamp.
93    #[must_use]
94    pub fn last_analyzed<S: Into<String>>(mut self, ts: S) -> Self {
95        self.last_analyzed = Some(ts.into());
96        self
97    }
98
99    /// Set the last modified timestamp.
100    #[must_use]
101    pub fn last_modified<S: Into<String>>(mut self, ts: S) -> Self {
102        self.last_modified = Some(ts.into());
103        self
104    }
105
106    /// Check if any stats are present.
107    #[must_use]
108    pub fn is_empty(&self) -> bool {
109        self.row_count.is_none()
110            && self.size_bytes.is_none()
111            && self.index_size_bytes.is_none()
112            && self.last_analyzed.is_none()
113            && self.last_modified.is_none()
114    }
115}
116
117/// Table information panel for displaying detailed table structure.
118///
119/// Displays a single table's complete information in a panel format,
120/// including columns, indexes, foreign keys, and optional statistics.
121#[derive(Debug, Clone)]
122pub struct TableInfo {
123    /// Table name
124    name: String,
125    /// Schema name (optional)
126    schema: Option<String>,
127    /// Columns
128    columns: Vec<ColumnData>,
129    /// Primary key column names
130    primary_key: Vec<String>,
131    /// Indexes
132    indexes: Vec<IndexData>,
133    /// Foreign keys
134    foreign_keys: Vec<ForeignKeyData>,
135    /// Optional runtime statistics
136    stats: Option<TableStats>,
137    /// Theme for styled output
138    theme: Theme,
139    /// Display width
140    width: Option<usize>,
141    /// Whether to show column types
142    show_types: bool,
143    /// Whether to show constraints
144    show_constraints: bool,
145}
146
147impl TableInfo {
148    /// Create a new table info display.
149    #[must_use]
150    pub fn new<S: Into<String>>(name: S, columns: Vec<ColumnData>) -> Self {
151        Self {
152            name: name.into(),
153            schema: None,
154            columns,
155            primary_key: Vec::new(),
156            indexes: Vec::new(),
157            foreign_keys: Vec::new(),
158            stats: None,
159            theme: Theme::default(),
160            width: None,
161            show_types: true,
162            show_constraints: true,
163        }
164    }
165
166    /// Create an empty table info.
167    #[must_use]
168    pub fn empty<S: Into<String>>(name: S) -> Self {
169        Self::new(name, Vec::new())
170    }
171
172    /// Set the schema name.
173    #[must_use]
174    pub fn schema<S: Into<String>>(mut self, schema: S) -> Self {
175        self.schema = Some(schema.into());
176        self
177    }
178
179    /// Set the primary key columns.
180    #[must_use]
181    pub fn with_primary_key(mut self, columns: Vec<String>) -> Self {
182        self.primary_key = columns;
183        self
184    }
185
186    /// Add an index.
187    #[must_use]
188    pub fn add_index(mut self, index: IndexData) -> Self {
189        self.indexes.push(index);
190        self
191    }
192
193    /// Set all indexes.
194    #[must_use]
195    pub fn with_indexes(mut self, indexes: Vec<IndexData>) -> Self {
196        self.indexes = indexes;
197        self
198    }
199
200    /// Add a foreign key.
201    #[must_use]
202    pub fn add_foreign_key(mut self, fk: ForeignKeyData) -> Self {
203        self.foreign_keys.push(fk);
204        self
205    }
206
207    /// Set all foreign keys.
208    #[must_use]
209    pub fn with_foreign_keys(mut self, fks: Vec<ForeignKeyData>) -> Self {
210        self.foreign_keys = fks;
211        self
212    }
213
214    /// Set table statistics.
215    #[must_use]
216    pub fn with_stats(mut self, stats: TableStats) -> Self {
217        self.stats = Some(stats);
218        self
219    }
220
221    /// Set the theme for styled output.
222    #[must_use]
223    pub fn theme(mut self, theme: Theme) -> Self {
224        self.theme = theme;
225        self
226    }
227
228    /// Set the display width.
229    #[must_use]
230    pub fn width(mut self, width: usize) -> Self {
231        self.width = Some(width);
232        self
233    }
234
235    /// Set whether to show column types.
236    #[must_use]
237    pub fn show_types(mut self, show: bool) -> Self {
238        self.show_types = show;
239        self
240    }
241
242    /// Set whether to show constraints.
243    #[must_use]
244    pub fn show_constraints(mut self, show: bool) -> Self {
245        self.show_constraints = show;
246        self
247    }
248
249    /// Get the full table name (schema.table if schema is set).
250    #[must_use]
251    pub fn full_name(&self) -> String {
252        if let Some(ref schema) = self.schema {
253            format!("{}.{}", schema, self.name)
254        } else {
255            self.name.clone()
256        }
257    }
258
259    /// Render as plain text for agent consumption.
260    #[must_use]
261    pub fn render_plain(&self) -> String {
262        let mut lines = Vec::new();
263
264        // Header with table name
265        let pk_info = if self.primary_key.is_empty() {
266            String::new()
267        } else {
268            format!(" (PK: {})", self.primary_key.join(", "))
269        };
270
271        lines.push(format!("TABLE: {}{}", self.full_name(), pk_info));
272        lines.push("=".repeat(lines[0].len().min(60)));
273
274        // Statistics section
275        if let Some(ref stats) = self.stats {
276            if !stats.is_empty() {
277                let mut stats_parts = Vec::new();
278                if let Some(rows) = stats.row_count {
279                    stats_parts.push(format!("Rows: {}", format_number(rows)));
280                }
281                if let Some(size) = stats.size_bytes {
282                    stats_parts.push(format!("Size: {}", format_bytes(size)));
283                }
284                if let Some(idx_size) = stats.index_size_bytes {
285                    stats_parts.push(format!("Index Size: {}", format_bytes(idx_size)));
286                }
287                if !stats_parts.is_empty() {
288                    lines.push(stats_parts.join(" | "));
289                }
290                if let Some(ref analyzed) = stats.last_analyzed {
291                    lines.push(format!("Last Analyzed: {}", analyzed));
292                }
293                if let Some(ref modified) = stats.last_modified {
294                    lines.push(format!("Last Modified: {}", modified));
295                }
296                lines.push(String::new());
297            }
298        }
299
300        // Columns section
301        lines.push("COLUMNS:".to_string());
302        lines.push("-".repeat(40));
303
304        if self.columns.is_empty() {
305            lines.push("  (no columns)".to_string());
306        } else {
307            // Calculate column widths for alignment
308            let max_name_len = self.columns.iter().map(|c| c.name.len()).max().unwrap_or(4);
309            let max_type_len = self
310                .columns
311                .iter()
312                .map(|c| c.sql_type.len())
313                .max()
314                .unwrap_or(4);
315
316            for col in &self.columns {
317                let mut parts = vec![format!("  {:<width$}", col.name, width = max_name_len)];
318
319                if self.show_types {
320                    parts.push(format!("{:<width$}", col.sql_type, width = max_type_len));
321                }
322
323                if self.show_constraints {
324                    let mut constraints: Vec<String> = Vec::new();
325                    if col.primary_key {
326                        constraints.push("PK".into());
327                    }
328                    if col.auto_increment {
329                        constraints.push("AUTO".into());
330                    }
331                    if col.nullable {
332                        constraints.push("NULL".into());
333                    } else {
334                        constraints.push("NOT NULL".into());
335                    }
336                    if let Some(ref default) = col.default {
337                        constraints.push(format!("DEFAULT {}", default));
338                    }
339                    parts.push(format!("[{}]", constraints.join(", ")));
340                }
341
342                lines.push(parts.join("  "));
343            }
344        }
345
346        // Indexes section
347        if !self.indexes.is_empty() {
348            lines.push(String::new());
349            lines.push("INDEXES:".to_string());
350            lines.push("-".repeat(40));
351
352            for idx in &self.indexes {
353                let unique_marker = if idx.unique { "UNIQUE " } else { "" };
354                lines.push(format!(
355                    "  {}{} ({})",
356                    unique_marker,
357                    idx.name,
358                    idx.columns.join(", ")
359                ));
360            }
361        }
362
363        // Foreign keys section
364        if !self.foreign_keys.is_empty() {
365            lines.push(String::new());
366            lines.push("FOREIGN KEYS:".to_string());
367            lines.push("-".repeat(40));
368
369            for fk in &self.foreign_keys {
370                let name = fk.name.as_deref().unwrap_or("(unnamed)");
371                let mut parts = vec![format!(
372                    "  {}: {} -> {}.{}",
373                    name, fk.column, fk.foreign_table, fk.foreign_column
374                )];
375
376                if let Some(ref on_delete) = fk.on_delete {
377                    parts.push(format!("ON DELETE {}", on_delete));
378                }
379                if let Some(ref on_update) = fk.on_update {
380                    parts.push(format!("ON UPDATE {}", on_update));
381                }
382
383                lines.push(parts.join(" "));
384            }
385        }
386
387        lines.join("\n")
388    }
389
390    /// Render with ANSI colors for terminal display.
391    #[must_use]
392    pub fn render_styled(&self) -> String {
393        let width = self.width.unwrap_or(70);
394        let reset = "\x1b[0m";
395        let dim = self.theme.dim.color_code();
396        let header_color = self.theme.header.color_code();
397        let keyword_color = self.theme.sql_keyword.color_code();
398        let name_color = self.theme.sql_identifier.color_code();
399        let type_color = self.theme.sql_keyword.color_code();
400        let success_color = self.theme.success.color_code();
401        let info_color = self.theme.info.color_code();
402
403        let mut lines = Vec::new();
404
405        // Top border
406        lines.push(format!("{dim}┌{}┐{reset}", "─".repeat(width - 2)));
407
408        // Title with table name
409        let pk_info = if self.primary_key.is_empty() {
410            String::new()
411        } else {
412            format!(" {dim}(PK: {}){reset}", self.primary_key.join(", "))
413        };
414
415        let title = format!(
416            "{keyword_color}TABLE:{reset} {name_color}{}{reset}{pk_info}",
417            self.full_name()
418        );
419        let title_visible_len = self.full_name().len()
420            + 7
421            + if self.primary_key.is_empty() {
422                0
423            } else {
424                6 + self.primary_key.join(", ").len()
425            };
426        let padding = width.saturating_sub(title_visible_len + 4);
427        lines.push(format!(
428            "{dim}│{reset} {}{:padding$} {dim}│{reset}",
429            title,
430            "",
431            padding = padding
432        ));
433
434        // Separator
435        lines.push(format!("{dim}├{}┤{reset}", "─".repeat(width - 2)));
436
437        // Statistics section
438        if let Some(ref stats) = self.stats {
439            if !stats.is_empty() {
440                let mut stats_parts = Vec::new();
441                if let Some(rows) = stats.row_count {
442                    stats_parts.push(format!("{info_color}Rows:{reset} {}", format_number(rows)));
443                }
444                if let Some(size) = stats.size_bytes {
445                    stats_parts.push(format!("{info_color}Size:{reset} {}", format_bytes(size)));
446                }
447                if let Some(idx_size) = stats.index_size_bytes {
448                    stats_parts.push(format!(
449                        "{info_color}Idx:{reset} {}",
450                        format_bytes(idx_size)
451                    ));
452                }
453                if !stats_parts.is_empty() {
454                    let stats_line = stats_parts.join(" {dim}│{reset} ");
455                    lines.push(format!(
456                        "{dim}│{reset} {:<width$} {dim}│{reset}",
457                        stats_line,
458                        width = width - 4
459                    ));
460                }
461                if let Some(ref analyzed) = stats.last_analyzed {
462                    lines.push(format!(
463                        "{dim}│{reset} {info_color}Analyzed:{reset} {:<width$} {dim}│{reset}",
464                        analyzed,
465                        width = width - 14
466                    ));
467                }
468                lines.push(format!("{dim}├{}┤{reset}", "─".repeat(width - 2)));
469            }
470        }
471
472        // Columns header
473        lines.push(format!(
474            "{dim}│{reset} {header_color}COLUMNS{reset}{:width$} {dim}│{reset}",
475            "",
476            width = width - 11
477        ));
478
479        // Column rows
480        if self.columns.is_empty() {
481            lines.push(format!(
482                "{dim}│{reset}   {dim}(no columns){reset}{:width$} {dim}│{reset}",
483                "",
484                width = width - 17
485            ));
486        } else {
487            let max_name_len = self.columns.iter().map(|c| c.name.len()).max().unwrap_or(4);
488            let max_type_len = self
489                .columns
490                .iter()
491                .map(|c| c.sql_type.len())
492                .max()
493                .unwrap_or(4);
494
495            for col in &self.columns {
496                let mut content = format!(
497                    "  {name_color}{:<name_w$}{reset}  {type_color}{:<type_w$}{reset}",
498                    col.name,
499                    col.sql_type,
500                    name_w = max_name_len,
501                    type_w = max_type_len
502                );
503
504                if self.show_constraints {
505                    let mut constraints = Vec::new();
506                    if col.primary_key {
507                        constraints.push(format!("{success_color}PK{reset}"));
508                    }
509                    if col.auto_increment {
510                        constraints.push(format!("{info_color}AUTO{reset}"));
511                    }
512                    if !col.nullable {
513                        constraints.push(format!("{dim}NOT NULL{reset}"));
514                    }
515                    if let Some(ref default) = col.default {
516                        constraints.push(format!("{dim}DEFAULT {}{reset}", default));
517                    }
518                    if !constraints.is_empty() {
519                        content.push_str(&format!("  {}", constraints.join(" ")));
520                    }
521                }
522
523                // Approximate visible length for padding
524                let visible_len = 2 + max_name_len + 2 + max_type_len + 20;
525                let padding = width.saturating_sub(visible_len + 4);
526                lines.push(format!(
527                    "{dim}│{reset}{}{:padding$} {dim}│{reset}",
528                    content,
529                    "",
530                    padding = padding
531                ));
532            }
533        }
534
535        // Indexes section
536        if !self.indexes.is_empty() {
537            lines.push(format!("{dim}├{}┤{reset}", "─".repeat(width - 2)));
538            lines.push(format!(
539                "{dim}│{reset} {header_color}INDEXES{reset}{:width$} {dim}│{reset}",
540                "",
541                width = width - 11
542            ));
543
544            for idx in &self.indexes {
545                let unique_marker = if idx.unique {
546                    format!("{keyword_color}UNIQUE {reset}")
547                } else {
548                    String::new()
549                };
550                let content = format!(
551                    "  {}{name_color}{}{reset} {dim}({}){reset}",
552                    unique_marker,
553                    idx.name,
554                    idx.columns.join(", ")
555                );
556                let visible_len = 2
557                    + if idx.unique { 7 } else { 0 }
558                    + idx.name.len()
559                    + 3
560                    + idx.columns.join(", ").len();
561                let padding = width.saturating_sub(visible_len + 4);
562                lines.push(format!(
563                    "{dim}│{reset}{}{:padding$} {dim}│{reset}",
564                    content,
565                    "",
566                    padding = padding
567                ));
568            }
569        }
570
571        // Foreign keys section
572        if !self.foreign_keys.is_empty() {
573            lines.push(format!("{dim}├{}┤{reset}", "─".repeat(width - 2)));
574            lines.push(format!(
575                "{dim}│{reset} {header_color}FOREIGN KEYS{reset}{:width$} {dim}│{reset}",
576                "",
577                width = width - 16
578            ));
579
580            for fk in &self.foreign_keys {
581                let name = fk.name.as_deref().unwrap_or("(unnamed)");
582                let ref_color = self.theme.string_value.color_code();
583                let content = format!(
584                    "  {name_color}{}{reset}: {dim}{}{reset} → {ref_color}{}.{}{reset}",
585                    name, fk.column, fk.foreign_table, fk.foreign_column
586                );
587
588                let mut actions = Vec::new();
589                if let Some(ref on_delete) = fk.on_delete {
590                    actions.push(format!("{dim}DEL:{}{reset}", on_delete));
591                }
592                if let Some(ref on_update) = fk.on_update {
593                    actions.push(format!("{dim}UPD:{}{reset}", on_update));
594                }
595
596                let full_content = if actions.is_empty() {
597                    content
598                } else {
599                    format!("{} {}", content, actions.join(" "))
600                };
601
602                let visible_len = 2
603                    + name.len()
604                    + 2
605                    + fk.column.len()
606                    + 3
607                    + fk.foreign_table.len()
608                    + 1
609                    + fk.foreign_column.len();
610                let padding = width.saturating_sub(visible_len + 20);
611                lines.push(format!(
612                    "{dim}│{reset}{}{:padding$} {dim}│{reset}",
613                    full_content,
614                    "",
615                    padding = padding
616                ));
617            }
618        }
619
620        // Bottom border
621        lines.push(format!("{dim}└{}┘{reset}", "─".repeat(width - 2)));
622
623        lines.join("\n")
624    }
625
626    /// Convert to JSON representation.
627    #[must_use]
628    pub fn to_json(&self) -> serde_json::Value {
629        let columns: Vec<serde_json::Value> = self
630            .columns
631            .iter()
632            .map(|col| {
633                serde_json::json!({
634                    "name": col.name,
635                    "type": col.sql_type,
636                    "nullable": col.nullable,
637                    "default": col.default,
638                    "primary_key": col.primary_key,
639                    "auto_increment": col.auto_increment,
640                })
641            })
642            .collect();
643
644        let indexes: Vec<serde_json::Value> = self
645            .indexes
646            .iter()
647            .map(|idx| {
648                serde_json::json!({
649                    "name": idx.name,
650                    "columns": idx.columns,
651                    "unique": idx.unique,
652                })
653            })
654            .collect();
655
656        let foreign_keys: Vec<serde_json::Value> = self
657            .foreign_keys
658            .iter()
659            .map(|fk| {
660                serde_json::json!({
661                    "name": fk.name,
662                    "column": fk.column,
663                    "foreign_table": fk.foreign_table,
664                    "foreign_column": fk.foreign_column,
665                    "on_delete": fk.on_delete,
666                    "on_update": fk.on_update,
667                })
668            })
669            .collect();
670
671        let mut result = serde_json::json!({
672            "table": {
673                "name": self.name,
674                "schema": self.schema,
675                "full_name": self.full_name(),
676                "columns": columns,
677                "primary_key": self.primary_key,
678                "indexes": indexes,
679                "foreign_keys": foreign_keys,
680            }
681        });
682
683        if let Some(ref stats) = self.stats {
684            result["table"]["stats"] = serde_json::json!({
685                "row_count": stats.row_count,
686                "size_bytes": stats.size_bytes,
687                "index_size_bytes": stats.index_size_bytes,
688                "last_analyzed": stats.last_analyzed,
689                "last_modified": stats.last_modified,
690            });
691        }
692
693        result
694    }
695}
696
697/// Format a number with thousand separators.
698///
699/// # Example
700///
701/// ```rust
702/// use sqlmodel_console::renderables::table_info::format_number;
703///
704/// assert_eq!(format_number(1234567), "1,234,567");
705/// assert_eq!(format_number(999), "999");
706/// ```
707#[must_use]
708pub fn format_number(n: u64) -> String {
709    let s = n.to_string();
710    let bytes: Vec<char> = s.chars().collect();
711    let mut result = String::new();
712
713    for (i, &c) in bytes.iter().enumerate() {
714        if i > 0 && (bytes.len() - i) % 3 == 0 {
715            result.push(',');
716        }
717        result.push(c);
718    }
719
720    result
721}
722
723/// Format bytes as human-readable size (KB, MB, GB).
724///
725/// # Example
726///
727/// ```rust
728/// use sqlmodel_console::renderables::table_info::format_bytes;
729///
730/// assert_eq!(format_bytes(1024), "1.0 KB");
731/// assert_eq!(format_bytes(1_048_576), "1.0 MB");
732/// assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
733/// ```
734#[must_use]
735pub fn format_bytes(bytes: u64) -> String {
736    const KB: u64 = 1024;
737    const MB: u64 = KB * 1024;
738    const GB: u64 = MB * 1024;
739    const TB: u64 = GB * 1024;
740
741    if bytes >= TB {
742        format!("{:.1} TB", bytes as f64 / TB as f64)
743    } else if bytes >= GB {
744        format!("{:.1} GB", bytes as f64 / GB as f64)
745    } else if bytes >= MB {
746        format!("{:.1} MB", bytes as f64 / MB as f64)
747    } else if bytes >= KB {
748        format!("{:.1} KB", bytes as f64 / KB as f64)
749    } else {
750        format!("{} B", bytes)
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    fn sample_column(name: &str, sql_type: &str, primary_key: bool) -> ColumnData {
759        ColumnData {
760            name: name.to_string(),
761            sql_type: sql_type.to_string(),
762            nullable: !primary_key,
763            default: None,
764            primary_key,
765            auto_increment: primary_key,
766        }
767    }
768
769    fn sample_columns() -> Vec<ColumnData> {
770        vec![
771            sample_column("id", "BIGINT", true),
772            sample_column("name", "VARCHAR(255)", false),
773            sample_column("email", "VARCHAR(255)", false),
774            ColumnData {
775                name: "created_at".to_string(),
776                sql_type: "TIMESTAMP".to_string(),
777                nullable: false,
778                default: Some("CURRENT_TIMESTAMP".to_string()),
779                primary_key: false,
780                auto_increment: false,
781            },
782        ]
783    }
784
785    #[test]
786    fn test_table_info_creation() {
787        let info = TableInfo::new("users", sample_columns());
788        assert_eq!(info.name, "users");
789        assert_eq!(info.columns.len(), 4);
790    }
791
792    #[test]
793    fn test_table_info_empty() {
794        let info = TableInfo::empty("empty_table");
795        assert_eq!(info.name, "empty_table");
796        assert!(info.columns.is_empty());
797    }
798
799    #[test]
800    fn test_table_info_with_schema() {
801        let info = TableInfo::new("users", sample_columns()).schema("public");
802        assert_eq!(info.full_name(), "public.users");
803    }
804
805    #[test]
806    fn test_table_info_columns_display() {
807        let info = TableInfo::new("users", sample_columns());
808        let output = info.render_plain();
809        assert!(output.contains("id"));
810        assert!(output.contains("BIGINT"));
811        assert!(output.contains("name"));
812        assert!(output.contains("VARCHAR(255)"));
813    }
814
815    #[test]
816    fn test_table_info_primary_key() {
817        let info =
818            TableInfo::new("users", sample_columns()).with_primary_key(vec!["id".to_string()]);
819        let output = info.render_plain();
820        assert!(output.contains("(PK: id)"));
821    }
822
823    #[test]
824    fn test_table_info_indexes_section() {
825        let info = TableInfo::new("users", sample_columns()).add_index(IndexData {
826            name: "idx_email".to_string(),
827            columns: vec!["email".to_string()],
828            unique: true,
829        });
830        let output = info.render_plain();
831        assert!(output.contains("INDEXES:"));
832        assert!(output.contains("UNIQUE idx_email"));
833        assert!(output.contains("(email)"));
834    }
835
836    #[test]
837    fn test_table_info_foreign_keys() {
838        let info = TableInfo::new("posts", sample_columns()).add_foreign_key(ForeignKeyData {
839            name: Some("fk_user".to_string()),
840            column: "user_id".to_string(),
841            foreign_table: "users".to_string(),
842            foreign_column: "id".to_string(),
843            on_delete: Some("CASCADE".to_string()),
844            on_update: None,
845        });
846        let output = info.render_plain();
847        assert!(output.contains("FOREIGN KEYS:"));
848        assert!(output.contains("fk_user: user_id -> users.id"));
849        assert!(output.contains("ON DELETE CASCADE"));
850    }
851
852    #[test]
853    fn test_table_info_with_stats() {
854        let info = TableInfo::new("users", sample_columns()).with_stats(TableStats {
855            row_count: Some(10_000),
856            size_bytes: Some(2_500_000),
857            index_size_bytes: Some(500_000),
858            last_analyzed: Some("2026-01-22 10:30:00".to_string()),
859            last_modified: None,
860        });
861        let output = info.render_plain();
862        assert!(output.contains("Rows: 10,000"));
863        assert!(output.contains("Size: 2.4 MB"));
864        assert!(output.contains("Index Size: 488.3 KB"));
865        assert!(output.contains("Last Analyzed: 2026-01-22"));
866    }
867
868    #[test]
869    fn test_table_info_render_plain() {
870        let info = TableInfo::new("heroes", sample_columns());
871        let output = info.render_plain();
872        assert!(output.contains("TABLE: heroes"));
873        assert!(output.contains("COLUMNS:"));
874    }
875
876    #[test]
877    fn test_table_info_render_rich() {
878        let info = TableInfo::new("heroes", sample_columns()).width(80);
879        let styled = info.render_styled();
880        assert!(styled.contains('\x1b')); // Contains ANSI codes
881        assert!(styled.contains("┌")); // Box drawing
882        assert!(styled.contains("│"));
883        assert!(styled.contains("└"));
884    }
885
886    #[test]
887    fn test_table_info_width_constraint() {
888        let info = TableInfo::new("heroes", sample_columns()).width(60);
889        let styled = info.render_styled();
890        // Verify the box is roughly the right width
891        let lines: Vec<&str> = styled.lines().collect();
892        assert!(!lines.is_empty());
893    }
894
895    #[test]
896    fn test_table_info_empty_table() {
897        let info = TableInfo::empty("empty");
898        let output = info.render_plain();
899        assert!(output.contains("(no columns)"));
900    }
901
902    #[test]
903    fn test_table_info_to_json() {
904        let info = TableInfo::new("users", sample_columns())
905            .with_primary_key(vec!["id".to_string()])
906            .with_stats(TableStats::new().row_count(100));
907        let json = info.to_json();
908        assert_eq!(json["table"]["name"], "users");
909        assert!(json["table"]["columns"].is_array());
910        assert_eq!(json["table"]["primary_key"][0], "id");
911        assert_eq!(json["table"]["stats"]["row_count"], 100);
912    }
913
914    #[test]
915    fn test_format_number_thousands() {
916        assert_eq!(format_number(0), "0");
917        assert_eq!(format_number(999), "999");
918        assert_eq!(format_number(1000), "1,000");
919        assert_eq!(format_number(12345), "12,345");
920        assert_eq!(format_number(1_234_567), "1,234,567");
921        assert_eq!(format_number(1_234_567_890), "1,234,567,890");
922    }
923
924    #[test]
925    fn test_format_bytes_units() {
926        assert_eq!(format_bytes(0), "0 B");
927        assert_eq!(format_bytes(512), "512 B");
928        assert_eq!(format_bytes(1024), "1.0 KB");
929        assert_eq!(format_bytes(1536), "1.5 KB");
930        assert_eq!(format_bytes(1_048_576), "1.0 MB");
931        assert_eq!(format_bytes(1_572_864), "1.5 MB");
932        assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
933        assert_eq!(format_bytes(1_099_511_627_776), "1.0 TB");
934    }
935
936    #[test]
937    fn test_table_stats_builder() {
938        let stats = TableStats::new()
939            .row_count(1000)
940            .size_bytes(50000)
941            .last_analyzed("2026-01-22");
942        assert_eq!(stats.row_count, Some(1000));
943        assert_eq!(stats.size_bytes, Some(50000));
944        assert_eq!(stats.last_analyzed, Some("2026-01-22".to_string()));
945    }
946
947    #[test]
948    fn test_table_stats_is_empty() {
949        let empty = TableStats::new();
950        assert!(empty.is_empty());
951
952        let with_data = TableStats::new().row_count(100);
953        assert!(!with_data.is_empty());
954    }
955
956    #[test]
957    fn test_table_info_builder_pattern() {
958        let info = TableInfo::new("test", vec![])
959            .schema("myschema")
960            .with_primary_key(vec!["id".to_string()])
961            .with_indexes(vec![IndexData {
962                name: "idx1".to_string(),
963                columns: vec!["col".to_string()],
964                unique: false,
965            }])
966            .with_foreign_keys(vec![])
967            .theme(Theme::light())
968            .width(100)
969            .show_types(false)
970            .show_constraints(false);
971
972        assert_eq!(info.schema, Some("myschema".to_string()));
973        assert_eq!(info.width, Some(100));
974        assert!(!info.show_types);
975        assert!(!info.show_constraints);
976    }
977}