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#[derive(Debug, Deserialize, Serialize, Default)]
10pub struct StyleConfig {
11 #[serde(default)]
12 pub version: u32,
13
14 #[serde(default)]
16 pub columns: HashMap<String, Vec<ColumnRule>>,
17
18 #[serde(default)]
20 pub numeric_ranges: HashMap<String, Vec<NumericRule>>,
21
22 #[serde(default)]
24 pub patterns: Vec<PatternRule>,
25
26 #[serde(default)]
28 pub defaults: DefaultStyles,
29}
30
31#[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#[derive(Debug, Deserialize, Serialize, Clone)]
43pub struct NumericRule {
44 pub condition: String, pub fg_color: Option<String>,
46 #[serde(default)]
47 pub bold: bool,
48}
49
50#[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#[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 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 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 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
113pub fn build_styled_table(
116 column_names: &[String],
117 rows: &[Vec<String>],
118 config: &StyleConfig,
119) -> (Vec<Cell>, Vec<Vec<Cell>>) {
120 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 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 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 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 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
224pub 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
241fn 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
264fn evaluate_condition(value: f64, condition: &str) -> bool {
266 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
277fn 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}