log_watcher/
cli.rs

1use clap::{CommandFactory, Parser};
2use clap_complete::{generate, Shell};
3use std::io;
4use std::path::PathBuf;
5
6#[derive(Parser)]
7#[command(
8    name = "logwatcher",
9    about = "Real-time log file monitoring with pattern highlighting and desktop notifications",
10    version = env!("CARGO_PKG_VERSION"),
11    long_about = "LogWatcher is a CLI tool for monitoring log files in real-time. It provides pattern highlighting, desktop notifications, and handles file rotation automatically."
12)]
13pub struct Args {
14    /// Path(s) to log file(s) to watch
15    #[arg(short = 'f', long = "file", required_unless_present = "completions", num_args = 1..)]
16    pub files: Vec<PathBuf>,
17
18    /// Generate shell completions for the specified shell
19    #[arg(long = "completions", value_name = "SHELL")]
20    pub completions: Option<Shell>,
21
22    /// Comma-separated patterns to match
23    #[arg(short = 'p', long = "pattern", default_value = "ERROR,WARN")]
24    pub patterns: String,
25
26    /// Treat patterns as regular expressions
27    #[arg(short = 'r', long = "regex")]
28    pub regex: bool,
29
30    /// Case-insensitive pattern matching
31    #[arg(short = 'i', long = "case-insensitive")]
32    pub case_insensitive: bool,
33
34    /// Custom pattern:color mappings (e.g., "ERROR:red,WARN:yellow")
35    #[arg(short = 'c', long = "color-map")]
36    pub color_map: Option<String>,
37
38    /// Enable desktop notifications
39    #[arg(short = 'n', long = "notify", default_value = "true")]
40    pub notify: bool,
41
42    /// Specific patterns that trigger notifications (default: all patterns)
43    #[arg(long = "notify-patterns")]
44    pub notify_patterns: Option<String>,
45
46    /// Maximum notifications per second
47    #[arg(long = "notify-throttle", default_value = "5")]
48    pub notify_throttle: u32,
49
50    /// Preview mode (no tailing, no notifications)
51    #[arg(short = 'd', long = "dry-run")]
52    pub dry_run: bool,
53
54    /// Suppress non-matching lines
55    #[arg(short = 'q', long = "quiet")]
56    pub quiet: bool,
57
58    /// Comma-separated patterns to exclude (inverse matching)
59    #[arg(short = 'e', long = "exclude")]
60    pub exclude: Option<String>,
61
62    /// Disable ANSI colors
63    #[arg(long = "no-color")]
64    pub no_color: bool,
65
66    /// Prefix lines with filename (auto: true for multiple files)
67    #[arg(long = "prefix-file")]
68    pub prefix_file: Option<bool>,
69
70    /// File polling interval in milliseconds
71    #[arg(long = "poll-interval", default_value = "100")]
72    pub poll_interval: u64,
73
74    /// Read buffer size in bytes
75    #[arg(long = "buffer-size", default_value = "8192")]
76    pub buffer_size: usize,
77}
78
79impl Args {
80    /// Get the list of files to watch
81    pub fn files(&self) -> &[PathBuf] {
82        &self.files
83    }
84
85    /// Get the patterns as a vector of strings
86    pub fn patterns(&self) -> Vec<String> {
87        self.patterns
88            .split(',')
89            .map(|s| s.trim().to_string())
90            .filter(|s| !s.is_empty())
91            .collect()
92    }
93
94    /// Get notification patterns as a vector of strings
95    pub fn notify_patterns(&self) -> Vec<String> {
96        if let Some(ref patterns) = self.notify_patterns {
97            patterns
98                .split(',')
99                .map(|s| s.trim().to_string())
100                .filter(|s| !s.is_empty())
101                .collect()
102        } else {
103            self.patterns()
104        }
105    }
106
107    /// Get color mappings as a vector of (pattern, color) tuples
108    pub fn color_mappings(&self) -> Vec<(String, String)> {
109        if let Some(ref color_map) = self.color_map {
110            color_map
111                .split(',')
112                .filter_map(|mapping| {
113                    let parts: Vec<&str> = mapping.split(':').collect();
114                    if parts.len() == 2 {
115                        Some((parts[0].trim().to_string(), parts[1].trim().to_string()))
116                    } else {
117                        None
118                    }
119                })
120                .collect()
121        } else {
122            vec![]
123        }
124    }
125
126    /// Determine if filename prefixing should be enabled
127    pub fn should_prefix_files(&self) -> bool {
128        if let Some(prefix) = self.prefix_file {
129            prefix
130        } else {
131            self.files.len() > 1
132        }
133    }
134
135    /// Get exclude patterns as a vector of strings
136    pub fn exclude_patterns(&self) -> Vec<String> {
137        if let Some(ref patterns) = self.exclude {
138            patterns
139                .split(',')
140                .map(|s| s.trim().to_string())
141                .filter(|s| !s.is_empty())
142                .collect()
143        } else {
144            vec![]
145        }
146    }
147
148    /// Generate shell completions for the specified shell and write to stdout
149    pub fn generate_completions(shell: Shell) {
150        let mut cmd = Args::command();
151        generate(shell, &mut cmd, "logwatcher", &mut io::stdout());
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_color_mappings_invalid_format() {
161        let args = Args {
162            files: vec![PathBuf::from("test.log")],
163            completions: None,
164            patterns: "ERROR".to_string(),
165            regex: false,
166            case_insensitive: false,
167            color_map: Some("invalid_format".to_string()),
168            notify: false,
169            notify_patterns: None,
170            quiet: false,
171            dry_run: false,
172            exclude: None,
173            prefix_file: Some(false),
174            poll_interval: 1000,
175            buffer_size: 8192,
176            no_color: false,
177            notify_throttle: 0,
178        };
179
180        let mappings = args.color_mappings();
181        assert_eq!(mappings.len(), 0); // Should return empty map for invalid format
182    }
183
184    #[test]
185    fn test_exclude_patterns() {
186        let args = Args {
187            files: vec![PathBuf::from("test.log")],
188            completions: None,
189            patterns: "ERROR".to_string(),
190            regex: false,
191            case_insensitive: false,
192            color_map: None,
193            notify: false,
194            notify_patterns: None,
195            quiet: false,
196            dry_run: false,
197            exclude: Some("DEBUG,TRACE".to_string()),
198            prefix_file: Some(false),
199            poll_interval: 1000,
200            buffer_size: 8192,
201            no_color: false,
202            notify_throttle: 0,
203        };
204
205        let patterns = args.exclude_patterns();
206        assert_eq!(patterns.len(), 2);
207        assert!(patterns.contains(&"DEBUG".to_string()));
208        assert!(patterns.contains(&"TRACE".to_string()));
209    }
210
211    #[test]
212    fn test_exclude_patterns_empty() {
213        let args = Args {
214            files: vec![PathBuf::from("test.log")],
215            completions: None,
216            patterns: "ERROR".to_string(),
217            regex: false,
218            case_insensitive: false,
219            color_map: None,
220            notify: false,
221            notify_patterns: None,
222            quiet: false,
223            dry_run: false,
224            exclude: None,
225            prefix_file: Some(false),
226            poll_interval: 1000,
227            buffer_size: 8192,
228            no_color: false,
229            notify_throttle: 0,
230        };
231
232        let patterns = args.exclude_patterns();
233        assert!(patterns.is_empty());
234    }
235
236    #[test]
237    fn test_generate_completions() {
238        // Just verify the function doesn't panic
239        // We can't easily capture stdout in a unit test, but we can test it runs
240        use clap_complete::Shell;
241        Args::generate_completions(Shell::Bash);
242    }
243}