log_watcher/
highlighter.rs

1use crate::config::Config;
2use crate::matcher::MatchResult;
3use anyhow::Result;
4use std::io::Write;
5use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
6
7#[derive(Debug)]
8pub struct Highlighter {
9    config: Config,
10    stdout: StandardStream,
11    stderr: StandardStream,
12}
13
14impl Highlighter {
15    pub fn new(config: Config) -> Self {
16        let color_choice = if config.no_color {
17            ColorChoice::Never
18        } else {
19            ColorChoice::Auto
20        };
21
22        Self {
23            config,
24            stdout: StandardStream::stdout(color_choice),
25            stderr: StandardStream::stderr(color_choice),
26        }
27    }
28
29    pub fn print_line(
30        &mut self,
31        line: &str,
32        filename: Option<&str>,
33        match_result: &MatchResult,
34        dry_run: bool,
35    ) -> Result<()> {
36        // Skip non-matching lines in quiet mode
37        if self.config.quiet && !match_result.matched {
38            return Ok(());
39        }
40
41        let mut output_line = String::new();
42
43        // Add dry-run prefix if needed
44        if dry_run && match_result.matched {
45            output_line.push_str("[DRY-RUN] ");
46        }
47
48        // Add filename prefix if needed
49        if self.config.prefix_files {
50            if let Some(filename) = filename {
51                output_line.push_str(&format!("[{}] ", filename));
52            }
53        }
54
55        // Add the actual line content
56        output_line.push_str(line);
57
58        // Print with or without color
59        if match_result.matched && match_result.color.is_some() {
60            self.print_colored(&output_line, match_result.color.unwrap())?;
61        } else {
62            self.print_plain(&output_line)?;
63        }
64
65        Ok(())
66    }
67
68    fn print_colored(&mut self, text: &str, color: Color) -> Result<()> {
69        self.stdout
70            .set_color(ColorSpec::new().set_fg(Some(color)))?;
71        writeln!(self.stdout, "{}", text)?;
72        self.stdout.reset()?;
73        self.stdout.flush()?;
74        Ok(())
75    }
76
77    fn print_plain(&mut self, text: &str) -> Result<()> {
78        writeln!(self.stdout, "{}", text)?;
79        self.stdout.flush()?;
80        Ok(())
81    }
82
83    pub fn print_error(&mut self, message: &str) -> Result<()> {
84        self.stderr
85            .set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
86        writeln!(self.stderr, "Error: {}", message)?;
87        self.stderr.reset()?;
88        self.stderr.flush()?;
89        Ok(())
90    }
91
92    pub fn print_warning(&mut self, message: &str) -> Result<()> {
93        self.stderr
94            .set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
95        writeln!(self.stderr, "Warning: {}", message)?;
96        self.stderr.reset()?;
97        self.stderr.flush()?;
98        Ok(())
99    }
100
101    pub fn print_info(&mut self, message: &str) -> Result<()> {
102        self.stderr
103            .set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
104        writeln!(self.stderr, "Info: {}", message)?;
105        self.stderr.reset()?;
106        self.stderr.flush()?;
107        Ok(())
108    }
109
110    pub fn print_dry_run_summary(&mut self, matches: &[(String, usize)]) -> Result<()> {
111        if matches.is_empty() {
112            self.print_info("No matching lines found")?;
113            return Ok(());
114        }
115
116        self.print_info("Dry-run summary:")?;
117        for (pattern, count) in matches {
118            self.print_plain(&format!("  {}: {} matches", pattern, count))?;
119        }
120        self.print_info("Dry-run complete. No notifications sent.")?;
121        Ok(())
122    }
123
124    pub fn print_startup_info(&mut self) -> Result<()> {
125        self.print_info(&format!("Watching {} file(s)", self.config.files.len()))?;
126
127        if !self.config.patterns.is_empty() {
128            self.print_info(&format!("Patterns: {}", self.config.patterns.join(", ")))?;
129        }
130
131        if self.config.notify_enabled {
132            self.print_info("Desktop notifications enabled")?;
133        }
134
135        if self.config.dry_run {
136            self.print_info("Dry-run mode: reading existing content only")?;
137        }
138
139        Ok(())
140    }
141
142    pub fn print_file_rotation(&mut self, filename: &str) -> Result<()> {
143        self.print_warning(&format!("File rotation detected for {}", filename))?;
144        Ok(())
145    }
146
147    pub fn print_file_reopened(&mut self, filename: &str) -> Result<()> {
148        self.print_info(&format!("Reopened file: {}", filename))?;
149        Ok(())
150    }
151
152    pub fn print_file_error(&mut self, filename: &str, error: &str) -> Result<()> {
153        self.print_error(&format!("Error watching {}: {}", filename, error))?;
154        Ok(())
155    }
156
157    pub fn print_shutdown_summary(&mut self, stats: &WatcherStats) -> Result<()> {
158        self.print_info("Shutdown summary:")?;
159        self.print_plain(&format!("  Files watched: {}", stats.files_watched))?;
160        self.print_plain(&format!("  Lines processed: {}", stats.lines_processed))?;
161        self.print_plain(&format!("  Matches found: {}", stats.matches_found))?;
162        self.print_plain(&format!(
163            "  Notifications sent: {}",
164            stats.notifications_sent
165        ))?;
166        Ok(())
167    }
168}
169
170#[derive(Debug, Default)]
171pub struct WatcherStats {
172    pub files_watched: usize,
173    pub lines_processed: usize,
174    pub matches_found: usize,
175    pub notifications_sent: usize,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::cli::Args;
182    use std::path::PathBuf;
183
184    fn create_test_config() -> Config {
185        let args = Args {
186            files: vec![PathBuf::from("test.log")],
187            patterns: "ERROR".to_string(),
188            regex: false,
189            case_insensitive: false,
190            color_map: None,
191            notify: true,
192            notify_patterns: None,
193            notify_throttle: 5,
194            dry_run: false,
195            quiet: false,
196            no_color: true, // Disable colors for testing
197            prefix_file: None,
198            poll_interval: 100,
199            buffer_size: 8192,
200        };
201        Config::from_args(&args).unwrap()
202    }
203
204    #[test]
205    fn test_print_line_without_match() {
206        let config = create_test_config();
207        let mut highlighter = Highlighter::new(config);
208
209        let match_result = MatchResult {
210            matched: false,
211            pattern: None,
212            color: None,
213            should_notify: false,
214        };
215
216        // This should not panic
217        highlighter
218            .print_line("Normal line", None, &match_result, false)
219            .unwrap();
220    }
221
222    #[test]
223    fn test_print_line_with_match() {
224        let config = create_test_config();
225        let mut highlighter = Highlighter::new(config);
226
227        let match_result = MatchResult {
228            matched: true,
229            pattern: Some("ERROR".to_string()),
230            color: Some(Color::Red),
231            should_notify: true,
232        };
233
234        // This should not panic
235        highlighter
236            .print_line("ERROR: Something went wrong", None, &match_result, false)
237            .unwrap();
238    }
239
240    #[test]
241    fn test_dry_run_prefix() {
242        let config = create_test_config();
243        let mut highlighter = Highlighter::new(config);
244
245        let match_result = MatchResult {
246            matched: true,
247            pattern: Some("ERROR".to_string()),
248            color: Some(Color::Red),
249            should_notify: true,
250        };
251
252        // This should not panic
253        highlighter
254            .print_line("ERROR: Something went wrong", None, &match_result, true)
255            .unwrap();
256    }
257
258    #[test]
259    fn test_print_file_error() {
260        let config = create_test_config();
261        let mut highlighter = Highlighter::new(config);
262        let result = highlighter.print_file_error("test.log", "Permission denied");
263        assert!(result.is_ok());
264    }
265
266    #[test]
267    fn test_print_shutdown_summary() {
268        let config = create_test_config();
269        let mut highlighter = Highlighter::new(config);
270        let stats = WatcherStats {
271            files_watched: 2,
272            lines_processed: 100,
273            matches_found: 5,
274            notifications_sent: 3,
275        };
276        let result = highlighter.print_shutdown_summary(&stats);
277        assert!(result.is_ok());
278    }
279
280    #[test]
281    fn test_print_file_rotation() {
282        let config = create_test_config();
283        let mut highlighter = Highlighter::new(config);
284        let result = highlighter.print_file_rotation("test.log");
285        assert!(result.is_ok());
286    }
287
288    #[test]
289    fn test_print_file_reopened() {
290        let config = create_test_config();
291        let mut highlighter = Highlighter::new(config);
292        let result = highlighter.print_file_reopened("test.log");
293        assert!(result.is_ok());
294    }
295
296    #[test]
297    fn test_print_startup_info() {
298        let config = create_test_config();
299        let mut highlighter = Highlighter::new(config);
300        let result = highlighter.print_startup_info();
301        assert!(result.is_ok());
302    }
303
304    #[test]
305    fn test_print_colored_with_custom_color() {
306        let config = create_test_config();
307        let mut highlighter = Highlighter::new(config);
308        let result = highlighter.print_colored("Custom message", Color::Magenta);
309        assert!(result.is_ok());
310    }
311
312    #[test]
313    fn test_print_plain() {
314        let config = create_test_config();
315        let mut highlighter = Highlighter::new(config);
316        let result = highlighter.print_plain("Plain message");
317        assert!(result.is_ok());
318    }
319
320    #[test]
321    fn test_color_choice_never() {
322        let args = Args {
323            files: vec![PathBuf::from("test.log")],
324            patterns: "ERROR".to_string(),
325            regex: false,
326            case_insensitive: false,
327            color_map: None,
328            notify: false,
329            notify_patterns: None,
330            quiet: false,
331            dry_run: false,
332            prefix_file: Some(false),
333            poll_interval: 1000,
334            buffer_size: 8192,
335            no_color: true, // Force no color
336            notify_throttle: 0,
337        };
338
339        let config = Config::from_args(&args).unwrap();
340        let highlighter = Highlighter::new(config);
341
342        // Test that highlighter is created successfully with no_color = true
343        assert!(highlighter.config.no_color);
344    }
345
346    #[test]
347    fn test_quiet_mode_skip_non_matching() {
348        let args = Args {
349            files: vec![PathBuf::from("test.log")],
350            patterns: "ERROR".to_string(),
351            regex: false,
352            case_insensitive: false,
353            color_map: None,
354            notify: false,
355            notify_patterns: None,
356            quiet: true, // Enable quiet mode
357            dry_run: false,
358            prefix_file: Some(false),
359            poll_interval: 1000,
360            buffer_size: 8192,
361            no_color: false,
362            notify_throttle: 0,
363        };
364
365        let config = Config::from_args(&args).unwrap();
366        let mut highlighter = Highlighter::new(config);
367
368        // Test that non-matching lines are skipped in quiet mode
369        let match_result = MatchResult {
370            matched: false,
371            pattern: None,
372            color: None,
373            should_notify: false,
374        };
375
376        let result = highlighter.print_line("Normal line", None, &match_result, false);
377        assert!(result.is_ok());
378    }
379
380    #[test]
381    fn test_print_dry_run_summary_empty() {
382        let config = create_test_config();
383        let mut highlighter = Highlighter::new(config);
384
385        // Test empty matches (covers line 112-113)
386        let matches = vec![];
387        let result = highlighter.print_dry_run_summary(&matches);
388        assert!(result.is_ok());
389    }
390
391    #[test]
392    fn test_print_dry_run_summary_with_matches() {
393        let config = create_test_config();
394        let mut highlighter = Highlighter::new(config);
395
396        // Test with matches (covers line 116)
397        let matches = vec![("ERROR".to_string(), 5), ("WARN".to_string(), 3)];
398        let result = highlighter.print_dry_run_summary(&matches);
399        assert!(result.is_ok());
400    }
401}