jugar_probar/presentar/
validator.rs1use super::schema::PresentarConfig;
6use std::collections::HashSet;
7use thiserror::Error;
8
9#[derive(Debug, Clone, Error)]
11pub enum PresentarError {
12 #[error("Invalid refresh rate: {0}ms (minimum 16ms for 60 FPS)")]
14 InvalidRefreshRate(u32),
15
16 #[error("Invalid grid size: {0} (must be 2-16)")]
18 InvalidGridSize(u8),
19
20 #[error("Invalid panel width: {0} (minimum 10)")]
22 InvalidPanelWidth(u16),
23
24 #[error("Invalid panel height: {0} (minimum 3)")]
26 InvalidPanelHeight(u16),
27
28 #[error("Invalid layout ratio: top={0}, bottom={1} (must sum to 1.0)")]
30 InvalidLayoutRatio(f32, f32),
31
32 #[error("Duplicate keybinding: '{0}' used for both {1} and {2}")]
34 DuplicateKeybinding(char, String, String),
35
36 #[error("Invalid color format: {0} (expected #RRGGBB)")]
38 InvalidColorFormat(String),
39
40 #[error("No panels enabled")]
42 NoPanelsEnabled,
43
44 #[error("Invalid sparkline history: {0} (must be 1-3600 seconds)")]
46 InvalidSparklineHistory(u32),
47
48 #[error("Invalid process column: {0}")]
50 InvalidProcessColumn(String),
51
52 #[error("YAML parse error: {0}")]
54 ParseError(String),
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct ValidationResult {
60 pub errors: Vec<PresentarError>,
62 pub warnings: Vec<String>,
64}
65
66impl ValidationResult {
67 pub fn is_ok(&self) -> bool {
69 self.errors.is_empty()
70 }
71
72 pub fn is_err(&self) -> bool {
74 !self.errors.is_empty()
75 }
76
77 pub fn add_error(&mut self, error: PresentarError) {
79 self.errors.push(error);
80 }
81
82 pub fn add_warning(&mut self, warning: impl Into<String>) {
84 self.warnings.push(warning.into());
85 }
86}
87
88pub fn validate_config(config: &PresentarConfig) -> ValidationResult {
90 let mut result = ValidationResult::default();
91
92 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 if config.layout.grid_size < 2 || config.layout.grid_size > 16 {
104 result.add_error(PresentarError::InvalidGridSize(config.layout.grid_size));
105 }
106
107 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 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 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(config, &mut result);
141
142 validate_colors(config, &mut result);
144
145 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(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 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
218pub 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'; 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")); assert!(!is_valid_hex_color("#64C8F")); assert!(!is_valid_hex_color("#64C8FFF")); assert!(!is_valid_hex_color("#GGGGGG")); }
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}