Skip to main content

tess/
cli.rs

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    /// Chop long lines instead of wrapping.
8    #[arg(short = 'S', long = "chop-long-lines", display_order = 1)]
9    pub chop: bool,
10
11    /// Force the content type for `--prettify` (otherwise auto-detected from
12    /// the filename extension and the first bytes). Values:
13    /// `auto`, `raw`, `json`, `yaml`, `toml`, `xml`, `html`, `csv`.
14    /// Setting this implies `--prettify` (unless the value is `raw`/`auto`).
15    #[arg(long = "content-type", value_name = "TYPE", display_order = 2)]
16    pub content_type: Option<String>,
17
18    /// With `--filter`, dim non-matching lines instead of hiding them. Keeps
19    /// surrounding context visible.
20    #[arg(long = "dim", display_order = 3)]
21    pub dim: bool,
22
23    /// Render each parsed line through this template instead of showing the
24    /// raw line. Syntax: `<fieldname>` placeholders, `\<` for literal `<`,
25    /// `\\` for literal `\`. Example: `--display '[<time>] <status> <msg>'`.
26    /// Overrides the format's `display` key (if set). Requires `--format`.
27    /// Search still matches against the raw line.
28    #[arg(long = "display", value_name = "TEMPLATE", display_order = 4)]
29    pub display: Option<String>,
30
31    /// Print a curated list of usage examples and exit.
32    #[arg(long = "examples", display_order = 5)]
33    pub examples: bool,
34
35    /// Filter visible lines by parsed field. Repeatable; multiple filters AND.
36    /// Operators: `=` (exact), `!=` (exact ≠), `~` (regex), `!~` (regex ≠),
37    /// `<`, `<=`, `>`, `>=` (numeric if both sides parse as numbers, else
38    /// lexicographic). Examples: `--filter status=500`, `--filter ip~^10\.`,
39    /// `--filter 'status>=500'` (quote `<` and `>` to avoid shell redirection).
40    /// Requires `--format`.
41    #[arg(long = "filter", value_name = "FIELD<op>VALUE", display_order = 6)]
42    pub filter: Vec<String>,
43
44    /// Follow mode: keep watching the source for new bytes (like `tail -f`).
45    /// Jumps to the bottom on startup. Toggle with Shift-F at runtime.
46    #[arg(short = 'f', long = "follow", display_order = 7)]
47    pub follow: bool,
48
49    /// Apply a named log format (built-in or user-defined in
50    /// ~/.config/tess/formats.toml). Required by `--filter`.
51    #[arg(long = "format", value_name = "NAME", display_order = 8)]
52    pub format: Option<String>,
53
54    /// Show only the first N lines of the source. Mutually exclusive with --tail.
55    #[arg(long = "head", value_name = "N", conflicts_with = "tail", display_order = 9)]
56    pub head: Option<usize>,
57
58    /// Show line numbers.
59    #[arg(short = 'N', long = "LINE-NUMBERS", display_order = 10)]
60    pub line_numbers: bool,
61
62    /// Print available log formats and their named fields, then exit.
63    #[arg(long = "list-formats", display_order = 11)]
64    pub list_formats: bool,
65
66    /// Live mode: re-read the file when its on-disk content changes (mtime,
67    /// size, or inode). Use this for files rewritten in place — source files
68    /// being edited, files saved by an editor or AI agent. Different from
69    /// `--follow` (which watches for *appended* bytes); the two are mutually
70    /// exclusive. Press `R` inside the pager to force a reload.
71    #[arg(long = "live", conflicts_with = "follow", display_order = 12)]
72    pub live: bool,
73
74    /// Print the full user manual and exit.
75    #[arg(long = "manual", display_order = 13)]
76    pub manual: bool,
77
78    /// Non-interactive batch mode: apply --filter / --head / --tail / --prettify
79    /// to the source and write the resulting raw bytes to FILE, then exit.
80    /// Use `-` for stdout (`--stdout` is a synonym). Skips the alt-screen and
81    /// raw mode entirely. With `--follow`, doesn't exit — keeps appending
82    /// matching new bytes to FILE as they arrive (Ctrl-C to stop). Not
83    /// compatible with `--live`.
84    #[arg(short = 'o', long = "output", value_name = "FILE", display_order = 14)]
85    pub output: Option<String>,
86
87    /// Pretty-print structured content (JSON, YAML, TOML, XML, HTML, CSV).
88    /// Detects the type from the filename extension or the first bytes; use
89    /// `--content-type=NAME` to override. Static files only — not allowed
90    /// with `--follow`, `--live`, or `--filter`. Toggle interactively with
91    /// `Shift-P`; force a type with `-P` then a letter (j/y/t/x/h/c).
92    #[arg(long = "prettify", display_order = 15)]
93    pub prettify: bool,
94
95    /// Synonym for `--output -`: write the batch-mode output to stdout.
96    #[arg(long = "stdout", conflicts_with = "output", display_order = 16)]
97    pub stdout: bool,
98
99    /// Tab stop width (default 8).
100    #[arg(long = "tab-width", default_value_t = 8, display_order = 17)]
101    pub tab_width: u8,
102
103    /// Show only the last N lines of the source. For files this skips most of
104    /// the index work — useful for huge logs. Combine with `-f` for `tail -f`.
105    /// Mutually exclusive with --head. Streaming stdin is not supported.
106    #[arg(long = "tail", value_name = "N", conflicts_with = "head", display_order = 18)]
107    pub tail: Option<usize>,
108
109    /// Files to view (only the first is opened in MVP).
110    pub files: Vec<PathBuf>,
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn parses_no_flags_no_files() {
119        let a = Args::parse_from(["tess"]);
120        assert!(!a.line_numbers);
121        assert!(!a.chop);
122        assert_eq!(a.tab_width, 8);
123        assert!(a.files.is_empty());
124    }
125
126    #[test]
127    fn parses_short_flags_and_file() {
128        let a = Args::parse_from(["tess", "-N", "-S", "foo.txt"]);
129        assert!(a.line_numbers);
130        assert!(a.chop);
131        assert_eq!(a.files, vec![PathBuf::from("foo.txt")]);
132    }
133
134    #[test]
135    fn parses_tab_width() {
136        let a = Args::parse_from(["tess", "--tab-width", "4", "x"]);
137        assert_eq!(a.tab_width, 4);
138    }
139
140    #[test]
141    fn collects_multiple_files() {
142        let a = Args::parse_from(["tess", "a", "b", "c"]);
143        assert_eq!(a.files.len(), 3);
144    }
145
146    #[test]
147    fn parses_follow_short_flag() {
148        let a = Args::parse_from(["tess", "-f", "log.txt"]);
149        assert!(a.follow);
150        assert_eq!(a.files, vec![PathBuf::from("log.txt")]);
151    }
152
153    #[test]
154    fn parses_follow_long_flag() {
155        let a = Args::parse_from(["tess", "--follow"]);
156        assert!(a.follow);
157    }
158
159    #[test]
160    fn follow_defaults_off() {
161        let a = Args::parse_from(["tess", "x"]);
162        assert!(!a.follow);
163    }
164
165    #[test]
166    fn parses_head() {
167        let a = Args::parse_from(["tess", "--head", "100", "x"]);
168        assert_eq!(a.head, Some(100));
169        assert_eq!(a.tail, None);
170    }
171
172    #[test]
173    fn parses_tail() {
174        let a = Args::parse_from(["tess", "--tail", "50", "x"]);
175        assert_eq!(a.tail, Some(50));
176        assert_eq!(a.head, None);
177    }
178
179    #[test]
180    fn head_and_tail_are_mutually_exclusive() {
181        let r = Args::try_parse_from(["tess", "--head", "10", "--tail", "20", "x"]);
182        assert!(r.is_err(), "clap should reject combining --head and --tail");
183    }
184
185    #[test]
186    fn head_tail_default_to_none() {
187        let a = Args::parse_from(["tess", "x"]);
188        assert!(a.head.is_none());
189        assert!(a.tail.is_none());
190    }
191
192    #[test]
193    fn parses_format_and_filter() {
194        let a = Args::parse_from([
195            "tess", "--format", "apache-combined",
196            "--filter", "status=500",
197            "--filter", "ip~^10\\.",
198            "log",
199        ]);
200        assert_eq!(a.format.as_deref(), Some("apache-combined"));
201        assert_eq!(a.filter.len(), 2);
202        assert_eq!(a.filter[0], "status=500");
203    }
204
205    #[test]
206    fn parses_dim() {
207        let a = Args::parse_from(["tess", "--format", "x", "--filter", "y=z", "--dim", "f"]);
208        assert!(a.dim);
209    }
210
211    #[test]
212    fn parses_list_formats() {
213        let a = Args::parse_from(["tess", "--list-formats"]);
214        assert!(a.list_formats);
215    }
216
217    #[test]
218    fn parses_manual() {
219        let a = Args::parse_from(["tess", "--manual"]);
220        assert!(a.manual);
221    }
222
223    #[test]
224    fn parses_examples() {
225        let a = Args::parse_from(["tess", "--examples"]);
226        assert!(a.examples);
227    }
228
229    #[test]
230    fn parses_live() {
231        let a = Args::parse_from(["tess", "--live", "f"]);
232        assert!(a.live);
233        assert!(!a.follow);
234    }
235
236    #[test]
237    fn live_and_follow_are_mutually_exclusive() {
238        let r = Args::try_parse_from(["tess", "--live", "--follow", "f"]);
239        assert!(r.is_err(), "clap should reject combining --live and --follow");
240    }
241
242    #[test]
243    fn parses_prettify() {
244        let a = Args::parse_from(["tess", "--prettify", "f.json"]);
245        assert!(a.prettify);
246        assert_eq!(a.content_type, None);
247    }
248
249    #[test]
250    fn parses_content_type() {
251        let a = Args::parse_from(["tess", "--content-type", "json", "f"]);
252        assert_eq!(a.content_type.as_deref(), Some("json"));
253    }
254
255    #[test]
256    fn parses_output_long_and_short() {
257        let a = Args::parse_from(["tess", "-o", "/tmp/out.txt", "f"]);
258        assert_eq!(a.output.as_deref(), Some("/tmp/out.txt"));
259        let b = Args::parse_from(["tess", "--output", "/tmp/out.txt", "f"]);
260        assert_eq!(b.output.as_deref(), Some("/tmp/out.txt"));
261    }
262
263    #[test]
264    fn parses_stdout_flag() {
265        let a = Args::parse_from(["tess", "--stdout", "f"]);
266        assert!(a.stdout);
267        assert_eq!(a.output, None);
268    }
269
270    #[test]
271    fn output_and_stdout_are_mutually_exclusive() {
272        let r = Args::try_parse_from(["tess", "-o", "x", "--stdout", "f"]);
273        assert!(r.is_err(), "clap should reject combining --output and --stdout");
274    }
275
276    #[test]
277    fn help_lists_flags_in_alphabetical_order() {
278        use clap::CommandFactory;
279        let mut cmd = Args::command();
280        let help = cmd.render_help().to_string();
281
282        let expected = [
283            "--chop-long-lines",
284            "--content-type",
285            "--dim",
286            "--display",
287            "--examples",
288            "--filter",
289            "--follow",
290            "--format",
291            "--head",
292            "--LINE-NUMBERS",
293            "--list-formats",
294            "--live",
295            "--manual",
296            "--output",
297            "--prettify",
298            "--stdout",
299            "--tab-width",
300            "--tail",
301        ];
302        let listed: Vec<&str> = help
303            .lines()
304            .map(str::trim_start)
305            .filter(|l| l.starts_with('-'))
306            .filter_map(|l| {
307                l.split(|c: char| c.is_whitespace() || c == ',')
308                    .find(|tok| expected.contains(tok))
309            })
310            .collect();
311        assert_eq!(listed, expected, "help long-flag order should be alphabetical");
312    }
313}