Skip to main content

tess/
cli.rs

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