Skip to main content

tess/
format.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6
7/// A named log format: a regex with named capture groups identifying the
8/// fields of one log line. Used by filtering to look up field values by name.
9#[derive(Debug)]
10pub struct LogFormat {
11    pub name: String,
12    pub regex: Regex,
13    /// Capture group names declared in the regex, in declaration order.
14    /// Used by `--list-formats` to show users what fields are available.
15    pub field_names: Vec<String>,
16    /// Optional default display template (`display` key in formats.toml).
17    /// When set and no CLI override is given, the viewer / batch output
18    /// renders each parsed line through this template instead of the raw line.
19    pub display: Option<DisplayTemplate>,
20    pub record_start: Option<Regex>,
21    /// Optional default status-line prompt template (`prompt` key in formats.toml).
22    /// When set and no `--prompt` CLI flag is given, the viewport renders the
23    /// status line through this template instead of the built-in default.
24    pub prompt: Option<crate::prompt::ParsedPrompt>,
25    /// Optional default style for the status row when this format's prompt
26    /// is active. Per-format value; CLI `--prompt-style` overrides.
27    pub prompt_style: Option<crate::ansi::Style>,
28}
29
30impl LogFormat {
31    pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
32        Self::compile_full(name, pattern, None, None, None)
33    }
34
35    pub fn compile_with_display(
36        name: &str,
37        pattern: &str,
38        display: Option<&str>,
39    ) -> Result<Self, String> {
40        Self::compile_full(name, pattern, display, None, None)
41    }
42
43    pub fn compile_full(
44        name: &str,
45        pattern: &str,
46        display: Option<&str>,
47        record_start: Option<&str>,
48        prompt: Option<&str>,
49    ) -> Result<Self, String> {
50        let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
51        let field_names: Vec<String> = regex
52            .capture_names()
53            .flatten()
54            .map(|s| s.to_string())
55            .collect();
56        if field_names.is_empty() {
57            return Err(format!(
58                "format `{name}`: regex must declare at least one named capture group"
59            ));
60        }
61        let display = display
62            .map(|s| {
63                DisplayTemplate::compile(s, &field_names)
64                    .map_err(|e| format!("format `{name}`: display: {e}"))
65            })
66            .transpose()?;
67        let record_start = record_start
68            .map(|s| Regex::new(s).map_err(|e| format!("format `{name}`: record_start: {e}")))
69            .transpose()?;
70        let prompt = prompt
71            .map(|s| crate::prompt::ParsedPrompt::parse(s)
72                .map_err(|e| format!("format `{name}`: prompt: {e}")))
73            .transpose()?;
74        Ok(Self {
75            name: name.to_string(),
76            regex,
77            field_names,
78            display,
79            record_start,
80            prompt,
81            prompt_style: None,
82        })
83    }
84}
85
86/// Parsed display template (`display = '[<ts>] <level> <msg>'`).
87///
88/// Syntax:
89/// - `<fieldname>` — replaced with the field's captured value (empty if
90///   the regex didn't capture it on this line).
91/// - `\<` — literal `<`.
92/// - `\\` — literal `\`.
93/// - Anything else — literal.
94#[derive(Debug, Clone)]
95pub struct DisplayTemplate {
96    segments: Vec<DisplaySegment>,
97    source: String,
98}
99
100#[derive(Debug, Clone)]
101enum DisplaySegment {
102    Literal(String),
103    Field(String),
104}
105
106impl DisplayTemplate {
107    pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
108        if source.is_empty() {
109            return Err("template is empty (would render every line as nothing)".to_string());
110        }
111        let mut segments: Vec<DisplaySegment> = Vec::new();
112        let mut buf = String::new();
113        let mut chars = source.chars().peekable();
114        while let Some(c) = chars.next() {
115            match c {
116                '\\' => match chars.next() {
117                    Some('<') => buf.push('<'),
118                    Some('\\') => buf.push('\\'),
119                    Some('n') => buf.push('\n'),
120                    Some('t') => buf.push('\t'),
121                    Some('r') => buf.push('\r'),
122                    Some('e') => buf.push('\x1b'),
123                    Some('x') => {
124                        let h1 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
125                        let h2 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
126                        let hex: String = [h1, h2].iter().collect();
127                        let byte = u8::from_str_radix(&hex, 16)
128                            .map_err(|_| format!("invalid `\\x{hex}` escape"))?;
129                        buf.push(byte as char);
130                    }
131                    Some('0') => {
132                        let d1 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
133                        let d2 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
134                        let oct: String = ['0', d1, d2].iter().collect();
135                        let byte = u8::from_str_radix(&oct, 8)
136                            .map_err(|_| format!("invalid `\\{oct}` escape"))?;
137                        buf.push(byte as char);
138                    }
139                    Some(other) => {
140                        // Unknown escape: keep both bytes literally so users
141                        // don't have to escape every backslash in regex-like
142                        // strings.
143                        buf.push('\\');
144                        buf.push(other);
145                    }
146                    None => return Err("template ends with a lone `\\`".to_string()),
147                },
148                '<' => {
149                    if !buf.is_empty() {
150                        segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
151                    }
152                    let mut name = String::new();
153                    let mut closed = false;
154                    while let Some(&nc) = chars.peek() {
155                        chars.next();
156                        if nc == '>' { closed = true; break; }
157                        name.push(nc);
158                    }
159                    if !closed {
160                        return Err(format!("unterminated `<` (expected `<{name}>`)"));
161                    }
162                    if name.is_empty() {
163                        return Err("empty field reference `<>`".to_string());
164                    }
165                    if !field_names.iter().any(|n| n == &name) {
166                        return Err(format!(
167                            "unknown field `{name}` (available: {})",
168                            field_names.join(", ")
169                        ));
170                    }
171                    segments.push(DisplaySegment::Field(name));
172                }
173                _ => buf.push(c),
174            }
175        }
176        if !buf.is_empty() {
177            segments.push(DisplaySegment::Literal(buf));
178        }
179        Ok(Self { segments, source: source.to_string() })
180    }
181
182    /// Render the template against a captures-lookup closure. Returns the
183    /// rendered string. Missing fields render as empty.
184    pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
185        let mut out = String::new();
186        for seg in &self.segments {
187            match seg {
188                DisplaySegment::Literal(s) => out.push_str(s),
189                DisplaySegment::Field(name) => {
190                    if let Some(v) = lookup(name) { out.push_str(&v); }
191                }
192            }
193        }
194        out
195    }
196
197    pub fn source(&self) -> &str { &self.source }
198}
199
200/// Pairs a `DisplayTemplate` with the format's regex so callers can render
201/// any single line in one call. Owns its inputs so it's `Send`-friendly.
202#[derive(Debug, Clone)]
203pub struct DisplayRenderer {
204    template: DisplayTemplate,
205    regex: Regex,
206}
207
208impl DisplayRenderer {
209    pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
210        Self { template, regex }
211    }
212
213    pub fn template(&self) -> &DisplayTemplate { &self.template }
214
215    /// Render `line` (raw bytes) through the template. If the line doesn't
216    /// parse against the format regex, returns `None` — the caller decides
217    /// whether to fall back to the raw line, skip it, or show an error.
218    pub fn render_line(&self, line: &[u8]) -> Option<String> {
219        let s = std::str::from_utf8(line).ok()?;
220        let caps = self.regex.captures(s)?;
221        Some(self.template.render(|name| {
222            caps.name(name).map(|m| m.as_str().to_string())
223        }))
224    }
225}
226
227/// TOML schema for `~/.config/tess/formats.toml`:
228///
229/// ```toml
230/// [format.myapp]
231/// regex = "..."
232///
233/// [group.errorlog]
234/// format = "myapp"
235/// file = "/var/log/app.log"
236/// follow = true
237/// filter = ["level=ERROR"]
238/// ```
239#[derive(Debug, Deserialize)]
240struct UserConfig {
241    #[serde(default)]
242    format: HashMap<String, FormatEntry>,
243    #[serde(default)]
244    group: HashMap<String, GroupEntry>,
245}
246
247#[derive(Debug, Deserialize)]
248struct FormatEntry {
249    regex: String,
250    #[serde(default)]
251    display: Option<String>,
252    #[serde(default)]
253    record_start: Option<String>,
254    #[serde(default)]
255    prompt: Option<String>,
256    /// Optional style for the status row when this format is active and a
257    /// custom prompt is rendered. Parsed via `crate::style_spec`. CLI
258    /// `--prompt-style` overrides this.
259    #[serde(default)]
260    prompt_style: Option<String>,
261}
262
263/// Raw group entry as deserialized from TOML. Promoted to `Group` after
264/// validation.
265#[derive(Debug, Deserialize, Default)]
266struct GroupEntry {
267    format: Option<String>,
268    file: Option<String>,
269    follow: Option<bool>,
270    tail: Option<usize>,
271    head: Option<usize>,
272    dim: Option<bool>,
273    line_numbers: Option<bool>,
274    chop: Option<bool>,
275    tab_width: Option<u8>,
276    #[serde(default)]
277    filter: Vec<String>,
278    #[serde(default)]
279    grep: Vec<String>,
280}
281
282/// A user-defined CLI shortcut. When `tess --<group_name>` appears in argv,
283/// the group's flags are expanded inline and remaining positionals become
284/// `--filter` arguments.
285#[derive(Debug, Clone, Default)]
286pub struct Group {
287    pub name: String,
288    pub format: Option<String>,
289    pub file: Option<String>,
290    pub follow: bool,
291    pub tail: Option<usize>,
292    pub head: Option<usize>,
293    pub dim: bool,
294    pub line_numbers: bool,
295    pub chop: bool,
296    pub tab_width: Option<u8>,
297    pub filter: Vec<String>,
298    pub grep: Vec<String>,
299}
300
301/// Long-form names of every built-in clap flag. A group cannot reuse one of
302/// these names — it would shadow the real flag at expansion time.
303const RESERVED_LONG_FLAGS: &[&str] = &[
304    "format",
305    "filter",
306    "grep",
307    "dim",
308    "head",
309    "tail",
310    "follow",
311    "LINE-NUMBERS",
312    "chop-long-lines",
313    "tab-width",
314    "list-formats",
315    "live",
316    "manual",
317    "examples",
318    "prettify",
319    "content-type",
320    "help",
321    "version",
322    "record-start",
323    "hex",
324    "prompt",
325    "preprocess",
326    "no-preprocess",
327    "no-color",
328    "raw-control-chars",
329    "tag",
330    "tag-file",
331];
332
333/// Built-in formats compiled from this list of (name, pattern). Patterns use
334/// raw strings so backslashes don't need escaping.
335const BUILTINS: &[(&str, &str)] = &[
336    (
337        "apache-common",
338        r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
339    ),
340    (
341        "apache-combined",
342        r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+) "(?P<referer>[^"]*)" "(?P<agent>[^"]*)"$"#,
343    ),
344    (
345        "nginx-combined",
346        r#"^(?P<ip>\S+) - (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+) "(?P<referer>[^"]*)" "(?P<agent>[^"]*)"$"#,
347    ),
348];
349
350fn user_config_path() -> Option<PathBuf> {
351    std::env::var_os("HOME").map(|h| {
352        let mut p = PathBuf::from(h);
353        p.push(".config");
354        p.push("tess");
355        p.push("formats.toml");
356        p
357    })
358}
359
360fn load_user_config() -> Result<UserConfig, String> {
361    let Some(path) = user_config_path() else {
362        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
363    };
364    if !path.exists() {
365        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
366    }
367    let text = std::fs::read_to_string(&path)
368        .map_err(|e| format!("reading {}: {e}", path.display()))?;
369    toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))
370}
371
372struct FormatSource {
373    regex: String,
374    display: Option<String>,
375    record_start: Option<String>,
376    prompt: Option<String>,
377    prompt_style: Option<String>,
378}
379
380fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
381    let cfg = load_user_config()?;
382    Ok(cfg.format.into_iter().map(|(k, v)| (k, FormatSource {
383        regex: v.regex,
384        display: v.display,
385        record_start: v.record_start,
386        prompt: v.prompt,
387        prompt_style: v.prompt_style,
388    })).collect())
389}
390
391/// Load all user-defined groups from `~/.config/tess/formats.toml`. Built-ins
392/// are not provided — groups are entirely user-defined. Validates that group
393/// names don't shadow built-in flag names.
394pub fn load_groups() -> Result<HashMap<String, Group>, String> {
395    let cfg = load_user_config()?;
396    let mut out = HashMap::with_capacity(cfg.group.len());
397    for (name, entry) in cfg.group {
398        if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
399            return Err(format!(
400                "group `{name}`: name collides with built-in --{name} flag"
401            ));
402        }
403        out.insert(
404            name.clone(),
405            Group {
406                name,
407                format: entry.format,
408                file: entry.file,
409                follow: entry.follow.unwrap_or(false),
410                tail: entry.tail,
411                head: entry.head,
412                dim: entry.dim.unwrap_or(false),
413                line_numbers: entry.line_numbers.unwrap_or(false),
414                chop: entry.chop.unwrap_or(false),
415                tab_width: entry.tab_width,
416                filter: entry.filter,
417                grep: entry.grep,
418            },
419        );
420    }
421    Ok(out)
422}
423
424/// Load all formats: built-ins first, then any in `~/.config/tess/formats.toml`
425/// (which override built-ins of the same name). Returns the compiled map keyed
426/// by format name.
427pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
428    let mut sources: HashMap<String, FormatSource> = HashMap::new();
429    for (name, pat) in BUILTINS {
430        sources.insert(name.to_string(), FormatSource {
431            regex: pat.to_string(),
432            display: None,
433            record_start: None,
434            prompt: None,
435            prompt_style: None,
436        });
437    }
438    let user = load_user_formats()?;
439    for (name, src) in user {
440        sources.insert(name, src);
441    }
442    let mut compiled = HashMap::new();
443    for (name, src) in sources {
444        let mut fmt = LogFormat::compile_full(
445            &name,
446            &src.regex,
447            src.display.as_deref(),
448            src.record_start.as_deref(),
449            src.prompt.as_deref(),
450        )?;
451        if let Some(spec) = src.prompt_style.as_deref() {
452            fmt.prompt_style = Some(
453                crate::style_spec::parse(spec)
454                    .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
455            );
456        }
457        compiled.insert(name, fmt);
458    }
459    Ok(compiled)
460}
461
462/// Pre-process an argv vector before clap sees it. For every `--<name>`
463/// token that matches a defined group, expand the group's flags inline and
464/// switch into "filter mode" — bare positionals after the group token become
465/// `--filter <arg>` pairs. Group tokens before any flag still expand
466/// correctly; positionals before a group remain as-is.
467///
468/// CLI flags coming after the expansion override the group's values for
469/// `Option<T>` flags (clap takes the last occurrence) and add to repeatable
470/// flags like `--filter` (clap accumulates the `Vec<String>`).
471/// Long flags that take a separate value as the next argv token (e.g.
472/// `--tail 1000` rather than `--tail=1000`). Used by `expand_argv` so it
473/// doesn't mistake a flag's value for a positional.
474const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
475    "--format",
476    "--filter",
477    "--grep",
478    "--head",
479    "--tail",
480    "--tab-width",
481    "--record-start",
482];
483
484pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
485    if argv.is_empty() {
486        return argv;
487    }
488    let mut out = Vec::with_capacity(argv.len() * 2);
489    let mut iter = argv.into_iter();
490    out.push(iter.next().unwrap()); // argv[0] = program name
491    let mut filter_mode = false;
492    let mut pass_next = false;
493    for arg in iter {
494        if pass_next {
495            pass_next = false;
496            out.push(arg);
497            continue;
498        }
499        if let Some(name) = arg.strip_prefix("--") {
500            // `--flag=value` is a single token: don't try to match groups
501            // against `flag=value`.
502            if !name.contains('=') {
503                if let Some(g) = groups.get(name) {
504                    expand_group(g, &mut out);
505                    filter_mode = true;
506                    continue;
507                }
508                if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
509                    // The next token is this flag's value; pass it through
510                    // even in filter mode.
511                    out.push(arg);
512                    pass_next = true;
513                    continue;
514                }
515            }
516        }
517        if filter_mode && !arg.starts_with('-') {
518            out.push("--filter".into());
519            out.push(arg);
520            continue;
521        }
522        out.push(arg);
523    }
524    out
525}
526
527fn expand_group(g: &Group, out: &mut Vec<String>) {
528    if let Some(format) = &g.format {
529        out.push("--format".into());
530        out.push(format.clone());
531    }
532    if g.follow {
533        out.push("--follow".into());
534    }
535    if let Some(t) = g.tail {
536        out.push("--tail".into());
537        out.push(t.to_string());
538    }
539    if let Some(h) = g.head {
540        out.push("--head".into());
541        out.push(h.to_string());
542    }
543    if g.dim {
544        out.push("--dim".into());
545    }
546    if g.line_numbers {
547        out.push("-N".into());
548    }
549    if g.chop {
550        out.push("-S".into());
551    }
552    if let Some(t) = g.tab_width {
553        out.push("--tab-width".into());
554        out.push(t.to_string());
555    }
556    for f in &g.filter {
557        out.push("--filter".into());
558        out.push(f.clone());
559    }
560    for g_pat in &g.grep {
561        out.push("--grep".into());
562        out.push(g_pat.clone());
563    }
564    if let Some(file) = &g.file {
565        out.push(file.clone());
566    }
567}
568
569/// Print one line per format, with the named field list, to stdout. Used by
570/// `--list-formats`.
571pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
572    let mut names: Vec<&String> = formats.keys().collect();
573    names.sort();
574    for name in names {
575        let fmt = &formats[name];
576        let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
577        println!("{}: {}", name, fields.join(", "));
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use std::sync::Mutex;
585
586    /// Serializes tests that mutate the `HOME` env var; otherwise they
587    /// trample each other when cargo runs tests in parallel.
588    static HOME_LOCK: Mutex<()> = Mutex::new(());
589
590    #[test]
591    fn builtins_all_compile() {
592        for (name, pat) in BUILTINS {
593            LogFormat::compile(name, pat)
594                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
595        }
596    }
597
598    // ----- DisplayTemplate -----
599
600    fn fields() -> Vec<String> {
601        vec!["ts".into(), "level".into(), "msg".into()]
602    }
603
604    #[test]
605    fn display_template_compiles_basic() {
606        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
607        assert_eq!(t.source(), "[<ts>] <level> <msg>");
608    }
609
610    #[test]
611    fn display_template_renders_substitutions() {
612        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
613        let mut map = std::collections::HashMap::new();
614        map.insert("level".to_string(), "ERROR".to_string());
615        map.insert("msg".to_string(), "boom".to_string());
616        let out = t.render(|n| map.get(n).cloned());
617        assert_eq!(out, "ERROR: boom");
618    }
619
620    #[test]
621    fn display_template_missing_field_renders_empty() {
622        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
623        let mut map = std::collections::HashMap::new();
624        map.insert("level".to_string(), "ERROR".to_string());
625        // msg is absent
626        let out = t.render(|n| map.get(n).cloned());
627        assert_eq!(out, "ERROR:");
628    }
629
630    #[test]
631    fn display_template_escape_sequences() {
632        // Only `\<` and `\\` are recognized escapes; `>` is always literal
633        // (a stray `>` outside `<...>` is fine).
634        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
635        let mut map = std::collections::HashMap::new();
636        map.insert("level".to_string(), "X".to_string());
637        let out = t.render(|n| map.get(n).cloned());
638        assert_eq!(out, "<not a field> X");
639    }
640
641    #[test]
642    fn display_template_escape_backslash() {
643        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
644        let mut map = std::collections::HashMap::new();
645        map.insert("level".to_string(), "X".to_string());
646        let out = t.render(|n| map.get(n).cloned());
647        assert_eq!(out, r"a\b X");
648    }
649
650    #[test]
651    fn display_template_escape_e_emits_esc() {
652        let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
653        let mut map = std::collections::HashMap::new();
654        map.insert("level".to_string(), "X".to_string());
655        let out = t.render(|n| map.get(n).cloned());
656        assert_eq!(out, "\x1b[31mX\x1b[0m");
657    }
658
659    #[test]
660    fn display_template_escape_x1b_emits_esc() {
661        let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
662        let out = t.render(|_| Some("Y".to_string()));
663        assert_eq!(out, "\x1b[1mY");
664    }
665
666    #[test]
667    fn display_template_escape_octal_emits_esc() {
668        let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
669        let out = t.render(|_| Some("Z".to_string()));
670        assert_eq!(out, "\x1b[1mZ");
671    }
672
673    #[test]
674    fn display_template_escape_n_t_r() {
675        let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
676        let out = t.render(|_| Some("Q".to_string()));
677        assert_eq!(out, "\n\t\rQ");
678    }
679
680    #[test]
681    fn display_template_escape_unknown_preserves_backslash() {
682        let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
683        let out = t.render(|_| Some("Q".to_string()));
684        assert_eq!(out, r"\qQ");
685    }
686
687    #[test]
688    fn display_template_escape_x_incomplete_errors() {
689        let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
690        assert!(err.contains("incomplete"), "{err}");
691    }
692
693    #[test]
694    fn display_template_escape_invalid_hex_errors() {
695        let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
696        assert!(err.contains("invalid"), "{err}");
697    }
698
699    #[test]
700    fn display_template_rejects_empty() {
701        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
702        assert!(err.contains("empty"), "{err}");
703    }
704
705    #[test]
706    fn display_template_rejects_unknown_field() {
707        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
708        assert!(err.contains("unknown field"), "{err}");
709    }
710
711    #[test]
712    fn display_template_rejects_unterminated() {
713        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
714        assert!(err.contains("unterminated"), "{err}");
715    }
716
717    #[test]
718    fn display_template_rejects_empty_ref() {
719        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
720        assert!(err.contains("empty field reference"), "{err}");
721    }
722
723    #[test]
724    fn apache_common_extracts_fields() {
725        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
726        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
727        let caps = fmt.regex.captures(line).expect("should match");
728        assert_eq!(&caps["ip"], "127.0.0.1");
729        assert_eq!(&caps["user"], "alice");
730        assert_eq!(&caps["method"], "GET");
731        assert_eq!(&caps["url"], "/index.html");
732        assert_eq!(&caps["status"], "200");
733        assert_eq!(&caps["size"], "2326");
734    }
735
736    #[test]
737    fn apache_combined_extracts_referer_and_agent() {
738        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
739        let line = r#"10.1.2.3 - bob [10/Oct/2023:13:55:36 +0000] "POST /api/login HTTP/1.1" 401 512 "https://example.com/" "Mozilla/5.0""#;
740        let caps = fmt.regex.captures(line).expect("should match");
741        assert_eq!(&caps["status"], "401");
742        assert_eq!(&caps["url"], "/api/login");
743        assert_eq!(&caps["referer"], "https://example.com/");
744        assert_eq!(&caps["agent"], "Mozilla/5.0");
745    }
746
747    #[test]
748    fn field_names_listed_in_order() {
749        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
750        assert_eq!(
751            fmt.field_names,
752            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
753        );
754    }
755
756    #[test]
757    fn compile_rejects_regex_without_named_groups() {
758        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
759        assert!(err.contains("at least one named capture"), "{err}");
760    }
761
762    #[test]
763    fn compile_rejects_invalid_regex() {
764        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
765        assert!(err.contains("bad"), "{err}");
766    }
767
768    #[test]
769    fn load_groups_reads_user_config() {
770        let _g = HOME_LOCK.lock().unwrap();
771        let tmp = tempfile::tempdir().unwrap();
772        let cfg_dir = tmp.path().join(".config").join("tess");
773        std::fs::create_dir_all(&cfg_dir).unwrap();
774        std::fs::write(
775            cfg_dir.join("formats.toml"),
776            r#"
777[group.errorlog]
778format = "apache-combined"
779file = "/var/log/access.log"
780follow = true
781tail = 1000
782filter = ["status~^5"]
783
784[group.minimal]
785file = "/tmp/x.log"
786"#,
787        )
788        .unwrap();
789        let saved = std::env::var_os("HOME");
790        std::env::set_var("HOME", tmp.path());
791        let result = load_groups();
792        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
793        let groups = result.unwrap();
794        let err = &groups["errorlog"];
795        assert_eq!(err.format.as_deref(), Some("apache-combined"));
796        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
797        assert!(err.follow);
798        assert_eq!(err.tail, Some(1000));
799        assert_eq!(err.filter, vec!["status~^5".to_string()]);
800        let min = &groups["minimal"];
801        assert!(!min.follow);
802        assert!(min.tail.is_none());
803        assert_eq!(min.filter, Vec::<String>::new());
804    }
805
806    fn group(name: &str) -> Group {
807        Group { name: name.into(), ..Group::default() }
808    }
809
810    fn argv(parts: &[&str]) -> Vec<String> {
811        parts.iter().map(|s| s.to_string()).collect()
812    }
813
814    #[test]
815    fn expand_argv_passes_through_when_no_group_matches() {
816        let groups: HashMap<String, Group> = HashMap::new();
817        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
818        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
819    }
820
821    #[test]
822    fn expand_argv_inserts_group_flags_and_file() {
823        let mut groups: HashMap<String, Group> = HashMap::new();
824        groups.insert(
825            "errorlog".into(),
826            Group {
827                name: "errorlog".into(),
828                format: Some("apache-combined".into()),
829                file: Some("/var/log/access.log".into()),
830                follow: true,
831                tail: Some(1000),
832                filter: vec!["status~^5".into()],
833                ..Group::default()
834            },
835        );
836        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
837        assert_eq!(
838            out,
839            argv(&[
840                "tess",
841                "--format", "apache-combined",
842                "--follow",
843                "--tail", "1000",
844                "--filter", "status~^5",
845                "/var/log/access.log",
846            ])
847        );
848    }
849
850    #[test]
851    fn expand_argv_converts_positionals_to_filters_after_group() {
852        let mut groups: HashMap<String, Group> = HashMap::new();
853        groups.insert(
854            "errorlog".into(),
855            Group {
856                name: "errorlog".into(),
857                format: Some("apache-combined".into()),
858                file: Some("/log".into()),
859                ..Group::default()
860            },
861        );
862        let out = expand_argv(
863            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
864            &groups,
865        );
866        assert_eq!(
867            out,
868            argv(&[
869                "tess",
870                "--format", "apache-combined",
871                "/log",
872                "--filter", "msg~test",
873                "--filter", "url~/api/",
874            ])
875        );
876    }
877
878    #[test]
879    fn expand_argv_leaves_flags_alone_after_group() {
880        let mut groups: HashMap<String, Group> = HashMap::new();
881        groups.insert("errorlog".into(), group("errorlog"));
882        let out = expand_argv(
883            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
884            &groups,
885        );
886        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
887        assert_eq!(
888            out,
889            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
890        );
891    }
892
893    #[test]
894    fn expand_argv_user_flag_after_group_can_override_tail() {
895        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
896        // so user's 50 wins.
897        let mut groups: HashMap<String, Group> = HashMap::new();
898        groups.insert(
899            "errorlog".into(),
900            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
901        );
902        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
903        // --tail 1000 from group, then --tail 50 from user. Order preserved.
904        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
905        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
906        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
907        let pos_50 = out.iter().position(|x| x == "50").unwrap();
908        assert!(pos_1000 < pos_50, "user's value must come after group's");
909    }
910
911    #[test]
912    fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
913        let mut groups: HashMap<String, Group> = HashMap::new();
914        groups.insert("errorlog".into(), group("errorlog"));
915        let out = expand_argv(
916            argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
917            &groups,
918        );
919        // `timeout` is --grep's value, not a positional → not converted to --filter.
920        assert_eq!(
921            out,
922            argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
923        );
924    }
925
926    #[test]
927    fn expand_argv_unknown_double_dash_passes_through() {
928        let groups: HashMap<String, Group> = HashMap::new();
929        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
930        assert_eq!(out, argv(&["tess", "--unknown"]));
931    }
932
933    #[test]
934    fn load_groups_rejects_reserved_name() {
935        let _g = HOME_LOCK.lock().unwrap();
936        let tmp = tempfile::tempdir().unwrap();
937        let cfg_dir = tmp.path().join(".config").join("tess");
938        std::fs::create_dir_all(&cfg_dir).unwrap();
939        std::fs::write(
940            cfg_dir.join("formats.toml"),
941            r#"
942[group.follow]
943file = "/x.log"
944"#,
945        )
946        .unwrap();
947        let saved = std::env::var_os("HOME");
948        std::env::set_var("HOME", tmp.path());
949        let result = load_groups();
950        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
951        let err = result.unwrap_err();
952        assert!(err.contains("collides with built-in --follow"), "{err}");
953    }
954
955    #[test]
956    fn user_config_overrides_builtin_via_load_all() {
957        let _g = HOME_LOCK.lock().unwrap();
958        // Use a temp HOME to avoid touching the real user's config.
959        let tmp = tempfile::tempdir().unwrap();
960        let cfg_dir = tmp.path().join(".config").join("tess");
961        std::fs::create_dir_all(&cfg_dir).unwrap();
962        let cfg_file = cfg_dir.join("formats.toml");
963        std::fs::write(
964            &cfg_file,
965            r#"
966[format.apache-common]
967regex = "^(?P<custom>\\S+)$"
968"#,
969        )
970        .unwrap();
971        // Save and replace HOME for the duration of this test.
972        let saved = std::env::var_os("HOME");
973        std::env::set_var("HOME", tmp.path());
974        let result = load_all();
975        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
976        let formats = result.unwrap();
977        let common = &formats["apache-common"];
978        assert_eq!(common.field_names, vec!["custom"], "user config should win");
979    }
980
981    #[test]
982    fn format_entry_parses_record_start() {
983        let toml_text = r#"
984            [format.myapp]
985            regex = '^(?P<line>.*)$'
986            record_start = '^\['
987        "#;
988        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
989        let entry = cfg.format.get("myapp").expect("myapp present");
990        assert_eq!(entry.regex, "^(?P<line>.*)$");
991        assert_eq!(entry.record_start.as_deref(), Some("^\\["));
992    }
993
994    #[test]
995    fn format_entry_record_start_optional() {
996        let toml_text = r#"
997            [format.myapp]
998            regex = '^(?P<line>.*)$'
999        "#;
1000        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1001        let entry = cfg.format.get("myapp").expect("myapp present");
1002        assert!(entry.record_start.is_none());
1003    }
1004
1005    #[test]
1006    fn log_format_compile_full_with_record_start() {
1007        let fmt = LogFormat::compile_full(
1008            "test",
1009            r"^(?P<msg>.+)$",
1010            None,
1011            Some(r"^\["),
1012            None,
1013        ).expect("compile");
1014        assert!(fmt.record_start.is_some());
1015        assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1016        assert!(!fmt.record_start.as_ref().unwrap().is_match("  continuation"));
1017    }
1018
1019    #[test]
1020    fn log_format_compile_full_bad_record_start_errors() {
1021        let err = LogFormat::compile_full(
1022            "test",
1023            r"^(?P<msg>.+)$",
1024            None,
1025            Some(r"["),  // unclosed bracket
1026            None,
1027        ).expect_err("should fail");
1028        assert!(err.contains("record_start"), "error mentions record_start: {err}");
1029    }
1030
1031    #[test]
1032    fn group_with_grep_field_deserializes() {
1033        let toml_text = r#"
1034            [group.errorlog]
1035            format = "app"
1036            grep = ["timeout", "deadlock"]
1037        "#;
1038        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1039        let entry = cfg.group.get("errorlog").expect("errorlog present");
1040        assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1041    }
1042
1043    #[test]
1044    fn expand_argv_emits_group_grep_flags() {
1045        let mut groups = HashMap::new();
1046        groups.insert("errorlog".to_string(), Group {
1047            name: "errorlog".to_string(),
1048            grep: vec!["timeout".to_string(), "deadlock".to_string()],
1049            ..Default::default()
1050        });
1051        let out = expand_argv(
1052            argv(&["tess", "--errorlog", "logs.txt"]),
1053            &groups,
1054        );
1055        let joined = out.join(" ");
1056        assert!(joined.contains("--grep timeout"), "got: {joined}");
1057        assert!(joined.contains("--grep deadlock"), "got: {joined}");
1058    }
1059
1060    #[test]
1061    fn user_grep_after_group_accumulates() {
1062        let mut groups = HashMap::new();
1063        groups.insert("errorlog".to_string(), Group {
1064            name: "errorlog".to_string(),
1065            grep: vec!["timeout".to_string()],
1066            ..Default::default()
1067        });
1068        let out = expand_argv(
1069            argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1070            &groups,
1071        );
1072        let joined = out.join(" ");
1073        assert!(joined.contains("--grep timeout"));
1074        assert!(joined.contains("--grep extra"));
1075    }
1076
1077    #[test]
1078    fn format_entry_parses_prompt() {
1079        let toml_text = r#"
1080            [format.myapp]
1081            regex = '^(?P<line>.*)$'
1082            prompt = '<label> <pct>%'
1083        "#;
1084        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1085        let entry = cfg.format.get("myapp").expect("myapp present");
1086        assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1087    }
1088}