Skip to main content

tess/
format.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6
7use crate::config_path;
8
9/// A named log format: a regex with named capture groups identifying the
10/// fields of one log line. Used by filtering to look up field values by name.
11#[derive(Debug)]
12pub struct LogFormat {
13    pub name: String,
14    pub regex: Regex,
15    /// Capture group names declared in the regex, in declaration order.
16    /// Used by `--list-formats` to show users what fields are available.
17    pub field_names: Vec<String>,
18    /// Optional default display template (`display` key in formats.toml).
19    /// When set and no CLI override is given, the viewer / batch output
20    /// renders each parsed line through this template instead of the raw line.
21    pub display: Option<DisplayTemplate>,
22    pub record_start: Option<Regex>,
23    /// Optional default status-line prompt template (`prompt` key in formats.toml).
24    /// When set and no `--prompt` CLI flag is given, the viewport renders the
25    /// status line through this template instead of the built-in default.
26    pub prompt: Option<crate::prompt::ParsedPrompt>,
27    /// Optional default style for the status row when this format's prompt
28    /// is active. Per-format value; CLI `--prompt-style` overrides.
29    pub prompt_style: Option<crate::ansi::Style>,
30    pub(crate) source: crate::config_path::ConfigSource,
31    pub(crate) overrides: Option<crate::config_path::ConfigSource>,
32}
33
34impl LogFormat {
35    pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
36        Self::compile_full(name, pattern, None, None, None)
37    }
38
39    pub fn compile_with_display(
40        name: &str,
41        pattern: &str,
42        display: Option<&str>,
43    ) -> Result<Self, String> {
44        Self::compile_full(name, pattern, display, None, None)
45    }
46
47    pub fn compile_full(
48        name: &str,
49        pattern: &str,
50        display: Option<&str>,
51        record_start: Option<&str>,
52        prompt: Option<&str>,
53    ) -> Result<Self, String> {
54        let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
55        let field_names: Vec<String> = regex
56            .capture_names()
57            .flatten()
58            .map(|s| s.to_string())
59            .collect();
60        if field_names.is_empty() {
61            return Err(format!(
62                "format `{name}`: regex must declare at least one named capture group"
63            ));
64        }
65        let display = display
66            .map(|s| {
67                DisplayTemplate::compile(s, &field_names)
68                    .map_err(|e| format!("format `{name}`: display: {e}"))
69            })
70            .transpose()?;
71        let record_start = record_start
72            .map(|s| Regex::new(s).map_err(|e| format!("format `{name}`: record_start: {e}")))
73            .transpose()?;
74        let prompt = prompt
75            .map(|s| crate::prompt::ParsedPrompt::parse(s)
76                .map_err(|e| format!("format `{name}`: prompt: {e}")))
77            .transpose()?;
78        Ok(Self {
79            name: name.to_string(),
80            regex,
81            field_names,
82            display,
83            record_start,
84            prompt,
85            prompt_style: None,
86            source: crate::config_path::ConfigSource::Builtin,
87            overrides: None,
88        })
89    }
90}
91
92/// Parsed display template (`display = '[<ts>] <level> <msg>'`).
93///
94/// Syntax:
95/// - `<fieldname>` — replaced with the field's captured value (empty if
96///   the regex didn't capture it on this line).
97/// - `\<` — literal `<`.
98/// - `\\` — literal `\`.
99/// - Anything else — literal.
100#[derive(Debug, Clone)]
101pub struct DisplayTemplate {
102    segments: Vec<DisplaySegment>,
103    source: String,
104}
105
106#[derive(Debug, Clone)]
107enum DisplaySegment {
108    Literal(String),
109    Field(String),
110}
111
112impl DisplayTemplate {
113    pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
114        if source.is_empty() {
115            return Err("template is empty (would render every line as nothing)".to_string());
116        }
117        let mut segments: Vec<DisplaySegment> = Vec::new();
118        let mut buf = String::new();
119        let mut chars = source.chars().peekable();
120        while let Some(c) = chars.next() {
121            match c {
122                '\\' => match chars.next() {
123                    Some('<') => buf.push('<'),
124                    Some('\\') => buf.push('\\'),
125                    Some('n') => buf.push('\n'),
126                    Some('t') => buf.push('\t'),
127                    Some('r') => buf.push('\r'),
128                    Some('e') => buf.push('\x1b'),
129                    Some('x') => {
130                        let h1 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
131                        let h2 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
132                        let hex: String = [h1, h2].iter().collect();
133                        let byte = u8::from_str_radix(&hex, 16)
134                            .map_err(|_| format!("invalid `\\x{hex}` escape"))?;
135                        buf.push(byte as char);
136                    }
137                    Some('0') => {
138                        let d1 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
139                        let d2 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
140                        let oct: String = ['0', d1, d2].iter().collect();
141                        let byte = u8::from_str_radix(&oct, 8)
142                            .map_err(|_| format!("invalid `\\{oct}` escape"))?;
143                        buf.push(byte as char);
144                    }
145                    Some(other) => {
146                        // Unknown escape: keep both bytes literally so users
147                        // don't have to escape every backslash in regex-like
148                        // strings.
149                        buf.push('\\');
150                        buf.push(other);
151                    }
152                    None => return Err("template ends with a lone `\\`".to_string()),
153                },
154                '<' => {
155                    if !buf.is_empty() {
156                        segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
157                    }
158                    let mut name = String::new();
159                    let mut closed = false;
160                    while let Some(&nc) = chars.peek() {
161                        chars.next();
162                        if nc == '>' { closed = true; break; }
163                        name.push(nc);
164                    }
165                    if !closed {
166                        return Err(format!("unterminated `<` (expected `<{name}>`)"));
167                    }
168                    if name.is_empty() {
169                        return Err("empty field reference `<>`".to_string());
170                    }
171                    if !field_names.iter().any(|n| n == &name) {
172                        return Err(format!(
173                            "unknown field `{name}` (available: {})",
174                            field_names.join(", ")
175                        ));
176                    }
177                    segments.push(DisplaySegment::Field(name));
178                }
179                _ => buf.push(c),
180            }
181        }
182        if !buf.is_empty() {
183            segments.push(DisplaySegment::Literal(buf));
184        }
185        Ok(Self { segments, source: source.to_string() })
186    }
187
188    /// Render the template against a captures-lookup closure. Returns the
189    /// rendered string. Missing fields render as empty.
190    pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
191        let mut out = String::new();
192        for seg in &self.segments {
193            match seg {
194                DisplaySegment::Literal(s) => out.push_str(s),
195                DisplaySegment::Field(name) => {
196                    if let Some(v) = lookup(name) { out.push_str(&v); }
197                }
198            }
199        }
200        out
201    }
202
203    pub fn source(&self) -> &str { &self.source }
204}
205
206/// Pairs a `DisplayTemplate` with the format's regex so callers can render
207/// any single line in one call. Owns its inputs so it's `Send`-friendly.
208#[derive(Debug, Clone)]
209pub struct DisplayRenderer {
210    template: DisplayTemplate,
211    regex: Regex,
212}
213
214impl DisplayRenderer {
215    pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
216        Self { template, regex }
217    }
218
219    pub fn template(&self) -> &DisplayTemplate { &self.template }
220
221    /// Render `line` (raw bytes) through the template. If the line doesn't
222    /// parse against the format regex, returns `None` — the caller decides
223    /// whether to fall back to the raw line, skip it, or show an error.
224    pub fn render_line(&self, line: &[u8]) -> Option<String> {
225        let s = std::str::from_utf8(line).ok()?;
226        let caps = self.regex.captures(s)?;
227        Some(self.template.render(|name| {
228            caps.name(name).map(|m| m.as_str().to_string())
229        }))
230    }
231}
232
233/// TOML schema for `~/.config/tess/formats.toml`:
234///
235/// ```toml
236/// [format.myapp]
237/// regex = "..."
238///
239/// [group.errorlog]
240/// format = "myapp"
241/// file = "/var/log/app.log"
242/// follow = true
243/// filter = ["level=ERROR"]
244/// ```
245#[derive(Debug, Default, Deserialize)]
246struct UserConfig {
247    #[serde(default)]
248    format: HashMap<String, FormatEntry>,
249    #[serde(default)]
250    group: HashMap<String, GroupEntry>,
251}
252
253#[derive(Debug, Deserialize)]
254struct FormatEntry {
255    regex: String,
256    #[serde(default)]
257    display: Option<String>,
258    #[serde(default)]
259    record_start: Option<String>,
260    #[serde(default)]
261    prompt: Option<String>,
262    /// Optional style for the status row when this format is active and a
263    /// custom prompt is rendered. Parsed via `crate::style_spec`. CLI
264    /// `--prompt-style` overrides this.
265    #[serde(default)]
266    prompt_style: Option<String>,
267}
268
269/// Raw group entry as deserialized from TOML. Promoted to `Group` after
270/// validation.
271#[derive(Debug, Deserialize, Default)]
272struct GroupEntry {
273    format: Option<String>,
274    file: Option<String>,
275    follow: Option<bool>,
276    tail: Option<usize>,
277    head: Option<usize>,
278    dim: Option<bool>,
279    line_numbers: Option<bool>,
280    chop: Option<bool>,
281    tab_width: Option<u8>,
282    display: Option<String>,
283    #[serde(default)]
284    filter: Vec<String>,
285    #[serde(default)]
286    grep: Vec<String>,
287}
288
289/// A user-defined CLI shortcut. When `tess --<group_name>` appears in argv,
290/// the group's flags are expanded inline and remaining positionals become
291/// `--filter` arguments.
292#[derive(Debug, Clone, Default)]
293pub struct Group {
294    pub name: String,
295    pub format: Option<String>,
296    pub file: Option<String>,
297    pub follow: bool,
298    pub tail: Option<usize>,
299    pub head: Option<usize>,
300    pub dim: bool,
301    pub line_numbers: bool,
302    pub chop: bool,
303    pub tab_width: Option<u8>,
304    /// Default `--display` template for this group. Emitted as `--display
305    /// <value>` at expansion time; a later CLI `--display` overrides it
306    /// (clap takes the last occurrence). Requires the group (or CLI) to also
307    /// set a `--format`, same as the bare `--display` flag.
308    pub display: Option<String>,
309    pub filter: Vec<String>,
310    pub grep: Vec<String>,
311    // Populated by the layered loader to track which config layer a group came
312    // from (and what it overrode); reserved for group source annotation in
313    // `--list-formats`. Not yet read, hence the allow.
314    #[allow(dead_code)]
315    pub(crate) source: crate::config_path::ConfigSource,
316    #[allow(dead_code)]
317    pub(crate) overrides: Option<crate::config_path::ConfigSource>,
318}
319
320/// Long-form names of every built-in clap flag. A group cannot reuse one of
321/// these names — it would shadow the real flag at expansion time.
322const RESERVED_LONG_FLAGS: &[&str] = &[
323    "format",
324    "filter",
325    "grep",
326    "dim",
327    "head",
328    "tail",
329    "follow",
330    "LINE-NUMBERS",
331    "chop-long-lines",
332    "tab-width",
333    "list-formats",
334    "live",
335    "manual",
336    "examples",
337    "prettify",
338    "content-type",
339    "help",
340    "version",
341    "record-start",
342    "hex",
343    "prompt",
344    "display",
345    "preprocess",
346    "no-preprocess",
347    "no-color",
348    "raw-control-chars",
349    "tag",
350    "tag-file",
351];
352
353/// Built-in formats compiled from this list of (name, pattern). Patterns use
354/// raw strings so backslashes don't need escaping.
355const BUILTINS: &[(&str, &str)] = &[
356    (
357        "apache-common",
358        r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
359    ),
360    (
361        "apache-combined",
362        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>[^"]*)"$"#,
363    ),
364    (
365        "nginx-combined",
366        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>[^"]*)"$"#,
367    ),
368];
369
370fn formats_path_in(dir: &std::path::Path) -> PathBuf {
371    dir.join("formats.toml")
372}
373
374/// Parsed contents of both global and local `formats.toml`. Empty
375/// `UserConfig` represents "layer absent or unreadable".
376#[derive(Debug, Default)]
377struct LayeredConfig {
378    global: UserConfig,
379    local: UserConfig,
380}
381
382fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
383    let text = std::fs::read_to_string(path)
384        .map_err(|e| format!("reading {}: {e}", path.display()))?;
385    toml::from_str(&text)
386        .map_err(|e| format!("parsing {}: {e}", path.display()))
387}
388
389fn load_layered_config() -> Result<LayeredConfig, String> {
390    let mut layered = LayeredConfig::default();
391
392    // Global: warn-and-continue on parse error.
393    if let Some(dir) = config_path::global_config_dir() {
394        let path = formats_path_in(&dir);
395        if path.exists() {
396            match read_formats_toml(&path) {
397                Ok(cfg) => layered.global = cfg,
398                Err(e) => eprintln!(
399                    "tess: warning: {e}; ignoring global config"
400                ),
401            }
402        }
403    }
404
405    // Local: fail-startup on parse error (unchanged behavior).
406    if let Some(dir) = config_path::user_config_dir() {
407        let path = formats_path_in(&dir);
408        if path.exists() {
409            layered.local = read_formats_toml(&path)?;
410        }
411    }
412
413    Ok(layered)
414}
415
416struct FormatSource {
417    regex: String,
418    display: Option<String>,
419    record_start: Option<String>,
420    prompt: Option<String>,
421    prompt_style: Option<String>,
422    source: crate::config_path::ConfigSource,
423    overrides: Option<crate::config_path::ConfigSource>,
424}
425
426fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
427    let cfg = load_layered_config()?;
428    let mut out: HashMap<String, FormatSource> = HashMap::new();
429    for (k, v) in cfg.global.format {
430        out.insert(k, FormatSource {
431            regex: v.regex,
432            display: v.display,
433            record_start: v.record_start,
434            prompt: v.prompt,
435            prompt_style: v.prompt_style,
436            source: crate::config_path::ConfigSource::Global,
437            overrides: None,
438        });
439    }
440    for (k, v) in cfg.local.format {
441        let overrides = out.get(&k).map(|prev| prev.source);
442        out.insert(k, FormatSource {
443            regex: v.regex,
444            display: v.display,
445            record_start: v.record_start,
446            prompt: v.prompt,
447            prompt_style: v.prompt_style,
448            source: crate::config_path::ConfigSource::Local,
449            overrides,
450        });
451    }
452    Ok(out)
453}
454
455/// Load all user-defined groups from global and local `formats.toml`. Built-ins
456/// are not provided — groups are entirely user-defined. Validates that group
457/// names don't shadow built-in flag names.
458pub fn load_groups() -> Result<HashMap<String, Group>, String> {
459    let cfg = load_layered_config()?;
460
461    struct StagedGroup {
462        entry: GroupEntry,
463        source: crate::config_path::ConfigSource,
464        overrides: Option<crate::config_path::ConfigSource>,
465    }
466
467    let mut staged: HashMap<String, StagedGroup> = HashMap::new();
468    for (k, v) in cfg.global.group {
469        staged.insert(k, StagedGroup {
470            entry: v,
471            source: crate::config_path::ConfigSource::Global,
472            overrides: None,
473        });
474    }
475    for (k, v) in cfg.local.group {
476        let overrides = staged.get(&k).map(|prev| prev.source);
477        staged.insert(k, StagedGroup {
478            entry: v,
479            source: crate::config_path::ConfigSource::Local,
480            overrides,
481        });
482    }
483
484    let mut out = HashMap::with_capacity(staged.len());
485    for (name, sg) in staged {
486        if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
487            return Err(format!(
488                "group `{name}`: name collides with built-in --{name} flag"
489            ));
490        }
491        out.insert(
492            name.clone(),
493            Group {
494                name,
495                format: sg.entry.format,
496                file: sg.entry.file,
497                follow: sg.entry.follow.unwrap_or(false),
498                tail: sg.entry.tail,
499                head: sg.entry.head,
500                dim: sg.entry.dim.unwrap_or(false),
501                line_numbers: sg.entry.line_numbers.unwrap_or(false),
502                chop: sg.entry.chop.unwrap_or(false),
503                tab_width: sg.entry.tab_width,
504                display: sg.entry.display,
505                filter: sg.entry.filter,
506                grep: sg.entry.grep,
507                source: sg.source,
508                overrides: sg.overrides,
509            },
510        );
511    }
512    Ok(out)
513}
514
515/// Load all formats: built-ins first, then any in `~/.config/tess/formats.toml`
516/// (which override built-ins of the same name). Returns the compiled map keyed
517/// by format name.
518pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
519    let mut sources: HashMap<String, FormatSource> = HashMap::new();
520    for (name, pat) in BUILTINS {
521        sources.insert(name.to_string(), FormatSource {
522            regex: pat.to_string(),
523            display: None,
524            record_start: None,
525            prompt: None,
526            prompt_style: None,
527            source: crate::config_path::ConfigSource::Builtin,
528            overrides: None,
529        });
530    }
531    let user = load_user_formats()?;
532    for (name, mut src) in user {
533        // load_user_formats doesn't know about built-ins, so we detect
534        // direct built-in shadowing here. If `src.overrides` is already
535        // set, local was shadowing global — leave that alone.
536        if src.overrides.is_none() && sources.contains_key(&name) {
537            src.overrides = Some(crate::config_path::ConfigSource::Builtin);
538        }
539        sources.insert(name, src);
540    }
541    let mut compiled = HashMap::new();
542    for (name, src) in sources {
543        let mut fmt = LogFormat::compile_full(
544            &name,
545            &src.regex,
546            src.display.as_deref(),
547            src.record_start.as_deref(),
548            src.prompt.as_deref(),
549        )?;
550        if let Some(spec) = src.prompt_style.as_deref() {
551            fmt.prompt_style = Some(
552                crate::style_spec::parse(spec)
553                    .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
554            );
555        }
556        fmt.source = src.source;
557        fmt.overrides = src.overrides;
558        compiled.insert(name, fmt);
559    }
560    Ok(compiled)
561}
562
563/// Pre-process an argv vector before clap sees it. For every `--<name>`
564/// token that matches a defined group, expand the group's flags inline and
565/// switch into "filter mode" — bare positionals after the group token become
566/// `--filter <arg>` pairs. Group tokens before any flag still expand
567/// correctly; positionals before a group remain as-is.
568///
569/// CLI flags coming after the expansion override the group's values for
570/// `Option<T>` flags (clap takes the last occurrence) and add to repeatable
571/// flags like `--filter` (clap accumulates the `Vec<String>`).
572/// Long flags that take a separate value as the next argv token (e.g.
573/// `--tail 1000` rather than `--tail=1000`). Used by `expand_argv` so it
574/// doesn't mistake a flag's value for a positional in filter mode — without
575/// this, `--errs --display '<msg>'` would rewrite the template into a
576/// `--filter`. Must list *every* value-taking long flag clap defines.
577const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
578    "--content-type",
579    "--display",
580    "--filter",
581    "--format",
582    "--grep",
583    "--head",
584    "--header",
585    "--hex-group",
586    "--image-width",
587    "--output",
588    "--preprocess",
589    "--prompt",
590    "--prompt-style",
591    "--record-start",
592    "--rscroll",
593    "--status-style",
594    "--tab-width",
595    "--tag",
596    "--tag-file",
597    "--tail",
598    "--truecolor",
599    "--window",
600];
601
602/// Short flags that take a separate value as the next argv token (`-o FILE`,
603/// `-z N`, `-t NAME`, `-T PATH`). The boolean short flags (`-N`, `-S`, `-f`,
604/// …) are intentionally absent — they must not swallow the following token.
605/// The attached form (`-ovalue`) is a single token and needs no entry here.
606const VALUE_TAKING_SHORT_FLAGS: &[&str] = &[
607    "-o",
608    "-z",
609    "-t",
610    "-T",
611];
612
613pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
614    if argv.is_empty() {
615        return argv;
616    }
617    let mut out = Vec::with_capacity(argv.len() * 2);
618    let mut iter = argv.into_iter();
619    out.push(iter.next().unwrap()); // argv[0] = program name
620    let mut filter_mode = false;
621    let mut pass_next = false;
622    for arg in iter {
623        if pass_next {
624            pass_next = false;
625            out.push(arg);
626            continue;
627        }
628        if let Some(name) = arg.strip_prefix("--") {
629            // `--flag=value` is a single token: don't try to match groups
630            // against `flag=value`.
631            if !name.contains('=') {
632                if let Some(g) = groups.get(name) {
633                    expand_group(g, &mut out);
634                    filter_mode = true;
635                    continue;
636                }
637            }
638        }
639        // A value-taking flag (long or short, separated form): emit it and
640        // pass its value through untouched, even in filter mode, so the value
641        // isn't mistaken for a positional and rewritten into a `--filter`.
642        // The `--flag=value` / `-ovalue` attached forms are single tokens and
643        // fall through harmlessly (they start with `-`, so aren't converted).
644        if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str())
645            || VALUE_TAKING_SHORT_FLAGS.contains(&arg.as_str())
646        {
647            out.push(arg);
648            pass_next = true;
649            continue;
650        }
651        if filter_mode && !arg.starts_with('-') {
652            out.push("--filter".into());
653            out.push(arg);
654            continue;
655        }
656        out.push(arg);
657    }
658    out
659}
660
661fn expand_group(g: &Group, out: &mut Vec<String>) {
662    if let Some(format) = &g.format {
663        out.push("--format".into());
664        out.push(format.clone());
665    }
666    if let Some(display) = &g.display {
667        out.push("--display".into());
668        out.push(display.clone());
669    }
670    if g.follow {
671        out.push("--follow".into());
672    }
673    if let Some(t) = g.tail {
674        out.push("--tail".into());
675        out.push(t.to_string());
676    }
677    if let Some(h) = g.head {
678        out.push("--head".into());
679        out.push(h.to_string());
680    }
681    if g.dim {
682        out.push("--dim".into());
683    }
684    if g.line_numbers {
685        out.push("-N".into());
686    }
687    if g.chop {
688        out.push("-S".into());
689    }
690    if let Some(t) = g.tab_width {
691        out.push("--tab-width".into());
692        out.push(t.to_string());
693    }
694    for f in &g.filter {
695        out.push("--filter".into());
696        out.push(f.clone());
697    }
698    for g_pat in &g.grep {
699        out.push("--grep".into());
700        out.push(g_pat.clone());
701    }
702    if let Some(file) = &g.file {
703        out.push(file.clone());
704    }
705}
706
707/// Render the bracketed source annotation for a format. The `overrides`
708/// argument is the immediately-replaced layer produced by `load_all`.
709fn format_source_label(
710    source: crate::config_path::ConfigSource,
711    overrides: Option<crate::config_path::ConfigSource>,
712) -> String {
713    use crate::config_path::ConfigSource::*;
714    let layer = match source {
715        Builtin => "built-in",
716        Global => "global",
717        Local => "local",
718    };
719    match overrides {
720        None => format!("[{layer}]"),
721        Some(Builtin) => format!("[{layer}, overrides built-in]"),
722        Some(Global) => format!("[{layer}, overrides global]"),
723        // Lower layers can't replace local; this arm is unreachable in
724        // practice but kept for total-match completeness.
725        Some(Local) => format!("[{layer}, overrides local]"),
726    }
727}
728
729/// Print one line per format, with the named field list and source
730/// label, to stdout. Used by `--list-formats`.
731pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
732    let mut names: Vec<&String> = formats.keys().collect();
733    names.sort();
734
735    // Column-align names for readability when field lists vary.
736    let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
737
738    for name in names {
739        let fmt = &formats[name];
740        let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
741        let label = format_source_label(fmt.source, fmt.overrides);
742        println!(
743            "{:<width$}  {}  {}",
744            name,
745            label,
746            fields.join(", "),
747            width = name_width
748        );
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use std::sync::Mutex;
756
757    /// Serializes tests that mutate the `HOME` env var; otherwise they
758    /// trample each other when cargo runs tests in parallel.
759    static HOME_LOCK: Mutex<()> = Mutex::new(());
760
761    #[test]
762    fn builtins_all_compile() {
763        for (name, pat) in BUILTINS {
764            LogFormat::compile(name, pat)
765                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
766        }
767    }
768
769    // ----- DisplayTemplate -----
770
771    fn fields() -> Vec<String> {
772        vec!["ts".into(), "level".into(), "msg".into()]
773    }
774
775    #[test]
776    fn display_template_compiles_basic() {
777        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
778        assert_eq!(t.source(), "[<ts>] <level> <msg>");
779    }
780
781    #[test]
782    fn display_template_renders_substitutions() {
783        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
784        let mut map = std::collections::HashMap::new();
785        map.insert("level".to_string(), "ERROR".to_string());
786        map.insert("msg".to_string(), "boom".to_string());
787        let out = t.render(|n| map.get(n).cloned());
788        assert_eq!(out, "ERROR: boom");
789    }
790
791    #[test]
792    fn display_template_missing_field_renders_empty() {
793        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
794        let mut map = std::collections::HashMap::new();
795        map.insert("level".to_string(), "ERROR".to_string());
796        // msg is absent
797        let out = t.render(|n| map.get(n).cloned());
798        assert_eq!(out, "ERROR:");
799    }
800
801    #[test]
802    fn display_template_escape_sequences() {
803        // Only `\<` and `\\` are recognized escapes; `>` is always literal
804        // (a stray `>` outside `<...>` is fine).
805        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
806        let mut map = std::collections::HashMap::new();
807        map.insert("level".to_string(), "X".to_string());
808        let out = t.render(|n| map.get(n).cloned());
809        assert_eq!(out, "<not a field> X");
810    }
811
812    #[test]
813    fn display_template_escape_backslash() {
814        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
815        let mut map = std::collections::HashMap::new();
816        map.insert("level".to_string(), "X".to_string());
817        let out = t.render(|n| map.get(n).cloned());
818        assert_eq!(out, r"a\b X");
819    }
820
821    #[test]
822    fn display_template_escape_e_emits_esc() {
823        let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
824        let mut map = std::collections::HashMap::new();
825        map.insert("level".to_string(), "X".to_string());
826        let out = t.render(|n| map.get(n).cloned());
827        assert_eq!(out, "\x1b[31mX\x1b[0m");
828    }
829
830    #[test]
831    fn display_template_escape_x1b_emits_esc() {
832        let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
833        let out = t.render(|_| Some("Y".to_string()));
834        assert_eq!(out, "\x1b[1mY");
835    }
836
837    #[test]
838    fn display_template_escape_octal_emits_esc() {
839        let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
840        let out = t.render(|_| Some("Z".to_string()));
841        assert_eq!(out, "\x1b[1mZ");
842    }
843
844    #[test]
845    fn display_template_escape_n_t_r() {
846        let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
847        let out = t.render(|_| Some("Q".to_string()));
848        assert_eq!(out, "\n\t\rQ");
849    }
850
851    #[test]
852    fn display_template_escape_unknown_preserves_backslash() {
853        let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
854        let out = t.render(|_| Some("Q".to_string()));
855        assert_eq!(out, r"\qQ");
856    }
857
858    #[test]
859    fn display_template_escape_x_incomplete_errors() {
860        let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
861        assert!(err.contains("incomplete"), "{err}");
862    }
863
864    #[test]
865    fn display_template_escape_invalid_hex_errors() {
866        let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
867        assert!(err.contains("invalid"), "{err}");
868    }
869
870    #[test]
871    fn display_template_rejects_empty() {
872        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
873        assert!(err.contains("empty"), "{err}");
874    }
875
876    #[test]
877    fn display_template_rejects_unknown_field() {
878        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
879        assert!(err.contains("unknown field"), "{err}");
880    }
881
882    #[test]
883    fn display_template_rejects_unterminated() {
884        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
885        assert!(err.contains("unterminated"), "{err}");
886    }
887
888    #[test]
889    fn display_template_rejects_empty_ref() {
890        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
891        assert!(err.contains("empty field reference"), "{err}");
892    }
893
894    #[test]
895    fn apache_common_extracts_fields() {
896        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
897        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
898        let caps = fmt.regex.captures(line).expect("should match");
899        assert_eq!(&caps["ip"], "127.0.0.1");
900        assert_eq!(&caps["user"], "alice");
901        assert_eq!(&caps["method"], "GET");
902        assert_eq!(&caps["url"], "/index.html");
903        assert_eq!(&caps["status"], "200");
904        assert_eq!(&caps["size"], "2326");
905    }
906
907    #[test]
908    fn apache_combined_extracts_referer_and_agent() {
909        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
910        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""#;
911        let caps = fmt.regex.captures(line).expect("should match");
912        assert_eq!(&caps["status"], "401");
913        assert_eq!(&caps["url"], "/api/login");
914        assert_eq!(&caps["referer"], "https://example.com/");
915        assert_eq!(&caps["agent"], "Mozilla/5.0");
916    }
917
918    #[test]
919    fn field_names_listed_in_order() {
920        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
921        assert_eq!(
922            fmt.field_names,
923            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
924        );
925    }
926
927    #[test]
928    fn compile_rejects_regex_without_named_groups() {
929        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
930        assert!(err.contains("at least one named capture"), "{err}");
931    }
932
933    #[test]
934    fn compile_rejects_invalid_regex() {
935        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
936        assert!(err.contains("bad"), "{err}");
937    }
938
939    #[test]
940    fn load_groups_reads_user_config() {
941        let _g = HOME_LOCK.lock().unwrap();
942        let tmp = tempfile::tempdir().unwrap();
943        let cfg_dir = tmp.path().join(".config").join("tess");
944        std::fs::create_dir_all(&cfg_dir).unwrap();
945        std::fs::write(
946            cfg_dir.join("formats.toml"),
947            r#"
948[group.errorlog]
949format = "apache-combined"
950file = "/var/log/access.log"
951follow = true
952tail = 1000
953filter = ["status~^5"]
954display = "<status> <url>"
955
956[group.minimal]
957file = "/tmp/x.log"
958"#,
959        )
960        .unwrap();
961        let saved = std::env::var_os("HOME");
962        std::env::set_var("HOME", tmp.path());
963        let result = load_groups();
964        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
965        let groups = result.unwrap();
966        let err = &groups["errorlog"];
967        assert_eq!(err.format.as_deref(), Some("apache-combined"));
968        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
969        assert!(err.follow);
970        assert_eq!(err.tail, Some(1000));
971        assert_eq!(err.filter, vec!["status~^5".to_string()]);
972        assert_eq!(err.display.as_deref(), Some("<status> <url>"));
973        let min = &groups["minimal"];
974        assert!(!min.follow);
975        assert!(min.tail.is_none());
976        assert_eq!(min.filter, Vec::<String>::new());
977        assert!(min.display.is_none());
978    }
979
980    fn group(name: &str) -> Group {
981        Group { name: name.into(), ..Group::default() }
982    }
983
984    fn argv(parts: &[&str]) -> Vec<String> {
985        parts.iter().map(|s| s.to_string()).collect()
986    }
987
988    #[test]
989    fn expand_argv_passes_through_when_no_group_matches() {
990        let groups: HashMap<String, Group> = HashMap::new();
991        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
992        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
993    }
994
995    #[test]
996    fn expand_argv_inserts_group_flags_and_file() {
997        let mut groups: HashMap<String, Group> = HashMap::new();
998        groups.insert(
999            "errorlog".into(),
1000            Group {
1001                name: "errorlog".into(),
1002                format: Some("apache-combined".into()),
1003                file: Some("/var/log/access.log".into()),
1004                follow: true,
1005                tail: Some(1000),
1006                filter: vec!["status~^5".into()],
1007                ..Group::default()
1008            },
1009        );
1010        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
1011        assert_eq!(
1012            out,
1013            argv(&[
1014                "tess",
1015                "--format", "apache-combined",
1016                "--follow",
1017                "--tail", "1000",
1018                "--filter", "status~^5",
1019                "/var/log/access.log",
1020            ])
1021        );
1022    }
1023
1024    #[test]
1025    fn expand_argv_converts_positionals_to_filters_after_group() {
1026        let mut groups: HashMap<String, Group> = HashMap::new();
1027        groups.insert(
1028            "errorlog".into(),
1029            Group {
1030                name: "errorlog".into(),
1031                format: Some("apache-combined".into()),
1032                file: Some("/log".into()),
1033                ..Group::default()
1034            },
1035        );
1036        let out = expand_argv(
1037            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
1038            &groups,
1039        );
1040        assert_eq!(
1041            out,
1042            argv(&[
1043                "tess",
1044                "--format", "apache-combined",
1045                "/log",
1046                "--filter", "msg~test",
1047                "--filter", "url~/api/",
1048            ])
1049        );
1050    }
1051
1052    #[test]
1053    fn expand_argv_leaves_flags_alone_after_group() {
1054        let mut groups: HashMap<String, Group> = HashMap::new();
1055        groups.insert("errorlog".into(), group("errorlog"));
1056        let out = expand_argv(
1057            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1058            &groups,
1059        );
1060        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
1061        assert_eq!(
1062            out,
1063            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1064        );
1065    }
1066
1067    #[test]
1068    fn expand_argv_user_flag_after_group_can_override_tail() {
1069        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
1070        // so user's 50 wins.
1071        let mut groups: HashMap<String, Group> = HashMap::new();
1072        groups.insert(
1073            "errorlog".into(),
1074            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1075        );
1076        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1077        // --tail 1000 from group, then --tail 50 from user. Order preserved.
1078        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1079        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1080        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1081        let pos_50 = out.iter().position(|x| x == "50").unwrap();
1082        assert!(pos_1000 < pos_50, "user's value must come after group's");
1083    }
1084
1085    #[test]
1086    fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1087        let mut groups: HashMap<String, Group> = HashMap::new();
1088        groups.insert("errorlog".into(), group("errorlog"));
1089        let out = expand_argv(
1090            argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1091            &groups,
1092        );
1093        // `timeout` is --grep's value, not a positional → not converted to --filter.
1094        assert_eq!(
1095            out,
1096            argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1097        );
1098    }
1099
1100    #[test]
1101    fn expand_argv_unknown_double_dash_passes_through() {
1102        let groups: HashMap<String, Group> = HashMap::new();
1103        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1104        assert_eq!(out, argv(&["tess", "--unknown"]));
1105    }
1106
1107    #[test]
1108    fn expand_argv_passes_display_template_through_after_group() {
1109        // Regression: a `--display` template after a group must NOT be
1110        // rewritten into a `--filter` (which previously left `--display`
1111        // value-less and clap erroring "a value is required").
1112        let mut groups: HashMap<String, Group> = HashMap::new();
1113        groups.insert("errorlog".into(), group("errorlog"));
1114        let out = expand_argv(
1115            argv(&["tess", "--errorlog", "--display", "<lvl>: <msg>", "lvl=ERROR"]),
1116            &groups,
1117        );
1118        assert_eq!(
1119            out,
1120            argv(&[
1121                "tess",
1122                "--display", "<lvl>: <msg>",
1123                "--filter", "lvl=ERROR",
1124            ])
1125        );
1126    }
1127
1128    #[test]
1129    fn expand_argv_passes_short_value_flag_through_after_group() {
1130        // `-o FILE` (and the other separated short value flags) must keep
1131        // their value instead of converting it to a filter in filter mode.
1132        let mut groups: HashMap<String, Group> = HashMap::new();
1133        groups.insert("errorlog".into(), group("errorlog"));
1134        let out = expand_argv(
1135            argv(&["tess", "--errorlog", "-o", "out.txt", "lvl=ERROR"]),
1136            &groups,
1137        );
1138        assert_eq!(
1139            out,
1140            argv(&["tess", "-o", "out.txt", "--filter", "lvl=ERROR"])
1141        );
1142    }
1143
1144    #[test]
1145    fn expand_group_emits_display_when_set() {
1146        let g = Group {
1147            name: "errs".into(),
1148            format: Some("simple".into()),
1149            display: Some("<lvl>!! <msg>".into()),
1150            filter: vec!["lvl=ERROR".into()],
1151            ..Group::default()
1152        };
1153        let out = expand_argv(argv(&["tess", "--errs"]), &{
1154            let mut m = HashMap::new();
1155            m.insert("errs".to_string(), g);
1156            m
1157        });
1158        assert_eq!(
1159            out,
1160            argv(&[
1161                "tess",
1162                "--format", "simple",
1163                "--display", "<lvl>!! <msg>",
1164                "--filter", "lvl=ERROR",
1165            ])
1166        );
1167    }
1168
1169    #[test]
1170    fn expand_argv_cli_display_overrides_group_display() {
1171        // Group sets a display; a later CLI `--display` is emitted after it,
1172        // so clap's last-occurrence wins (the CLI value).
1173        let g = Group {
1174            name: "errs".into(),
1175            format: Some("simple".into()),
1176            display: Some("group-tmpl".into()),
1177            ..Group::default()
1178        };
1179        let out = expand_argv(argv(&["tess", "--errs", "--display", "cli-tmpl"]), &{
1180            let mut m = HashMap::new();
1181            m.insert("errs".to_string(), g);
1182            m
1183        });
1184        let pos_group = out.iter().position(|x| x == "group-tmpl").unwrap();
1185        let pos_cli = out.iter().position(|x| x == "cli-tmpl").unwrap();
1186        assert!(pos_group < pos_cli, "CLI display must come after group's so it wins");
1187    }
1188
1189    #[test]
1190    fn load_groups_rejects_reserved_name() {
1191        let _g = HOME_LOCK.lock().unwrap();
1192        let tmp = tempfile::tempdir().unwrap();
1193        let cfg_dir = tmp.path().join(".config").join("tess");
1194        std::fs::create_dir_all(&cfg_dir).unwrap();
1195        std::fs::write(
1196            cfg_dir.join("formats.toml"),
1197            r#"
1198[group.follow]
1199file = "/x.log"
1200"#,
1201        )
1202        .unwrap();
1203        let saved = std::env::var_os("HOME");
1204        std::env::set_var("HOME", tmp.path());
1205        let result = load_groups();
1206        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1207        let err = result.unwrap_err();
1208        assert!(err.contains("collides with built-in --follow"), "{err}");
1209    }
1210
1211    #[test]
1212    fn user_config_overrides_builtin_via_load_all() {
1213        let _g = HOME_LOCK.lock().unwrap();
1214        // Use a temp HOME to avoid touching the real user's config.
1215        let tmp = tempfile::tempdir().unwrap();
1216        let cfg_dir = tmp.path().join(".config").join("tess");
1217        std::fs::create_dir_all(&cfg_dir).unwrap();
1218        let cfg_file = cfg_dir.join("formats.toml");
1219        std::fs::write(
1220            &cfg_file,
1221            r#"
1222[format.apache-common]
1223regex = "^(?P<custom>\\S+)$"
1224"#,
1225        )
1226        .unwrap();
1227        // Save and replace HOME for the duration of this test.
1228        let saved = std::env::var_os("HOME");
1229        std::env::set_var("HOME", tmp.path());
1230        let result = load_all();
1231        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1232        let formats = result.unwrap();
1233        let common = &formats["apache-common"];
1234        assert_eq!(common.field_names, vec!["custom"], "user config should win");
1235    }
1236
1237    #[test]
1238    fn format_entry_parses_record_start() {
1239        let toml_text = r#"
1240            [format.myapp]
1241            regex = '^(?P<line>.*)$'
1242            record_start = '^\['
1243        "#;
1244        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1245        let entry = cfg.format.get("myapp").expect("myapp present");
1246        assert_eq!(entry.regex, "^(?P<line>.*)$");
1247        assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1248    }
1249
1250    #[test]
1251    fn format_entry_record_start_optional() {
1252        let toml_text = r#"
1253            [format.myapp]
1254            regex = '^(?P<line>.*)$'
1255        "#;
1256        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1257        let entry = cfg.format.get("myapp").expect("myapp present");
1258        assert!(entry.record_start.is_none());
1259    }
1260
1261    #[test]
1262    fn layered_loader_local_overrides_global() {
1263        let _guard = HOME_LOCK.lock().unwrap();
1264        let prev_home = std::env::var_os("HOME");
1265        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1266
1267        let home = tempfile::tempdir().unwrap();
1268        let global = tempfile::tempdir().unwrap();
1269
1270        std::env::set_var("HOME", home.path());
1271        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1272
1273        std::fs::write(
1274            global.path().join("formats.toml"),
1275            r#"
1276[format.shared]
1277regex = "^GLOBAL (?P<msg>.+)$"
1278
1279[format.both]
1280regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1281"#,
1282        )
1283        .unwrap();
1284
1285        let cfg_dir = home.path().join(".config").join("tess");
1286        std::fs::create_dir_all(&cfg_dir).unwrap();
1287        std::fs::write(
1288            cfg_dir.join("formats.toml"),
1289            r#"
1290[format.both]
1291regex = "^LOCAL_BOTH (?P<msg>.+)$"
1292
1293[format.local-only]
1294regex = "^LOCAL (?P<msg>.+)$"
1295"#,
1296        )
1297        .unwrap();
1298
1299        let cfg = load_layered_config().unwrap();
1300
1301        // Global-only format survives.
1302        assert!(cfg.global.format.contains_key("shared"));
1303        assert!(!cfg.local.format.contains_key("shared"));
1304
1305        // Same-name format: both layers carry it, merge step (next task)
1306        // is responsible for resolving. Here we just verify both files
1307        // parsed correctly.
1308        assert_eq!(
1309            cfg.global.format.get("both").unwrap().regex,
1310            "^GLOBAL_BOTH (?P<msg>.+)$"
1311        );
1312        assert_eq!(
1313            cfg.local.format.get("both").unwrap().regex,
1314            "^LOCAL_BOTH (?P<msg>.+)$"
1315        );
1316
1317        // Local-only format present.
1318        assert!(cfg.local.format.contains_key("local-only"));
1319
1320        match prev_home {
1321            Some(v) => std::env::set_var("HOME", v),
1322            None => std::env::remove_var("HOME"),
1323        }
1324        match prev_global {
1325            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1326            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1327        }
1328    }
1329
1330    #[test]
1331    fn layered_loader_warns_on_bad_global_toml() {
1332        let _guard = HOME_LOCK.lock().unwrap();
1333        let prev_home = std::env::var_os("HOME");
1334        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1335
1336        let home = tempfile::tempdir().unwrap();
1337        let global = tempfile::tempdir().unwrap();
1338
1339        std::env::set_var("HOME", home.path());
1340        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1341
1342        std::fs::write(
1343            global.path().join("formats.toml"),
1344            "this is not valid toml = = =",
1345        )
1346        .unwrap();
1347
1348        // Should NOT error — global parse failures are warnings, not errors.
1349        let cfg = load_layered_config().unwrap();
1350        assert!(cfg.global.format.is_empty());
1351        assert!(cfg.global.group.is_empty());
1352
1353        match prev_home {
1354            Some(v) => std::env::set_var("HOME", v),
1355            None => std::env::remove_var("HOME"),
1356        }
1357        match prev_global {
1358            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1359            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1360        }
1361    }
1362
1363    #[test]
1364    fn layered_loader_fails_on_bad_local_toml() {
1365        let _guard = HOME_LOCK.lock().unwrap();
1366        let prev_home = std::env::var_os("HOME");
1367        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1368
1369        let home = tempfile::tempdir().unwrap();
1370        std::env::set_var("HOME", home.path());
1371        std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1372
1373        let cfg_dir = home.path().join(".config").join("tess");
1374        std::fs::create_dir_all(&cfg_dir).unwrap();
1375        std::fs::write(
1376            cfg_dir.join("formats.toml"),
1377            "this is not valid toml = = =",
1378        )
1379        .unwrap();
1380
1381        let err = load_layered_config().unwrap_err();
1382        assert!(err.contains("formats.toml"), "got: {err}");
1383
1384        match prev_home {
1385            Some(v) => std::env::set_var("HOME", v),
1386            None => std::env::remove_var("HOME"),
1387        }
1388        match prev_global {
1389            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1390            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1391        }
1392    }
1393
1394    #[test]
1395    fn log_format_compile_full_with_record_start() {
1396        let fmt = LogFormat::compile_full(
1397            "test",
1398            r"^(?P<msg>.+)$",
1399            None,
1400            Some(r"^\["),
1401            None,
1402        ).expect("compile");
1403        assert!(fmt.record_start.is_some());
1404        assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1405        assert!(!fmt.record_start.as_ref().unwrap().is_match("  continuation"));
1406    }
1407
1408    #[test]
1409    fn log_format_compile_full_bad_record_start_errors() {
1410        let err = LogFormat::compile_full(
1411            "test",
1412            r"^(?P<msg>.+)$",
1413            None,
1414            Some(r"["),  // unclosed bracket
1415            None,
1416        ).expect_err("should fail");
1417        assert!(err.contains("record_start"), "error mentions record_start: {err}");
1418    }
1419
1420    #[test]
1421    fn group_with_grep_field_deserializes() {
1422        let toml_text = r#"
1423            [group.errorlog]
1424            format = "app"
1425            grep = ["timeout", "deadlock"]
1426        "#;
1427        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1428        let entry = cfg.group.get("errorlog").expect("errorlog present");
1429        assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1430    }
1431
1432    #[test]
1433    fn expand_argv_emits_group_grep_flags() {
1434        let mut groups = HashMap::new();
1435        groups.insert("errorlog".to_string(), Group {
1436            name: "errorlog".to_string(),
1437            grep: vec!["timeout".to_string(), "deadlock".to_string()],
1438            ..Default::default()
1439        });
1440        let out = expand_argv(
1441            argv(&["tess", "--errorlog", "logs.txt"]),
1442            &groups,
1443        );
1444        let joined = out.join(" ");
1445        assert!(joined.contains("--grep timeout"), "got: {joined}");
1446        assert!(joined.contains("--grep deadlock"), "got: {joined}");
1447    }
1448
1449    #[test]
1450    fn user_grep_after_group_accumulates() {
1451        let mut groups = HashMap::new();
1452        groups.insert("errorlog".to_string(), Group {
1453            name: "errorlog".to_string(),
1454            grep: vec!["timeout".to_string()],
1455            ..Default::default()
1456        });
1457        let out = expand_argv(
1458            argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1459            &groups,
1460        );
1461        let joined = out.join(" ");
1462        assert!(joined.contains("--grep timeout"));
1463        assert!(joined.contains("--grep extra"));
1464    }
1465
1466    #[test]
1467    fn format_entry_parses_prompt() {
1468        let toml_text = r#"
1469            [format.myapp]
1470            regex = '^(?P<line>.*)$'
1471            prompt = '<label> <pct>%'
1472        "#;
1473        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1474        let entry = cfg.format.get("myapp").expect("myapp present");
1475        assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1476    }
1477
1478    #[test]
1479    fn load_all_tags_source_correctly() {
1480        let _guard = HOME_LOCK.lock().unwrap();
1481        let prev_home = std::env::var_os("HOME");
1482        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1483
1484        let home = tempfile::tempdir().unwrap();
1485        let global = tempfile::tempdir().unwrap();
1486
1487        std::env::set_var("HOME", home.path());
1488        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1489
1490        std::fs::write(
1491            global.path().join("formats.toml"),
1492            r#"
1493[format.global-only]
1494regex = "^G (?P<msg>.+)$"
1495
1496[format.both]
1497regex = "^GLOBAL (?P<msg>.+)$"
1498"#,
1499        )
1500        .unwrap();
1501
1502        let cfg_dir = home.path().join(".config").join("tess");
1503        std::fs::create_dir_all(&cfg_dir).unwrap();
1504        std::fs::write(
1505            cfg_dir.join("formats.toml"),
1506            r#"
1507[format.local-only]
1508regex = "^L (?P<msg>.+)$"
1509
1510[format.both]
1511regex = "^LOCAL (?P<msg>.+)$"
1512"#,
1513        )
1514        .unwrap();
1515
1516        let all = load_all().unwrap();
1517
1518        // Built-in still tagged builtin.
1519        assert_eq!(
1520            all["apache-common"].source,
1521            crate::config_path::ConfigSource::Builtin
1522        );
1523        assert!(all["apache-common"].overrides.is_none());
1524
1525        // Global-only.
1526        assert_eq!(
1527            all["global-only"].source,
1528            crate::config_path::ConfigSource::Global
1529        );
1530        assert!(all["global-only"].overrides.is_none());
1531
1532        // Local-only.
1533        assert_eq!(
1534            all["local-only"].source,
1535            crate::config_path::ConfigSource::Local
1536        );
1537        assert!(all["local-only"].overrides.is_none());
1538
1539        // Same-name: local wins, marked as overriding global.
1540        assert_eq!(
1541            all["both"].source,
1542            crate::config_path::ConfigSource::Local
1543        );
1544        assert_eq!(
1545            all["both"].overrides,
1546            Some(crate::config_path::ConfigSource::Global)
1547        );
1548
1549        match prev_home {
1550            Some(v) => std::env::set_var("HOME", v),
1551            None => std::env::remove_var("HOME"),
1552        }
1553        match prev_global {
1554            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1555            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1556        }
1557    }
1558
1559    #[test]
1560    fn source_label_renders_correctly() {
1561        use crate::config_path::ConfigSource;
1562        assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1563        assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1564        assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1565        assert_eq!(
1566            format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1567            "[local, overrides global]"
1568        );
1569        assert_eq!(
1570            format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1571            "[local, overrides built-in]"
1572        );
1573        assert_eq!(
1574            format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1575            "[global, overrides built-in]"
1576        );
1577    }
1578}