Skip to main content

sel/
cli.rs

1//! Command-line argument parsing using clap.
2
3use clap::Parser;
4use std::io::IsTerminal;
5use std::path::PathBuf;
6
7/// `sel` — Select slices from text files by line numbers, ranges, positions, or regex.
8#[derive(Parser, Debug)]
9#[command(name = "sel")]
10#[command(author = "InkyQuill")]
11#[command(version = env!("CARGO_PKG_VERSION"))]
12#[command(about = "Select slices from text files", long_about = None)]
13#[command(
14    long_about = "Extract fragments from text files by line numbers, ranges, positions (line:column), or regex patterns.
15
16EXAMPLES:
17    sel 30-35 file.txt           Output lines 30-35
18    sel 10,15-20,22 file.txt     Output lines 10, 15-20, and 22
19    sel -c 3 42 file.txt         Show line 42 with 3 lines of context
20    sel -n 10 23:260 file.txt    Show position line 23, column 260 with char context
21    sel -e ERROR log.txt         Search for 'ERROR' pattern
22    sel file.txt                 Output entire file with line numbers (like cat -n)"
23)]
24pub struct Cli {
25    /// Show N lines of context before and after matches
26    #[arg(short = 'c', long = "context", value_name = "N")]
27    pub context: Option<usize>,
28
29    /// Show N characters of context around position
30    ///
31    /// Only works with positional selectors (L:C) or with -e.
32    #[arg(short = 'n', long = "char-context", value_name = "N")]
33    pub char_context: Option<usize>,
34
35    /// Don't output line numbers
36    ///
37    /// Filenames are still shown when processing multiple files.
38    #[arg(short = 'l', long = "no-line-numbers")]
39    pub no_line_numbers: bool,
40
41    /// Regular expression pattern (PCRE-like syntax)
42    ///
43    /// When using -e, the selector argument is ignored.
44    /// Multiple files can be specified with -e.
45    #[arg(short = 'e', long = "regex", value_name = "PATTERN")]
46    pub regex: Option<String>,
47
48    /// Invert the regex match: emit lines that do NOT match -e.
49    #[arg(short = 'v', long = "invert-match")]
50    pub invert: bool,
51
52    /// Always print filename prefix
53    ///
54    /// By default, filename is only shown when processing multiple files.
55    #[arg(short = 'H', long = "with-filename")]
56    pub with_filename: bool,
57
58    /// Color output [auto, always, never]
59    ///
60    /// Default is 'auto' (enabled when stdout is a terminal).
61    #[arg(long = "color", value_name = "WHEN")]
62    pub color: Option<String>,
63
64    /// Write output to FILE instead of stdout. Use `-` for stdout explicitly.
65    #[arg(short = 'o', long = "output", value_name = "FILE")]
66    pub output: Option<String>,
67
68    /// With `-o`, overwrite an existing file.
69    #[arg(long = "force")]
70    pub force: bool,
71
72    /// Selector and/or file(s)
73    ///
74    /// The first positional argument can be:
75    /// - A selector (line number, range, position) if it matches selector syntax
76    /// - A filename otherwise
77    ///
78    /// When using -e, all positional arguments are treated as files.
79    #[arg(value_name = "SELECTOR_OR_FILE")]
80    pub args: Vec<String>,
81}
82
83impl Cli {
84    /// Get the selector from arguments (only valid when not using -e).
85    pub fn get_selector(&self) -> Option<String> {
86        if self.regex.is_some() {
87            return None;
88        }
89
90        if self.args.is_empty() {
91            return None;
92        }
93
94        // Check if first arg looks like a selector
95        let first = &self.args[0];
96
97        // A selector is:
98        // - A single number (e.g., "42")
99        // - A range (e.g., "10-20")
100        // - A comma-separated list (e.g., "1,5,10-15")
101        // - A position (e.g., "23:260")
102        // - Contains only digits, commas, colons, and hyphens
103        if self.looks_like_selector(first) {
104            Some(first.clone())
105        } else {
106            None
107        }
108    }
109
110    /// Get the list of input files.
111    ///
112    /// Returns at least one entry — falls back to `-` (stdin) when no
113    /// explicit files are provided.
114    pub fn get_files(&self) -> Vec<PathBuf> {
115        if self.args.is_empty() {
116            return vec![PathBuf::from("-")];
117        }
118
119        // If using regex mode, all args are files
120        if self.regex.is_some() {
121            return self.args.iter().map(PathBuf::from).collect();
122        }
123
124        // If first arg is a selector, skip it
125        let start = if self.looks_like_selector(&self.args[0]) {
126            1
127        } else {
128            0
129        };
130
131        let files: Vec<_> = self.args[start..].iter().map(PathBuf::from).collect();
132        if files.is_empty() {
133            vec![PathBuf::from("-")]
134        } else {
135            files
136        }
137    }
138
139    /// Check if a string looks like a selector.
140    fn looks_like_selector(&self, s: &str) -> bool {
141        // `-` is the stdin sentinel, not a selector.
142        if s == "-" {
143            return false;
144        }
145        // Empty string is not a selector
146        if s.is_empty() {
147            return false;
148        }
149
150        // Check if it's a valid selector pattern
151        // Contains only: digits, commas, colons, hyphens
152        // And at least one digit
153        let has_digit = s.chars().any(|c| c.is_ascii_digit());
154        if !has_digit {
155            return false;
156        }
157
158        // Check for invalid characters
159        let valid_chars = s
160            .chars()
161            .all(|c| c.is_ascii_digit() || c == ',' || c == ':' || c == '-');
162
163        if !valid_chars {
164            return false;
165        }
166
167        // Additional validation: colons must be between numbers
168        // e.g., "23:260" is valid, but ":260" or "23:" is not
169        if s.contains(':') {
170            for part in s.split(',') {
171                if let Some((line, col)) = part.split_once(':') {
172                    // Both sides must be non-empty numbers
173                    if line.is_empty() || col.is_empty() {
174                        return false;
175                    }
176                    if !line.chars().all(|c| c.is_ascii_digit()) {
177                        return false;
178                    }
179                    if !col.chars().all(|c| c.is_ascii_digit()) {
180                        return false;
181                    }
182                }
183            }
184        }
185
186        true
187    }
188
189    /// Validate CLI arguments and check for conflicts.
190    pub fn validate(&self) -> crate::Result<()> {
191        if self.invert && self.regex.is_none() {
192            return Err(crate::SelError::InvertWithoutRegex);
193        }
194        if self.char_context.is_some()
195            && self.regex.is_none()
196            && !self
197                .get_selector()
198                .as_ref()
199                .is_some_and(|s| s.contains(':'))
200        {
201            return Err(crate::SelError::CharContextWithoutTarget);
202        }
203
204        Ok(())
205    }
206
207    /// Get the color mode based on the --color flag and terminal detection.
208    pub fn color_mode(&self) -> ColorMode {
209        match self.color.as_deref() {
210            Some("always") => ColorMode::Always,
211            Some("never") => ColorMode::Never,
212            Some("auto") | None => {
213                // Check if stdout is a terminal
214                if std::io::stdout().is_terminal() {
215                    ColorMode::Always
216                } else {
217                    ColorMode::Never
218                }
219            }
220            Some(_) => ColorMode::Never, // Invalid value, default to never
221        }
222    }
223}
224
225use crate::app::{NonSeek, Seek, Stage1};
226use crate::context::{LineContext, NoContext};
227use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter};
228use crate::matcher::{AllMatcher, LineMatcher, PositionMatcher, RegexMatcher};
229use crate::sink::{FileSink, StdoutSink};
230use crate::source::{FileSource, Source, StdinSource};
231use crate::{App, Selector};
232
233impl Cli {
234    /// Construct the output sink based on `--output`/`--force` flags.
235    fn make_sink(&self) -> crate::Result<Box<dyn crate::sink::Sink>> {
236        match self.output.as_deref() {
237            None | Some("-") => Ok(Box::new(StdoutSink::new())),
238            Some(path) => {
239                let sink = FileSink::create(std::path::Path::new(path), self.force)?;
240                Ok(Box::new(sink))
241            }
242        }
243    }
244
245    /// Resolve `--color` against whether the sink is a terminal.
246    fn resolve_color(&self, to_terminal: bool) -> bool {
247        match self.color.as_deref() {
248            Some("always") => true,
249            Some("never") => false,
250            _ => to_terminal,
251        }
252    }
253
254    /// Build a ready-to-run `App` for a single file.
255    ///
256    /// Callers iterate over `get_files()` and build one `App` per file.
257    pub fn into_app_for_file(
258        &self,
259        path: &std::path::Path,
260        show_filename: bool,
261    ) -> crate::Result<App<Seek>> {
262        let source = FileSource::open(path)?;
263        let filename = if show_filename {
264            Some(source.label().to_string())
265        } else {
266            None
267        };
268        let sink = self.make_sink()?;
269        let color = self.resolve_color(sink.is_terminal());
270        let opts = FormatOpts {
271            show_line_numbers: !self.no_line_numbers,
272            show_filename,
273            filename,
274            color,
275            // Target marker (`> `) only appears in context-aware output.
276            target_marker: matches!(self.context, Some(n) if n > 0),
277        };
278
279        // Matcher + seek stage.
280        let stage2 = Stage1::with_seekable_source(Box::new(source));
281        let stage3 = if let Some(pat) = &self.regex {
282            stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
283        } else if let Some(raw) = self.get_selector() {
284            let sel = Selector::parse(&raw)?;
285            match sel {
286                Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
287                Selector::LineNumbers(_) => {
288                    stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
289                }
290                Selector::Positions(_) => {
291                    stage2.with_position_matcher(PositionMatcher::from_selector(&sel))
292                }
293            }
294        } else {
295            stage2.with_matcher(Box::new(AllMatcher))
296        };
297
298        // Expander.
299        let stage4 = match self.context {
300            Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
301            _ => stage3.with_expander(Box::new(NoContext)),
302        };
303
304        // Formatter.
305        let stage5 = if let Some(n) = self.char_context {
306            stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
307        } else {
308            stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
309        };
310
311        Ok(stage5.with_sink(sink))
312    }
313
314    /// Build a ready-to-run `App` for stdin input.
315    ///
316    /// Returns `PositionalWithStdin` when paired with a positional selector
317    /// (line:column), which requires a seekable source.
318    pub fn into_app_for_stdin(&self, show_filename: bool) -> crate::Result<App<NonSeek>> {
319        if let Some(raw) = self.get_selector()
320            && raw.contains(':')
321        {
322            return Err(crate::SelError::PositionalWithStdin);
323        }
324        let source = StdinSource::new();
325        let filename = if show_filename {
326            Some("-".to_string())
327        } else {
328            None
329        };
330        let sink = self.make_sink()?;
331        let color = self.resolve_color(sink.is_terminal());
332        let opts = FormatOpts {
333            show_line_numbers: !self.no_line_numbers,
334            show_filename,
335            filename,
336            color,
337            // Target marker (`> `) only appears in context-aware output.
338            target_marker: matches!(self.context, Some(n) if n > 0),
339        };
340
341        let stage2 = Stage1::with_nonseekable_source(Box::new(source));
342        let stage3 = if let Some(pat) = &self.regex {
343            stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
344        } else if let Some(raw) = self.get_selector() {
345            let sel = Selector::parse(&raw)?;
346            match sel {
347                Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
348                Selector::LineNumbers(_) => {
349                    stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
350                }
351                Selector::Positions(_) => return Err(crate::SelError::PositionalWithStdin),
352            }
353        } else {
354            stage2.with_matcher(Box::new(AllMatcher))
355        };
356
357        let stage4 = match self.context {
358            Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
359            _ => stage3.with_expander(Box::new(NoContext)),
360        };
361
362        let stage5 = if let Some(n) = self.char_context {
363            stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
364        } else {
365            stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
366        };
367
368        Ok(stage5.with_sink(sink))
369    }
370}
371
372/// Color output mode.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum ColorMode {
375    /// Always colorize output.
376    Always,
377    /// Never colorize output.
378    Never,
379}
380
381impl ColorMode {
382    /// Returns true if coloring should be applied.
383    pub fn should_colorize(&self) -> bool {
384        matches!(self, Self::Always)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_cli_with_selector() {
394        let cli = Cli::parse_from(["sel", "10-20", "file.txt"]);
395        assert_eq!(cli.get_selector(), Some("10-20".to_string()));
396        assert_eq!(cli.get_files().len(), 1);
397        assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
398    }
399
400    #[test]
401    fn test_cli_without_selector() {
402        let cli = Cli::parse_from(["sel", "file.txt"]);
403        assert_eq!(cli.get_selector(), None);
404        assert_eq!(cli.get_files().len(), 1);
405        assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
406    }
407
408    #[test]
409    fn test_cli_with_context() {
410        let cli = Cli::parse_from(["sel", "-c", "3", "42", "file.txt"]);
411        assert_eq!(cli.context, Some(3));
412        assert_eq!(cli.get_selector(), Some("42".to_string()));
413        assert_eq!(cli.get_files().len(), 1);
414    }
415
416    #[test]
417    fn test_cli_regex_mode() {
418        let cli = Cli::parse_from(["sel", "-e", "ERROR", "log.txt"]);
419        assert_eq!(cli.regex, Some("ERROR".to_string()));
420        assert_eq!(cli.get_selector(), None);
421        assert_eq!(cli.get_files().len(), 1);
422        assert_eq!(cli.get_files()[0], PathBuf::from("log.txt"));
423    }
424
425    #[test]
426    fn test_cli_regex_multiple_files() {
427        let cli = Cli::parse_from(["sel", "-e", "ERROR", "log1.txt", "log2.txt"]);
428        assert_eq!(cli.regex, Some("ERROR".to_string()));
429        assert_eq!(cli.get_files().len(), 2);
430    }
431
432    #[test]
433    fn test_looks_like_selector() {
434        let cli = Cli::parse_from(["sel", "file.txt"]);
435        assert!(cli.looks_like_selector("42"));
436        assert!(cli.looks_like_selector("10-20"));
437        assert!(cli.looks_like_selector("1,5,10-15"));
438        assert!(cli.looks_like_selector("23:260"));
439        assert!(!cli.looks_like_selector("file.txt"));
440        assert!(!cli.looks_like_selector(""));
441        assert!(!cli.looks_like_selector(":260"));
442        assert!(!cli.looks_like_selector("23:"));
443    }
444}