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    use std::sync::Mutex;
820
821    /// Serializes tests that mutate the `HOME` env var; otherwise they
822    /// trample each other when cargo runs tests in parallel.
823    static HOME_LOCK: Mutex<()> = Mutex::new(());
824
825    #[test]
826    fn builtins_all_compile() {
827        for (name, pat) in BUILTINS {
828            LogFormat::compile(name, pat)
829                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
830        }
831    }
832
833    // ----- DisplayTemplate -----
834
835    fn fields() -> Vec<String> {
836        vec!["ts".into(), "level".into(), "msg".into()]
837    }
838
839    #[test]
840    fn display_template_compiles_basic() {
841        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
842        assert_eq!(t.source(), "[<ts>] <level> <msg>");
843    }
844
845    #[test]
846    fn display_template_renders_substitutions() {
847        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
848        let mut map = std::collections::HashMap::new();
849        map.insert("level".to_string(), "ERROR".to_string());
850        map.insert("msg".to_string(), "boom".to_string());
851        let out = t.render(|n| map.get(n).cloned());
852        assert_eq!(out, "ERROR: boom");
853    }
854
855    #[test]
856    fn display_template_missing_field_renders_empty() {
857        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
858        let mut map = std::collections::HashMap::new();
859        map.insert("level".to_string(), "ERROR".to_string());
860        // msg is absent
861        let out = t.render(|n| map.get(n).cloned());
862        assert_eq!(out, "ERROR:");
863    }
864
865    #[test]
866    fn display_template_escape_sequences() {
867        // Only `\<` and `\\` are recognized escapes; `>` is always literal
868        // (a stray `>` outside `<...>` is fine).
869        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
870        let mut map = std::collections::HashMap::new();
871        map.insert("level".to_string(), "X".to_string());
872        let out = t.render(|n| map.get(n).cloned());
873        assert_eq!(out, "<not a field> X");
874    }
875
876    #[test]
877    fn display_template_escape_backslash() {
878        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
879        let mut map = std::collections::HashMap::new();
880        map.insert("level".to_string(), "X".to_string());
881        let out = t.render(|n| map.get(n).cloned());
882        assert_eq!(out, r"a\b X");
883    }
884
885    #[test]
886    fn display_template_escape_e_emits_esc() {
887        let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
888        let mut map = std::collections::HashMap::new();
889        map.insert("level".to_string(), "X".to_string());
890        let out = t.render(|n| map.get(n).cloned());
891        assert_eq!(out, "\x1b[31mX\x1b[0m");
892    }
893
894    #[test]
895    fn display_template_escape_x1b_emits_esc() {
896        let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
897        let out = t.render(|_| Some("Y".to_string()));
898        assert_eq!(out, "\x1b[1mY");
899    }
900
901    #[test]
902    fn display_template_escape_octal_emits_esc() {
903        let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
904        let out = t.render(|_| Some("Z".to_string()));
905        assert_eq!(out, "\x1b[1mZ");
906    }
907
908    #[test]
909    fn display_template_escape_n_t_r() {
910        let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
911        let out = t.render(|_| Some("Q".to_string()));
912        assert_eq!(out, "\n\t\rQ");
913    }
914
915    #[test]
916    fn display_template_escape_unknown_preserves_backslash() {
917        let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
918        let out = t.render(|_| Some("Q".to_string()));
919        assert_eq!(out, r"\qQ");
920    }
921
922    #[test]
923    fn display_template_escape_x_incomplete_errors() {
924        let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
925        assert!(err.contains("incomplete"), "{err}");
926    }
927
928    #[test]
929    fn display_template_escape_invalid_hex_errors() {
930        let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
931        assert!(err.contains("invalid"), "{err}");
932    }
933
934    #[test]
935    fn display_template_rejects_empty() {
936        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
937        assert!(err.contains("empty"), "{err}");
938    }
939
940    #[test]
941    fn display_template_rejects_unknown_field() {
942        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
943        assert!(err.contains("unknown field"), "{err}");
944    }
945
946    #[test]
947    fn display_template_rejects_unterminated() {
948        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
949        assert!(err.contains("unterminated"), "{err}");
950    }
951
952    #[test]
953    fn display_template_rejects_empty_ref() {
954        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
955        assert!(err.contains("empty field reference"), "{err}");
956    }
957
958    #[test]
959    fn apache_common_extracts_fields() {
960        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
961        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
962        let caps = fmt.regex.captures(line).expect("should match");
963        assert_eq!(&caps["ip"], "127.0.0.1");
964        assert_eq!(&caps["user"], "alice");
965        assert_eq!(&caps["method"], "GET");
966        assert_eq!(&caps["url"], "/index.html");
967        assert_eq!(&caps["status"], "200");
968        assert_eq!(&caps["size"], "2326");
969    }
970
971    #[test]
972    fn apache_combined_extracts_referer_and_agent() {
973        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
974        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""#;
975        let caps = fmt.regex.captures(line).expect("should match");
976        assert_eq!(&caps["status"], "401");
977        assert_eq!(&caps["url"], "/api/login");
978        assert_eq!(&caps["referer"], "https://example.com/");
979        assert_eq!(&caps["agent"], "Mozilla/5.0");
980    }
981
982    #[test]
983    fn field_names_listed_in_order() {
984        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
985        assert_eq!(
986            fmt.field_names,
987            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
988        );
989    }
990
991    #[test]
992    fn compile_rejects_regex_without_named_groups() {
993        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
994        assert!(err.contains("at least one named capture"), "{err}");
995    }
996
997    #[test]
998    fn compile_rejects_invalid_regex() {
999        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
1000        assert!(err.contains("bad"), "{err}");
1001    }
1002
1003    #[test]
1004    fn load_groups_reads_user_config() {
1005        let _g = HOME_LOCK.lock().unwrap();
1006        let tmp = tempfile::tempdir().unwrap();
1007        let cfg_dir = tmp.path().join(".config").join("tess");
1008        std::fs::create_dir_all(&cfg_dir).unwrap();
1009        std::fs::write(
1010            cfg_dir.join("formats.toml"),
1011            r#"
1012[group.errorlog]
1013format = "apache-combined"
1014file = "/var/log/access.log"
1015follow = true
1016tail = 1000
1017filter = ["status~^5"]
1018display = "<status> <url>"
1019
1020[group.minimal]
1021file = "/tmp/x.log"
1022"#,
1023        )
1024        .unwrap();
1025        let saved = std::env::var_os("HOME");
1026        std::env::set_var("HOME", tmp.path());
1027        let result = load_groups();
1028        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1029        let groups = result.unwrap();
1030        let err = &groups["errorlog"];
1031        assert_eq!(err.format.as_deref(), Some("apache-combined"));
1032        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
1033        assert!(err.follow);
1034        assert_eq!(err.tail, Some(1000));
1035        assert_eq!(err.filter, vec!["status~^5".to_string()]);
1036        assert_eq!(err.display.as_deref(), Some("<status> <url>"));
1037        let min = &groups["minimal"];
1038        assert!(!min.follow);
1039        assert!(min.tail.is_none());
1040        assert_eq!(min.filter, Vec::<String>::new());
1041        assert!(min.display.is_none());
1042    }
1043
1044    fn group(name: &str) -> Group {
1045        Group { name: name.into(), ..Group::default() }
1046    }
1047
1048    fn argv(parts: &[&str]) -> Vec<String> {
1049        parts.iter().map(|s| s.to_string()).collect()
1050    }
1051
1052    #[test]
1053    fn expand_argv_passes_through_when_no_group_matches() {
1054        let groups: HashMap<String, Group> = HashMap::new();
1055        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
1056        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
1057    }
1058
1059    #[test]
1060    fn expand_argv_inserts_group_flags_and_file() {
1061        let mut groups: HashMap<String, Group> = HashMap::new();
1062        groups.insert(
1063            "errorlog".into(),
1064            Group {
1065                name: "errorlog".into(),
1066                format: Some("apache-combined".into()),
1067                file: Some("/var/log/access.log".into()),
1068                follow: true,
1069                tail: Some(1000),
1070                filter: vec!["status~^5".into()],
1071                ..Group::default()
1072            },
1073        );
1074        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
1075        assert_eq!(
1076            out,
1077            argv(&[
1078                "tess",
1079                "--format", "apache-combined",
1080                "--follow",
1081                "--tail", "1000",
1082                "--filter", "status~^5",
1083                "/var/log/access.log",
1084            ])
1085        );
1086    }
1087
1088    #[test]
1089    fn expand_argv_converts_positionals_to_filters_after_group() {
1090        let mut groups: HashMap<String, Group> = HashMap::new();
1091        groups.insert(
1092            "errorlog".into(),
1093            Group {
1094                name: "errorlog".into(),
1095                format: Some("apache-combined".into()),
1096                file: Some("/log".into()),
1097                ..Group::default()
1098            },
1099        );
1100        let out = expand_argv(
1101            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
1102            &groups,
1103        );
1104        assert_eq!(
1105            out,
1106            argv(&[
1107                "tess",
1108                "--format", "apache-combined",
1109                "/log",
1110                "--filter", "msg~test",
1111                "--filter", "url~/api/",
1112            ])
1113        );
1114    }
1115
1116    #[test]
1117    fn expand_argv_leaves_flags_alone_after_group() {
1118        let mut groups: HashMap<String, Group> = HashMap::new();
1119        groups.insert("errorlog".into(), group("errorlog"));
1120        let out = expand_argv(
1121            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1122            &groups,
1123        );
1124        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
1125        assert_eq!(
1126            out,
1127            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1128        );
1129    }
1130
1131    #[test]
1132    fn expand_argv_user_flag_after_group_can_override_tail() {
1133        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
1134        // so user's 50 wins.
1135        let mut groups: HashMap<String, Group> = HashMap::new();
1136        groups.insert(
1137            "errorlog".into(),
1138            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1139        );
1140        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1141        // --tail 1000 from group, then --tail 50 from user. Order preserved.
1142        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1143        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1144        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1145        let pos_50 = out.iter().position(|x| x == "50").unwrap();
1146        assert!(pos_1000 < pos_50, "user's value must come after group's");
1147    }
1148
1149    #[test]
1150    fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1151        let mut groups: HashMap<String, Group> = HashMap::new();
1152        groups.insert("errorlog".into(), group("errorlog"));
1153        let out = expand_argv(
1154            argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1155            &groups,
1156        );
1157        // `timeout` is --grep's value, not a positional → not converted to --filter.
1158        assert_eq!(
1159            out,
1160            argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1161        );
1162    }
1163
1164    #[test]
1165    fn expand_argv_unknown_double_dash_passes_through() {
1166        let groups: HashMap<String, Group> = HashMap::new();
1167        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1168        assert_eq!(out, argv(&["tess", "--unknown"]));
1169    }
1170
1171    #[test]
1172    fn expand_argv_passes_display_template_through_after_group() {
1173        // Regression: a `--display` template after a group must NOT be
1174        // rewritten into a `--filter` (which previously left `--display`
1175        // value-less and clap erroring "a value is required").
1176        let mut groups: HashMap<String, Group> = HashMap::new();
1177        groups.insert("errorlog".into(), group("errorlog"));
1178        let out = expand_argv(
1179            argv(&["tess", "--errorlog", "--display", "<lvl>: <msg>", "lvl=ERROR"]),
1180            &groups,
1181        );
1182        assert_eq!(
1183            out,
1184            argv(&[
1185                "tess",
1186                "--display", "<lvl>: <msg>",
1187                "--filter", "lvl=ERROR",
1188            ])
1189        );
1190    }
1191
1192    #[test]
1193    fn expand_argv_passes_short_value_flag_through_after_group() {
1194        // `-o FILE` (and the other separated short value flags) must keep
1195        // their value instead of converting it to a filter in filter mode.
1196        let mut groups: HashMap<String, Group> = HashMap::new();
1197        groups.insert("errorlog".into(), group("errorlog"));
1198        let out = expand_argv(
1199            argv(&["tess", "--errorlog", "-o", "out.txt", "lvl=ERROR"]),
1200            &groups,
1201        );
1202        assert_eq!(
1203            out,
1204            argv(&["tess", "-o", "out.txt", "--filter", "lvl=ERROR"])
1205        );
1206    }
1207
1208    #[test]
1209    fn expand_group_emits_display_when_set() {
1210        let g = Group {
1211            name: "errs".into(),
1212            format: Some("simple".into()),
1213            display: Some("<lvl>!! <msg>".into()),
1214            filter: vec!["lvl=ERROR".into()],
1215            ..Group::default()
1216        };
1217        let out = expand_argv(argv(&["tess", "--errs"]), &{
1218            let mut m = HashMap::new();
1219            m.insert("errs".to_string(), g);
1220            m
1221        });
1222        assert_eq!(
1223            out,
1224            argv(&[
1225                "tess",
1226                "--format", "simple",
1227                "--display", "<lvl>!! <msg>",
1228                "--filter", "lvl=ERROR",
1229            ])
1230        );
1231    }
1232
1233    #[test]
1234    fn expand_argv_cli_display_overrides_group_display() {
1235        // Group sets a display; a later CLI `--display` is emitted after it,
1236        // so clap's last-occurrence wins (the CLI value).
1237        let g = Group {
1238            name: "errs".into(),
1239            format: Some("simple".into()),
1240            display: Some("group-tmpl".into()),
1241            ..Group::default()
1242        };
1243        let out = expand_argv(argv(&["tess", "--errs", "--display", "cli-tmpl"]), &{
1244            let mut m = HashMap::new();
1245            m.insert("errs".to_string(), g);
1246            m
1247        });
1248        let pos_group = out.iter().position(|x| x == "group-tmpl").unwrap();
1249        let pos_cli = out.iter().position(|x| x == "cli-tmpl").unwrap();
1250        assert!(pos_group < pos_cli, "CLI display must come after group's so it wins");
1251    }
1252
1253    #[test]
1254    fn load_groups_rejects_reserved_name() {
1255        let _g = HOME_LOCK.lock().unwrap();
1256        let tmp = tempfile::tempdir().unwrap();
1257        let cfg_dir = tmp.path().join(".config").join("tess");
1258        std::fs::create_dir_all(&cfg_dir).unwrap();
1259        std::fs::write(
1260            cfg_dir.join("formats.toml"),
1261            r#"
1262[group.follow]
1263file = "/x.log"
1264"#,
1265        )
1266        .unwrap();
1267        let saved = std::env::var_os("HOME");
1268        std::env::set_var("HOME", tmp.path());
1269        let result = load_groups();
1270        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1271        let err = result.unwrap_err();
1272        assert!(err.contains("collides with built-in --follow"), "{err}");
1273    }
1274
1275    #[test]
1276    fn user_config_overrides_builtin_via_load_all() {
1277        let _g = HOME_LOCK.lock().unwrap();
1278        // Use a temp HOME to avoid touching the real user's config.
1279        let tmp = tempfile::tempdir().unwrap();
1280        let cfg_dir = tmp.path().join(".config").join("tess");
1281        std::fs::create_dir_all(&cfg_dir).unwrap();
1282        let cfg_file = cfg_dir.join("formats.toml");
1283        std::fs::write(
1284            &cfg_file,
1285            r#"
1286[format.apache-common]
1287regex = "^(?P<custom>\\S+)$"
1288"#,
1289        )
1290        .unwrap();
1291        // Save and replace HOME for the duration of this test.
1292        let saved = std::env::var_os("HOME");
1293        std::env::set_var("HOME", tmp.path());
1294        let result = load_all();
1295        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1296        let formats = result.unwrap();
1297        let common = &formats["apache-common"];
1298        assert_eq!(common.field_names, vec!["custom"], "user config should win");
1299    }
1300
1301    #[test]
1302    fn format_entry_parses_record_start() {
1303        let toml_text = r#"
1304            [format.myapp]
1305            regex = '^(?P<line>.*)$'
1306            record_start = '^\['
1307        "#;
1308        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1309        let entry = cfg.format.get("myapp").expect("myapp present");
1310        assert_eq!(entry.regex, "^(?P<line>.*)$");
1311        assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1312    }
1313
1314    #[test]
1315    fn format_entry_record_start_optional() {
1316        let toml_text = r#"
1317            [format.myapp]
1318            regex = '^(?P<line>.*)$'
1319        "#;
1320        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1321        let entry = cfg.format.get("myapp").expect("myapp present");
1322        assert!(entry.record_start.is_none());
1323    }
1324
1325    #[test]
1326    fn layered_loader_local_overrides_global() {
1327        let _guard = HOME_LOCK.lock().unwrap();
1328        let prev_home = std::env::var_os("HOME");
1329        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1330
1331        let home = tempfile::tempdir().unwrap();
1332        let global = tempfile::tempdir().unwrap();
1333
1334        std::env::set_var("HOME", home.path());
1335        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1336
1337        std::fs::write(
1338            global.path().join("formats.toml"),
1339            r#"
1340[format.shared]
1341regex = "^GLOBAL (?P<msg>.+)$"
1342
1343[format.both]
1344regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1345"#,
1346        )
1347        .unwrap();
1348
1349        let cfg_dir = home.path().join(".config").join("tess");
1350        std::fs::create_dir_all(&cfg_dir).unwrap();
1351        std::fs::write(
1352            cfg_dir.join("formats.toml"),
1353            r#"
1354[format.both]
1355regex = "^LOCAL_BOTH (?P<msg>.+)$"
1356
1357[format.local-only]
1358regex = "^LOCAL (?P<msg>.+)$"
1359"#,
1360        )
1361        .unwrap();
1362
1363        let cfg = load_layered_config().unwrap();
1364
1365        // Global-only format survives.
1366        assert!(cfg.global.format.contains_key("shared"));
1367        assert!(!cfg.local.format.contains_key("shared"));
1368
1369        // Same-name format: both layers carry it, merge step (next task)
1370        // is responsible for resolving. Here we just verify both files
1371        // parsed correctly.
1372        assert_eq!(
1373            cfg.global.format.get("both").unwrap().regex,
1374            "^GLOBAL_BOTH (?P<msg>.+)$"
1375        );
1376        assert_eq!(
1377            cfg.local.format.get("both").unwrap().regex,
1378            "^LOCAL_BOTH (?P<msg>.+)$"
1379        );
1380
1381        // Local-only format present.
1382        assert!(cfg.local.format.contains_key("local-only"));
1383
1384        match prev_home {
1385            Some(v) => std::env::set_var("HOME", v),
1386            None => std::env::remove_var("HOME"),
1387        }
1388        match prev_global {
1389            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1390            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1391        }
1392    }
1393
1394    #[test]
1395    fn layered_loader_warns_on_bad_global_toml() {
1396        let _guard = HOME_LOCK.lock().unwrap();
1397        let prev_home = std::env::var_os("HOME");
1398        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1399
1400        let home = tempfile::tempdir().unwrap();
1401        let global = tempfile::tempdir().unwrap();
1402
1403        std::env::set_var("HOME", home.path());
1404        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1405
1406        std::fs::write(
1407            global.path().join("formats.toml"),
1408            "this is not valid toml = = =",
1409        )
1410        .unwrap();
1411
1412        // Should NOT error — global parse failures are warnings, not errors.
1413        let cfg = load_layered_config().unwrap();
1414        assert!(cfg.global.format.is_empty());
1415        assert!(cfg.global.group.is_empty());
1416
1417        match prev_home {
1418            Some(v) => std::env::set_var("HOME", v),
1419            None => std::env::remove_var("HOME"),
1420        }
1421        match prev_global {
1422            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1423            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1424        }
1425    }
1426
1427    #[test]
1428    fn layered_loader_fails_on_bad_local_toml() {
1429        let _guard = HOME_LOCK.lock().unwrap();
1430        let prev_home = std::env::var_os("HOME");
1431        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1432
1433        let home = tempfile::tempdir().unwrap();
1434        std::env::set_var("HOME", home.path());
1435        std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1436
1437        let cfg_dir = home.path().join(".config").join("tess");
1438        std::fs::create_dir_all(&cfg_dir).unwrap();
1439        std::fs::write(
1440            cfg_dir.join("formats.toml"),
1441            "this is not valid toml = = =",
1442        )
1443        .unwrap();
1444
1445        let err = load_layered_config().unwrap_err();
1446        assert!(err.contains("formats.toml"), "got: {err}");
1447
1448        match prev_home {
1449            Some(v) => std::env::set_var("HOME", v),
1450            None => std::env::remove_var("HOME"),
1451        }
1452        match prev_global {
1453            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1454            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1455        }
1456    }
1457
1458    #[test]
1459    fn log_format_compile_full_with_record_start() {
1460        let fmt = LogFormat::compile_full(
1461            "test",
1462            r"^(?P<msg>.+)$",
1463            None,
1464            Some(r"^\["),
1465            None,
1466        ).expect("compile");
1467        assert!(fmt.record_start.is_some());
1468        assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1469        assert!(!fmt.record_start.as_ref().unwrap().is_match("  continuation"));
1470    }
1471
1472    #[test]
1473    fn log_format_compile_full_bad_record_start_errors() {
1474        let err = LogFormat::compile_full(
1475            "test",
1476            r"^(?P<msg>.+)$",
1477            None,
1478            Some(r"["),  // unclosed bracket
1479            None,
1480        ).expect_err("should fail");
1481        assert!(err.contains("record_start"), "error mentions record_start: {err}");
1482    }
1483
1484    #[test]
1485    fn group_with_grep_field_deserializes() {
1486        let toml_text = r#"
1487            [group.errorlog]
1488            format = "app"
1489            grep = ["timeout", "deadlock"]
1490        "#;
1491        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1492        let entry = cfg.group.get("errorlog").expect("errorlog present");
1493        assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1494    }
1495
1496    #[test]
1497    fn expand_argv_emits_group_grep_flags() {
1498        let mut groups = HashMap::new();
1499        groups.insert("errorlog".to_string(), Group {
1500            name: "errorlog".to_string(),
1501            grep: vec!["timeout".to_string(), "deadlock".to_string()],
1502            ..Default::default()
1503        });
1504        let out = expand_argv(
1505            argv(&["tess", "--errorlog", "logs.txt"]),
1506            &groups,
1507        );
1508        let joined = out.join(" ");
1509        assert!(joined.contains("--grep timeout"), "got: {joined}");
1510        assert!(joined.contains("--grep deadlock"), "got: {joined}");
1511    }
1512
1513    #[test]
1514    fn user_grep_after_group_accumulates() {
1515        let mut groups = HashMap::new();
1516        groups.insert("errorlog".to_string(), Group {
1517            name: "errorlog".to_string(),
1518            grep: vec!["timeout".to_string()],
1519            ..Default::default()
1520        });
1521        let out = expand_argv(
1522            argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1523            &groups,
1524        );
1525        let joined = out.join(" ");
1526        assert!(joined.contains("--grep timeout"));
1527        assert!(joined.contains("--grep extra"));
1528    }
1529
1530    #[test]
1531    fn format_entry_parses_prompt() {
1532        let toml_text = r#"
1533            [format.myapp]
1534            regex = '^(?P<line>.*)$'
1535            prompt = '<label> <pct>%'
1536        "#;
1537        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1538        let entry = cfg.format.get("myapp").expect("myapp present");
1539        assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1540    }
1541
1542    #[test]
1543    fn load_all_tags_source_correctly() {
1544        let _guard = HOME_LOCK.lock().unwrap();
1545        let prev_home = std::env::var_os("HOME");
1546        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1547
1548        let home = tempfile::tempdir().unwrap();
1549        let global = tempfile::tempdir().unwrap();
1550
1551        std::env::set_var("HOME", home.path());
1552        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1553
1554        std::fs::write(
1555            global.path().join("formats.toml"),
1556            r#"
1557[format.global-only]
1558regex = "^G (?P<msg>.+)$"
1559
1560[format.both]
1561regex = "^GLOBAL (?P<msg>.+)$"
1562"#,
1563        )
1564        .unwrap();
1565
1566        let cfg_dir = home.path().join(".config").join("tess");
1567        std::fs::create_dir_all(&cfg_dir).unwrap();
1568        std::fs::write(
1569            cfg_dir.join("formats.toml"),
1570            r#"
1571[format.local-only]
1572regex = "^L (?P<msg>.+)$"
1573
1574[format.both]
1575regex = "^LOCAL (?P<msg>.+)$"
1576"#,
1577        )
1578        .unwrap();
1579
1580        let all = load_all().unwrap();
1581
1582        // Built-in still tagged builtin.
1583        assert_eq!(
1584            all["apache-common"].source,
1585            crate::config_path::ConfigSource::Builtin
1586        );
1587        assert!(all["apache-common"].overrides.is_none());
1588
1589        // Global-only.
1590        assert_eq!(
1591            all["global-only"].source,
1592            crate::config_path::ConfigSource::Global
1593        );
1594        assert!(all["global-only"].overrides.is_none());
1595
1596        // Local-only.
1597        assert_eq!(
1598            all["local-only"].source,
1599            crate::config_path::ConfigSource::Local
1600        );
1601        assert!(all["local-only"].overrides.is_none());
1602
1603        // Same-name: local wins, marked as overriding global.
1604        assert_eq!(
1605            all["both"].source,
1606            crate::config_path::ConfigSource::Local
1607        );
1608        assert_eq!(
1609            all["both"].overrides,
1610            Some(crate::config_path::ConfigSource::Global)
1611        );
1612
1613        match prev_home {
1614            Some(v) => std::env::set_var("HOME", v),
1615            None => std::env::remove_var("HOME"),
1616        }
1617        match prev_global {
1618            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1619            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1620        }
1621    }
1622
1623    #[test]
1624    fn source_label_renders_correctly() {
1625        use crate::config_path::ConfigSource;
1626        assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1627        assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1628        assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1629        assert_eq!(
1630            format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1631            "[local, overrides global]"
1632        );
1633        assert_eq!(
1634            format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1635            "[local, overrides built-in]"
1636        );
1637        assert_eq!(
1638            format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1639            "[global, overrides built-in]"
1640        );
1641    }
1642
1643    #[test]
1644    fn load_groups_reads_or_conditions() {
1645        let _g = HOME_LOCK.lock().unwrap();
1646        let tmp = tempfile::tempdir().unwrap();
1647        let cfg_dir = tmp.path().join(".config").join("tess");
1648        std::fs::create_dir_all(&cfg_dir).unwrap();
1649        std::fs::write(
1650            cfg_dir.join("formats.toml"),
1651            r#"
1652[group.intrusion]
1653format = "app"
1654or_filter = ["lvl=ERROR"]
1655or_grep = ["panic"]
1656
1657[group.intrusion.or.svc]
1658filter = ["status=403"]
1659grep = ["ssh", "sshd"]
1660"#,
1661        )
1662        .unwrap();
1663        let saved = std::env::var_os("HOME");
1664        std::env::set_var("HOME", tmp.path());
1665        let result = load_groups();
1666        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1667        let groups = result.unwrap();
1668        let g = &groups["intrusion"];
1669        assert_eq!(g.or_filter, vec!["lvl=ERROR".to_string()]);
1670        assert_eq!(g.or_grep, vec!["panic".to_string()]);
1671        assert_eq!(g.or_named, vec![("svc".to_string(), vec!["status=403".to_string()], vec!["ssh".to_string(), "sshd".to_string()])]);
1672    }
1673
1674    #[test]
1675    fn expand_group_emits_or_conditions_in_marker_form() {
1676        let mut groups: HashMap<String, Group> = HashMap::new();
1677        groups.insert(
1678            "intrusion".into(),
1679            Group {
1680                name: "intrusion".into(),
1681                format: Some("app".into()),
1682                or_grep: vec!["panic".into()],
1683                or_named: vec![("svc".into(), vec!["status=403".into()], vec!["ssh".into()])],
1684                ..Group::default()
1685            },
1686        );
1687        let out = expand_argv(argv(&["tess", "--intrusion"]), &groups);
1688        assert_eq!(
1689            out,
1690            argv(&[
1691                "tess",
1692                "--format", "app",
1693                "--or-grep", "panic",
1694                "--or-group", "svc",
1695                "--or-filter", "status=403",
1696                "--or-grep", "ssh",
1697            ])
1698        );
1699    }
1700}