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