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