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 let Some(color) = match_result.color {
60            self.print_colored(&output_line, color)?;
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        if stats.lines_excluded > 0 {
162            self.print_plain(&format!("  Lines excluded: {}", stats.lines_excluded))?;
163        }
164        self.print_plain(&format!("  Matches found: {}", stats.matches_found))?;
165        self.print_plain(&format!(
166            "  Notifications sent: {}",
167            stats.notifications_sent
168        ))?;
169        Ok(())
170    }
171}
172
173#[derive(Debug, Default)]
174pub struct WatcherStats {
175    pub files_watched: usize,
176    pub lines_processed: usize,
177    pub lines_excluded: usize,
178    pub matches_found: usize,
179    pub notifications_sent: usize,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::cli::Args;
186    use std::path::PathBuf;
187
188    fn create_test_config() -> Config {
189        let args = Args {
190            files: vec![PathBuf::from("test.log")],
191            completions: None,
192            patterns: "ERROR".to_string(),
193            regex: false,
194            case_insensitive: false,
195            color_map: None,
196            notify: true,
197            notify_patterns: None,
198            notify_throttle: 5,
199            dry_run: false,
200            quiet: false,
201            exclude: None,
202            no_color: true, // Disable colors for testing
203            prefix_file: None,
204            poll_interval: 100,
205            buffer_size: 8192,
206        };
207        Config::from_args(&args).unwrap()
208    }
209
210    #[test]
211    fn test_print_line_without_match() {
212        let config = create_test_config();
213        let mut highlighter = Highlighter::new(config);
214
215        let match_result = MatchResult {
216            matched: false,
217            pattern: None,
218            color: None,
219            should_notify: false,
220        };
221
222        // This should not panic
223        highlighter
224            .print_line("Normal line", None, &match_result, false)
225            .unwrap();
226    }
227
228    #[test]
229    fn test_print_line_with_match() {
230        let config = create_test_config();
231        let mut highlighter = Highlighter::new(config);
232
233        let match_result = MatchResult {
234            matched: true,
235            pattern: Some("ERROR".to_string()),
236            color: Some(Color::Red),
237            should_notify: true,
238        };
239
240        // This should not panic
241        highlighter
242            .print_line("ERROR: Something went wrong", None, &match_result, false)
243            .unwrap();
244    }
245
246    #[test]
247    fn test_dry_run_prefix() {
248        let config = create_test_config();
249        let mut highlighter = Highlighter::new(config);
250
251        let match_result = MatchResult {
252            matched: true,
253            pattern: Some("ERROR".to_string()),
254            color: Some(Color::Red),
255            should_notify: true,
256        };
257
258        // This should not panic
259        highlighter
260            .print_line("ERROR: Something went wrong", None, &match_result, true)
261            .unwrap();
262    }
263
264    #[test]
265    fn test_print_file_error() {
266        let config = create_test_config();
267        let mut highlighter = Highlighter::new(config);
268        let result = highlighter.print_file_error("test.log", "Permission denied");
269        assert!(result.is_ok());
270    }
271
272    #[test]
273    fn test_print_shutdown_summary() {
274        let config = create_test_config();
275        let mut highlighter = Highlighter::new(config);
276        let stats = WatcherStats {
277            files_watched: 2,
278            lines_processed: 100,
279            lines_excluded: 10,
280            matches_found: 5,
281            notifications_sent: 3,
282        };
283        let result = highlighter.print_shutdown_summary(&stats);
284        assert!(result.is_ok());
285    }
286
287    #[test]
288    fn test_print_file_rotation() {
289        let config = create_test_config();
290        let mut highlighter = Highlighter::new(config);
291        let result = highlighter.print_file_rotation("test.log");
292        assert!(result.is_ok());
293    }
294
295    #[test]
296    fn test_print_file_reopened() {
297        let config = create_test_config();
298        let mut highlighter = Highlighter::new(config);
299        let result = highlighter.print_file_reopened("test.log");
300        assert!(result.is_ok());
301    }
302
303    #[test]
304    fn test_print_startup_info() {
305        let config = create_test_config();
306        let mut highlighter = Highlighter::new(config);
307        let result = highlighter.print_startup_info();
308        assert!(result.is_ok());
309    }
310
311    #[test]
312    fn test_print_colored_with_custom_color() {
313        let config = create_test_config();
314        let mut highlighter = Highlighter::new(config);
315        let result = highlighter.print_colored("Custom message", Color::Magenta);
316        assert!(result.is_ok());
317    }
318
319    #[test]
320    fn test_print_plain() {
321        let config = create_test_config();
322        let mut highlighter = Highlighter::new(config);
323        let result = highlighter.print_plain("Plain message");
324        assert!(result.is_ok());
325    }
326
327    #[test]
328    fn test_color_choice_never() {
329        let args = Args {
330            files: vec![PathBuf::from("test.log")],
331            completions: None,
332            patterns: "ERROR".to_string(),
333            regex: false,
334            case_insensitive: false,
335            color_map: None,
336            notify: false,
337            notify_patterns: None,
338            quiet: false,
339            dry_run: false,
340            exclude: None,
341            prefix_file: Some(false),
342            poll_interval: 1000,
343            buffer_size: 8192,
344            no_color: true, // Force no color
345            notify_throttle: 0,
346        };
347
348        let config = Config::from_args(&args).unwrap();
349        let highlighter = Highlighter::new(config);
350
351        // Test that highlighter is created successfully with no_color = true
352        assert!(highlighter.config.no_color);
353    }
354
355    #[test]
356    fn test_quiet_mode_skip_non_matching() {
357        let args = Args {
358            files: vec![PathBuf::from("test.log")],
359            completions: None,
360            patterns: "ERROR".to_string(),
361            regex: false,
362            case_insensitive: false,
363            color_map: None,
364            notify: false,
365            notify_patterns: None,
366            quiet: true, // Enable quiet mode
367            dry_run: false,
368            exclude: None,
369            prefix_file: Some(false),
370            poll_interval: 1000,
371            buffer_size: 8192,
372            no_color: false,
373            notify_throttle: 0,
374        };
375
376        let config = Config::from_args(&args).unwrap();
377        let mut highlighter = Highlighter::new(config);
378
379        // Test that non-matching lines are skipped in quiet mode
380        let match_result = MatchResult {
381            matched: false,
382            pattern: None,
383            color: None,
384            should_notify: false,
385        };
386
387        let result = highlighter.print_line("Normal line", None, &match_result, false);
388        assert!(result.is_ok());
389    }
390
391    #[test]
392    fn test_print_dry_run_summary_empty() {
393        let config = create_test_config();
394        let mut highlighter = Highlighter::new(config);
395
396        // Test empty matches (covers line 112-113)
397        let matches = vec![];
398        let result = highlighter.print_dry_run_summary(&matches);
399        assert!(result.is_ok());
400    }
401
402    #[test]
403    fn test_print_dry_run_summary_with_matches() {
404        let config = create_test_config();
405        let mut highlighter = Highlighter::new(config);
406
407        // Test with matches (covers line 116)
408        let matches = vec![("ERROR".to_string(), 5), ("WARN".to_string(), 3)];
409        let result = highlighter.print_dry_run_summary(&matches);
410        assert!(result.is_ok());
411    }
412
413    #[test]
414    fn test_print_dry_run_summary_coverage_line_116() {
415        let config = create_test_config();
416        let mut highlighter = Highlighter::new(config);
417
418        // Test print_dry_run_summary to cover line 116 (self.print_info("Dry-run summary:"))
419        let matches = vec![("ERROR".to_string(), 2)];
420        let result = highlighter.print_dry_run_summary(&matches);
421        assert!(result.is_ok());
422    }
423}