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    /// Show line numbers.
84    #[arg(short = 'N', long = "LINE-NUMBERS")]
85    pub line_numbers: bool,
86
87    /// Print available log formats and their named fields, then exit.
88    #[arg(long = "list-formats")]
89    pub list_formats: bool,
90
91    /// Live mode: re-read the file when its on-disk content changes (mtime,
92    /// size, or inode). Use this for files rewritten in place — source files
93    /// being edited, files saved by an editor or AI agent. Different from
94    /// `--follow` (which watches for *appended* bytes); the two are mutually
95    /// exclusive. Press `R` inside the pager to force a reload.
96    #[arg(long = "live", conflicts_with = "follow")]
97    pub live: bool,
98
99    /// Print the full user manual and exit.
100    #[arg(long = "manual")]
101    pub manual: bool,
102
103    /// Enable mouse capture: click rows in the file picker / help overlay,
104    /// and scrollwheel scrolls the body. Trade-off: most terminals disable
105    /// their native text selection while mouse capture is on.
106    #[arg(long = "mouse")]
107    pub mouse: bool,
108
109    /// Show raw control bytes as `^X` glyphs (pre-0.18 default). Disables
110    /// SGR / OSC interpretation. Honoured also by the `NO_COLOR` environment
111    /// variable (any non-empty value) and `CLICOLOR=0`.
112    #[arg(long = "no-color")]
113    pub no_color: bool,
114
115    /// Ignore $LESSOPEN. Useful when LESSOPEN is exported but not wanted
116    /// for one invocation.
117    #[arg(long = "no-preprocess", conflicts_with = "preprocess")]
118    pub no_preprocess: bool,
119
120    /// Non-interactive batch mode: apply --filter / --grep / --head / --tail / --prettify
121    /// to the source and write the resulting raw bytes to FILE, then exit.
122    /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
123    /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
124    /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
125    /// compatible with `--live`.
126    #[arg(short = 'o', long = "output", value_name = "FILE")]
127    pub output: Option<String>,
128
129    /// Pipe the source file through this command before rendering.
130    /// Must start with `|`; `%s` is substituted with the file path.
131    /// Example: `--preprocess '|pdftotext %s -'`. Overrides $LESSOPEN.
132    #[arg(
133        long = "preprocess",
134        value_name = "CMD",
135        conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
136    )]
137    pub preprocess: Option<String>,
138
139    /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
140    /// Detects the type from the filename extension or the first bytes; use
141    /// `--content-type=NAME` to override. Static files only — not allowed
142    /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
143    /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
144    #[arg(long = "prettify")]
145    pub prettify: bool,
146
147    /// Replace the hardcoded status format with a templated string.
148    /// Uses the same `<field>` syntax as `--display`. Available fields:
149    /// label, top, bottom, total, pct, rec-top, rec-bottom, rec-total,
150    /// rec-block, wrap-offset, format-tag, filter-tag, grep-tag,
151    /// hide-tag, search-tag, pretty-tag, live-tag, follow-tag.
152    /// Per-format default can be set via `prompt = '...'` in formats.toml.
153    /// Mutually exclusive with --hex.
154    #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
155    pub prompt: Option<String>,
156
157    /// Pass every byte to the terminal raw, including cursor moves and
158    /// non-SGR escape sequences. Risky: scroll math may break on long lines.
159    /// Less-style -r. Mutually exclusive with --no-color.
160    #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
161    pub raw_control_chars: bool,
162
163    /// Treat lines matching REGEX as record boundaries. Lines that don't
164    /// match are joined to the preceding record. Affects search, filter,
165    /// grep, and the status line — all operate on whole records when set.
166    /// Overrides the active --format's record_start if both are present.
167    /// Without --format, this is the only way to enable records mode for
168    /// plain text. Example: --record-start '^\['
169    #[arg(long = "record-start", value_name = "REGEX")]
170    pub record_start: Option<String>,
171
172    /// Synonym for `--output -`: write the batch-mode output to stdout.
173    #[arg(long = "stdout", conflicts_with = "output")]
174    pub stdout: bool,
175
176    /// Tab stop width (default 8).
177    #[arg(long = "tab-width", default_value_t = 8)]
178    pub tab_width: u8,
179
180    /// Jump to the tag NAME at startup (requires a tags file).
181    #[arg(short = 't', long = "tag", value_name = "NAME")]
182    pub tag: Option<String>,
183
184    /// Path to the tags file. Default: walk up from CWD looking for `tags`.
185    #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
186    pub tag_file: Option<std::path::PathBuf>,
187
188    /// Show only the last N lines of the source. For files this skips most of
189    /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
190    /// Mutually exclusive with --head. Streaming stdin is not supported.
191    #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
192    pub tail: Option<usize>,
193
194    /// Files to view (only the first is opened in MVP).
195    pub files: Vec<PathBuf>,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn parses_no_flags_no_files() {
204        let a = Args::parse_from(["tess"]);
205        assert!(!a.line_numbers);
206        assert!(!a.chop);
207        assert_eq!(a.tab_width, 8);
208        assert!(a.files.is_empty());
209    }
210
211    #[test]
212    fn parses_short_flags_and_file() {
213        let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
214        assert!(a.line_numbers);
215        assert!(a.chop);
216        assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
217    }
218
219    #[test]
220    fn parses_tab_width() {
221        let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
222        assert_eq!(a.tab_width, 4);
223    }
224
225    #[test]
226    fn collects_multiple_files() {
227        let a = Args::parse_from(["tess", "a", "b", "c"]);
228        assert_eq!(a.files.len(), 3);
229    }
230
231    #[test]
232    fn parses_follow_short_flag() {
233        let a = Args::parse_from(["tess", "-f", "log.txt"]);
234        assert!(a.follow);
235        assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
236    }
237
238    #[test]
239    fn parses_follow_long_flag() {
240        let a = Args::parse_from(["tess", "--follow"]);
241        assert!(a.follow);
242    }
243
244    #[test]
245    fn follow_defaults_off() {
246        let a = Args::parse_from(["tess", "x"]);
247        assert!(!a.follow);
248    }
249
250    #[test]
251    fn parses_head() {
252        let a = Args::parse_from(["tess", "--head", "100", "x"]);
253        assert_eq!(a.head, Some(100));
254        assert_eq!(a.tail, None);
255    }
256
257    #[test]
258    fn parses_tail() {
259        let a = Args::parse_from(["tess", "--tail", "50", "x"]);
260        assert_eq!(a.tail, Some(50));
261        assert_eq!(a.head, None);
262    }
263
264    #[test]
265    fn head_and_tail_are_mutually_exclusive() {
266        let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
267        assert!(r.is_err(), "clap should reject combining --head and --tail");
268    }
269
270    #[test]
271    fn head_tail_default_to_none() {
272        let a = Args::parse_from(["tess", "x"]);
273        assert!(a.head.is_none());
274        assert!(a.tail.is_none());
275    }
276
277    #[test]
278    fn parses_grep_repeatable_and_no_format_required() {
279        let a = Args::parse_from([
280            "tess",
281            "--grep", "error",
282            "--grep", r"^\[",
283            "log",
284        ]);
285        assert_eq!(a.grep.len(), 2);
286        assert_eq!(a.grep[0], "error");
287        assert_eq!(a.grep[1], r"^\[");
288        assert_eq!(a.format, None);
289    }
290
291    #[test]
292    fn parses_format_and_filter() {
293        let a = Args::parse_from([
294            "tess", "--format", "apache-combined",
295            "--filter", "status=500",
296            "--filter", "ip~^10\\.",
297            "log",
298        ]);
299        assert_eq!(a.format.as_deref(), Some("apache-combined"));
300        assert_eq!(a.filter.len(), 2);
301        assert_eq!(a.filter[0], "status=500");
302    }
303
304    #[test]
305    fn parses_dim() {
306        let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
307        assert!(a.dim);
308    }
309
310    #[test]
311    fn parses_list_formats() {
312        let a = Args::parse_from(["tess", "--list-formats"]);
313        assert!(a.list_formats);
314    }
315
316    #[test]
317    fn parses_manual() {
318        let a = Args::parse_from(["tess", "--manual"]);
319        assert!(a.manual);
320    }
321
322    #[test]
323    fn parses_examples() {
324        let a = Args::parse_from(["tess", "--examples"]);
325        assert!(a.examples);
326    }
327
328    #[test]
329    fn parses_live() {
330        let a = Args::parse_from(["tess", "--live", "f"]);
331        assert!(a.live);
332        assert!(!a.follow);
333    }
334
335    #[test]
336    fn live_and_follow_are_mutually_exclusive() {
337        let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
338        assert!(r.is_err(), "clap should reject combining --live and --follow");
339    }
340
341    #[test]
342    fn parses_prettify() {
343        let a = Args::parse_from(["tess", "--prettify", "f.json"]);
344        assert!(a.prettify);
345        assert_eq!(a.content_type, None);
346    }
347
348    #[test]
349    fn parses_content_type() {
350        let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
351        assert_eq!(a.content_type.as_deref(), Some("json"));
352    }
353
354    #[test]
355    fn parses_output_long_and_short() {
356        let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
357        assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
358        let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
359        assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
360    }
361
362    #[test]
363    fn parses_stdout_flag() {
364        let a = Args::parse_from(["tess", "--stdout", "f"]);
365        assert!(a.stdout);
366        assert_eq!(a.output, None);
367    }
368
369    #[test]
370    fn output_and_stdout_are_mutually_exclusive() {
371        let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
372        assert!(r.is_err(), "clap should reject combining --output and --stdout");
373    }
374
375    #[test]
376    fn parses_mouse_flag() {
377        let a = Args::parse_from(["tess", "--mouse", "f"]);
378        assert!(a.mouse);
379    }
380
381    #[test]
382    fn mouse_defaults_off() {
383        let a = Args::parse_from(["tess", "f"]);
384        assert!(!a.mouse);
385    }
386
387    #[test]
388    fn help_lists_flags_in_alphabetical_order() {
389        use clap::CommandFactory;
390        let mut cmd = Args::command();
391        let help = cmd.render_help().to_string();
392
393        let expected = [
394            "--chop-long-lines",
395            "--content-type",
396            "--dim",
397            "--display",
398            "--examples",
399            "--filter",
400            "--follow",
401            "--format",
402            "--grep",
403            "--head",
404            "--hex",
405            "--LINE-NUMBERS",
406            "--list-formats",
407            "--live",
408            "--manual",
409            "--mouse",
410            "--no-color",
411            "--no-preprocess",
412            "--output",
413            "--preprocess",
414            "--prettify",
415            "--prompt",
416            "--raw-control-chars",
417            "--record-start",
418            "--stdout",
419            "--tab-width",
420            "--tag",
421            "--tag-file",
422            "--tail",
423        ];
424        let listed: Vec<&str> = help
425            .lines()
426            .map(str::trim_start)
427            .filter(|l| l.starts_with('-'))
428            .filter_map(|l| {
429                l.split(|c: char| c.is_whitespace() || c == ',')
430                    .find(|tok| expected.contains(tok))
431            })
432            .collect();
433        assert_eq!(listed, expected, "help long-flag order should be alphabetical");
434    }
435}