1use std::path::PathBuf;
2use clap::Parser;
3
4#[derive(Parser, Debug, Clone)]
5#[command(name = "tess", version, about = "A less-style terminal pager.")]
6pub struct Args {
7 #[arg(short = 'S', long = "chop-long-lines", display_order = 1)]
9 pub chop: bool,
10
11 #[arg(long = "content-type", value_name = "TYPE", display_order = 2)]
16 pub content_type: Option<String>,
17
18 #[arg(long = "dim", display_order = 3)]
21 pub dim: bool,
22
23 #[arg(long = "display", value_name = "TEMPLATE", display_order = 4)]
29 pub display: Option<String>,
30
31 #[arg(long = "examples", display_order = 5)]
33 pub examples: bool,
34
35 #[arg(long = "filter", value_name = "FIELD<op>VALUE", display_order = 6)]
42 pub filter: Vec<String>,
43
44 #[arg(short = 'f', long = "follow", display_order = 7)]
47 pub follow: bool,
48
49 #[arg(long = "format", value_name = "NAME", display_order = 8)]
52 pub format: Option<String>,
53
54 #[arg(long = "grep", value_name = "PATTERN", display_order = 9)]
60 pub grep: Vec<String>,
61
62 #[arg(long = "head", value_name = "N", conflicts_with = "tail", display_order = 10)]
64 pub head: Option<usize>,
65
66 #[arg(
70 long = "hex",
71 display_order = 11,
72 conflicts_with_all = ["filter", "grep", "prettify", "format", "display", "record_start", "prompt", "preprocess"],
73 )]
74 pub hex: bool,
75
76 #[arg(short = 'N', long = "LINE-NUMBERS", display_order = 12)]
78 pub line_numbers: bool,
79
80 #[arg(long = "list-formats", display_order = 13)]
82 pub list_formats: bool,
83
84 #[arg(long = "live", conflicts_with = "follow", display_order = 14)]
90 pub live: bool,
91
92 #[arg(long = "manual", display_order = 15)]
94 pub manual: bool,
95
96 #[arg(long = "no-color", display_order = 16)]
100 pub no_color: bool,
101
102 #[arg(long = "no-preprocess", conflicts_with = "preprocess", display_order = 17)]
105 pub no_preprocess: bool,
106
107 #[arg(short = 'o', long = "output", value_name = "FILE", display_order = 18)]
114 pub output: Option<String>,
115
116 #[arg(
120 long = "preprocess",
121 value_name = "CMD",
122 conflicts_with_all = ["no_preprocess", "hex", "follow", "live"],
123 display_order = 19,
124 )]
125 pub preprocess: Option<String>,
126
127 #[arg(long = "prettify", display_order = 20)]
133 pub prettify: bool,
134
135 #[arg(long = "prompt", value_name = "TEMPLATE", conflicts_with = "hex", display_order = 21)]
143 pub prompt: Option<String>,
144
145 #[arg(short = 'r', long = "raw-control-chars", conflicts_with = "no_color", display_order = 22)]
149 pub raw_control_chars: bool,
150
151 #[arg(long = "record-start", value_name = "REGEX", display_order = 23)]
158 pub record_start: Option<String>,
159
160 #[arg(long = "stdout", conflicts_with = "output", display_order = 24)]
162 pub stdout: bool,
163
164 #[arg(long = "tab-width", default_value_t = 8, display_order = 25)]
166 pub tab_width: u8,
167
168 #[arg(short = 't', long = "tag", value_name = "NAME", display_order = 26)]
170 pub tag: Option<String>,
171
172 #[arg(short = 'T', long = "tag-file", value_name = "PATH", display_order = 27)]
174 pub tag_file: Option<std::path::PathBuf>,
175
176 #[arg(long = "tail", value_name = "N", conflicts_with = "head", display_order = 28)]
180 pub tail: Option<usize>,
181
182 pub files: Vec<PathBuf>,
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn parses_no_flags_no_files() {
192 let a = Args::parse_from(["tess"]);
193 assert!(!a.line_numbers);
194 assert!(!a.chop);
195 assert_eq!(a.tab_width, 8);
196 assert!(a.files.is_empty());
197 }
198
199 #[test]
200 fn parses_short_flags_and_file() {
201 let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
202 assert!(a.line_numbers);
203 assert!(a.chop);
204 assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
205 }
206
207 #[test]
208 fn parses_tab_width() {
209 let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
210 assert_eq!(a.tab_width, 4);
211 }
212
213 #[test]
214 fn collects_multiple_files() {
215 let a = Args::parse_from(["tess", "a", "b", "c"]);
216 assert_eq!(a.files.len(), 3);
217 }
218
219 #[test]
220 fn parses_follow_short_flag() {
221 let a = Args::parse_from(["tess", "-f", "log.txt"]);
222 assert!(a.follow);
223 assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
224 }
225
226 #[test]
227 fn parses_follow_long_flag() {
228 let a = Args::parse_from(["tess", "--follow"]);
229 assert!(a.follow);
230 }
231
232 #[test]
233 fn follow_defaults_off() {
234 let a = Args::parse_from(["tess", "x"]);
235 assert!(!a.follow);
236 }
237
238 #[test]
239 fn parses_head() {
240 let a = Args::parse_from(["tess", "--head", "100", "x"]);
241 assert_eq!(a.head, Some(100));
242 assert_eq!(a.tail, None);
243 }
244
245 #[test]
246 fn parses_tail() {
247 let a = Args::parse_from(["tess", "--tail", "50", "x"]);
248 assert_eq!(a.tail, Some(50));
249 assert_eq!(a.head, None);
250 }
251
252 #[test]
253 fn head_and_tail_are_mutually_exclusive() {
254 let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
255 assert!(r.is_err(), "clap should reject combining --head and --tail");
256 }
257
258 #[test]
259 fn head_tail_default_to_none() {
260 let a = Args::parse_from(["tess", "x"]);
261 assert!(a.head.is_none());
262 assert!(a.tail.is_none());
263 }
264
265 #[test]
266 fn parses_grep_repeatable_and_no_format_required() {
267 let a = Args::parse_from([
268 "tess",
269 "--grep", "error",
270 "--grep", r"^\[",
271 "log",
272 ]);
273 assert_eq!(a.grep.len(), 2);
274 assert_eq!(a.grep[0], "error");
275 assert_eq!(a.grep[1], r"^\[");
276 assert_eq!(a.format, None);
277 }
278
279 #[test]
280 fn parses_format_and_filter() {
281 let a = Args::parse_from([
282 "tess", "--format", "apache-combined",
283 "--filter", "status=500",
284 "--filter", "ip~^10\\.",
285 "log",
286 ]);
287 assert_eq!(a.format.as_deref(), Some("apache-combined"));
288 assert_eq!(a.filter.len(), 2);
289 assert_eq!(a.filter[0], "status=500");
290 }
291
292 #[test]
293 fn parses_dim() {
294 let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
295 assert!(a.dim);
296 }
297
298 #[test]
299 fn parses_list_formats() {
300 let a = Args::parse_from(["tess", "--list-formats"]);
301 assert!(a.list_formats);
302 }
303
304 #[test]
305 fn parses_manual() {
306 let a = Args::parse_from(["tess", "--manual"]);
307 assert!(a.manual);
308 }
309
310 #[test]
311 fn parses_examples() {
312 let a = Args::parse_from(["tess", "--examples"]);
313 assert!(a.examples);
314 }
315
316 #[test]
317 fn parses_live() {
318 let a = Args::parse_from(["tess", "--live", "f"]);
319 assert!(a.live);
320 assert!(!a.follow);
321 }
322
323 #[test]
324 fn live_and_follow_are_mutually_exclusive() {
325 let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
326 assert!(r.is_err(), "clap should reject combining --live and --follow");
327 }
328
329 #[test]
330 fn parses_prettify() {
331 let a = Args::parse_from(["tess", "--prettify", "f.json"]);
332 assert!(a.prettify);
333 assert_eq!(a.content_type, None);
334 }
335
336 #[test]
337 fn parses_content_type() {
338 let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
339 assert_eq!(a.content_type.as_deref(), Some("json"));
340 }
341
342 #[test]
343 fn parses_output_long_and_short() {
344 let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
345 assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
346 let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
347 assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
348 }
349
350 #[test]
351 fn parses_stdout_flag() {
352 let a = Args::parse_from(["tess", "--stdout", "f"]);
353 assert!(a.stdout);
354 assert_eq!(a.output, None);
355 }
356
357 #[test]
358 fn output_and_stdout_are_mutually_exclusive() {
359 let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
360 assert!(r.is_err(), "clap should reject combining --output and --stdout");
361 }
362
363 #[test]
364 fn help_lists_flags_in_alphabetical_order() {
365 use clap::CommandFactory;
366 let mut cmd = Args::command();
367 let help = cmd.render_help().to_string();
368
369 let expected = [
370 "--chop-long-lines",
371 "--content-type",
372 "--dim",
373 "--display",
374 "--examples",
375 "--filter",
376 "--follow",
377 "--format",
378 "--grep",
379 "--head",
380 "--hex",
381 "--LINE-NUMBERS",
382 "--list-formats",
383 "--live",
384 "--manual",
385 "--no-color",
386 "--no-preprocess",
387 "--output",
388 "--preprocess",
389 "--prettify",
390 "--prompt",
391 "--raw-control-chars",
392 "--record-start",
393 "--stdout",
394 "--tab-width",
395 "--tag",
396 "--tag-file",
397 "--tail",
398 ];
399 let listed: Vec<&str> = help
400 .lines()
401 .map(str::trim_start)
402 .filter(|l| l.starts_with('-'))
403 .filter_map(|l| {
404 l.split(|c: char| c.is_whitespace() || c == ',')
405 .find(|tok| expected.contains(tok))
406 })
407 .collect();
408 assert_eq!(listed, expected, "help long-flag order should be alphabetical");
409 }
410}