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)]
14// Repeated scalar flags take the last value rather than erroring. This makes
15// `less`-style "last flag wins" work and, crucially, lets a CLI flag override
16// the same flag injected by a group expansion (e.g. `--mygroup --display ...`
17// overriding the group's own `display`). Repeatable flags (`--filter`,
18// `--grep`) use Append actions and are unaffected — they still accumulate.
19#[command(args_override_self = true)]
20// Fields are ordered alphabetically by long-flag name (case-insensitive) so
21// that `--help` lists them in that order — clap's derive renders options in
22// field-declaration order. The `help_lists_flags_in_alphabetical_order` test
23// enforces this; keep new flags in their sorted slot.
24//
25// `IGNORE_CASE` (-I) and `QUIT_AT_EOF` (-E) are intentionally upper-case to
26// mirror less's case-distinguished flag pairs (`-i`/`-I`, `-e`/`-E`) and to
27// stay distinct from their lower-case `ignore_case`/`quit_at_eof` siblings.
28#[allow(non_snake_case)]
29pub struct Args {
30 /// Render images with Unicode half-blocks (▀, fg=top pixel, bg=bottom
31 /// pixel) for ~2× vertical detail instead of the default character ramp.
32 #[arg(long = "blocks")]
33 pub blocks: bool,
34
35 /// Chop long lines instead of wrapping.
36 #[arg(short = 'S', long = "chop-long-lines")]
37 pub chop: bool,
38
39 /// Enable interactive clipboard yank: `:yank` copies the current line to the
40 /// system clipboard. Bind a key via `clipboard-yank-line` in keys.toml
41 /// (unbound by default, so it never clobbers existing keys).
42 #[arg(long = "clipboard")]
43 pub clipboard: bool,
44
45 /// Force the content type for `--prettify` (otherwise auto-detected from
46 /// the filename extension and the first bytes). Values:
47 /// `auto`, `raw`, `json`, `yaml`, `toml`, `xml`, `html`, `csv`.
48 /// Setting this implies `--prettify` (unless the value is `raw`/`auto`).
49 #[arg(long = "content-type", value_name = "TYPE")]
50 pub content_type: Option<String>,
51
52 /// With `--filter`, dim non-matching lines instead of hiding them. Keeps
53 /// surrounding context visible.
54 #[arg(long = "dim")]
55 pub dim: bool,
56
57 /// Render each parsed line through this template instead of showing the
58 /// raw line. Syntax: `<fieldname>` placeholders, `\<` for literal `<`,
59 /// `\\` for literal `\`. Example: `--display '[<time>] <status> <msg>'`.
60 /// Overrides the format's `display` key (if set). Requires `--format`.
61 /// Search still matches against the raw line.
62 #[arg(long = "display", value_name = "TEMPLATE")]
63 pub display: Option<String>,
64
65 /// Print a curated list of usage examples and exit.
66 #[arg(long = "examples")]
67 pub examples: bool,
68
69 /// In follow mode with piped stdin, exit when the upstream writer
70 /// closes the pipe. Default behavior (off): tess remains open on
71 /// the captured content after stdin EOF. Mirrors
72 /// `less --exit-follow-on-close`.
73 #[arg(long = "exit-follow-on-close")]
74 pub exit_follow_on_close: bool,
75
76 /// Filter visible lines by parsed field. Repeatable; multiple filters AND.
77 /// Operators: `=` (exact), `!=` (exact ≠), `~` (regex), `!~` (regex ≠),
78 /// `<`, `<=`, `>`, `>=` (numeric if both sides parse as numbers, else
79 /// lexicographic). Examples: `--filter status=500`, `--filter ip~^10\.`,
80 /// `--filter 'status>=500'` (quote `<` and `>` to avoid shell redirection).
81 /// Requires `--format`.
82 #[arg(long = "filter", value_name = "FIELD<op>VALUE")]
83 pub filter: Vec<String>,
84
85 /// Follow mode: keep watching the source for new bytes (like `tail -f`).
86 /// Jumps to the bottom on startup. Toggle with Shift-F at runtime.
87 #[arg(short = 'f', long = "follow")]
88 pub follow: bool,
89
90 /// Follow the file by path rather than by descriptor (matches
91 /// `tail -F` / `less --follow-name`). `tess` already does this —
92 /// rotation and truncation are detected on every poll and the
93 /// source re-opens by path (since 0.25.0). This flag is accepted
94 /// for compatibility and currently has no behavioral effect.
95 #[arg(long = "follow-name")]
96 pub follow_name: bool,
97
98 /// In follow mode, any user motion (scroll, page, goto-line) suspends
99 /// following. Re-engage with Shift-F. Default off: today's behavior
100 /// (movement keeps follow on; auto-scroll suspended while the viewport
101 /// is not at bottom). Matches `less +F` semantics when enabled.
102 #[arg(long = "follow-suspend-on-motion")]
103 pub follow_suspend_on_motion: bool,
104
105 /// Apply a named log format (built-in or user-defined in
106 /// ~/.config/tess/formats.toml). Required by `--filter`.
107 #[arg(long = "format", value_name = "NAME")]
108 pub format: Option<String>,
109
110 /// Use the system clipboard contents as input (like `pbpaste | tess`).
111 /// Mutually exclusive with file arguments and piped stdin.
112 #[arg(long = "from-clipboard", conflicts_with = "files")]
113 pub from_clipboard: bool,
114
115 /// Filter visible lines by regex against the raw line. Repeatable;
116 /// multiple `--grep` arguments AND. Works on any input — no `--format`
117 /// required. Composes with `--filter` (both must match) and with
118 /// `--dim` (non-matches stay visible but faded).
119 /// Example: `--grep error --grep '^\['`.
120 #[arg(long = "grep", value_name = "PATTERN")]
121 pub grep: Vec<String>,
122
123 /// Show only the first N lines of the source. Mutually exclusive with --tail.
124 #[arg(long = "head", value_name = "N", conflicts_with = "tail")]
125 pub head: Option<usize>,
126
127 /// Pin the top L source lines (and the left C columns, when
128 /// horizontal scroll is supported) at the top of the viewport.
129 /// Form: `L` or `L,C`. Default `0,0` (off). Mirrors `less --header`.
130 /// Runtime adjustment: `:header L [C]`.
131 #[arg(long = "header", value_name = "L[,C]")]
132 pub header: Option<String>,
133
134 /// Render the source as an xxd-style hex dump instead of byte-faithful
135 /// text. 16 bytes per row, offset prefix, ASCII gutter. Mutually
136 /// exclusive with parsing- and rendering-oriented flags.
137 #[arg(
138 long = "hex",
139 conflicts_with_all = ["filter", "grep", "prettify", "format", "display", "record_start", "prompt", "preprocess", "or_filter", "or_grep", "or_group"],
140 )]
141 pub hex: bool,
142
143 /// Hex characters per group in `--hex` mode. One of 2, 4, 8, 16, 32
144 /// (default 4, matching `xxd`). 32 means the whole row as a single
145 /// group with no spacing between hex pairs. Requires `--hex`. Can be
146 /// changed at runtime with `:hex N`.
147 #[arg(
148 long = "hex-group",
149 value_name = "N",
150 default_value_t = 4,
151 requires = "hex",
152 )]
153 pub hex_group: usize,
154
155 /// Smart-case search. `/`, `?`, `--grep`, and `--filter`'s `~` / `!~`
156 /// operators match case-insensitively unless the pattern contains an
157 /// uppercase character. Mirrors `less -i` / ripgrep / vim smartcase.
158 /// Mutually exclusive with `-I`. Runtime toggle: `:case`.
159 #[arg(short = 'i', long = "ignore-case", conflicts_with = "IGNORE_CASE")]
160 pub ignore_case: bool,
161
162 /// Force case-insensitive search regardless of pattern case. Mirrors
163 /// `less -I`. Mutually exclusive with `-i`.
164 #[arg(short = 'I', long = "IGNORE-CASE")]
165 pub IGNORE_CASE: bool,
166
167 /// Image rendering protocol: `auto` (detect terminal graphics support and
168 /// fall back to ASCII), `kitty`, `sixel`, or `ascii`.
169 #[arg(long = "image-protocol", value_name = "MODE", default_value = "auto")]
170 pub image_protocol: String,
171
172 /// Target width in columns for image rendering. Defaults to the terminal
173 /// width interactively, or 80 when exporting to a file/stdout.
174 #[arg(long = "image-width", value_name = "N")]
175 pub image_width: Option<usize>,
176
177 /// Incremental search: in the `/`/`?` prompt, jump to and highlight the
178 /// first match as you type (from where the prompt opened). Esc restores the
179 /// original position; Enter commits. Default off. Mirrors `less --incsearch`.
180 /// Runtime toggle: `:incsearch`.
181 #[arg(long = "incsearch")]
182 pub incsearch: bool,
183
184 /// Show line numbers.
185 #[arg(short = 'N', long = "LINE-NUMBERS")]
186 pub line_numbers: bool,
187
188 /// Print available log formats and their named fields, then exit.
189 #[arg(long = "list-formats")]
190 pub list_formats: bool,
191
192 /// Live mode: re-read the file when its on-disk content changes (mtime,
193 /// size, or inode). Use this for files rewritten in place — source files
194 /// being edited, files saved by an editor or AI agent. Different from
195 /// `--follow` (which watches for *appended* bytes); the two are mutually
196 /// exclusive. Press `R` inside the pager to force a reload.
197 #[arg(long = "live", conflicts_with = "follow")]
198 pub live: bool,
199
200 /// Print the full user manual and exit.
201 #[arg(long = "manual")]
202 pub manual: bool,
203
204 /// Enable mouse capture: click rows in the file picker / help overlay,
205 /// and scrollwheel scrolls the body. Trade-off: most terminals disable
206 /// their native text selection while mouse capture is on.
207 #[arg(long = "mouse")]
208 pub mouse: bool,
209
210 /// Show raw control bytes as `^X` glyphs (pre-0.18 default). Disables
211 /// SGR / OSC interpretation. Honoured also by the `NO_COLOR` environment
212 /// variable (any non-empty value) and `CLICOLOR=0`.
213 #[arg(long = "no-color")]
214 pub no_color: bool,
215
216 /// Disable search-match highlighting by default. Search still
217 /// navigates (`n` / `N` jump to matches); the visual reverse-video
218 /// highlight is suppressed. Runtime toggle: `:hlsearch` / `:nohlsearch`.
219 /// Mirrors `less -G`.
220 #[arg(short = 'G', long = "no-hilite-search")]
221 pub no_hilite_search: bool,
222
223 /// Treat a detected image file as raw/normal text instead of rendering it
224 /// as ASCII art. Has no effect on non-image inputs.
225 #[arg(long = "no-image")]
226 pub no_image: bool,
227
228 /// Don't enter the alt-screen on startup. Content remains in
229 /// terminal scrollback after exit. Crucial for piped use and
230 /// debugging. Mirrors `less -X` / `--no-init`.
231 #[arg(short = 'X', long = "no-init")]
232 pub no_init: bool,
233
234 /// Ignore $LESSOPEN. Useful when LESSOPEN is exported but not wanted
235 /// for one invocation.
236 #[arg(long = "no-preprocess", conflicts_with = "preprocess")]
237 pub no_preprocess: bool,
238
239 /// OR-filter: a field condition where matching ANY condition in its
240 /// OR-group is enough (the group is satisfied). AND'd with the required
241 /// --filter/--grep. Joins the group set by the most recent --or-group, or
242 /// `default` if none. Requires --format. Repeatable.
243 #[arg(long = "or-filter", value_name = "FIELD<op>VALUE")]
244 pub or_filter: Vec<String>,
245
246 /// OR-grep: a raw-regex condition where matching ANY condition in its
247 /// OR-group is enough. Works on any input. Joins the group set by the most
248 /// recent --or-group, or `default` if none. Repeatable.
249 #[arg(long = "or-grep", value_name = "PATTERN")]
250 pub or_grep: Vec<String>,
251
252 /// Open an OR-group: subsequent --or-filter/--or-grep join NAME until the
253 /// next --or-group. Conditions before any marker form the `default` group.
254 /// Every non-empty group must have ≥1 match (groups are AND'd). Repeatable.
255 #[arg(long = "or-group", value_name = "NAME")]
256 pub or_group: Vec<String>,
257
258 /// Non-interactive batch mode: apply --filter / --grep / --head / --tail / --prettify
259 /// to the source and write the resulting raw bytes to FILE, then exit.
260 /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
261 /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
262 /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
263 /// compatible with `--live`.
264 #[arg(short = 'o', long = "output", value_name = "FILE")]
265 pub output: Option<String>,
266
267 /// Pipe the source file through this command before rendering.
268 /// Must start with `|`; `%s` is substituted with the file path.
269 /// Example: `--preprocess '|pdftotext %s -'`. Overrides $LESSOPEN.
270 #[arg(
271 long = "preprocess",
272 value_name = "CMD",
273 conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
274 )]
275 pub preprocess: Option<String>,
276
277 /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
278 /// Detects the type from the filename extension or the first bytes; use
279 /// `--content-type=NAME` to override. Static files only — not allowed
280 /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
281 /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
282 #[arg(long = "prettify")]
283 pub prettify: bool,
284
285 /// Replace the hardcoded status format with a templated string.
286 /// Uses the same `<field>` syntax as `--display`. Available fields:
287 /// label, top, bottom, total, pct, rec-top, rec-bottom, rec-total,
288 /// rec-block, wrap-offset, format-tag, filter-tag, grep-tag,
289 /// hide-tag, search-tag, pretty-tag, live-tag, follow-tag.
290 /// Per-format default can be set via `prompt = '...'` in formats.toml.
291 /// Mutually exclusive with --hex.
292 #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
293 pub prompt: Option<String>,
294
295 /// Style for `--prompt` output (and per-format `prompt_style`). Same
296 /// grammar as `--status-style`. Default: empty (no extra styling on top
297 /// of what the prompt template itself emits).
298 #[arg(long = "prompt-style", value_name = "SPEC", default_value = "")]
299 pub prompt_style: String,
300
301 /// Quit when the user tries to scroll forward past end-of-file for
302 /// the second time. Mirrors `less -e`. Mutually exclusive with `-E`.
303 #[arg(short = 'e', long = "quit-at-eof", conflicts_with = "QUIT_AT_EOF")]
304 pub quit_at_eof: bool,
305
306 /// Quit the first time end-of-file is reached. Mirrors `less -E`.
307 #[arg(short = 'E', long = "QUIT-AT-EOF")]
308 pub QUIT_AT_EOF: bool,
309
310 /// Exit immediately (without paging) if the entire source fits on
311 /// one screen. Ignored with piped stdin in follow mode. Mirrors
312 /// `less -F`.
313 #[arg(short = 'F', long = "quit-if-one-screen")]
314 pub quit_if_one_screen: bool,
315
316 /// Accepted for `less` compatibility. tess always exits on Ctrl-C
317 /// (Ctrl-C → Command::Quit in the input table), so this flag is a
318 /// no-op. Provided so existing `less` invocations work unchanged.
319 #[arg(short = 'K', long = "quit-on-intr")]
320 pub quit_on_intr: bool,
321
322 /// Pass every byte to the terminal raw, including cursor moves and
323 /// non-SGR escape sequences. Risky: scroll math may break on long lines.
324 /// Less-style -r. Mutually exclusive with --no-color.
325 #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
326 pub raw_control_chars: bool,
327
328 /// Accept `less -R`: interpret SGR/OSC color escapes, strip other control
329 /// sequences — which is tess's default. Provided for drop-in `less -R`
330 /// compatibility. Distinct from `-r` (full raw) and `--no-color`.
331 #[arg(short = 'R', long = "RAW-CONTROL-CHARS",
332 conflicts_with_all = ["raw_control_chars", "no_color"])]
333 pub RAW_CONTROL_CHARS: bool,
334
335 /// Treat lines matching REGEX as record boundaries. Lines that don't
336 /// match are joined to the preceding record. Affects search, filter,
337 /// grep, and the status line — all operate on whole records when set.
338 /// Overrides the active --format's record_start if both are present.
339 /// Without --format, this is the only way to enable records mode for
340 /// plain text. Example: --record-start '^\['
341 #[arg(long = "record-start", value_name = "REGEX")]
342 pub record_start: Option<String>,
343
344 /// Character to show at the right edge of a chopped line (`-S` chop
345 /// mode) indicating "more content right". Default `>`. Pass an empty
346 /// string to disable. Mirrors `less --rscroll=c`.
347 #[arg(long = "rscroll", value_name = "CHAR", default_value = ">")]
348 pub rscroll: String,
349
350 /// Column count for the ←/→ horizontal-scroll commands (default: half
351 /// screen). `0` keeps the half-screen default. Mirrors `less -#`/`--shift`.
352 #[arg(short = '#', long = "shift", value_name = "N")]
353 pub shift: Option<u16>,
354
355 /// Collapse runs of two or more consecutive blank lines into a
356 /// single blank line at display time. Real line numbers, search,
357 /// and tag jumps are unaffected (they reference the original
358 /// count). Mirrors `less -s`.
359 #[arg(short = 's', long = "squeeze-blank-lines")]
360 pub squeeze_blanks: bool,
361
362 /// Show a 1-column status gutter at the far left: a mark letter on marked
363 /// lines, else `*` on lines containing a current-search match. Stays fixed
364 /// under horizontal scroll. No-op in --hex/-r/image modes. Mirrors `less -J`.
365 #[arg(short = 'J', long = "status-column")]
366 pub status_column: bool,
367
368 /// Style for the status row. Comma-separated tokens: `bold`, `dim`,
369 /// `italic`, `underline`, `reverse`, `fg=COLOR`, `bg=COLOR`. COLOR is a
370 /// named color (`black`..`white`, optional `bright-` prefix), `#RRGGBB`,
371 /// or an indexed value (0–255). Empty string disables theming.
372 /// Default: `reverse`.
373 #[arg(long = "status-style", value_name = "SPEC", default_value = "reverse")]
374 pub status_style: String,
375
376 /// Synonym for `--output -`: write the batch-mode output to stdout.
377 #[arg(long = "stdout", conflicts_with = "output")]
378 pub stdout: bool,
379
380 /// Tab stop width (default 8).
381 #[arg(long = "tab-width", default_value_t = 8)]
382 pub tab_width: u8,
383
384 /// Tab stops as a comma-separated column list, e.g. `-x4,8,16`. A single
385 /// value is equivalent to `--tab-width`. With multiple values, tabs advance
386 /// to the next listed column; past the last stop the final interval
387 /// repeats. Overrides `--tab-width`. Mirrors `less -x`.
388 #[arg(short = 'x', long = "tabs", value_name = "LIST")]
389 pub tabs: Option<String>,
390
391 /// Jump to the tag NAME at startup (requires a tags file).
392 #[arg(short = 't', long = "tag", value_name = "NAME")]
393 pub tag: Option<String>,
394
395 /// Path to the tags file. Default: walk up from CWD looking for `tags`.
396 #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
397 pub tag_file: Option<std::path::PathBuf>,
398
399 /// Show only the last N lines of the source. For files this skips most of
400 /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
401 /// Mutually exclusive with --head. Streaming stdin is not supported.
402 #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
403 pub tail: Option<usize>,
404
405 /// Batch sink: apply filters/head/tail/prettify and copy the result to the
406 /// system clipboard, then exit. Mutually exclusive with -o/--stdout.
407 #[arg(long = "to-clipboard", conflicts_with_all = ["output", "stdout"])]
408 pub to_clipboard: bool,
409
410 /// Truecolor (24-bit RGB) handling. `auto` (default) checks `$COLORTERM`
411 /// and downsamples when truecolor isn't advertised; `never` always
412 /// downsamples to the 256-color palette; `always` passes RGB through
413 /// regardless of terminal capability.
414 #[arg(long = "truecolor", value_name = "MODE", default_value = "auto")]
415 pub truecolor: String,
416
417 /// Body lines scrolled per mouse-wheel notch under `--mouse` (default 3).
418 #[arg(long = "wheel-lines", value_name = "N")]
419 pub wheel_lines: Option<u16>,
420
421 /// PageDown / PageUp step size in lines. Default: full screen
422 /// height (body rows). Half-page commands always advance by half
423 /// the screen regardless. Mirrors `less -zn` / `--window=n`.
424 #[arg(short = 'z', long = "window", value_name = "N")]
425 pub window: Option<u16>,
426
427 /// In wrap mode, break lines on whitespace boundaries instead of
428 /// mid-character when possible. Falls back to mid-character break
429 /// when no whitespace fits in the row. Mirrors `less --wordwrap`.
430 #[arg(long = "wordwrap")]
431 pub word_wrap: bool,
432
433 /// Files to view (only the first is opened in MVP).
434 pub files: Vec<PathBuf>,
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn parses_no_flags_no_files() {
443 let a = Args::parse_from(["tess"]);
444 assert!(!a.line_numbers);
445 assert!(!a.chop);
446 assert_eq!(a.tab_width, 8);
447 assert!(a.files.is_empty());
448 }
449
450 #[test]
451 fn parses_raw_control_chars_alias() {
452 let a = Args::parse_from(["tess", "-R", "f"]);
453 assert!(a.RAW_CONTROL_CHARS);
454 }
455 #[test]
456 fn raw_control_chars_conflicts_with_raw_and_no_color() {
457 assert!(Args::try_parse_from(["tess", "-R", "-r", "f"]).is_err());
458 assert!(Args::try_parse_from(["tess", "-R", "--no-color", "f"]).is_err());
459 }
460 #[test]
461 fn parses_shift_and_wheel_lines() {
462 let a = Args::parse_from(["tess", "--shift", "12", "--wheel-lines", "5", "f"]);
463 assert_eq!(a.shift, Some(12));
464 assert_eq!(a.wheel_lines, Some(5));
465 }
466
467 #[test]
468 fn parses_short_flags_and_file() {
469 let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
470 assert!(a.line_numbers);
471 assert!(a.chop);
472 assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
473 }
474
475 #[test]
476 fn parses_tab_width() {
477 let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
478 assert_eq!(a.tab_width, 4);
479 }
480
481 #[test]
482 fn parses_tabs_list() {
483 let a = Args::parse_from(["tess", "--tabs", "4,8,16", "f"]);
484 assert_eq!(a.tabs.as_deref(), Some("4,8,16"));
485 let b = Args::parse_from(["tess", "-x", "4", "f"]);
486 assert_eq!(b.tabs.as_deref(), Some("4"));
487 }
488
489 #[test]
490 fn collects_multiple_files() {
491 let a = Args::parse_from(["tess", "a", "b", "c"]);
492 assert_eq!(a.files.len(), 3);
493 }
494
495 #[test]
496 fn parses_follow_short_flag() {
497 let a = Args::parse_from(["tess", "-f", "log.txt"]);
498 assert!(a.follow);
499 assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
500 }
501
502 #[test]
503 fn parses_follow_long_flag() {
504 let a = Args::parse_from(["tess", "--follow"]);
505 assert!(a.follow);
506 }
507
508 #[test]
509 fn follow_defaults_off() {
510 let a = Args::parse_from(["tess", "x"]);
511 assert!(!a.follow);
512 }
513
514 #[test]
515 fn parses_head() {
516 let a = Args::parse_from(["tess", "--head", "100", "x"]);
517 assert_eq!(a.head, Some(100));
518 assert_eq!(a.tail, None);
519 }
520
521 #[test]
522 fn parses_tail() {
523 let a = Args::parse_from(["tess", "--tail", "50", "x"]);
524 assert_eq!(a.tail, Some(50));
525 assert_eq!(a.head, None);
526 }
527
528 #[test]
529 fn head_and_tail_are_mutually_exclusive() {
530 let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
531 assert!(r.is_err(), "clap should reject combining --head and --tail");
532 }
533
534 #[test]
535 fn head_tail_default_to_none() {
536 let a = Args::parse_from(["tess", "x"]);
537 assert!(a.head.is_none());
538 assert!(a.tail.is_none());
539 }
540
541 #[test]
542 fn parses_grep_repeatable_and_no_format_required() {
543 let a = Args::parse_from([
544 "tess",
545 "--grep", "error",
546 "--grep", r"^\[",
547 "log",
548 ]);
549 assert_eq!(a.grep.len(), 2);
550 assert_eq!(a.grep[0], "error");
551 assert_eq!(a.grep[1], r"^\[");
552 assert_eq!(a.format, None);
553 }
554
555 #[test]
556 fn parses_format_and_filter() {
557 let a = Args::parse_from([
558 "tess", "--format", "apache-combined",
559 "--filter", "status=500",
560 "--filter", "ip~^10\\.",
561 "log",
562 ]);
563 assert_eq!(a.format.as_deref(), Some("apache-combined"));
564 assert_eq!(a.filter.len(), 2);
565 assert_eq!(a.filter[0], "status=500");
566 }
567
568 #[test]
569 fn parses_dim() {
570 let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
571 assert!(a.dim);
572 }
573
574 #[test]
575 fn parses_list_formats() {
576 let a = Args::parse_from(["tess", "--list-formats"]);
577 assert!(a.list_formats);
578 }
579
580 #[test]
581 fn parses_manual() {
582 let a = Args::parse_from(["tess", "--manual"]);
583 assert!(a.manual);
584 }
585
586 #[test]
587 fn parses_examples() {
588 let a = Args::parse_from(["tess", "--examples"]);
589 assert!(a.examples);
590 }
591
592 #[test]
593 fn parses_live() {
594 let a = Args::parse_from(["tess", "--live", "f"]);
595 assert!(a.live);
596 assert!(!a.follow);
597 }
598
599 #[test]
600 fn live_and_follow_are_mutually_exclusive() {
601 let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
602 assert!(r.is_err(), "clap should reject combining --live and --follow");
603 }
604
605 #[test]
606 fn parses_prettify() {
607 let a = Args::parse_from(["tess", "--prettify", "f.json"]);
608 assert!(a.prettify);
609 assert_eq!(a.content_type, None);
610 }
611
612 #[test]
613 fn parses_content_type() {
614 let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
615 assert_eq!(a.content_type.as_deref(), Some("json"));
616 }
617
618 #[test]
619 fn parses_output_long_and_short() {
620 let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
621 assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
622 let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
623 assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
624 }
625
626 #[test]
627 fn parses_status_column() {
628 let a = Args::parse_from(["tess", "-J", "f"]);
629 assert!(a.status_column);
630 let b = Args::parse_from(["tess", "--status-column", "f"]);
631 assert!(b.status_column);
632 let c = Args::parse_from(["tess", "f"]);
633 assert!(!c.status_column, "status_column defaults off");
634 }
635
636 #[test]
637 fn parses_stdout_flag() {
638 let a = Args::parse_from(["tess", "--stdout", "f"]);
639 assert!(a.stdout);
640 assert_eq!(a.output, None);
641 }
642
643 #[test]
644 fn output_and_stdout_are_mutually_exclusive() {
645 let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
646 assert!(r.is_err(), "clap should reject combining --output and --stdout");
647 }
648
649 #[test]
650 fn parses_mouse_flag() {
651 let a = Args::parse_from(["tess", "--mouse", "f"]);
652 assert!(a.mouse);
653 }
654
655 #[test]
656 fn mouse_defaults_off() {
657 let a = Args::parse_from(["tess", "f"]);
658 assert!(!a.mouse);
659 }
660
661 #[test]
662 fn parses_no_image_flag() {
663 let a = Args::parse_from(["tess", "--no-image", "cat.png"]);
664 assert!(a.no_image);
665 }
666
667 #[test]
668 fn parses_blocks_flag() {
669 let a = Args::parse_from(["tess", "--blocks", "cat.png"]);
670 assert!(a.blocks);
671 }
672
673 #[test]
674 fn parses_image_protocol() {
675 assert_eq!(Args::parse_from(["tess", "f"]).image_protocol, "auto");
676 assert_eq!(Args::parse_from(["tess", "--image-protocol", "kitty", "f"]).image_protocol, "kitty");
677 }
678
679 #[test]
680 fn parses_image_width() {
681 let a = Args::parse_from(["tess", "--image-width", "120", "cat.png"]);
682 assert_eq!(a.image_width, Some(120));
683 }
684
685 #[test]
686 fn parses_incsearch() {
687 let a = Args::parse_from(["tess", "--incsearch", "f"]);
688 assert!(a.incsearch);
689 }
690
691 #[test]
692 fn incsearch_defaults_off() {
693 let a = Args::parse_from(["tess", "f"]);
694 assert!(!a.incsearch);
695 }
696
697 #[test]
698 fn image_flags_default_off() {
699 let a = Args::parse_from(["tess", "f"]);
700 assert!(!a.no_image);
701 assert!(!a.blocks);
702 assert_eq!(a.image_width, None);
703 }
704
705 #[test]
706 fn repeated_scalar_flag_takes_last_value() {
707 // args_override_self: a group can inject `--display X` and a later CLI
708 // `--display Y` wins instead of clap erroring "cannot be used multiple
709 // times". Also covers plain `less`-style last-wins.
710 let a = Args::parse_from(["tess", "--display", "X", "--display", "Y", "--format", "f"]);
711 assert_eq!(a.display.as_deref(), Some("Y"));
712 let b = Args::parse_from(["tess", "--tail", "5", "--tail", "1", "x"]);
713 assert_eq!(b.tail, Some(1));
714 }
715
716 #[test]
717 fn repeatable_flags_still_accumulate_with_override_self() {
718 // args_override_self must not collapse Append-action Vec flags.
719 let a = Args::parse_from(["tess", "--grep", "a", "--grep", "b", "x"]);
720 assert_eq!(a.grep, vec!["a".to_string(), "b".to_string()]);
721 }
722
723 #[test]
724 fn parses_or_flags_repeatable() {
725 let a = Args::parse_from([
726 "tess",
727 "--or-grep", "failed",
728 "--or-group", "svc",
729 "--or-filter", "lvl=ERROR",
730 "x",
731 ]);
732 assert_eq!(a.or_grep, vec!["failed".to_string()]);
733 assert_eq!(a.or_group, vec!["svc".to_string()]);
734 assert_eq!(a.or_filter, vec!["lvl=ERROR".to_string()]);
735 }
736
737 #[test]
738 fn or_flags_conflict_with_hex() {
739 let r = Args::try_parse_from(["tess", "--hex", "--or-grep", "x", "f"]);
740 assert!(r.is_err(), "clap should reject --hex with --or-grep");
741 }
742
743 #[test]
744 fn parses_clipboard_flags() {
745 assert!(Args::parse_from(["tess", "--clipboard", "f"]).clipboard);
746 assert!(Args::parse_from(["tess", "--from-clipboard"]).from_clipboard);
747 assert!(Args::parse_from(["tess", "--to-clipboard", "f"]).to_clipboard);
748 }
749 #[test]
750 fn clipboard_sink_conflicts() {
751 assert!(Args::try_parse_from(["tess", "--to-clipboard", "--stdout", "f"]).is_err());
752 assert!(Args::try_parse_from(["tess", "--to-clipboard", "-o", "x", "f"]).is_err());
753 }
754 #[test]
755 fn from_clipboard_conflicts_with_files() {
756 assert!(Args::try_parse_from(["tess", "--from-clipboard", "f"]).is_err());
757 }
758
759 #[test]
760 fn help_lists_flags_in_alphabetical_order() {
761 use clap::CommandFactory;
762 let mut cmd = Args::command();
763 let help = cmd.render_help().to_string();
764
765 // The full set of long flags, in the order we expect `--help` to list
766 // them: alphabetical by long name, case-insensitive. clap's auto-added
767 // --help / --version are excluded (they're not in this list, so the
768 // first-token scan below skips their lines).
769 let expected = [
770 "--blocks",
771 "--chop-long-lines",
772 "--clipboard",
773 "--content-type",
774 "--dim",
775 "--display",
776 "--examples",
777 "--exit-follow-on-close",
778 "--filter",
779 "--follow",
780 "--follow-name",
781 "--follow-suspend-on-motion",
782 "--format",
783 "--from-clipboard",
784 "--grep",
785 "--head",
786 "--header",
787 "--hex",
788 "--hex-group",
789 "--ignore-case",
790 "--IGNORE-CASE",
791 "--image-protocol",
792 "--image-width",
793 "--incsearch",
794 "--LINE-NUMBERS",
795 "--list-formats",
796 "--live",
797 "--manual",
798 "--mouse",
799 "--no-color",
800 "--no-hilite-search",
801 "--no-image",
802 "--no-init",
803 "--no-preprocess",
804 "--or-filter",
805 "--or-grep",
806 "--or-group",
807 "--output",
808 "--preprocess",
809 "--prettify",
810 "--prompt",
811 "--prompt-style",
812 "--quit-at-eof",
813 "--QUIT-AT-EOF",
814 "--quit-if-one-screen",
815 "--quit-on-intr",
816 "--raw-control-chars",
817 "--RAW-CONTROL-CHARS",
818 "--record-start",
819 "--rscroll",
820 "--shift",
821 "--squeeze-blank-lines",
822 "--status-column",
823 "--status-style",
824 "--stdout",
825 "--tab-width",
826 "--tabs",
827 "--tag",
828 "--tag-file",
829 "--tail",
830 "--to-clipboard",
831 "--truecolor",
832 "--wheel-lines",
833 "--window",
834 "--wordwrap",
835 ];
836
837 // Confirm `expected` is itself sorted case-insensitively — this guards
838 // against a typo here masking a real ordering regression in the struct.
839 let mut sorted = expected.to_vec();
840 sorted.sort_by_key(|s| s.trim_start_matches('-').to_ascii_lowercase());
841 assert_eq!(
842 expected.to_vec(),
843 sorted,
844 "the `expected` list must itself be in case-insensitive alphabetical order"
845 );
846
847 // Walk the rendered help line by line. For each option line (after
848 // trimming, it starts with '-'), take the first `--long` token that is
849 // one of our flags. Because the flag name always precedes its own
850 // description on the line, embedded `--flag` references in descriptions
851 // are never matched first.
852 let listed: Vec<&str> = help
853 .lines()
854 .map(str::trim_start)
855 .filter(|l| l.starts_with('-'))
856 .filter_map(|l| {
857 l.split(|c: char| c.is_whitespace() || c == ',')
858 .find(|tok| expected.contains(tok))
859 })
860 .collect();
861 assert_eq!(listed, expected, "help long-flag order should be alphabetical");
862 }
863}