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