Skip to main content

tess/
cli.rs

1use std::path::PathBuf;
2use clap::Parser;
3use clap::builder::styling::{AnsiColor, Color, Style};
4use clap::builder::Styles;
5
6const HELP_STYLES: Styles = Styles::styled()
7    .header(Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Yellow))))
8    .usage(Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Yellow))))
9    .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))))
10    .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))));
11
12#[derive(Parser, Debug, Clone)]
13#[command(name = "tess", version, about = "A less-style terminal pager.", styles = HELP_STYLES)]
14pub struct Args {
15    /// Chop long lines instead of wrapping.
16    #[arg(short = 'S', long = "chop-long-lines")]
17    pub chop: bool,
18
19    /// Force the content type for `--prettify` (otherwise auto-detected from
20    /// the filename extension and the first bytes). Values:
21    /// `auto`, `raw`, `json`, `yaml`, `toml`, `xml`, `html`, `csv`.
22    /// Setting this implies `--prettify` (unless the value is `raw`/`auto`).
23    #[arg(long = "content-type", value_name = "TYPE")]
24    pub content_type: Option<String>,
25
26    /// With `--filter`, dim non-matching lines instead of hiding them. Keeps
27    /// surrounding context visible.
28    #[arg(long = "dim")]
29    pub dim: bool,
30
31    /// Render each parsed line through this template instead of showing the
32    /// raw line. Syntax: `<fieldname>` placeholders, `\<` for literal `<`,
33    /// `\\` for literal `\`. Example: `--display '[<time>] <status> <msg>'`.
34    /// Overrides the format's `display` key (if set). Requires `--format`.
35    /// Search still matches against the raw line.
36    #[arg(long = "display", value_name = "TEMPLATE")]
37    pub display: Option<String>,
38
39    /// Print a curated list of usage examples and exit.
40    #[arg(long = "examples")]
41    pub examples: bool,
42
43    /// Filter visible lines by parsed field. Repeatable; multiple filters AND.
44    /// Operators: `=` (exact), `!=` (exact ≠), `~` (regex), `!~` (regex ≠),
45    /// `<`, `<=`, `>`, `>=` (numeric if both sides parse as numbers, else
46    /// lexicographic). Examples: `--filter status=500`, `--filter ip~^10\.`,
47    /// `--filter 'status>=500'` (quote `<` and `>` to avoid shell redirection).
48    /// Requires `--format`.
49    #[arg(long = "filter", value_name = "FIELD<op>VALUE")]
50    pub filter: Vec<String>,
51
52    /// Follow mode: keep watching the source for new bytes (like `tail -f`).
53    /// Jumps to the bottom on startup. Toggle with Shift-F at runtime.
54    #[arg(short = 'f', long = "follow")]
55    pub follow: bool,
56
57    /// Apply a named log format (built-in or user-defined in
58    /// ~/.config/tess/formats.toml). Required by `--filter`.
59    #[arg(long = "format", value_name = "NAME")]
60    pub format: Option<String>,
61
62    /// Filter visible lines by regex against the raw line. Repeatable;
63    /// multiple `--grep` arguments AND. Works on any input — no `--format`
64    /// required. Composes with `--filter` (both must match) and with
65    /// `--dim` (non-matches stay visible but faded).
66    /// Example: `--grep error --grep '^\['`.
67    #[arg(long = "grep", value_name = "PATTERN")]
68    pub grep: Vec<String>,
69
70    /// Show only the first N lines of the source. Mutually exclusive with --tail.
71    #[arg(long = "head", value_name = "N", conflicts_with = "tail")]
72    pub head: Option<usize>,
73
74    /// Render the source as an xxd-style hex dump instead of byte-faithful
75    /// text. 16 bytes per row, offset prefix, ASCII gutter. Mutually
76    /// exclusive with parsing- and rendering-oriented flags.
77    #[arg(
78        long = "hex",
79        conflicts_with_all = ["filter", "grep", "prettify", "format", "display", "record_start", "prompt", "preprocess"],
80    )]
81    pub hex: bool,
82
83    /// Hex characters per group in `--hex` mode. One of 2, 4, 8, 16, 32
84    /// (default 4, matching `xxd`). 32 means the whole row as a single
85    /// group with no spacing between hex pairs. Requires `--hex`. Can be
86    /// changed at runtime with `:hex N`.
87    #[arg(
88        long = "hex-group",
89        value_name = "N",
90        default_value_t = 4,
91        requires = "hex",
92    )]
93    pub hex_group: usize,
94
95    /// Show line numbers.
96    #[arg(short = 'N', long = "LINE-NUMBERS")]
97    pub line_numbers: bool,
98
99    /// Print available log formats and their named fields, then exit.
100    #[arg(long = "list-formats")]
101    pub list_formats: bool,
102
103    /// Live mode: re-read the file when its on-disk content changes (mtime,
104    /// size, or inode). Use this for files rewritten in place — source files
105    /// being edited, files saved by an editor or AI agent. Different from
106    /// `--follow` (which watches for *appended* bytes); the two are mutually
107    /// exclusive. Press `R` inside the pager to force a reload.
108    #[arg(long = "live", conflicts_with = "follow")]
109    pub live: bool,
110
111    /// Print the full user manual and exit.
112    #[arg(long = "manual")]
113    pub manual: bool,
114
115    /// Enable mouse capture: click rows in the file picker / help overlay,
116    /// and scrollwheel scrolls the body. Trade-off: most terminals disable
117    /// their native text selection while mouse capture is on.
118    #[arg(long = "mouse")]
119    pub mouse: bool,
120
121    /// Show raw control bytes as `^X` glyphs (pre-0.18 default). Disables
122    /// SGR / OSC interpretation. Honoured also by the `NO_COLOR` environment
123    /// variable (any non-empty value) and `CLICOLOR=0`.
124    #[arg(long = "no-color")]
125    pub no_color: bool,
126
127    /// Ignore $LESSOPEN. Useful when LESSOPEN is exported but not wanted
128    /// for one invocation.
129    #[arg(long = "no-preprocess", conflicts_with = "preprocess")]
130    pub no_preprocess: bool,
131
132    /// Non-interactive batch mode: apply --filter / --grep / --head / --tail / --prettify
133    /// to the source and write the resulting raw bytes to FILE, then exit.
134    /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
135    /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
136    /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
137    /// compatible with `--live`.
138    #[arg(short = 'o', long = "output", value_name = "FILE")]
139    pub output: Option<String>,
140
141    /// Pipe the source file through this command before rendering.
142    /// Must start with `|`; `%s` is substituted with the file path.
143    /// Example: `--preprocess '|pdftotext %s -'`. Overrides $LESSOPEN.
144    #[arg(
145        long = "preprocess",
146        value_name = "CMD",
147        conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
148    )]
149    pub preprocess: Option<String>,
150
151    /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
152    /// Detects the type from the filename extension or the first bytes; use
153    /// `--content-type=NAME` to override. Static files only — not allowed
154    /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
155    /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
156    #[arg(long = "prettify")]
157    pub prettify: bool,
158
159    /// Replace the hardcoded status format with a templated string.
160    /// Uses the same `<field>` syntax as `--display`. Available fields:
161    /// label, top, bottom, total, pct, rec-top, rec-bottom, rec-total,
162    /// rec-block, wrap-offset, format-tag, filter-tag, grep-tag,
163    /// hide-tag, search-tag, pretty-tag, live-tag, follow-tag.
164    /// Per-format default can be set via `prompt = '...'` in formats.toml.
165    /// Mutually exclusive with --hex.
166    #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
167    pub prompt: Option<String>,
168
169    /// Pass every byte to the terminal raw, including cursor moves and
170    /// non-SGR escape sequences. Risky: scroll math may break on long lines.
171    /// Less-style -r. Mutually exclusive with --no-color.
172    #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
173    pub raw_control_chars: bool,
174
175    /// Treat lines matching REGEX as record boundaries. Lines that don't
176    /// match are joined to the preceding record. Affects search, filter,
177    /// grep, and the status line — all operate on whole records when set.
178    /// Overrides the active --format's record_start if both are present.
179    /// Without --format, this is the only way to enable records mode for
180    /// plain text. Example: --record-start '^\['
181    #[arg(long = "record-start", value_name = "REGEX")]
182    pub record_start: Option<String>,
183
184    /// Synonym for `--output -`: write the batch-mode output to stdout.
185    #[arg(long = "stdout", conflicts_with = "output")]
186    pub stdout: bool,
187
188    /// Tab stop width (default 8).
189    #[arg(long = "tab-width", default_value_t = 8)]
190    pub tab_width: u8,
191
192    /// Jump to the tag NAME at startup (requires a tags file).
193    #[arg(short = 't', long = "tag", value_name = "NAME")]
194    pub tag: Option<String>,
195
196    /// Path to the tags file. Default: walk up from CWD looking for `tags`.
197    #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
198    pub tag_file: Option<std::path::PathBuf>,
199
200    /// Show only the last N lines of the source. For files this skips most of
201    /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
202    /// Mutually exclusive with --head. Streaming stdin is not supported.
203    #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
204    pub tail: Option<usize>,
205
206    /// Files to view (only the first is opened in MVP).
207    pub files: Vec<PathBuf>,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn parses_no_flags_no_files() {
216        let a = Args::parse_from(["tess"]);
217        assert!(!a.line_numbers);
218        assert!(!a.chop);
219        assert_eq!(a.tab_width, 8);
220        assert!(a.files.is_empty());
221    }
222
223    #[test]
224    fn parses_short_flags_and_file() {
225        let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
226        assert!(a.line_numbers);
227        assert!(a.chop);
228        assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
229    }
230
231    #[test]
232    fn parses_tab_width() {
233        let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
234        assert_eq!(a.tab_width, 4);
235    }
236
237    #[test]
238    fn collects_multiple_files() {
239        let a = Args::parse_from(["tess", "a", "b", "c"]);
240        assert_eq!(a.files.len(), 3);
241    }
242
243    #[test]
244    fn parses_follow_short_flag() {
245        let a = Args::parse_from(["tess", "-f", "log.txt"]);
246        assert!(a.follow);
247        assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
248    }
249
250    #[test]
251    fn parses_follow_long_flag() {
252        let a = Args::parse_from(["tess", "--follow"]);
253        assert!(a.follow);
254    }
255
256    #[test]
257    fn follow_defaults_off() {
258        let a = Args::parse_from(["tess", "x"]);
259        assert!(!a.follow);
260    }
261
262    #[test]
263    fn parses_head() {
264        let a = Args::parse_from(["tess", "--head", "100", "x"]);
265        assert_eq!(a.head, Some(100));
266        assert_eq!(a.tail, None);
267    }
268
269    #[test]
270    fn parses_tail() {
271        let a = Args::parse_from(["tess", "--tail", "50", "x"]);
272        assert_eq!(a.tail, Some(50));
273        assert_eq!(a.head, None);
274    }
275
276    #[test]
277    fn head_and_tail_are_mutually_exclusive() {
278        let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
279        assert!(r.is_err(), "clap should reject combining --head and --tail");
280    }
281
282    #[test]
283    fn head_tail_default_to_none() {
284        let a = Args::parse_from(["tess", "x"]);
285        assert!(a.head.is_none());
286        assert!(a.tail.is_none());
287    }
288
289    #[test]
290    fn parses_grep_repeatable_and_no_format_required() {
291        let a = Args::parse_from([
292            "tess",
293            "--grep", "error",
294            "--grep", r"^\[",
295            "log",
296        ]);
297        assert_eq!(a.grep.len(), 2);
298        assert_eq!(a.grep[0], "error");
299        assert_eq!(a.grep[1], r"^\[");
300        assert_eq!(a.format, None);
301    }
302
303    #[test]
304    fn parses_format_and_filter() {
305        let a = Args::parse_from([
306            "tess", "--format", "apache-combined",
307            "--filter", "status=500",
308            "--filter", "ip~^10\\.",
309            "log",
310        ]);
311        assert_eq!(a.format.as_deref(), Some("apache-combined"));
312        assert_eq!(a.filter.len(), 2);
313        assert_eq!(a.filter[0], "status=500");
314    }
315
316    #[test]
317    fn parses_dim() {
318        let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
319        assert!(a.dim);
320    }
321
322    #[test]
323    fn parses_list_formats() {
324        let a = Args::parse_from(["tess", "--list-formats"]);
325        assert!(a.list_formats);
326    }
327
328    #[test]
329    fn parses_manual() {
330        let a = Args::parse_from(["tess", "--manual"]);
331        assert!(a.manual);
332    }
333
334    #[test]
335    fn parses_examples() {
336        let a = Args::parse_from(["tess", "--examples"]);
337        assert!(a.examples);
338    }
339
340    #[test]
341    fn parses_live() {
342        let a = Args::parse_from(["tess", "--live", "f"]);
343        assert!(a.live);
344        assert!(!a.follow);
345    }
346
347    #[test]
348    fn live_and_follow_are_mutually_exclusive() {
349        let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
350        assert!(r.is_err(), "clap should reject combining --live and --follow");
351    }
352
353    #[test]
354    fn parses_prettify() {
355        let a = Args::parse_from(["tess", "--prettify", "f.json"]);
356        assert!(a.prettify);
357        assert_eq!(a.content_type, None);
358    }
359
360    #[test]
361    fn parses_content_type() {
362        let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
363        assert_eq!(a.content_type.as_deref(), Some("json"));
364    }
365
366    #[test]
367    fn parses_output_long_and_short() {
368        let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
369        assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
370        let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
371        assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
372    }
373
374    #[test]
375    fn parses_stdout_flag() {
376        let a = Args::parse_from(["tess", "--stdout", "f"]);
377        assert!(a.stdout);
378        assert_eq!(a.output, None);
379    }
380
381    #[test]
382    fn output_and_stdout_are_mutually_exclusive() {
383        let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
384        assert!(r.is_err(), "clap should reject combining --output and --stdout");
385    }
386
387    #[test]
388    fn parses_mouse_flag() {
389        let a = Args::parse_from(["tess", "--mouse", "f"]);
390        assert!(a.mouse);
391    }
392
393    #[test]
394    fn mouse_defaults_off() {
395        let a = Args::parse_from(["tess", "f"]);
396        assert!(!a.mouse);
397    }
398
399    #[test]
400    fn help_lists_flags_in_alphabetical_order() {
401        use clap::CommandFactory;
402        let mut cmd = Args::command();
403        let help = cmd.render_help().to_string();
404
405        let expected = [
406            "--chop-long-lines",
407            "--content-type",
408            "--dim",
409            "--display",
410            "--examples",
411            "--filter",
412            "--follow",
413            "--format",
414            "--grep",
415            "--head",
416            "--hex",
417            "--hex-group",
418            "--LINE-NUMBERS",
419            "--list-formats",
420            "--live",
421            "--manual",
422            "--mouse",
423            "--no-color",
424            "--no-preprocess",
425            "--output",
426            "--preprocess",
427            "--prettify",
428            "--prompt",
429            "--raw-control-chars",
430            "--record-start",
431            "--stdout",
432            "--tab-width",
433            "--tag",
434            "--tag-file",
435            "--tail",
436        ];
437        let listed: Vec<&str> = help
438            .lines()
439            .map(str::trim_start)
440            .filter(|l| l.starts_with('-'))
441            .filter_map(|l| {
442                l.split(|c: char| c.is_whitespace() || c == ',')
443                    .find(|tok| expected.contains(tok))
444            })
445            .collect();
446        assert_eq!(listed, expected, "help long-flag order should be alphabetical");
447    }
448}