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"],
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 /// Non-interactive batch mode: apply --filter / --grep / --head / --tail / --prettify
217 /// to the source and write the resulting raw bytes to FILE, then exit.
218 /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
219 /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
220 /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
221 /// compatible with `--live`.
222 #[arg(short = 'o', long = "output", value_name = "FILE")]
223 pub output: Option<String>,
224
225 /// Pipe the source file through this command before rendering.
226 /// Must start with `|`; `%s` is substituted with the file path.
227 /// Example: `--preprocess '|pdftotext %s -'`. Overrides $LESSOPEN.
228 #[arg(
229 long = "preprocess",
230 value_name = "CMD",
231 conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
232 )]
233 pub preprocess: Option<String>,
234
235 /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
236 /// Detects the type from the filename extension or the first bytes; use
237 /// `--content-type=NAME` to override. Static files only — not allowed
238 /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
239 /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
240 #[arg(long = "prettify")]
241 pub prettify: bool,
242
243 /// Replace the hardcoded status format with a templated string.
244 /// Uses the same `<field>` syntax as `--display`. Available fields:
245 /// label, top, bottom, total, pct, rec-top, rec-bottom, rec-total,
246 /// rec-block, wrap-offset, format-tag, filter-tag, grep-tag,
247 /// hide-tag, search-tag, pretty-tag, live-tag, follow-tag.
248 /// Per-format default can be set via `prompt = '...'` in formats.toml.
249 /// Mutually exclusive with --hex.
250 #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
251 pub prompt: Option<String>,
252
253 /// Style for `--prompt` output (and per-format `prompt_style`). Same
254 /// grammar as `--status-style`. Default: empty (no extra styling on top
255 /// of what the prompt template itself emits).
256 #[arg(long = "prompt-style", value_name = "SPEC", default_value = "")]
257 pub prompt_style: String,
258
259 /// Quit when the user tries to scroll forward past end-of-file for
260 /// the second time. Mirrors `less -e`. Mutually exclusive with `-E`.
261 #[arg(short = 'e', long = "quit-at-eof", conflicts_with = "QUIT_AT_EOF")]
262 pub quit_at_eof: bool,
263
264 /// Quit the first time end-of-file is reached. Mirrors `less -E`.
265 #[arg(short = 'E', long = "QUIT-AT-EOF")]
266 pub QUIT_AT_EOF: bool,
267
268 /// Exit immediately (without paging) if the entire source fits on
269 /// one screen. Ignored with piped stdin in follow mode. Mirrors
270 /// `less -F`.
271 #[arg(short = 'F', long = "quit-if-one-screen")]
272 pub quit_if_one_screen: bool,
273
274 /// Accepted for `less` compatibility. tess always exits on Ctrl-C
275 /// (Ctrl-C → Command::Quit in the input table), so this flag is a
276 /// no-op. Provided so existing `less` invocations work unchanged.
277 #[arg(short = 'K', long = "quit-on-intr")]
278 pub quit_on_intr: bool,
279
280 /// Pass every byte to the terminal raw, including cursor moves and
281 /// non-SGR escape sequences. Risky: scroll math may break on long lines.
282 /// Less-style -r. Mutually exclusive with --no-color.
283 #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
284 pub raw_control_chars: bool,
285
286 /// Treat lines matching REGEX as record boundaries. Lines that don't
287 /// match are joined to the preceding record. Affects search, filter,
288 /// grep, and the status line — all operate on whole records when set.
289 /// Overrides the active --format's record_start if both are present.
290 /// Without --format, this is the only way to enable records mode for
291 /// plain text. Example: --record-start '^\['
292 #[arg(long = "record-start", value_name = "REGEX")]
293 pub record_start: Option<String>,
294
295 /// Character to show at the right edge of a chopped line (`-S` chop
296 /// mode) indicating "more content right". Default `>`. Pass an empty
297 /// string to disable. Mirrors `less --rscroll=c`.
298 #[arg(long = "rscroll", value_name = "CHAR", default_value = ">")]
299 pub rscroll: String,
300
301 /// Collapse runs of two or more consecutive blank lines into a
302 /// single blank line at display time. Real line numbers, search,
303 /// and tag jumps are unaffected (they reference the original
304 /// count). Mirrors `less -s`.
305 #[arg(short = 's', long = "squeeze-blank-lines")]
306 pub squeeze_blanks: bool,
307
308 /// Style for the status row. Comma-separated tokens: `bold`, `dim`,
309 /// `italic`, `underline`, `reverse`, `fg=COLOR`, `bg=COLOR`. COLOR is a
310 /// named color (`black`..`white`, optional `bright-` prefix), `#RRGGBB`,
311 /// or an indexed value (0–255). Empty string disables theming.
312 /// Default: `reverse`.
313 #[arg(long = "status-style", value_name = "SPEC", default_value = "reverse")]
314 pub status_style: String,
315
316 /// Synonym for `--output -`: write the batch-mode output to stdout.
317 #[arg(long = "stdout", conflicts_with = "output")]
318 pub stdout: bool,
319
320 /// Tab stop width (default 8).
321 #[arg(long = "tab-width", default_value_t = 8)]
322 pub tab_width: u8,
323
324 /// Jump to the tag NAME at startup (requires a tags file).
325 #[arg(short = 't', long = "tag", value_name = "NAME")]
326 pub tag: Option<String>,
327
328 /// Path to the tags file. Default: walk up from CWD looking for `tags`.
329 #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
330 pub tag_file: Option<std::path::PathBuf>,
331
332 /// Show only the last N lines of the source. For files this skips most of
333 /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
334 /// Mutually exclusive with --head. Streaming stdin is not supported.
335 #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
336 pub tail: Option<usize>,
337
338 /// Truecolor (24-bit RGB) handling. `auto` (default) checks `$COLORTERM`
339 /// and downsamples when truecolor isn't advertised; `never` always
340 /// downsamples to the 256-color palette; `always` passes RGB through
341 /// regardless of terminal capability.
342 #[arg(long = "truecolor", value_name = "MODE", default_value = "auto")]
343 pub truecolor: String,
344
345 /// PageDown / PageUp step size in lines. Default: full screen
346 /// height (body rows). Half-page commands always advance by half
347 /// the screen regardless. Mirrors `less -zn` / `--window=n`.
348 #[arg(short = 'z', long = "window", value_name = "N")]
349 pub window: Option<u16>,
350
351 /// In wrap mode, break lines on whitespace boundaries instead of
352 /// mid-character when possible. Falls back to mid-character break
353 /// when no whitespace fits in the row. Mirrors `less --wordwrap`.
354 #[arg(long = "wordwrap")]
355 pub word_wrap: bool,
356
357 /// Files to view (only the first is opened in MVP).
358 pub files: Vec<PathBuf>,
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn parses_no_flags_no_files() {
367 let a = Args::parse_from(["tess"]);
368 assert!(!a.line_numbers);
369 assert!(!a.chop);
370 assert_eq!(a.tab_width, 8);
371 assert!(a.files.is_empty());
372 }
373
374 #[test]
375 fn parses_short_flags_and_file() {
376 let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
377 assert!(a.line_numbers);
378 assert!(a.chop);
379 assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
380 }
381
382 #[test]
383 fn parses_tab_width() {
384 let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
385 assert_eq!(a.tab_width, 4);
386 }
387
388 #[test]
389 fn collects_multiple_files() {
390 let a = Args::parse_from(["tess", "a", "b", "c"]);
391 assert_eq!(a.files.len(), 3);
392 }
393
394 #[test]
395 fn parses_follow_short_flag() {
396 let a = Args::parse_from(["tess", "-f", "log.txt"]);
397 assert!(a.follow);
398 assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
399 }
400
401 #[test]
402 fn parses_follow_long_flag() {
403 let a = Args::parse_from(["tess", "--follow"]);
404 assert!(a.follow);
405 }
406
407 #[test]
408 fn follow_defaults_off() {
409 let a = Args::parse_from(["tess", "x"]);
410 assert!(!a.follow);
411 }
412
413 #[test]
414 fn parses_head() {
415 let a = Args::parse_from(["tess", "--head", "100", "x"]);
416 assert_eq!(a.head, Some(100));
417 assert_eq!(a.tail, None);
418 }
419
420 #[test]
421 fn parses_tail() {
422 let a = Args::parse_from(["tess", "--tail", "50", "x"]);
423 assert_eq!(a.tail, Some(50));
424 assert_eq!(a.head, None);
425 }
426
427 #[test]
428 fn head_and_tail_are_mutually_exclusive() {
429 let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
430 assert!(r.is_err(), "clap should reject combining --head and --tail");
431 }
432
433 #[test]
434 fn head_tail_default_to_none() {
435 let a = Args::parse_from(["tess", "x"]);
436 assert!(a.head.is_none());
437 assert!(a.tail.is_none());
438 }
439
440 #[test]
441 fn parses_grep_repeatable_and_no_format_required() {
442 let a = Args::parse_from([
443 "tess",
444 "--grep", "error",
445 "--grep", r"^\[",
446 "log",
447 ]);
448 assert_eq!(a.grep.len(), 2);
449 assert_eq!(a.grep[0], "error");
450 assert_eq!(a.grep[1], r"^\[");
451 assert_eq!(a.format, None);
452 }
453
454 #[test]
455 fn parses_format_and_filter() {
456 let a = Args::parse_from([
457 "tess", "--format", "apache-combined",
458 "--filter", "status=500",
459 "--filter", "ip~^10\\.",
460 "log",
461 ]);
462 assert_eq!(a.format.as_deref(), Some("apache-combined"));
463 assert_eq!(a.filter.len(), 2);
464 assert_eq!(a.filter[0], "status=500");
465 }
466
467 #[test]
468 fn parses_dim() {
469 let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
470 assert!(a.dim);
471 }
472
473 #[test]
474 fn parses_list_formats() {
475 let a = Args::parse_from(["tess", "--list-formats"]);
476 assert!(a.list_formats);
477 }
478
479 #[test]
480 fn parses_manual() {
481 let a = Args::parse_from(["tess", "--manual"]);
482 assert!(a.manual);
483 }
484
485 #[test]
486 fn parses_examples() {
487 let a = Args::parse_from(["tess", "--examples"]);
488 assert!(a.examples);
489 }
490
491 #[test]
492 fn parses_live() {
493 let a = Args::parse_from(["tess", "--live", "f"]);
494 assert!(a.live);
495 assert!(!a.follow);
496 }
497
498 #[test]
499 fn live_and_follow_are_mutually_exclusive() {
500 let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
501 assert!(r.is_err(), "clap should reject combining --live and --follow");
502 }
503
504 #[test]
505 fn parses_prettify() {
506 let a = Args::parse_from(["tess", "--prettify", "f.json"]);
507 assert!(a.prettify);
508 assert_eq!(a.content_type, None);
509 }
510
511 #[test]
512 fn parses_content_type() {
513 let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
514 assert_eq!(a.content_type.as_deref(), Some("json"));
515 }
516
517 #[test]
518 fn parses_output_long_and_short() {
519 let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
520 assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
521 let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
522 assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
523 }
524
525 #[test]
526 fn parses_stdout_flag() {
527 let a = Args::parse_from(["tess", "--stdout", "f"]);
528 assert!(a.stdout);
529 assert_eq!(a.output, None);
530 }
531
532 #[test]
533 fn output_and_stdout_are_mutually_exclusive() {
534 let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
535 assert!(r.is_err(), "clap should reject combining --output and --stdout");
536 }
537
538 #[test]
539 fn parses_mouse_flag() {
540 let a = Args::parse_from(["tess", "--mouse", "f"]);
541 assert!(a.mouse);
542 }
543
544 #[test]
545 fn mouse_defaults_off() {
546 let a = Args::parse_from(["tess", "f"]);
547 assert!(!a.mouse);
548 }
549
550 #[test]
551 fn parses_no_image_flag() {
552 let a = Args::parse_from(["tess", "--no-image", "cat.png"]);
553 assert!(a.no_image);
554 }
555
556 #[test]
557 fn parses_blocks_flag() {
558 let a = Args::parse_from(["tess", "--blocks", "cat.png"]);
559 assert!(a.blocks);
560 }
561
562 #[test]
563 fn parses_image_width() {
564 let a = Args::parse_from(["tess", "--image-width", "120", "cat.png"]);
565 assert_eq!(a.image_width, Some(120));
566 }
567
568 #[test]
569 fn image_flags_default_off() {
570 let a = Args::parse_from(["tess", "f"]);
571 assert!(!a.no_image);
572 assert!(!a.blocks);
573 assert_eq!(a.image_width, None);
574 }
575
576 #[test]
577 fn repeated_scalar_flag_takes_last_value() {
578 // args_override_self: a group can inject `--display X` and a later CLI
579 // `--display Y` wins instead of clap erroring "cannot be used multiple
580 // times". Also covers plain `less`-style last-wins.
581 let a = Args::parse_from(["tess", "--display", "X", "--display", "Y", "--format", "f"]);
582 assert_eq!(a.display.as_deref(), Some("Y"));
583 let b = Args::parse_from(["tess", "--tail", "5", "--tail", "1", "x"]);
584 assert_eq!(b.tail, Some(1));
585 }
586
587 #[test]
588 fn repeatable_flags_still_accumulate_with_override_self() {
589 // args_override_self must not collapse Append-action Vec flags.
590 let a = Args::parse_from(["tess", "--grep", "a", "--grep", "b", "x"]);
591 assert_eq!(a.grep, vec!["a".to_string(), "b".to_string()]);
592 }
593
594 #[test]
595 fn help_lists_flags_in_alphabetical_order() {
596 use clap::CommandFactory;
597 let mut cmd = Args::command();
598 let help = cmd.render_help().to_string();
599
600 // The full set of long flags, in the order we expect `--help` to list
601 // them: alphabetical by long name, case-insensitive. clap's auto-added
602 // --help / --version are excluded (they're not in this list, so the
603 // first-token scan below skips their lines).
604 let expected = [
605 "--blocks",
606 "--chop-long-lines",
607 "--content-type",
608 "--dim",
609 "--display",
610 "--examples",
611 "--exit-follow-on-close",
612 "--filter",
613 "--follow",
614 "--follow-name",
615 "--follow-suspend-on-motion",
616 "--format",
617 "--grep",
618 "--head",
619 "--header",
620 "--hex",
621 "--hex-group",
622 "--ignore-case",
623 "--IGNORE-CASE",
624 "--image-width",
625 "--LINE-NUMBERS",
626 "--list-formats",
627 "--live",
628 "--manual",
629 "--mouse",
630 "--no-color",
631 "--no-hilite-search",
632 "--no-image",
633 "--no-init",
634 "--no-preprocess",
635 "--output",
636 "--preprocess",
637 "--prettify",
638 "--prompt",
639 "--prompt-style",
640 "--quit-at-eof",
641 "--QUIT-AT-EOF",
642 "--quit-if-one-screen",
643 "--quit-on-intr",
644 "--raw-control-chars",
645 "--record-start",
646 "--rscroll",
647 "--squeeze-blank-lines",
648 "--status-style",
649 "--stdout",
650 "--tab-width",
651 "--tag",
652 "--tag-file",
653 "--tail",
654 "--truecolor",
655 "--window",
656 "--wordwrap",
657 ];
658
659 // Confirm `expected` is itself sorted case-insensitively — this guards
660 // against a typo here masking a real ordering regression in the struct.
661 let mut sorted = expected.to_vec();
662 sorted.sort_by_key(|s| s.trim_start_matches('-').to_ascii_lowercase());
663 assert_eq!(
664 expected.to_vec(),
665 sorted,
666 "the `expected` list must itself be in case-insensitive alphabetical order"
667 );
668
669 // Walk the rendered help line by line. For each option line (after
670 // trimming, it starts with '-'), take the first `--long` token that is
671 // one of our flags. Because the flag name always precedes its own
672 // description on the line, embedded `--flag` references in descriptions
673 // are never matched first.
674 let listed: Vec<&str> = help
675 .lines()
676 .map(str::trim_start)
677 .filter(|l| l.starts_with('-'))
678 .filter_map(|l| {
679 l.split(|c: char| c.is_whitespace() || c == ',')
680 .find(|tok| expected.contains(tok))
681 })
682 .collect();
683 assert_eq!(listed, expected, "help long-flag order should be alphabetical");
684 }
685}