log_watcher/
config.rs

1use crate::cli::Args;
2use anyhow::{Context, Result};
3use regex::Regex;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use termcolor::Color;
7
8/// Maximum size limit for regex patterns to prevent ReDoS attacks
9const REGEX_SIZE_LIMIT: usize = 10 * 1024 * 1024; // 10 MB
10
11#[derive(Debug, Clone)]
12pub struct Config {
13    pub files: Vec<PathBuf>,
14    pub patterns: Vec<String>,
15    pub regex_patterns: Vec<Regex>,
16    pub exclude_patterns: Vec<String>,
17    pub exclude_patterns_lowercase: Vec<String>, // Pre-computed for case-insensitive matching
18    pub exclude_regex_patterns: Vec<Regex>,
19    pub case_insensitive: bool,
20    pub color_mappings: HashMap<String, Color>,
21    pub notify_enabled: bool,
22    pub notify_patterns: Vec<String>,
23    pub notify_throttle: u32,
24    pub dry_run: bool,
25    pub quiet: bool,
26    pub no_color: bool,
27    pub prefix_files: bool,
28    pub poll_interval: u64,
29    pub buffer_size: usize,
30}
31
32impl Config {
33    pub fn from_args(args: &Args) -> Result<Self> {
34        let patterns = args.patterns();
35        let notify_patterns = args.notify_patterns();
36        let exclude_patterns = args.exclude_patterns();
37
38        // Validate and compile regex patterns if needed
39        let regex_patterns = if args.regex {
40            Self::compile_regex_patterns(&patterns, args.case_insensitive)?
41        } else {
42            vec![]
43        };
44
45        // Compile exclude patterns as regex if regex mode is enabled
46        let exclude_regex_patterns = if args.regex && !exclude_patterns.is_empty() {
47            Self::compile_regex_patterns(&exclude_patterns, args.case_insensitive)?
48        } else {
49            vec![]
50        };
51
52        // Pre-compute lowercase exclude patterns for case-insensitive matching
53        let exclude_patterns_lowercase = if args.case_insensitive {
54            exclude_patterns.iter().map(|p| p.to_lowercase()).collect()
55        } else {
56            vec![]
57        };
58
59        // Parse color mappings
60        let color_mappings = Self::parse_color_mappings(&args.color_mappings())?;
61
62        Ok(Config {
63            files: args.files().to_vec(),
64            patterns,
65            regex_patterns,
66            exclude_patterns,
67            exclude_patterns_lowercase,
68            exclude_regex_patterns,
69            case_insensitive: args.case_insensitive,
70            color_mappings,
71            notify_enabled: args.notify,
72            notify_patterns,
73            notify_throttle: args.notify_throttle,
74            dry_run: args.dry_run,
75            quiet: args.quiet,
76            no_color: args.no_color,
77            prefix_files: args.should_prefix_files(),
78            poll_interval: args.poll_interval,
79            buffer_size: args.buffer_size,
80        })
81    }
82
83    fn compile_regex_patterns(patterns: &[String], case_insensitive: bool) -> Result<Vec<Regex>> {
84        let mut compiled = Vec::new();
85
86        for pattern in patterns {
87            let mut regex_builder = regex::RegexBuilder::new(pattern);
88            regex_builder.case_insensitive(case_insensitive);
89            // ReDoS protection: limit compiled regex size to prevent catastrophic backtracking
90            regex_builder.size_limit(REGEX_SIZE_LIMIT);
91            // Also limit DFA size for additional protection
92            regex_builder.dfa_size_limit(REGEX_SIZE_LIMIT);
93
94            let regex = regex_builder
95                .build()
96                .with_context(|| format!("Invalid or too complex regex pattern: {}", pattern))?;
97
98            compiled.push(regex);
99        }
100
101        Ok(compiled)
102    }
103
104    fn parse_color_mappings(mappings: &[(String, String)]) -> Result<HashMap<String, Color>> {
105        let mut color_map = HashMap::new();
106
107        for (pattern, color_name) in mappings {
108            let color = Self::parse_color(color_name)?;
109            color_map.insert(pattern.clone(), color);
110        }
111
112        // Add default color mappings if not specified
113        Self::add_default_color_mappings(&mut color_map);
114
115        Ok(color_map)
116    }
117
118    fn parse_color(color_name: &str) -> Result<Color> {
119        match color_name.to_lowercase().as_str() {
120            "black" => Ok(Color::Black),
121            "red" => Ok(Color::Red),
122            "green" => Ok(Color::Green),
123            "yellow" => Ok(Color::Yellow),
124            "blue" => Ok(Color::Blue),
125            "magenta" => Ok(Color::Magenta),
126            "cyan" => Ok(Color::Cyan),
127            "white" => Ok(Color::White),
128            _ => Err(anyhow::anyhow!("Unknown color: {}", color_name)),
129        }
130    }
131
132    fn add_default_color_mappings(color_map: &mut HashMap<String, Color>) {
133        let defaults = [
134            ("ERROR", Color::Red),
135            ("WARN", Color::Yellow),
136            ("WARNING", Color::Yellow),
137            ("INFO", Color::Green),
138            ("DEBUG", Color::Cyan),
139            ("TRACE", Color::Magenta),
140            ("FATAL", Color::Red),
141            ("CRITICAL", Color::Red),
142        ];
143
144        for (pattern, color) in defaults {
145            color_map.entry(pattern.to_string()).or_insert(color);
146        }
147    }
148
149    /// Check if a pattern should trigger notifications
150    pub fn should_notify_for_pattern(&self, pattern: &str) -> bool {
151        self.notify_enabled && self.notify_patterns.contains(&pattern.to_string())
152    }
153
154    /// Get color for a pattern
155    pub fn get_color_for_pattern(&self, pattern: &str) -> Option<Color> {
156        self.color_mappings.get(pattern).copied()
157    }
158
159    /// Check if a line should be excluded based on exclude patterns
160    pub fn should_exclude(&self, line: &str) -> bool {
161        if self.exclude_patterns.is_empty() {
162            return false;
163        }
164
165        // If regex mode, use compiled exclude regex patterns
166        if !self.exclude_regex_patterns.is_empty() {
167            for regex in &self.exclude_regex_patterns {
168                if regex.is_match(line) {
169                    return true;
170                }
171            }
172        } else if self.case_insensitive {
173            // Use pre-computed lowercase patterns for case-insensitive matching
174            let search_line = line.to_lowercase();
175            for pattern in &self.exclude_patterns_lowercase {
176                if search_line.contains(pattern) {
177                    return true;
178                }
179            }
180        } else {
181            // Case-sensitive literal matching (no allocation needed for patterns)
182            for pattern in &self.exclude_patterns {
183                if line.contains(pattern.as_str()) {
184                    return true;
185                }
186            }
187        }
188
189        false
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_all_color_parsing() {
199        // Test all color mappings to cover the uncovered lines
200        assert_eq!(Config::parse_color("yellow").unwrap(), Color::Yellow);
201        assert_eq!(Config::parse_color("magenta").unwrap(), Color::Magenta);
202        assert_eq!(Config::parse_color("cyan").unwrap(), Color::Cyan);
203        assert_eq!(Config::parse_color("white").unwrap(), Color::White);
204    }
205
206    #[test]
207    fn test_parse_color_unknown_coverage_line_100() {
208        // Test unknown color to cover line 100 (_ => Err(...))
209        let result = Config::parse_color("unknown_color");
210        assert!(result.is_err());
211        assert!(result
212            .unwrap_err()
213            .to_string()
214            .contains("Unknown color: unknown_color"));
215    }
216
217    #[test]
218    fn test_get_color_for_pattern() {
219        let args = Args {
220            files: vec![PathBuf::from("test.log")],
221            completions: None,
222            patterns: "ERROR".to_string(),
223            regex: false,
224            case_insensitive: false,
225            color_map: None,
226            notify: false,
227            notify_patterns: None,
228            quiet: false,
229            dry_run: false,
230            exclude: None,
231            prefix_file: Some(false),
232            poll_interval: 1000,
233            buffer_size: 8192,
234            no_color: false,
235            notify_throttle: 0,
236        };
237
238        let config = Config::from_args(&args).unwrap();
239
240        // Test that default color mappings work
241        assert_eq!(config.get_color_for_pattern("ERROR"), Some(Color::Red));
242        assert_eq!(config.get_color_for_pattern("WARN"), Some(Color::Yellow));
243        assert_eq!(config.get_color_for_pattern("INFO"), Some(Color::Green));
244        assert_eq!(config.get_color_for_pattern("DEBUG"), Some(Color::Cyan));
245        assert_eq!(config.get_color_for_pattern("UNKNOWN"), None);
246    }
247
248    #[test]
249    fn test_should_exclude_literal() {
250        let args = Args {
251            files: vec![PathBuf::from("test.log")],
252            completions: None,
253            patterns: "ERROR".to_string(),
254            regex: false,
255            case_insensitive: false,
256            color_map: None,
257            notify: false,
258            notify_patterns: None,
259            quiet: false,
260            dry_run: false,
261            exclude: Some("DEBUG,TRACE".to_string()),
262            prefix_file: Some(false),
263            poll_interval: 1000,
264            buffer_size: 8192,
265            no_color: false,
266            notify_throttle: 0,
267        };
268
269        let config = Config::from_args(&args).unwrap();
270
271        assert!(config.should_exclude("DEBUG: Some debug message"));
272        assert!(config.should_exclude("TRACE: Some trace message"));
273        assert!(!config.should_exclude("ERROR: Some error message"));
274        assert!(!config.should_exclude("INFO: Some info message"));
275    }
276
277    #[test]
278    fn test_should_exclude_case_insensitive() {
279        let args = Args {
280            files: vec![PathBuf::from("test.log")],
281            completions: None,
282            patterns: "ERROR".to_string(),
283            regex: false,
284            case_insensitive: true,
285            color_map: None,
286            notify: false,
287            notify_patterns: None,
288            quiet: false,
289            dry_run: false,
290            exclude: Some("debug".to_string()),
291            prefix_file: Some(false),
292            poll_interval: 1000,
293            buffer_size: 8192,
294            no_color: false,
295            notify_throttle: 0,
296        };
297
298        let config = Config::from_args(&args).unwrap();
299
300        assert!(config.should_exclude("DEBUG: Some debug message"));
301        assert!(config.should_exclude("debug: Some debug message"));
302        assert!(!config.should_exclude("ERROR: Some error message"));
303    }
304
305    #[test]
306    fn test_should_exclude_regex() {
307        let args = Args {
308            files: vec![PathBuf::from("test.log")],
309            completions: None,
310            patterns: "ERROR".to_string(),
311            regex: true,
312            case_insensitive: false,
313            color_map: None,
314            notify: false,
315            notify_patterns: None,
316            quiet: false,
317            dry_run: false,
318            exclude: Some(r"DEBUG|TRACE".to_string()),
319            prefix_file: Some(false),
320            poll_interval: 1000,
321            buffer_size: 8192,
322            no_color: false,
323            notify_throttle: 0,
324        };
325
326        let config = Config::from_args(&args).unwrap();
327
328        assert!(config.should_exclude("DEBUG: Some debug message"));
329        assert!(config.should_exclude("TRACE: Some trace message"));
330        assert!(!config.should_exclude("ERROR: Some error message"));
331    }
332
333    #[test]
334    fn test_should_exclude_empty() {
335        let args = Args {
336            files: vec![PathBuf::from("test.log")],
337            completions: None,
338            patterns: "ERROR".to_string(),
339            regex: false,
340            case_insensitive: false,
341            color_map: None,
342            notify: false,
343            notify_patterns: None,
344            quiet: false,
345            dry_run: false,
346            exclude: None,
347            prefix_file: Some(false),
348            poll_interval: 1000,
349            buffer_size: 8192,
350            no_color: false,
351            notify_throttle: 0,
352        };
353
354        let config = Config::from_args(&args).unwrap();
355
356        assert!(!config.should_exclude("DEBUG: Some debug message"));
357        assert!(!config.should_exclude("ERROR: Some error message"));
358    }
359}