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