Skip to main content

jugar_probar/presentar/
validator.rs

1//! Presentar configuration validation.
2//!
3//! Validates presentar YAML configurations against the schema and business rules.
4
5use super::schema::PresentarConfig;
6use std::collections::HashSet;
7use thiserror::Error;
8
9/// Presentar validation errors.
10#[derive(Debug, Clone, Error)]
11pub enum PresentarError {
12    /// Refresh rate is too low (below 16ms for 60 FPS).
13    #[error("Invalid refresh rate: {0}ms (minimum 16ms for 60 FPS)")]
14    InvalidRefreshRate(u32),
15
16    /// Grid size is out of valid range (2-16).
17    #[error("Invalid grid size: {0} (must be 2-16)")]
18    InvalidGridSize(u8),
19
20    /// Panel width is below minimum (10).
21    #[error("Invalid panel width: {0} (minimum 10)")]
22    InvalidPanelWidth(u16),
23
24    /// Panel height is below minimum (3).
25    #[error("Invalid panel height: {0} (minimum 3)")]
26    InvalidPanelHeight(u16),
27
28    /// Layout ratios don't sum to 1.0.
29    #[error("Invalid layout ratio: top={0}, bottom={1} (must sum to 1.0)")]
30    InvalidLayoutRatio(f32, f32),
31
32    /// Same key is bound to multiple actions.
33    #[error("Duplicate keybinding: '{0}' used for both {1} and {2}")]
34    DuplicateKeybinding(char, String, String),
35
36    /// Color is not in valid #RRGGBB format.
37    #[error("Invalid color format: {0} (expected #RRGGBB)")]
38    InvalidColorFormat(String),
39
40    /// At least one panel must be enabled.
41    #[error("No panels enabled")]
42    NoPanelsEnabled,
43
44    /// Sparkline history is out of valid range (1-3600 seconds).
45    #[error("Invalid sparkline history: {0} (must be 1-3600 seconds)")]
46    InvalidSparklineHistory(u32),
47
48    /// Process column name is not recognized.
49    #[error("Invalid process column: {0}")]
50    InvalidProcessColumn(String),
51
52    /// YAML parsing failed.
53    #[error("YAML parse error: {0}")]
54    ParseError(String),
55}
56
57/// Validation result with warnings.
58#[derive(Debug, Clone, Default)]
59pub struct ValidationResult {
60    /// Validation errors (fatal).
61    pub errors: Vec<PresentarError>,
62    /// Validation warnings (non-fatal).
63    pub warnings: Vec<String>,
64}
65
66impl ValidationResult {
67    /// Check if validation passed (no errors).
68    pub fn is_ok(&self) -> bool {
69        self.errors.is_empty()
70    }
71
72    /// Check if validation failed.
73    pub fn is_err(&self) -> bool {
74        !self.errors.is_empty()
75    }
76
77    /// Add an error.
78    pub fn add_error(&mut self, error: PresentarError) {
79        self.errors.push(error);
80    }
81
82    /// Add a warning.
83    pub fn add_warning(&mut self, warning: impl Into<String>) {
84        self.warnings.push(warning.into());
85    }
86}
87
88/// Validate a presentar configuration.
89pub fn validate_config(config: &PresentarConfig) -> ValidationResult {
90    let mut result = ValidationResult::default();
91
92    // Validate refresh rate (minimum 16ms for 60 FPS)
93    if config.refresh_ms < 16 {
94        result.add_error(PresentarError::InvalidRefreshRate(config.refresh_ms));
95    } else if config.refresh_ms < 100 {
96        result.add_warning(format!(
97            "Refresh rate {}ms may cause high CPU usage",
98            config.refresh_ms
99        ));
100    }
101
102    // Validate grid size
103    if config.layout.grid_size < 2 || config.layout.grid_size > 16 {
104        result.add_error(PresentarError::InvalidGridSize(config.layout.grid_size));
105    }
106
107    // Validate panel dimensions
108    if config.layout.min_panel_width < 10 {
109        result.add_error(PresentarError::InvalidPanelWidth(
110            config.layout.min_panel_width,
111        ));
112    }
113    if config.layout.min_panel_height < 3 {
114        result.add_error(PresentarError::InvalidPanelHeight(
115            config.layout.min_panel_height,
116        ));
117    }
118
119    // Validate layout ratios
120    let ratio_sum = config.layout.top_height + config.layout.bottom_height;
121    if (ratio_sum - 1.0).abs() > 0.01 {
122        result.add_error(PresentarError::InvalidLayoutRatio(
123            config.layout.top_height,
124            config.layout.bottom_height,
125        ));
126    }
127
128    // Validate at least one panel enabled
129    let enabled_count = config
130        .panels
131        .iter_enabled()
132        .iter()
133        .filter(|(_, e)| *e)
134        .count();
135    if enabled_count == 0 {
136        result.add_error(PresentarError::NoPanelsEnabled);
137    }
138
139    // Validate keybindings (no duplicates)
140    validate_keybindings(config, &mut result);
141
142    // Validate colors
143    validate_colors(config, &mut result);
144
145    // Validate sparkline history
146    if config.panels.cpu.sparkline_history == 0 || config.panels.cpu.sparkline_history > 3600 {
147        result.add_error(PresentarError::InvalidSparklineHistory(
148            config.panels.cpu.sparkline_history,
149        ));
150    }
151
152    // Validate process columns
153    validate_process_columns(config, &mut result);
154
155    result
156}
157
158fn validate_keybindings(config: &PresentarConfig, result: &mut ValidationResult) {
159    let kb = &config.keybindings;
160    let mut seen: HashSet<char> = HashSet::new();
161    let bindings = [
162        (kb.quit, "quit"),
163        (kb.help, "help"),
164        (kb.toggle_fps, "toggle_fps"),
165        (kb.filter, "filter"),
166        (kb.sort_cpu, "sort_cpu"),
167        (kb.sort_mem, "sort_mem"),
168        (kb.sort_pid, "sort_pid"),
169        (kb.kill_process, "kill_process"),
170    ];
171
172    for (key, name) in bindings {
173        if !seen.insert(key) {
174            // Find the first binding with this key
175            let first = bindings
176                .iter()
177                .find(|(k, _)| *k == key)
178                .map(|(_, n)| *n)
179                .unwrap_or("unknown");
180            result.add_error(PresentarError::DuplicateKeybinding(
181                key,
182                first.to_string(),
183                name.to_string(),
184            ));
185        }
186    }
187}
188
189fn validate_colors(config: &PresentarConfig, result: &mut ValidationResult) {
190    for (panel, color) in config.theme.iter_panel_colors() {
191        if !is_valid_hex_color(color) {
192            result.add_error(PresentarError::InvalidColorFormat(format!(
193                "{}: {}",
194                panel.key(),
195                color
196            )));
197        }
198    }
199}
200
201fn is_valid_hex_color(color: &str) -> bool {
202    if !color.starts_with('#') {
203        return false;
204    }
205    let hex = &color[1..];
206    hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit())
207}
208
209fn validate_process_columns(config: &PresentarConfig, result: &mut ValidationResult) {
210    let valid_columns = ["pid", "user", "cpu", "mem", "cmd", "state", "time", "name"];
211    for col in &config.panels.process.columns {
212        if !valid_columns.contains(&col.as_str()) {
213            result.add_error(PresentarError::InvalidProcessColumn(col.clone()));
214        }
215    }
216}
217
218/// Parse and validate YAML in one step.
219pub fn parse_and_validate(
220    yaml: &str,
221) -> Result<(PresentarConfig, ValidationResult), PresentarError> {
222    let config =
223        PresentarConfig::from_yaml(yaml).map_err(|e| PresentarError::ParseError(e.to_string()))?;
224    let result = validate_config(&config);
225    Ok((config, result))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::super::schema::PanelType;
231    use super::*;
232
233    #[test]
234    fn test_valid_config() {
235        let config = PresentarConfig::default();
236        let result = validate_config(&config);
237        assert!(result.is_ok());
238    }
239
240    #[test]
241    fn test_invalid_refresh_rate() {
242        let mut config = PresentarConfig::default();
243        config.refresh_ms = 5;
244        let result = validate_config(&config);
245        assert!(result.is_err());
246        assert!(matches!(
247            &result.errors[0],
248            PresentarError::InvalidRefreshRate(5)
249        ));
250    }
251
252    #[test]
253    fn test_low_refresh_rate_warning() {
254        let mut config = PresentarConfig::default();
255        config.refresh_ms = 50;
256        let result = validate_config(&config);
257        assert!(result.is_ok());
258        assert!(!result.warnings.is_empty());
259    }
260
261    #[test]
262    fn test_invalid_grid_size() {
263        let mut config = PresentarConfig::default();
264        config.layout.grid_size = 1;
265        let result = validate_config(&config);
266        assert!(result.is_err());
267
268        config.layout.grid_size = 20;
269        let result = validate_config(&config);
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn test_invalid_panel_dimensions() {
275        let mut config = PresentarConfig::default();
276        config.layout.min_panel_width = 5;
277        let result = validate_config(&config);
278        assert!(result.is_err());
279
280        let mut config = PresentarConfig::default();
281        config.layout.min_panel_height = 2;
282        let result = validate_config(&config);
283        assert!(result.is_err());
284    }
285
286    #[test]
287    fn test_invalid_layout_ratio() {
288        let mut config = PresentarConfig::default();
289        config.layout.top_height = 0.3;
290        config.layout.bottom_height = 0.3;
291        let result = validate_config(&config);
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_no_panels_enabled() {
297        let mut config = PresentarConfig::default();
298        for panel in PanelType::all() {
299            config.panels.set_enabled(*panel, false);
300        }
301        let result = validate_config(&config);
302        assert!(result.is_err());
303    }
304
305    #[test]
306    fn test_duplicate_keybinding() {
307        let mut config = PresentarConfig::default();
308        config.keybindings.quit = 'q';
309        config.keybindings.help = 'q'; // Duplicate
310        let result = validate_config(&config);
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn test_invalid_color_format() {
316        let mut config = PresentarConfig::default();
317        config
318            .theme
319            .panel_colors
320            .insert("cpu".into(), "invalid".into());
321        let result = validate_config(&config);
322        assert!(result.is_err());
323    }
324
325    #[test]
326    fn test_valid_hex_color() {
327        assert!(is_valid_hex_color("#64C8FF"));
328        assert!(is_valid_hex_color("#000000"));
329        assert!(is_valid_hex_color("#FFFFFF"));
330        assert!(!is_valid_hex_color("64C8FF")); // Missing #
331        assert!(!is_valid_hex_color("#64C8F")); // Too short
332        assert!(!is_valid_hex_color("#64C8FFF")); // Too long
333        assert!(!is_valid_hex_color("#GGGGGG")); // Invalid hex
334    }
335
336    #[test]
337    fn test_invalid_process_column() {
338        let mut config = PresentarConfig::default();
339        config.panels.process.columns.push("invalid_col".into());
340        let result = validate_config(&config);
341        assert!(result.is_err());
342    }
343
344    #[test]
345    fn test_invalid_sparkline_history() {
346        let mut config = PresentarConfig::default();
347        config.panels.cpu.sparkline_history = 0;
348        let result = validate_config(&config);
349        assert!(result.is_err());
350
351        let mut config = PresentarConfig::default();
352        config.panels.cpu.sparkline_history = 5000;
353        let result = validate_config(&config);
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_parse_and_validate() {
359        let yaml = "refresh_ms: 1000";
360        let (config, result) = parse_and_validate(yaml).unwrap();
361        assert_eq!(config.refresh_ms, 1000);
362        assert!(result.is_ok());
363    }
364
365    #[test]
366    fn test_parse_and_validate_invalid_yaml() {
367        let yaml = "invalid: yaml: {{{{";
368        let result = parse_and_validate(yaml);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn test_validation_result_methods() {
374        let mut result = ValidationResult::default();
375        assert!(result.is_ok());
376        assert!(!result.is_err());
377
378        result.add_error(PresentarError::InvalidRefreshRate(5));
379        assert!(result.is_err());
380        assert!(!result.is_ok());
381
382        result.add_warning("test warning");
383        assert_eq!(result.warnings.len(), 1);
384    }
385
386    #[test]
387    fn test_error_display() {
388        let err = PresentarError::InvalidRefreshRate(5);
389        assert!(err.to_string().contains("16ms"));
390
391        let err = PresentarError::InvalidGridSize(1);
392        assert!(err.to_string().contains("2-16"));
393
394        let err = PresentarError::DuplicateKeybinding('q', "quit".into(), "help".into());
395        assert!(err.to_string().contains("'q'"));
396    }
397}