sql_cli/output/
styled_table.rs

1use anyhow::{Context, Result};
2use comfy_table::{Attribute, Cell, Color, Table};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8/// Configuration for styling table output with colors and formatting
9#[derive(Debug, Deserialize, Serialize, Default)]
10pub struct StyleConfig {
11    #[serde(default)]
12    pub version: u32,
13
14    /// Rules for styling specific columns by value
15    #[serde(default)]
16    pub columns: HashMap<String, Vec<ColumnRule>>,
17
18    /// Rules for styling numeric values based on ranges
19    #[serde(default)]
20    pub numeric_ranges: HashMap<String, Vec<NumericRule>>,
21
22    /// Pattern-based rules using regex
23    #[serde(default)]
24    pub patterns: Vec<PatternRule>,
25
26    /// Default styles for table elements
27    #[serde(default)]
28    pub defaults: DefaultStyles,
29}
30
31/// Rule for styling a column based on exact value match
32#[derive(Debug, Deserialize, Serialize, Clone)]
33pub struct ColumnRule {
34    pub value: String,
35    pub fg_color: Option<String>,
36    pub bg_color: Option<String>,
37    #[serde(default)]
38    pub bold: bool,
39}
40
41/// Rule for styling numeric values based on conditions
42#[derive(Debug, Deserialize, Serialize, Clone)]
43pub struct NumericRule {
44    pub condition: String, // "< 100", ">= 100 AND < 300"
45    pub fg_color: Option<String>,
46    #[serde(default)]
47    pub bold: bool,
48}
49
50/// Pattern-based rule using regex matching
51#[derive(Debug, Deserialize, Serialize, Clone)]
52pub struct PatternRule {
53    pub regex: String,
54    pub fg_color: Option<String>,
55    #[serde(default)]
56    pub bold: bool,
57}
58
59/// Default styles for table elements
60#[derive(Debug, Deserialize, Serialize)]
61pub struct DefaultStyles {
62    pub header_color: Option<String>,
63    #[serde(default = "default_true")]
64    pub header_bold: bool,
65}
66
67fn default_true() -> bool {
68    true
69}
70
71impl Default for DefaultStyles {
72    fn default() -> Self {
73        Self {
74            header_color: Some("white".to_string()),
75            header_bold: true,
76        }
77    }
78}
79
80impl StyleConfig {
81    /// Load style configuration from a YAML file
82    pub fn from_file(path: &PathBuf) -> Result<Self> {
83        let content = fs::read_to_string(path)
84            .with_context(|| format!("Failed to read style file: {}", path.display()))?;
85
86        let config: StyleConfig = serde_yaml::from_str(&content)
87            .with_context(|| format!("Failed to parse style YAML: {}", path.display()))?;
88
89        Ok(config)
90    }
91
92    /// Get the default style file path (~/.config/sql-cli/styles.yaml)
93    pub fn default_path() -> Option<PathBuf> {
94        dirs::config_dir().map(|mut p| {
95            p.push("sql-cli");
96            p.push("styles.yaml");
97            p
98        })
99    }
100
101    /// Load the default style configuration if it exists
102    pub fn load_default() -> Option<Self> {
103        Self::default_path().and_then(|path| {
104            if path.exists() {
105                Self::from_file(&path).ok()
106            } else {
107                None
108            }
109        })
110    }
111}
112
113/// Apply style configuration to a table by building styled headers and rows
114/// Returns (styled_headers, styled_rows) that can be added to the table
115pub fn build_styled_table(
116    column_names: &[String],
117    rows: &[Vec<String>],
118    config: &StyleConfig,
119) -> (Vec<Cell>, Vec<Vec<Cell>>) {
120    // Build styled headers
121    let styled_headers: Vec<Cell> = column_names
122        .iter()
123        .map(|name| {
124            let mut header_cell = Cell::new(name);
125
126            if config.defaults.header_bold {
127                header_cell = header_cell.add_attribute(Attribute::Bold);
128            }
129
130            if let Some(ref color_str) = config.defaults.header_color {
131                if let Some(color) = parse_color(color_str) {
132                    header_cell = header_cell.fg(color);
133                }
134            }
135
136            header_cell
137        })
138        .collect();
139
140    // Build styled data rows
141    let styled_rows: Vec<Vec<Cell>> = rows
142        .iter()
143        .map(|row_data| {
144            row_data
145                .iter()
146                .enumerate()
147                .map(|(col_idx, cell_value)| {
148                    if col_idx >= column_names.len() {
149                        return Cell::new(cell_value);
150                    }
151
152                    let col_name = &column_names[col_idx];
153                    let mut cell = Cell::new(cell_value);
154                    let mut style_applied = false;
155
156                    // 1. Apply column-specific rules (exact match)
157                    if let Some(rules) = config.columns.get(col_name) {
158                        for rule in rules {
159                            if rule.value == *cell_value {
160                                if let Some(ref color_str) = rule.fg_color {
161                                    if let Some(color) = parse_color(color_str) {
162                                        cell = cell.fg(color);
163                                    }
164                                }
165                                if rule.bold {
166                                    cell = cell.add_attribute(Attribute::Bold);
167                                }
168                                style_applied = true;
169                                break;
170                            }
171                        }
172                    }
173
174                    // 2. Apply numeric range rules
175                    if !style_applied {
176                        if let Some(rules) = config.numeric_ranges.get(col_name) {
177                            if let Ok(num) = cell_value.parse::<f64>() {
178                                for rule in rules {
179                                    if evaluate_condition(num, &rule.condition) {
180                                        if let Some(ref color_str) = rule.fg_color {
181                                            if let Some(color) = parse_color(color_str) {
182                                                cell = cell.fg(color);
183                                            }
184                                        }
185                                        if rule.bold {
186                                            cell = cell.add_attribute(Attribute::Bold);
187                                        }
188                                        style_applied = true;
189                                        break;
190                                    }
191                                }
192                            }
193                        }
194                    }
195
196                    // 3. Apply pattern rules (regex)
197                    if !style_applied {
198                        for pattern in &config.patterns {
199                            if let Ok(re) = regex::Regex::new(&pattern.regex) {
200                                if re.is_match(cell_value) {
201                                    if let Some(ref color_str) = pattern.fg_color {
202                                        if let Some(color) = parse_color(color_str) {
203                                            cell = cell.fg(color);
204                                        }
205                                    }
206                                    if pattern.bold {
207                                        cell = cell.add_attribute(Attribute::Bold);
208                                    }
209                                    break;
210                                }
211                            }
212                        }
213                    }
214
215                    cell
216                })
217                .collect()
218        })
219        .collect();
220
221    (styled_headers, styled_rows)
222}
223
224/// Apply style configuration to a table
225pub fn apply_styles_to_table(
226    table: &mut Table,
227    column_names: &[String],
228    rows: &[Vec<String>],
229    config: &StyleConfig,
230) -> Result<()> {
231    let (styled_headers, styled_rows) = build_styled_table(column_names, rows, config);
232
233    table.set_header(styled_headers);
234    for styled_row in styled_rows {
235        table.add_row(styled_row);
236    }
237
238    Ok(())
239}
240
241/// Parse color string to comfy_table::Color
242fn parse_color(color_str: &str) -> Option<Color> {
243    match color_str.to_lowercase().as_str() {
244        "red" => Some(Color::Red),
245        "green" => Some(Color::Green),
246        "blue" => Some(Color::Blue),
247        "yellow" => Some(Color::Yellow),
248        "cyan" => Some(Color::Cyan),
249        "magenta" => Some(Color::Magenta),
250        "white" => Some(Color::White),
251        "black" => Some(Color::Black),
252        "dark_grey" | "dark_gray" => Some(Color::DarkGrey),
253        "dark_red" => Some(Color::DarkRed),
254        "dark_green" => Some(Color::DarkGreen),
255        "dark_blue" => Some(Color::DarkBlue),
256        "dark_yellow" => Some(Color::DarkYellow),
257        "dark_cyan" => Some(Color::DarkCyan),
258        "dark_magenta" => Some(Color::DarkMagenta),
259        "grey" | "gray" => Some(Color::Grey),
260        _ => None,
261    }
262}
263
264/// Evaluate a numeric condition
265fn evaluate_condition(value: f64, condition: &str) -> bool {
266    // Handle compound conditions with AND
267    if condition.contains("AND") {
268        let parts: Vec<&str> = condition.split("AND").map(|s| s.trim()).collect();
269        return parts
270            .iter()
271            .all(|part| evaluate_simple_condition(value, part));
272    }
273
274    evaluate_simple_condition(value, condition)
275}
276
277/// Evaluate a simple numeric condition (e.g., "< 100", ">= 300")
278fn evaluate_simple_condition(value: f64, condition: &str) -> bool {
279    let condition = condition.trim();
280
281    if let Some(num_str) = condition.strip_prefix("<=") {
282        if let Ok(threshold) = num_str.trim().parse::<f64>() {
283            return value <= threshold;
284        }
285    } else if let Some(num_str) = condition.strip_prefix("<") {
286        if let Ok(threshold) = num_str.trim().parse::<f64>() {
287            return value < threshold;
288        }
289    } else if let Some(num_str) = condition.strip_prefix(">=") {
290        if let Ok(threshold) = num_str.trim().parse::<f64>() {
291            return value >= threshold;
292        }
293    } else if let Some(num_str) = condition.strip_prefix(">") {
294        if let Ok(threshold) = num_str.trim().parse::<f64>() {
295            return value > threshold;
296        }
297    } else if let Some(num_str) = condition.strip_prefix("==") {
298        if let Ok(threshold) = num_str.trim().parse::<f64>() {
299            return (value - threshold).abs() < f64::EPSILON;
300        }
301    }
302
303    false
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_evaluate_simple_condition() {
312        assert!(evaluate_simple_condition(50.0, "< 100"));
313        assert!(!evaluate_simple_condition(150.0, "< 100"));
314
315        assert!(evaluate_simple_condition(100.0, ">= 100"));
316        assert!(evaluate_simple_condition(150.0, ">= 100"));
317        assert!(!evaluate_simple_condition(99.0, ">= 100"));
318
319        assert!(evaluate_simple_condition(50.0, "<= 50"));
320        assert!(!evaluate_simple_condition(51.0, "<= 50"));
321
322        assert!(evaluate_simple_condition(200.0, "> 100"));
323        assert!(!evaluate_simple_condition(100.0, "> 100"));
324    }
325
326    #[test]
327    fn test_evaluate_compound_condition() {
328        assert!(evaluate_condition(150.0, ">= 100 AND < 200"));
329        assert!(!evaluate_condition(99.0, ">= 100 AND < 200"));
330        assert!(!evaluate_condition(200.0, ">= 100 AND < 200"));
331    }
332
333    #[test]
334    fn test_parse_color() {
335        assert!(matches!(parse_color("red"), Some(Color::Red)));
336        assert!(matches!(parse_color("Red"), Some(Color::Red)));
337        assert!(matches!(parse_color("RED"), Some(Color::Red)));
338        assert!(matches!(parse_color("green"), Some(Color::Green)));
339        assert!(matches!(parse_color("blue"), Some(Color::Blue)));
340        assert!(matches!(parse_color("dark_grey"), Some(Color::DarkGrey)));
341        assert!(matches!(parse_color("dark_gray"), Some(Color::DarkGrey)));
342        assert!(parse_color("invalid_color").is_none());
343    }
344}