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    /// Collapse runs of two or more consecutive blank lines into a
20    /// single blank line at display time. Real line numbers, search,
21    /// and tag jumps are unaffected (they reference the original
22    /// count). Mirrors `less -s`.
23    #[arg(short = 's', long = "squeeze-blank-lines")]
24    pub squeeze_blanks: bool,
25
26    /// Pin the top L source lines (and the left C columns, when
27    /// horizontal scroll is supported) at the top of the viewport.
28    /// Form: `L` or `L,C`. Default `0,0` (off). Mirrors `less --header`.
29    /// Runtime adjustment: `:header L [C]`.
30    #[arg(long = "header", value_name = "L[,C]")]
31    pub header: Option<String>,
32
33    /// Character to show at the right edge of a chopped line (`-S` chop
34    /// mode) indicating "more content right". Default `>`. Pass an empty
35    /// string to disable. Mirrors `less --rscroll=c`.
36    #[arg(long = "rscroll", value_name = "CHAR", default_value = ">")]
37    pub rscroll: String,
38
39    /// PageDown / PageUp step size in lines. Default: full screen
40    /// height (body rows). Half-page commands always advance by half
41    /// the screen regardless. Mirrors `less -zn` / `--window=n`.
42    #[arg(short = 'z', long = "window", value_name = "N")]
43    pub window: Option<u16>,
44
45    /// In wrap mode, break lines on whitespace boundaries instead of
46    /// mid-character when possible. Falls back to mid-character break
47    /// when no whitespace fits in the row. Mirrors `less --wordwrap`.
48    #[arg(long = "wordwrap")]
49    pub word_wrap: bool,
50
51    /// Follow the file by path rather than by descriptor (matches
52    /// `tail -F` / `less --follow-name`). `tess` already does this —
53    /// rotation and truncation are detected on every poll and the
54    /// source re-opens by path (since 0.25.0). This flag is accepted
55    /// for compatibility and currently has no behavioral effect.
56    #[arg(long = "follow-name")]
57    pub follow_name: bool,
58
59    /// In follow mode with piped stdin, exit when the upstream writer
60    /// closes the pipe. Default behavior (off): tess remains open on
61    /// the captured content after stdin EOF. Mirrors
62    /// `less --exit-follow-on-close`.
63    #[arg(long = "exit-follow-on-close")]
64    pub exit_follow_on_close: bool,
65
66    /// Force the content type for `--prettify` (otherwise auto-detected from
67    /// the filename extension and the first bytes). Values:
68    /// `auto`, `raw`, `json`, `yaml`, `toml`, `xml`, `html`, `csv`.
69    /// Setting this implies `--prettify` (unless the value is `raw`/`auto`).
70    #[arg(long = "content-type", value_name = "TYPE")]
71    pub content_type: Option<String>,
72
73    /// With `--filter`, dim non-matching lines instead of hiding them. Keeps
74    /// surrounding context visible.
75    #[arg(long = "dim")]
76    pub dim: bool,
77
78    /// Render each parsed line through this template instead of showing the
79    /// raw line. Syntax: `<fieldname>` placeholders, `\<` for literal `<`,
80    /// `\\` for literal `\`. Example: `--display '[<time>] <status> <msg>'`.
81    /// Overrides the format's `display` key (if set). Requires `--format`.
82    /// Search still matches against the raw line.
83    #[arg(long = "display", value_name = "TEMPLATE")]
84    pub display: Option<String>,
85
86    /// Print a curated list of usage examples and exit.
87    #[arg(long = "examples")]
88    pub examples: bool,
89
90    /// Filter visible lines by parsed field. Repeatable; multiple filters AND.
91    /// Operators: `=` (exact), `!=` (exact ≠), `~` (regex), `!~` (regex ≠),
92    /// `<`, `<=`, `>`, `>=` (numeric if both sides parse as numbers, else
93    /// lexicographic). Examples: `--filter status=500`, `--filter ip~^10\.`,
94    /// `--filter 'status>=500'` (quote `<` and `>` to avoid shell redirection).
95    /// Requires `--format`.
96    #[arg(long = "filter", value_name = "FIELD<op>VALUE")]
97    pub filter: Vec<String>,
98
99    /// Follow mode: keep watching the source for new bytes (like `tail -f`).
100    /// Jumps to the bottom on startup. Toggle with Shift-F at runtime.
101    #[arg(short = 'f', long = "follow")]
102    pub follow: bool,
103
104    /// In follow mode, any user motion (scroll, page, goto-line) suspends
105    /// following. Re-engage with Shift-F. Default off: today's behavior
106    /// (movement keeps follow on; auto-scroll suspended while the viewport
107    /// is not at bottom). Matches `less +F` semantics when enabled.
108    #[arg(long = "follow-suspend-on-motion")]
109    pub follow_suspend_on_motion: bool,
110
111    /// Apply a named log format (built-in or user-defined in
112    /// ~/.config/tess/formats.toml). Required by `--filter`.
113    #[arg(long = "format", value_name = "NAME")]
114    pub format: Option<String>,
115
116    /// Filter visible lines by regex against the raw line. Repeatable;
117    /// multiple `--grep` arguments AND. Works on any input — no `--format`
118    /// required. Composes with `--filter` (both must match) and with
119    /// `--dim` (non-matches stay visible but faded).
120    /// Example: `--grep error --grep '^\['`.
121    #[arg(long = "grep", value_name = "PATTERN")]
122    pub grep: Vec<String>,
123
124    /// Smart-case search. `/`, `?`, `--grep`, and `--filter`'s `~` / `!~`
125    /// operators match case-insensitively unless the pattern contains an
126    /// uppercase character. Mirrors `less -i` / ripgrep / vim smartcase.
127    /// Mutually exclusive with `-I`. Runtime toggle: `:case`.
128    #[arg(short = 'i', long = "ignore-case", conflicts_with = "IGNORE_CASE")]
129    pub ignore_case: bool,
130
131    /// Force case-insensitive search regardless of pattern case. Mirrors
132    /// `less -I`. Mutually exclusive with `-i`.
133    #[arg(short = 'I', long = "IGNORE-CASE")]
134    #[allow(non_snake_case)]
135    pub IGNORE_CASE: bool,
136
137    /// Disable search-match highlighting by default. Search still
138    /// navigates (`n` / `N` jump to matches); the visual reverse-video
139    /// highlight is suppressed. Runtime toggle: `:hlsearch` / `:nohlsearch`.
140    /// Mirrors `less -G`.
141    #[arg(short = 'G', long = "no-hilite-search")]
142    pub no_hilite_search: bool,
143
144    /// Don't enter the alt-screen on startup. Content remains in
145    /// terminal scrollback after exit. Crucial for piped use and
146    /// debugging. Mirrors `less -X` / `--no-init`.
147    #[arg(short = 'X', long = "no-init")]
148    pub no_init: bool,
149
150    /// Exit immediately (without paging) if the entire source fits on
151    /// one screen. Ignored with piped stdin in follow mode. Mirrors
152    /// `less -F`.
153    #[arg(short = 'F', long = "quit-if-one-screen")]
154    pub quit_if_one_screen: bool,
155
156    /// Accepted for `less` compatibility. tess always exits on Ctrl-C
157    /// (Ctrl-C → Command::Quit in the input table), so this flag is a
158    /// no-op. Provided so existing `less` invocations work unchanged.
159    #[arg(short = 'K', long = "quit-on-intr")]
160    pub quit_on_intr: bool,
161
162    /// Quit when the user tries to scroll forward past end-of-file for
163    /// the second time. Mirrors `less -e`. Mutually exclusive with `-E`.
164    #[arg(short = 'e', long = "quit-at-eof", conflicts_with = "QUIT_AT_EOF")]
165    pub quit_at_eof: bool,
166
167    /// Quit the first time end-of-file is reached. Mirrors `less -E`.
168    #[arg(short = 'E', long = "QUIT-AT-EOF")]
169    #[allow(non_snake_case)]
170    pub QUIT_AT_EOF: bool,
171
172    /// Show only the first N lines of the source. Mutually exclusive with --tail.
173    #[arg(long = "head", value_name = "N", conflicts_with = "tail")]
174    pub head: Option<usize>,
175
176    /// Render the source as an xxd-style hex dump instead of byte-faithful
177    /// text. 16 bytes per row, offset prefix, ASCII gutter. Mutually
178    /// exclusive with parsing- and rendering-oriented flags.
179    #[arg(
180        long = "hex",
181        conflicts_with_all = ["filter", "grep", "prettify", "format", "display", "record_start", "prompt", "preprocess"],
182    )]
183    pub hex: bool,
184
185    /// Hex characters per group in `--hex` mode. One of 2, 4, 8, 16, 32
186    /// (default 4, matching `xxd`). 32 means the whole row as a single
187    /// group with no spacing between hex pairs. Requires `--hex`. Can be
188    /// changed at runtime with `:hex N`.
189    #[arg(
190        long = "hex-group",
191        value_name = "N",
192        default_value_t = 4,
193        requires = "hex",
194    )]
195    pub hex_group: usize,
196
197    /// Show line numbers.
198    #[arg(short = 'N', long = "LINE-NUMBERS")]
199    pub line_numbers: bool,
200
201    /// Print available log formats and their named fields, then exit.
202    #[arg(long = "list-formats")]
203    pub list_formats: bool,
204
205    /// Live mode: re-read the file when its on-disk content changes (mtime,
206    /// size, or inode). Use this for files rewritten in place — source files
207    /// being edited, files saved by an editor or AI agent. Different from
208    /// `--follow` (which watches for *appended* bytes); the two are mutually
209    /// exclusive. Press `R` inside the pager to force a reload.
210    #[arg(long = "live", conflicts_with = "follow")]
211    pub live: bool,
212
213    /// Print the full user manual and exit.
214    #[arg(long = "manual")]
215    pub manual: bool,
216
217    /// Enable mouse capture: click rows in the file picker / help overlay,
218    /// and scrollwheel scrolls the body. Trade-off: most terminals disable
219    /// their native text selection while mouse capture is on.
220    #[arg(long = "mouse")]
221    pub mouse: bool,
222
223    /// Show raw control bytes as `^X` glyphs (pre-0.18 default). Disables
224    /// SGR / OSC interpretation. Honoured also by the `NO_COLOR` environment
225    /// variable (any non-empty value) and `CLICOLOR=0`.
226    #[arg(long = "no-color")]
227    pub no_color: bool,
228
229    /// Ignore $LESSOPEN. Useful when LESSOPEN is exported but not wanted
230    /// for one invocation.
231    #[arg(long = "no-preprocess", conflicts_with = "preprocess")]
232    pub no_preprocess: bool,
233
234    /// Non-interactive batch mode: apply --filter / --grep / --head / --tail / --prettify
235    /// to the source and write the resulting raw bytes to FILE, then exit.
236    /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
237    /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
238    /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
239    /// compatible with `--live`.
240    #[arg(short = 'o', long = "output", value_name = "FILE")]
241    pub output: Option<String>,
242
243    /// Pipe the source file through this command before rendering.
244    /// Must start with `|`; `%s` is substituted with the file path.
245    /// Example: `--preprocess '|pdftotext %s -'`. Overrides $LESSOPEN.
246    #[arg(
247        long = "preprocess",
248        value_name = "CMD",
249        conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
250    )]
251    pub preprocess: Option<String>,
252
253    /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
254    /// Detects the type from the filename extension or the first bytes; use
255    /// `--content-type=NAME` to override. Static files only — not allowed
256    /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
257    /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
258    #[arg(long = "prettify")]
259    pub prettify: bool,
260
261    /// Replace the hardcoded status format with a templated string.
262    /// Uses the same `<field>` syntax as `--display`. Available fields:
263    /// label, top, bottom, total, pct, rec-top, rec-bottom, rec-total,
264    /// rec-block, wrap-offset, format-tag, filter-tag, grep-tag,
265    /// hide-tag, search-tag, pretty-tag, live-tag, follow-tag.
266    /// Per-format default can be set via `prompt = '...'` in formats.toml.
267    /// Mutually exclusive with --hex.
268    #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
269    pub prompt: Option<String>,
270
271    /// Pass every byte to the terminal raw, including cursor moves and
272    /// non-SGR escape sequences. Risky: scroll math may break on long lines.
273    /// Less-style -r. Mutually exclusive with --no-color.
274    #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
275    pub raw_control_chars: bool,
276
277    /// Truecolor (24-bit RGB) handling. `auto` (default) checks `$COLORTERM`
278    /// and downsamples when truecolor isn't advertised; `never` always
279    /// downsamples to the 256-color palette; `always` passes RGB through
280    /// regardless of terminal capability.
281    #[arg(long = "truecolor", value_name = "MODE", default_value = "auto")]
282    pub truecolor: String,
283
284    /// Style for the status row. Comma-separated tokens: `bold`, `dim`,
285    /// `italic`, `underline`, `reverse`, `fg=COLOR`, `bg=COLOR`. COLOR is a
286    /// named color (`black`..`white`, optional `bright-` prefix), `#RRGGBB`,
287    /// or an indexed value (0–255). Empty string disables theming.
288    /// Default: `reverse`.
289    #[arg(long = "status-style", value_name = "SPEC", default_value = "reverse")]
290    pub status_style: String,
291
292    /// Style for `--prompt` output (and per-format `prompt_style`). Same
293    /// grammar as `--status-style`. Default: empty (no extra styling on top
294    /// of what the prompt template itself emits).
295    #[arg(long = "prompt-style", value_name = "SPEC", default_value = "")]
296    pub prompt_style: String,
297
298    /// Treat lines matching REGEX as record boundaries. Lines that don't
299    /// match are joined to the preceding record. Affects search, filter,
300    /// grep, and the status line — all operate on whole records when set.
301    /// Overrides the active --format's record_start if both are present.
302    /// Without --format, this is the only way to enable records mode for
303    /// plain text. Example: --record-start '^\['
304    #[arg(long = "record-start", value_name = "REGEX")]
305    pub record_start: Option<String>,
306
307    /// Synonym for `--output -`: write the batch-mode output to stdout.
308    #[arg(long = "stdout", conflicts_with = "output")]
309    pub stdout: bool,
310
311    /// Tab stop width (default 8).
312    #[arg(long = "tab-width", default_value_t = 8)]
313    pub tab_width: u8,
314
315    /// Jump to the tag NAME at startup (requires a tags file).
316    #[arg(short = 't', long = "tag", value_name = "NAME")]
317    pub tag: Option<String>,
318
319    /// Path to the tags file. Default: walk up from CWD looking for `tags`.
320    #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
321    pub tag_file: Option<std::path::PathBuf>,
322
323    /// Show only the last N lines of the source. For files this skips most of
324    /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
325    /// Mutually exclusive with --head. Streaming stdin is not supported.
326    #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
327    pub tail: Option<usize>,
328
329    /// Files to view (only the first is opened in MVP).
330    pub files: Vec<PathBuf>,
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn parses_no_flags_no_files() {
339        let a = Args::parse_from(["tess"]);
340        assert!(!a.line_numbers);
341        assert!(!a.chop);
342        assert_eq!(a.tab_width, 8);
343        assert!(a.files.is_empty());
344    }
345
346    #[test]
347    fn parses_short_flags_and_file() {
348        let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
349        assert!(a.line_numbers);
350        assert!(a.chop);
351        assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
352    }
353
354    #[test]
355    fn parses_tab_width() {
356        let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
357        assert_eq!(a.tab_width, 4);
358    }
359
360    #[test]
361    fn collects_multiple_files() {
362        let a = Args::parse_from(["tess", "a", "b", "c"]);
363        assert_eq!(a.files.len(), 3);
364    }
365
366    #[test]
367    fn parses_follow_short_flag() {
368        let a = Args::parse_from(["tess", "-f", "log.txt"]);
369        assert!(a.follow);
370        assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
371    }
372
373    #[test]
374    fn parses_follow_long_flag() {
375        let a = Args::parse_from(["tess", "--follow"]);
376        assert!(a.follow);
377    }
378
379    #[test]
380    fn follow_defaults_off() {
381        let a = Args::parse_from(["tess", "x"]);
382        assert!(!a.follow);
383    }
384
385    #[test]
386    fn parses_head() {
387        let a = Args::parse_from(["tess", "--head", "100", "x"]);
388        assert_eq!(a.head, Some(100));
389        assert_eq!(a.tail, None);
390    }
391
392    #[test]
393    fn parses_tail() {
394        let a = Args::parse_from(["tess", "--tail", "50", "x"]);
395        assert_eq!(a.tail, Some(50));
396        assert_eq!(a.head, None);
397    }
398
399    #[test]
400    fn head_and_tail_are_mutually_exclusive() {
401        let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
402        assert!(r.is_err(), "clap should reject combining --head and --tail");
403    }
404
405    #[test]
406    fn head_tail_default_to_none() {
407        let a = Args::parse_from(["tess", "x"]);
408        assert!(a.head.is_none());
409        assert!(a.tail.is_none());
410    }
411
412    #[test]
413    fn parses_grep_repeatable_and_no_format_required() {
414        let a = Args::parse_from([
415            "tess",
416            "--grep", "error",
417            "--grep", r"^\[",
418            "log",
419        ]);
420        assert_eq!(a.grep.len(), 2);
421        assert_eq!(a.grep[0], "error");
422        assert_eq!(a.grep[1], r"^\[");
423        assert_eq!(a.format, None);
424    }
425
426    #[test]
427    fn parses_format_and_filter() {
428        let a = Args::parse_from([
429            "tess", "--format", "apache-combined",
430            "--filter", "status=500",
431            "--filter", "ip~^10\\.",
432            "log",
433        ]);
434        assert_eq!(a.format.as_deref(), Some("apache-combined"));
435        assert_eq!(a.filter.len(), 2);
436        assert_eq!(a.filter[0], "status=500");
437    }
438
439    #[test]
440    fn parses_dim() {
441        let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
442        assert!(a.dim);
443    }
444
445    #[test]
446    fn parses_list_formats() {
447        let a = Args::parse_from(["tess", "--list-formats"]);
448        assert!(a.list_formats);
449    }
450
451    #[test]
452    fn parses_manual() {
453        let a = Args::parse_from(["tess", "--manual"]);
454        assert!(a.manual);
455    }
456
457    #[test]
458    fn parses_examples() {
459        let a = Args::parse_from(["tess", "--examples"]);
460        assert!(a.examples);
461    }
462
463    #[test]
464    fn parses_live() {
465        let a = Args::parse_from(["tess", "--live", "f"]);
466        assert!(a.live);
467        assert!(!a.follow);
468    }
469
470    #[test]
471    fn live_and_follow_are_mutually_exclusive() {
472        let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
473        assert!(r.is_err(), "clap should reject combining --live and --follow");
474    }
475
476    #[test]
477    fn parses_prettify() {
478        let a = Args::parse_from(["tess", "--prettify", "f.json"]);
479        assert!(a.prettify);
480        assert_eq!(a.content_type, None);
481    }
482
483    #[test]
484    fn parses_content_type() {
485        let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
486        assert_eq!(a.content_type.as_deref(), Some("json"));
487    }
488
489    #[test]
490    fn parses_output_long_and_short() {
491        let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
492        assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
493        let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
494        assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
495    }
496
497    #[test]
498    fn parses_stdout_flag() {
499        let a = Args::parse_from(["tess", "--stdout", "f"]);
500        assert!(a.stdout);
501        assert_eq!(a.output, None);
502    }
503
504    #[test]
505    fn output_and_stdout_are_mutually_exclusive() {
506        let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
507        assert!(r.is_err(), "clap should reject combining --output and --stdout");
508    }
509
510    #[test]
511    fn parses_mouse_flag() {
512        let a = Args::parse_from(["tess", "--mouse", "f"]);
513        assert!(a.mouse);
514    }
515
516    #[test]
517    fn mouse_defaults_off() {
518        let a = Args::parse_from(["tess", "f"]);
519        assert!(!a.mouse);
520    }
521
522    #[test]
523    fn help_lists_flags_in_alphabetical_order() {
524        use clap::CommandFactory;
525        let mut cmd = Args::command();
526        let help = cmd.render_help().to_string();
527
528        let expected = [
529            "--chop-long-lines",
530            "--content-type",
531            "--dim",
532            "--display",
533            "--examples",
534            "--filter",
535            "--follow",
536            "--format",
537            "--grep",
538            "--head",
539            "--hex",
540            "--hex-group",
541            "--LINE-NUMBERS",
542            "--list-formats",
543            "--live",
544            "--manual",
545            "--mouse",
546            "--no-color",
547            "--no-preprocess",
548            "--output",
549            "--preprocess",
550            "--prettify",
551            "--prompt",
552            "--raw-control-chars",
553            "--record-start",
554            "--stdout",
555            "--tab-width",
556            "--tag",
557            "--tag-file",
558            "--tail",
559        ];
560        let listed: Vec<&str> = help
561            .lines()
562            .map(str::trim_start)
563            .filter(|l| l.starts_with('-'))
564            .filter_map(|l| {
565                l.split(|c: char| c.is_whitespace() || c == ',')
566                    .find(|tok| expected.contains(tok))
567            })
568            .collect();
569        assert_eq!(listed, expected, "help long-flag order should be alphabetical");
570    }
571}