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)]
14pub struct Args {
15 #[arg(short = 'S', long = "chop-long-lines")]
17 pub chop: bool,
18
19 #[arg(long = "content-type", value_name = "TYPE")]
24 pub content_type: Option<String>,
25
26 #[arg(long = "dim")]
29 pub dim: bool,
30
31 #[arg(long = "display", value_name = "TEMPLATE")]
37 pub display: Option<String>,
38
39 #[arg(long = "examples")]
41 pub examples: bool,
42
43 #[arg(long = "filter", value_name = "FIELD<op>VALUE")]
50 pub filter: Vec<String>,
51
52 #[arg(short = 'f', long = "follow")]
55 pub follow: bool,
56
57 #[arg(long = "format", value_name = "NAME")]
60 pub format: Option<String>,
61
62 #[arg(long = "grep", value_name = "PATTERN")]
68 pub grep: Vec<String>,
69
70 #[arg(long = "head", value_name = "N", conflicts_with = "tail")]
72 pub head: Option<usize>,
73
74 #[arg(
78 long = "hex",
79 conflicts_with_all = ["filter", "grep", "prettify", "format", "display", "record_start", "prompt", "preprocess"],
80 )]
81 pub hex: bool,
82
83 #[arg(
88 long = "hex-group",
89 value_name = "N",
90 default_value_t = 4,
91 requires = "hex",
92 )]
93 pub hex_group: usize,
94
95 #[arg(short = 'N', long = "LINE-NUMBERS")]
97 pub line_numbers: bool,
98
99 #[arg(long = "list-formats")]
101 pub list_formats: bool,
102
103 #[arg(long = "live", conflicts_with = "follow")]
109 pub live: bool,
110
111 #[arg(long = "manual")]
113 pub manual: bool,
114
115 #[arg(long = "mouse")]
119 pub mouse: bool,
120
121 #[arg(long = "no-color")]
125 pub no_color: bool,
126
127 #[arg(long = "no-preprocess", conflicts_with = "preprocess")]
130 pub no_preprocess: bool,
131
132 #[arg(short = 'o', long = "output", value_name = "FILE")]
139 pub output: Option<String>,
140
141 #[arg(
145 long = "preprocess",
146 value_name = "CMD",
147 conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
148 )]
149 pub preprocess: Option<String>,
150
151 #[arg(long = "prettify")]
157 pub prettify: bool,
158
159 #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex")]
167 pub prompt: Option<String>,
168
169 #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color")]
173 pub raw_control_chars: bool,
174
175 #[arg(long = "record-start", value_name = "REGEX")]
182 pub record_start: Option<String>,
183
184 #[arg(long = "stdout", conflicts_with = "output")]
186 pub stdout: bool,
187
188 #[arg(long = "tab-width", default_value_t = 8)]
190 pub tab_width: u8,
191
192 #[arg(short = 't', long = "tag", value_name = "NAME")]
194 pub tag: Option<String>,
195
196 #[arg(short = 'T', long = "tag-file", value_name = "PATH")]
198 pub tag_file: Option<std::path::PathBuf>,
199
200 #[arg(long = "tail", value_name = "N", conflicts_with = "head")]
204 pub tail: Option<usize>,
205
206 pub files: Vec<PathBuf>,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn parses_no_flags_no_files() {
216 let a = Args::parse_from(["tess"]);
217 assert!(!a.line_numbers);
218 assert!(!a.chop);
219 assert_eq!(a.tab_width, 8);
220 assert!(a.files.is_empty());
221 }
222
223 #[test]
224 fn parses_short_flags_and_file() {
225 let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
226 assert!(a.line_numbers);
227 assert!(a.chop);
228 assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
229 }
230
231 #[test]
232 fn parses_tab_width() {
233 let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
234 assert_eq!(a.tab_width, 4);
235 }
236
237 #[test]
238 fn collects_multiple_files() {
239 let a = Args::parse_from(["tess", "a", "b", "c"]);
240 assert_eq!(a.files.len(), 3);
241 }
242
243 #[test]
244 fn parses_follow_short_flag() {
245 let a = Args::parse_from(["tess", "-f", "log.txt"]);
246 assert!(a.follow);
247 assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
248 }
249
250 #[test]
251 fn parses_follow_long_flag() {
252 let a = Args::parse_from(["tess", "--follow"]);
253 assert!(a.follow);
254 }
255
256 #[test]
257 fn follow_defaults_off() {
258 let a = Args::parse_from(["tess", "x"]);
259 assert!(!a.follow);
260 }
261
262 #[test]
263 fn parses_head() {
264 let a = Args::parse_from(["tess", "--head", "100", "x"]);
265 assert_eq!(a.head, Some(100));
266 assert_eq!(a.tail, None);
267 }
268
269 #[test]
270 fn parses_tail() {
271 let a = Args::parse_from(["tess", "--tail", "50", "x"]);
272 assert_eq!(a.tail, Some(50));
273 assert_eq!(a.head, None);
274 }
275
276 #[test]
277 fn head_and_tail_are_mutually_exclusive() {
278 let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
279 assert!(r.is_err(), "clap should reject combining --head and --tail");
280 }
281
282 #[test]
283 fn head_tail_default_to_none() {
284 let a = Args::parse_from(["tess", "x"]);
285 assert!(a.head.is_none());
286 assert!(a.tail.is_none());
287 }
288
289 #[test]
290 fn parses_grep_repeatable_and_no_format_required() {
291 let a = Args::parse_from([
292 "tess",
293 "--grep", "error",
294 "--grep", r"^\[",
295 "log",
296 ]);
297 assert_eq!(a.grep.len(), 2);
298 assert_eq!(a.grep[0], "error");
299 assert_eq!(a.grep[1], r"^\[");
300 assert_eq!(a.format, None);
301 }
302
303 #[test]
304 fn parses_format_and_filter() {
305 let a = Args::parse_from([
306 "tess", "--format", "apache-combined",
307 "--filter", "status=500",
308 "--filter", "ip~^10\\.",
309 "log",
310 ]);
311 assert_eq!(a.format.as_deref(), Some("apache-combined"));
312 assert_eq!(a.filter.len(), 2);
313 assert_eq!(a.filter[0], "status=500");
314 }
315
316 #[test]
317 fn parses_dim() {
318 let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
319 assert!(a.dim);
320 }
321
322 #[test]
323 fn parses_list_formats() {
324 let a = Args::parse_from(["tess", "--list-formats"]);
325 assert!(a.list_formats);
326 }
327
328 #[test]
329 fn parses_manual() {
330 let a = Args::parse_from(["tess", "--manual"]);
331 assert!(a.manual);
332 }
333
334 #[test]
335 fn parses_examples() {
336 let a = Args::parse_from(["tess", "--examples"]);
337 assert!(a.examples);
338 }
339
340 #[test]
341 fn parses_live() {
342 let a = Args::parse_from(["tess", "--live", "f"]);
343 assert!(a.live);
344 assert!(!a.follow);
345 }
346
347 #[test]
348 fn live_and_follow_are_mutually_exclusive() {
349 let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
350 assert!(r.is_err(), "clap should reject combining --live and --follow");
351 }
352
353 #[test]
354 fn parses_prettify() {
355 let a = Args::parse_from(["tess", "--prettify", "f.json"]);
356 assert!(a.prettify);
357 assert_eq!(a.content_type, None);
358 }
359
360 #[test]
361 fn parses_content_type() {
362 let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
363 assert_eq!(a.content_type.as_deref(), Some("json"));
364 }
365
366 #[test]
367 fn parses_output_long_and_short() {
368 let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
369 assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
370 let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
371 assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
372 }
373
374 #[test]
375 fn parses_stdout_flag() {
376 let a = Args::parse_from(["tess", "--stdout", "f"]);
377 assert!(a.stdout);
378 assert_eq!(a.output, None);
379 }
380
381 #[test]
382 fn output_and_stdout_are_mutually_exclusive() {
383 let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
384 assert!(r.is_err(), "clap should reject combining --output and --stdout");
385 }
386
387 #[test]
388 fn parses_mouse_flag() {
389 let a = Args::parse_from(["tess", "--mouse", "f"]);
390 assert!(a.mouse);
391 }
392
393 #[test]
394 fn mouse_defaults_off() {
395 let a = Args::parse_from(["tess", "f"]);
396 assert!(!a.mouse);
397 }
398
399 #[test]
400 fn help_lists_flags_in_alphabetical_order() {
401 use clap::CommandFactory;
402 let mut cmd = Args::command();
403 let help = cmd.render_help().to_string();
404
405 let expected = [
406 "--chop-long-lines",
407 "--content-type",
408 "--dim",
409 "--display",
410 "--examples",
411 "--filter",
412 "--follow",
413 "--format",
414 "--grep",
415 "--head",
416 "--hex",
417 "--hex-group",
418 "--LINE-NUMBERS",
419 "--list-formats",
420 "--live",
421 "--manual",
422 "--mouse",
423 "--no-color",
424 "--no-preprocess",
425 "--output",
426 "--preprocess",
427 "--prettify",
428 "--prompt",
429 "--raw-control-chars",
430 "--record-start",
431 "--stdout",
432 "--tab-width",
433 "--tag",
434 "--tag-file",
435 "--tail",
436 ];
437 let listed: Vec<&str> = help
438 .lines()
439 .map(str::trim_start)
440 .filter(|l| l.starts_with('-'))
441 .filter_map(|l| {
442 l.split(|c: char| c.is_whitespace() || c == ',')
443 .find(|tok| expected.contains(tok))
444 })
445 .collect();
446 assert_eq!(listed, expected, "help long-flag order should be alphabetical");
447 }
448}