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    #[serde(default)]
283    filter: Vec<String>,
284    #[serde(default)]
285    grep: Vec<String>,
286}
287
288/// A user-defined CLI shortcut. When `tess --<group_name>` appears in argv,
289/// the group's flags are expanded inline and remaining positionals become
290/// `--filter` arguments.
291#[derive(Debug, Clone, Default)]
292pub struct Group {
293    pub name: String,
294    pub format: Option<String>,
295    pub file: Option<String>,
296    pub follow: bool,
297    pub tail: Option<usize>,
298    pub head: Option<usize>,
299    pub dim: bool,
300    pub line_numbers: bool,
301    pub chop: bool,
302    pub tab_width: Option<u8>,
303    pub filter: Vec<String>,
304    pub grep: Vec<String>,
305    // Populated by the layered loader to track which config layer a group came
306    // from (and what it overrode); reserved for group source annotation in
307    // `--list-formats`. Not yet read, hence the allow.
308    #[allow(dead_code)]
309    pub(crate) source: crate::config_path::ConfigSource,
310    #[allow(dead_code)]
311    pub(crate) overrides: Option<crate::config_path::ConfigSource>,
312}
313
314/// Long-form names of every built-in clap flag. A group cannot reuse one of
315/// these names — it would shadow the real flag at expansion time.
316const RESERVED_LONG_FLAGS: &[&str] = &[
317    "format",
318    "filter",
319    "grep",
320    "dim",
321    "head",
322    "tail",
323    "follow",
324    "LINE-NUMBERS",
325    "chop-long-lines",
326    "tab-width",
327    "list-formats",
328    "live",
329    "manual",
330    "examples",
331    "prettify",
332    "content-type",
333    "help",
334    "version",
335    "record-start",
336    "hex",
337    "prompt",
338    "preprocess",
339    "no-preprocess",
340    "no-color",
341    "raw-control-chars",
342    "tag",
343    "tag-file",
344];
345
346/// Built-in formats compiled from this list of (name, pattern). Patterns use
347/// raw strings so backslashes don't need escaping.
348const BUILTINS: &[(&str, &str)] = &[
349    (
350        "apache-common",
351        r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
352    ),
353    (
354        "apache-combined",
355        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>[^"]*)"$"#,
356    ),
357    (
358        "nginx-combined",
359        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>[^"]*)"$"#,
360    ),
361];
362
363fn formats_path_in(dir: &std::path::Path) -> PathBuf {
364    dir.join("formats.toml")
365}
366
367/// Parsed contents of both global and local `formats.toml`. Empty
368/// `UserConfig` represents "layer absent or unreadable".
369#[derive(Debug, Default)]
370struct LayeredConfig {
371    global: UserConfig,
372    local: UserConfig,
373}
374
375fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
376    let text = std::fs::read_to_string(path)
377        .map_err(|e| format!("reading {}: {e}", path.display()))?;
378    toml::from_str(&text)
379        .map_err(|e| format!("parsing {}: {e}", path.display()))
380}
381
382fn load_layered_config() -> Result<LayeredConfig, String> {
383    let mut layered = LayeredConfig::default();
384
385    // Global: warn-and-continue on parse error.
386    if let Some(dir) = config_path::global_config_dir() {
387        let path = formats_path_in(&dir);
388        if path.exists() {
389            match read_formats_toml(&path) {
390                Ok(cfg) => layered.global = cfg,
391                Err(e) => eprintln!(
392                    "tess: warning: {e}; ignoring global config"
393                ),
394            }
395        }
396    }
397
398    // Local: fail-startup on parse error (unchanged behavior).
399    if let Some(dir) = config_path::user_config_dir() {
400        let path = formats_path_in(&dir);
401        if path.exists() {
402            layered.local = read_formats_toml(&path)?;
403        }
404    }
405
406    Ok(layered)
407}
408
409struct FormatSource {
410    regex: String,
411    display: Option<String>,
412    record_start: Option<String>,
413    prompt: Option<String>,
414    prompt_style: Option<String>,
415    source: crate::config_path::ConfigSource,
416    overrides: Option<crate::config_path::ConfigSource>,
417}
418
419fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
420    let cfg = load_layered_config()?;
421    let mut out: HashMap<String, FormatSource> = HashMap::new();
422    for (k, v) in cfg.global.format {
423        out.insert(k, FormatSource {
424            regex: v.regex,
425            display: v.display,
426            record_start: v.record_start,
427            prompt: v.prompt,
428            prompt_style: v.prompt_style,
429            source: crate::config_path::ConfigSource::Global,
430            overrides: None,
431        });
432    }
433    for (k, v) in cfg.local.format {
434        let overrides = out.get(&k).map(|prev| prev.source);
435        out.insert(k, FormatSource {
436            regex: v.regex,
437            display: v.display,
438            record_start: v.record_start,
439            prompt: v.prompt,
440            prompt_style: v.prompt_style,
441            source: crate::config_path::ConfigSource::Local,
442            overrides,
443        });
444    }
445    Ok(out)
446}
447
448/// Load all user-defined groups from global and local `formats.toml`. Built-ins
449/// are not provided — groups are entirely user-defined. Validates that group
450/// names don't shadow built-in flag names.
451pub fn load_groups() -> Result<HashMap<String, Group>, String> {
452    let cfg = load_layered_config()?;
453
454    struct StagedGroup {
455        entry: GroupEntry,
456        source: crate::config_path::ConfigSource,
457        overrides: Option<crate::config_path::ConfigSource>,
458    }
459
460    let mut staged: HashMap<String, StagedGroup> = HashMap::new();
461    for (k, v) in cfg.global.group {
462        staged.insert(k, StagedGroup {
463            entry: v,
464            source: crate::config_path::ConfigSource::Global,
465            overrides: None,
466        });
467    }
468    for (k, v) in cfg.local.group {
469        let overrides = staged.get(&k).map(|prev| prev.source);
470        staged.insert(k, StagedGroup {
471            entry: v,
472            source: crate::config_path::ConfigSource::Local,
473            overrides,
474        });
475    }
476
477    let mut out = HashMap::with_capacity(staged.len());
478    for (name, sg) in staged {
479        if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
480            return Err(format!(
481                "group `{name}`: name collides with built-in --{name} flag"
482            ));
483        }
484        out.insert(
485            name.clone(),
486            Group {
487                name,
488                format: sg.entry.format,
489                file: sg.entry.file,
490                follow: sg.entry.follow.unwrap_or(false),
491                tail: sg.entry.tail,
492                head: sg.entry.head,
493                dim: sg.entry.dim.unwrap_or(false),
494                line_numbers: sg.entry.line_numbers.unwrap_or(false),
495                chop: sg.entry.chop.unwrap_or(false),
496                tab_width: sg.entry.tab_width,
497                filter: sg.entry.filter,
498                grep: sg.entry.grep,
499                source: sg.source,
500                overrides: sg.overrides,
501            },
502        );
503    }
504    Ok(out)
505}
506
507/// Load all formats: built-ins first, then any in `~/.config/tess/formats.toml`
508/// (which override built-ins of the same name). Returns the compiled map keyed
509/// by format name.
510pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
511    let mut sources: HashMap<String, FormatSource> = HashMap::new();
512    for (name, pat) in BUILTINS {
513        sources.insert(name.to_string(), FormatSource {
514            regex: pat.to_string(),
515            display: None,
516            record_start: None,
517            prompt: None,
518            prompt_style: None,
519            source: crate::config_path::ConfigSource::Builtin,
520            overrides: None,
521        });
522    }
523    let user = load_user_formats()?;
524    for (name, mut src) in user {
525        // load_user_formats doesn't know about built-ins, so we detect
526        // direct built-in shadowing here. If `src.overrides` is already
527        // set, local was shadowing global — leave that alone.
528        if src.overrides.is_none() && sources.contains_key(&name) {
529            src.overrides = Some(crate::config_path::ConfigSource::Builtin);
530        }
531        sources.insert(name, src);
532    }
533    let mut compiled = HashMap::new();
534    for (name, src) in sources {
535        let mut fmt = LogFormat::compile_full(
536            &name,
537            &src.regex,
538            src.display.as_deref(),
539            src.record_start.as_deref(),
540            src.prompt.as_deref(),
541        )?;
542        if let Some(spec) = src.prompt_style.as_deref() {
543            fmt.prompt_style = Some(
544                crate::style_spec::parse(spec)
545                    .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
546            );
547        }
548        fmt.source = src.source;
549        fmt.overrides = src.overrides;
550        compiled.insert(name, fmt);
551    }
552    Ok(compiled)
553}
554
555/// Pre-process an argv vector before clap sees it. For every `--<name>`
556/// token that matches a defined group, expand the group's flags inline and
557/// switch into "filter mode" — bare positionals after the group token become
558/// `--filter <arg>` pairs. Group tokens before any flag still expand
559/// correctly; positionals before a group remain as-is.
560///
561/// CLI flags coming after the expansion override the group's values for
562/// `Option<T>` flags (clap takes the last occurrence) and add to repeatable
563/// flags like `--filter` (clap accumulates the `Vec<String>`).
564/// Long flags that take a separate value as the next argv token (e.g.
565/// `--tail 1000` rather than `--tail=1000`). Used by `expand_argv` so it
566/// doesn't mistake a flag's value for a positional.
567const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
568    "--format",
569    "--filter",
570    "--grep",
571    "--head",
572    "--tail",
573    "--tab-width",
574    "--record-start",
575];
576
577pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
578    if argv.is_empty() {
579        return argv;
580    }
581    let mut out = Vec::with_capacity(argv.len() * 2);
582    let mut iter = argv.into_iter();
583    out.push(iter.next().unwrap()); // argv[0] = program name
584    let mut filter_mode = false;
585    let mut pass_next = false;
586    for arg in iter {
587        if pass_next {
588            pass_next = false;
589            out.push(arg);
590            continue;
591        }
592        if let Some(name) = arg.strip_prefix("--") {
593            // `--flag=value` is a single token: don't try to match groups
594            // against `flag=value`.
595            if !name.contains('=') {
596                if let Some(g) = groups.get(name) {
597                    expand_group(g, &mut out);
598                    filter_mode = true;
599                    continue;
600                }
601                if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
602                    // The next token is this flag's value; pass it through
603                    // even in filter mode.
604                    out.push(arg);
605                    pass_next = true;
606                    continue;
607                }
608            }
609        }
610        if filter_mode && !arg.starts_with('-') {
611            out.push("--filter".into());
612            out.push(arg);
613            continue;
614        }
615        out.push(arg);
616    }
617    out
618}
619
620fn expand_group(g: &Group, out: &mut Vec<String>) {
621    if let Some(format) = &g.format {
622        out.push("--format".into());
623        out.push(format.clone());
624    }
625    if g.follow {
626        out.push("--follow".into());
627    }
628    if let Some(t) = g.tail {
629        out.push("--tail".into());
630        out.push(t.to_string());
631    }
632    if let Some(h) = g.head {
633        out.push("--head".into());
634        out.push(h.to_string());
635    }
636    if g.dim {
637        out.push("--dim".into());
638    }
639    if g.line_numbers {
640        out.push("-N".into());
641    }
642    if g.chop {
643        out.push("-S".into());
644    }
645    if let Some(t) = g.tab_width {
646        out.push("--tab-width".into());
647        out.push(t.to_string());
648    }
649    for f in &g.filter {
650        out.push("--filter".into());
651        out.push(f.clone());
652    }
653    for g_pat in &g.grep {
654        out.push("--grep".into());
655        out.push(g_pat.clone());
656    }
657    if let Some(file) = &g.file {
658        out.push(file.clone());
659    }
660}
661
662/// Render the bracketed source annotation for a format. The `overrides`
663/// argument is the immediately-replaced layer produced by `load_all`.
664fn format_source_label(
665    source: crate::config_path::ConfigSource,
666    overrides: Option<crate::config_path::ConfigSource>,
667) -> String {
668    use crate::config_path::ConfigSource::*;
669    let layer = match source {
670        Builtin => "built-in",
671        Global => "global",
672        Local => "local",
673    };
674    match overrides {
675        None => format!("[{layer}]"),
676        Some(Builtin) => format!("[{layer}, overrides built-in]"),
677        Some(Global) => format!("[{layer}, overrides global]"),
678        // Lower layers can't replace local; this arm is unreachable in
679        // practice but kept for total-match completeness.
680        Some(Local) => format!("[{layer}, overrides local]"),
681    }
682}
683
684/// Print one line per format, with the named field list and source
685/// label, to stdout. Used by `--list-formats`.
686pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
687    let mut names: Vec<&String> = formats.keys().collect();
688    names.sort();
689
690    // Column-align names for readability when field lists vary.
691    let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
692
693    for name in names {
694        let fmt = &formats[name];
695        let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
696        let label = format_source_label(fmt.source, fmt.overrides);
697        println!(
698            "{:<width$}  {}  {}",
699            name,
700            label,
701            fields.join(", "),
702            width = name_width
703        );
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use std::sync::Mutex;
711
712    /// Serializes tests that mutate the `HOME` env var; otherwise they
713    /// trample each other when cargo runs tests in parallel.
714    static HOME_LOCK: Mutex<()> = Mutex::new(());
715
716    #[test]
717    fn builtins_all_compile() {
718        for (name, pat) in BUILTINS {
719            LogFormat::compile(name, pat)
720                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
721        }
722    }
723
724    // ----- DisplayTemplate -----
725
726    fn fields() -> Vec<String> {
727        vec!["ts".into(), "level".into(), "msg".into()]
728    }
729
730    #[test]
731    fn display_template_compiles_basic() {
732        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
733        assert_eq!(t.source(), "[<ts>] <level> <msg>");
734    }
735
736    #[test]
737    fn display_template_renders_substitutions() {
738        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
739        let mut map = std::collections::HashMap::new();
740        map.insert("level".to_string(), "ERROR".to_string());
741        map.insert("msg".to_string(), "boom".to_string());
742        let out = t.render(|n| map.get(n).cloned());
743        assert_eq!(out, "ERROR: boom");
744    }
745
746    #[test]
747    fn display_template_missing_field_renders_empty() {
748        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
749        let mut map = std::collections::HashMap::new();
750        map.insert("level".to_string(), "ERROR".to_string());
751        // msg is absent
752        let out = t.render(|n| map.get(n).cloned());
753        assert_eq!(out, "ERROR:");
754    }
755
756    #[test]
757    fn display_template_escape_sequences() {
758        // Only `\<` and `\\` are recognized escapes; `>` is always literal
759        // (a stray `>` outside `<...>` is fine).
760        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
761        let mut map = std::collections::HashMap::new();
762        map.insert("level".to_string(), "X".to_string());
763        let out = t.render(|n| map.get(n).cloned());
764        assert_eq!(out, "<not a field> X");
765    }
766
767    #[test]
768    fn display_template_escape_backslash() {
769        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
770        let mut map = std::collections::HashMap::new();
771        map.insert("level".to_string(), "X".to_string());
772        let out = t.render(|n| map.get(n).cloned());
773        assert_eq!(out, r"a\b X");
774    }
775
776    #[test]
777    fn display_template_escape_e_emits_esc() {
778        let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
779        let mut map = std::collections::HashMap::new();
780        map.insert("level".to_string(), "X".to_string());
781        let out = t.render(|n| map.get(n).cloned());
782        assert_eq!(out, "\x1b[31mX\x1b[0m");
783    }
784
785    #[test]
786    fn display_template_escape_x1b_emits_esc() {
787        let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
788        let out = t.render(|_| Some("Y".to_string()));
789        assert_eq!(out, "\x1b[1mY");
790    }
791
792    #[test]
793    fn display_template_escape_octal_emits_esc() {
794        let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
795        let out = t.render(|_| Some("Z".to_string()));
796        assert_eq!(out, "\x1b[1mZ");
797    }
798
799    #[test]
800    fn display_template_escape_n_t_r() {
801        let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
802        let out = t.render(|_| Some("Q".to_string()));
803        assert_eq!(out, "\n\t\rQ");
804    }
805
806    #[test]
807    fn display_template_escape_unknown_preserves_backslash() {
808        let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
809        let out = t.render(|_| Some("Q".to_string()));
810        assert_eq!(out, r"\qQ");
811    }
812
813    #[test]
814    fn display_template_escape_x_incomplete_errors() {
815        let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
816        assert!(err.contains("incomplete"), "{err}");
817    }
818
819    #[test]
820    fn display_template_escape_invalid_hex_errors() {
821        let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
822        assert!(err.contains("invalid"), "{err}");
823    }
824
825    #[test]
826    fn display_template_rejects_empty() {
827        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
828        assert!(err.contains("empty"), "{err}");
829    }
830
831    #[test]
832    fn display_template_rejects_unknown_field() {
833        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
834        assert!(err.contains("unknown field"), "{err}");
835    }
836
837    #[test]
838    fn display_template_rejects_unterminated() {
839        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
840        assert!(err.contains("unterminated"), "{err}");
841    }
842
843    #[test]
844    fn display_template_rejects_empty_ref() {
845        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
846        assert!(err.contains("empty field reference"), "{err}");
847    }
848
849    #[test]
850    fn apache_common_extracts_fields() {
851        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
852        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
853        let caps = fmt.regex.captures(line).expect("should match");
854        assert_eq!(&caps["ip"], "127.0.0.1");
855        assert_eq!(&caps["user"], "alice");
856        assert_eq!(&caps["method"], "GET");
857        assert_eq!(&caps["url"], "/index.html");
858        assert_eq!(&caps["status"], "200");
859        assert_eq!(&caps["size"], "2326");
860    }
861
862    #[test]
863    fn apache_combined_extracts_referer_and_agent() {
864        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
865        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""#;
866        let caps = fmt.regex.captures(line).expect("should match");
867        assert_eq!(&caps["status"], "401");
868        assert_eq!(&caps["url"], "/api/login");
869        assert_eq!(&caps["referer"], "https://example.com/");
870        assert_eq!(&caps["agent"], "Mozilla/5.0");
871    }
872
873    #[test]
874    fn field_names_listed_in_order() {
875        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
876        assert_eq!(
877            fmt.field_names,
878            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
879        );
880    }
881
882    #[test]
883    fn compile_rejects_regex_without_named_groups() {
884        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
885        assert!(err.contains("at least one named capture"), "{err}");
886    }
887
888    #[test]
889    fn compile_rejects_invalid_regex() {
890        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
891        assert!(err.contains("bad"), "{err}");
892    }
893
894    #[test]
895    fn load_groups_reads_user_config() {
896        let _g = HOME_LOCK.lock().unwrap();
897        let tmp = tempfile::tempdir().unwrap();
898        let cfg_dir = tmp.path().join(".config").join("tess");
899        std::fs::create_dir_all(&cfg_dir).unwrap();
900        std::fs::write(
901            cfg_dir.join("formats.toml"),
902            r#"
903[group.errorlog]
904format = "apache-combined"
905file = "/var/log/access.log"
906follow = true
907tail = 1000
908filter = ["status~^5"]
909
910[group.minimal]
911file = "/tmp/x.log"
912"#,
913        )
914        .unwrap();
915        let saved = std::env::var_os("HOME");
916        std::env::set_var("HOME", tmp.path());
917        let result = load_groups();
918        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
919        let groups = result.unwrap();
920        let err = &groups["errorlog"];
921        assert_eq!(err.format.as_deref(), Some("apache-combined"));
922        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
923        assert!(err.follow);
924        assert_eq!(err.tail, Some(1000));
925        assert_eq!(err.filter, vec!["status~^5".to_string()]);
926        let min = &groups["minimal"];
927        assert!(!min.follow);
928        assert!(min.tail.is_none());
929        assert_eq!(min.filter, Vec::<String>::new());
930    }
931
932    fn group(name: &str) -> Group {
933        Group { name: name.into(), ..Group::default() }
934    }
935
936    fn argv(parts: &[&str]) -> Vec<String> {
937        parts.iter().map(|s| s.to_string()).collect()
938    }
939
940    #[test]
941    fn expand_argv_passes_through_when_no_group_matches() {
942        let groups: HashMap<String, Group> = HashMap::new();
943        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
944        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
945    }
946
947    #[test]
948    fn expand_argv_inserts_group_flags_and_file() {
949        let mut groups: HashMap<String, Group> = HashMap::new();
950        groups.insert(
951            "errorlog".into(),
952            Group {
953                name: "errorlog".into(),
954                format: Some("apache-combined".into()),
955                file: Some("/var/log/access.log".into()),
956                follow: true,
957                tail: Some(1000),
958                filter: vec!["status~^5".into()],
959                ..Group::default()
960            },
961        );
962        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
963        assert_eq!(
964            out,
965            argv(&[
966                "tess",
967                "--format", "apache-combined",
968                "--follow",
969                "--tail", "1000",
970                "--filter", "status~^5",
971                "/var/log/access.log",
972            ])
973        );
974    }
975
976    #[test]
977    fn expand_argv_converts_positionals_to_filters_after_group() {
978        let mut groups: HashMap<String, Group> = HashMap::new();
979        groups.insert(
980            "errorlog".into(),
981            Group {
982                name: "errorlog".into(),
983                format: Some("apache-combined".into()),
984                file: Some("/log".into()),
985                ..Group::default()
986            },
987        );
988        let out = expand_argv(
989            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
990            &groups,
991        );
992        assert_eq!(
993            out,
994            argv(&[
995                "tess",
996                "--format", "apache-combined",
997                "/log",
998                "--filter", "msg~test",
999                "--filter", "url~/api/",
1000            ])
1001        );
1002    }
1003
1004    #[test]
1005    fn expand_argv_leaves_flags_alone_after_group() {
1006        let mut groups: HashMap<String, Group> = HashMap::new();
1007        groups.insert("errorlog".into(), group("errorlog"));
1008        let out = expand_argv(
1009            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1010            &groups,
1011        );
1012        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
1013        assert_eq!(
1014            out,
1015            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1016        );
1017    }
1018
1019    #[test]
1020    fn expand_argv_user_flag_after_group_can_override_tail() {
1021        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
1022        // so user's 50 wins.
1023        let mut groups: HashMap<String, Group> = HashMap::new();
1024        groups.insert(
1025            "errorlog".into(),
1026            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1027        );
1028        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1029        // --tail 1000 from group, then --tail 50 from user. Order preserved.
1030        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1031        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1032        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1033        let pos_50 = out.iter().position(|x| x == "50").unwrap();
1034        assert!(pos_1000 < pos_50, "user's value must come after group's");
1035    }
1036
1037    #[test]
1038    fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1039        let mut groups: HashMap<String, Group> = HashMap::new();
1040        groups.insert("errorlog".into(), group("errorlog"));
1041        let out = expand_argv(
1042            argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1043            &groups,
1044        );
1045        // `timeout` is --grep's value, not a positional → not converted to --filter.
1046        assert_eq!(
1047            out,
1048            argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1049        );
1050    }
1051
1052    #[test]
1053    fn expand_argv_unknown_double_dash_passes_through() {
1054        let groups: HashMap<String, Group> = HashMap::new();
1055        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1056        assert_eq!(out, argv(&["tess", "--unknown"]));
1057    }
1058
1059    #[test]
1060    fn load_groups_rejects_reserved_name() {
1061        let _g = HOME_LOCK.lock().unwrap();
1062        let tmp = tempfile::tempdir().unwrap();
1063        let cfg_dir = tmp.path().join(".config").join("tess");
1064        std::fs::create_dir_all(&cfg_dir).unwrap();
1065        std::fs::write(
1066            cfg_dir.join("formats.toml"),
1067            r#"
1068[group.follow]
1069file = "/x.log"
1070"#,
1071        )
1072        .unwrap();
1073        let saved = std::env::var_os("HOME");
1074        std::env::set_var("HOME", tmp.path());
1075        let result = load_groups();
1076        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1077        let err = result.unwrap_err();
1078        assert!(err.contains("collides with built-in --follow"), "{err}");
1079    }
1080
1081    #[test]
1082    fn user_config_overrides_builtin_via_load_all() {
1083        let _g = HOME_LOCK.lock().unwrap();
1084        // Use a temp HOME to avoid touching the real user's config.
1085        let tmp = tempfile::tempdir().unwrap();
1086        let cfg_dir = tmp.path().join(".config").join("tess");
1087        std::fs::create_dir_all(&cfg_dir).unwrap();
1088        let cfg_file = cfg_dir.join("formats.toml");
1089        std::fs::write(
1090            &cfg_file,
1091            r#"
1092[format.apache-common]
1093regex = "^(?P<custom>\\S+)$"
1094"#,
1095        )
1096        .unwrap();
1097        // Save and replace HOME for the duration of this test.
1098        let saved = std::env::var_os("HOME");
1099        std::env::set_var("HOME", tmp.path());
1100        let result = load_all();
1101        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1102        let formats = result.unwrap();
1103        let common = &formats["apache-common"];
1104        assert_eq!(common.field_names, vec!["custom"], "user config should win");
1105    }
1106
1107    #[test]
1108    fn format_entry_parses_record_start() {
1109        let toml_text = r#"
1110            [format.myapp]
1111            regex = '^(?P<line>.*)$'
1112            record_start = '^\['
1113        "#;
1114        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1115        let entry = cfg.format.get("myapp").expect("myapp present");
1116        assert_eq!(entry.regex, "^(?P<line>.*)$");
1117        assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1118    }
1119
1120    #[test]
1121    fn format_entry_record_start_optional() {
1122        let toml_text = r#"
1123            [format.myapp]
1124            regex = '^(?P<line>.*)$'
1125        "#;
1126        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1127        let entry = cfg.format.get("myapp").expect("myapp present");
1128        assert!(entry.record_start.is_none());
1129    }
1130
1131    #[test]
1132    fn layered_loader_local_overrides_global() {
1133        let _guard = HOME_LOCK.lock().unwrap();
1134        let prev_home = std::env::var_os("HOME");
1135        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1136
1137        let home = tempfile::tempdir().unwrap();
1138        let global = tempfile::tempdir().unwrap();
1139
1140        std::env::set_var("HOME", home.path());
1141        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1142
1143        std::fs::write(
1144            global.path().join("formats.toml"),
1145            r#"
1146[format.shared]
1147regex = "^GLOBAL (?P<msg>.+)$"
1148
1149[format.both]
1150regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1151"#,
1152        )
1153        .unwrap();
1154
1155        let cfg_dir = home.path().join(".config").join("tess");
1156        std::fs::create_dir_all(&cfg_dir).unwrap();
1157        std::fs::write(
1158            cfg_dir.join("formats.toml"),
1159            r#"
1160[format.both]
1161regex = "^LOCAL_BOTH (?P<msg>.+)$"
1162
1163[format.local-only]
1164regex = "^LOCAL (?P<msg>.+)$"
1165"#,
1166        )
1167        .unwrap();
1168
1169        let cfg = load_layered_config().unwrap();
1170
1171        // Global-only format survives.
1172        assert!(cfg.global.format.contains_key("shared"));
1173        assert!(!cfg.local.format.contains_key("shared"));
1174
1175        // Same-name format: both layers carry it, merge step (next task)
1176        // is responsible for resolving. Here we just verify both files
1177        // parsed correctly.
1178        assert_eq!(
1179            cfg.global.format.get("both").unwrap().regex,
1180            "^GLOBAL_BOTH (?P<msg>.+)$"
1181        );
1182        assert_eq!(
1183            cfg.local.format.get("both").unwrap().regex,
1184            "^LOCAL_BOTH (?P<msg>.+)$"
1185        );
1186
1187        // Local-only format present.
1188        assert!(cfg.local.format.contains_key("local-only"));
1189
1190        match prev_home {
1191            Some(v) => std::env::set_var("HOME", v),
1192            None => std::env::remove_var("HOME"),
1193        }
1194        match prev_global {
1195            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1196            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1197        }
1198    }
1199
1200    #[test]
1201    fn layered_loader_warns_on_bad_global_toml() {
1202        let _guard = HOME_LOCK.lock().unwrap();
1203        let prev_home = std::env::var_os("HOME");
1204        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1205
1206        let home = tempfile::tempdir().unwrap();
1207        let global = tempfile::tempdir().unwrap();
1208
1209        std::env::set_var("HOME", home.path());
1210        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1211
1212        std::fs::write(
1213            global.path().join("formats.toml"),
1214            "this is not valid toml = = =",
1215        )
1216        .unwrap();
1217
1218        // Should NOT error — global parse failures are warnings, not errors.
1219        let cfg = load_layered_config().unwrap();
1220        assert!(cfg.global.format.is_empty());
1221        assert!(cfg.global.group.is_empty());
1222
1223        match prev_home {
1224            Some(v) => std::env::set_var("HOME", v),
1225            None => std::env::remove_var("HOME"),
1226        }
1227        match prev_global {
1228            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1229            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1230        }
1231    }
1232
1233    #[test]
1234    fn layered_loader_fails_on_bad_local_toml() {
1235        let _guard = HOME_LOCK.lock().unwrap();
1236        let prev_home = std::env::var_os("HOME");
1237        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1238
1239        let home = tempfile::tempdir().unwrap();
1240        std::env::set_var("HOME", home.path());
1241        std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1242
1243        let cfg_dir = home.path().join(".config").join("tess");
1244        std::fs::create_dir_all(&cfg_dir).unwrap();
1245        std::fs::write(
1246            cfg_dir.join("formats.toml"),
1247            "this is not valid toml = = =",
1248        )
1249        .unwrap();
1250
1251        let err = load_layered_config().unwrap_err();
1252        assert!(err.contains("formats.toml"), "got: {err}");
1253
1254        match prev_home {
1255            Some(v) => std::env::set_var("HOME", v),
1256            None => std::env::remove_var("HOME"),
1257        }
1258        match prev_global {
1259            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1260            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1261        }
1262    }
1263
1264    #[test]
1265    fn log_format_compile_full_with_record_start() {
1266        let fmt = LogFormat::compile_full(
1267            "test",
1268            r"^(?P<msg>.+)$",
1269            None,
1270            Some(r"^\["),
1271            None,
1272        ).expect("compile");
1273        assert!(fmt.record_start.is_some());
1274        assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1275        assert!(!fmt.record_start.as_ref().unwrap().is_match("  continuation"));
1276    }
1277
1278    #[test]
1279    fn log_format_compile_full_bad_record_start_errors() {
1280        let err = LogFormat::compile_full(
1281            "test",
1282            r"^(?P<msg>.+)$",
1283            None,
1284            Some(r"["),  // unclosed bracket
1285            None,
1286        ).expect_err("should fail");
1287        assert!(err.contains("record_start"), "error mentions record_start: {err}");
1288    }
1289
1290    #[test]
1291    fn group_with_grep_field_deserializes() {
1292        let toml_text = r#"
1293            [group.errorlog]
1294            format = "app"
1295            grep = ["timeout", "deadlock"]
1296        "#;
1297        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1298        let entry = cfg.group.get("errorlog").expect("errorlog present");
1299        assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1300    }
1301
1302    #[test]
1303    fn expand_argv_emits_group_grep_flags() {
1304        let mut groups = HashMap::new();
1305        groups.insert("errorlog".to_string(), Group {
1306            name: "errorlog".to_string(),
1307            grep: vec!["timeout".to_string(), "deadlock".to_string()],
1308            ..Default::default()
1309        });
1310        let out = expand_argv(
1311            argv(&["tess", "--errorlog", "logs.txt"]),
1312            &groups,
1313        );
1314        let joined = out.join(" ");
1315        assert!(joined.contains("--grep timeout"), "got: {joined}");
1316        assert!(joined.contains("--grep deadlock"), "got: {joined}");
1317    }
1318
1319    #[test]
1320    fn user_grep_after_group_accumulates() {
1321        let mut groups = HashMap::new();
1322        groups.insert("errorlog".to_string(), Group {
1323            name: "errorlog".to_string(),
1324            grep: vec!["timeout".to_string()],
1325            ..Default::default()
1326        });
1327        let out = expand_argv(
1328            argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1329            &groups,
1330        );
1331        let joined = out.join(" ");
1332        assert!(joined.contains("--grep timeout"));
1333        assert!(joined.contains("--grep extra"));
1334    }
1335
1336    #[test]
1337    fn format_entry_parses_prompt() {
1338        let toml_text = r#"
1339            [format.myapp]
1340            regex = '^(?P<line>.*)$'
1341            prompt = '<label> <pct>%'
1342        "#;
1343        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1344        let entry = cfg.format.get("myapp").expect("myapp present");
1345        assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1346    }
1347
1348    #[test]
1349    fn load_all_tags_source_correctly() {
1350        let _guard = HOME_LOCK.lock().unwrap();
1351        let prev_home = std::env::var_os("HOME");
1352        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1353
1354        let home = tempfile::tempdir().unwrap();
1355        let global = tempfile::tempdir().unwrap();
1356
1357        std::env::set_var("HOME", home.path());
1358        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1359
1360        std::fs::write(
1361            global.path().join("formats.toml"),
1362            r#"
1363[format.global-only]
1364regex = "^G (?P<msg>.+)$"
1365
1366[format.both]
1367regex = "^GLOBAL (?P<msg>.+)$"
1368"#,
1369        )
1370        .unwrap();
1371
1372        let cfg_dir = home.path().join(".config").join("tess");
1373        std::fs::create_dir_all(&cfg_dir).unwrap();
1374        std::fs::write(
1375            cfg_dir.join("formats.toml"),
1376            r#"
1377[format.local-only]
1378regex = "^L (?P<msg>.+)$"
1379
1380[format.both]
1381regex = "^LOCAL (?P<msg>.+)$"
1382"#,
1383        )
1384        .unwrap();
1385
1386        let all = load_all().unwrap();
1387
1388        // Built-in still tagged builtin.
1389        assert_eq!(
1390            all["apache-common"].source,
1391            crate::config_path::ConfigSource::Builtin
1392        );
1393        assert!(all["apache-common"].overrides.is_none());
1394
1395        // Global-only.
1396        assert_eq!(
1397            all["global-only"].source,
1398            crate::config_path::ConfigSource::Global
1399        );
1400        assert!(all["global-only"].overrides.is_none());
1401
1402        // Local-only.
1403        assert_eq!(
1404            all["local-only"].source,
1405            crate::config_path::ConfigSource::Local
1406        );
1407        assert!(all["local-only"].overrides.is_none());
1408
1409        // Same-name: local wins, marked as overriding global.
1410        assert_eq!(
1411            all["both"].source,
1412            crate::config_path::ConfigSource::Local
1413        );
1414        assert_eq!(
1415            all["both"].overrides,
1416            Some(crate::config_path::ConfigSource::Global)
1417        );
1418
1419        match prev_home {
1420            Some(v) => std::env::set_var("HOME", v),
1421            None => std::env::remove_var("HOME"),
1422        }
1423        match prev_global {
1424            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1425            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1426        }
1427    }
1428
1429    #[test]
1430    fn source_label_renders_correctly() {
1431        use crate::config_path::ConfigSource;
1432        assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1433        assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1434        assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1435        assert_eq!(
1436            format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1437            "[local, overrides global]"
1438        );
1439        assert_eq!(
1440            format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1441            "[local, overrides built-in]"
1442        );
1443        assert_eq!(
1444            format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1445            "[global, overrides built-in]"
1446        );
1447    }
1448}