Skip to main content

tess/
format.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6
7/// A named log format: a regex with named capture groups identifying the
8/// fields of one log line. Used by filtering to look up field values by name.
9#[derive(Debug)]
10pub struct LogFormat {
11    pub name: String,
12    pub regex: Regex,
13    /// Capture group names declared in the regex, in declaration order.
14    /// Used by `--list-formats` to show users what fields are available.
15    pub field_names: Vec<String>,
16    /// Optional default display template (`display` key in formats.toml).
17    /// When set and no CLI override is given, the viewer / batch output
18    /// renders each parsed line through this template instead of the raw line.
19    pub display: Option<DisplayTemplate>,
20}
21
22impl LogFormat {
23    pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
24        Self::compile_with_display(name, pattern, None)
25    }
26
27    pub fn compile_with_display(
28        name: &str,
29        pattern: &str,
30        display: Option<&str>,
31    ) -> Result<Self, String> {
32        let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
33        // capture_names() includes all groups (including the implicit whole-match
34        // group as None at index 0); skip those.
35        let field_names: Vec<String> = regex
36            .capture_names()
37            .flatten()
38            .map(|s| s.to_string())
39            .collect();
40        if field_names.is_empty() {
41            return Err(format!(
42                "format `{name}`: regex must declare at least one named capture group"
43            ));
44        }
45        let display = display
46            .map(|s| {
47                DisplayTemplate::compile(s, &field_names)
48                    .map_err(|e| format!("format `{name}`: display: {e}"))
49            })
50            .transpose()?;
51        Ok(Self {
52            name: name.to_string(),
53            regex,
54            field_names,
55            display,
56        })
57    }
58}
59
60/// Parsed display template (`display = '[<ts>] <level> <msg>'`).
61///
62/// Syntax:
63/// - `<fieldname>` — replaced with the field's captured value (empty if
64///   the regex didn't capture it on this line).
65/// - `\<` — literal `<`.
66/// - `\\` — literal `\`.
67/// - Anything else — literal.
68#[derive(Debug, Clone)]
69pub struct DisplayTemplate {
70    segments: Vec<DisplaySegment>,
71    source: String,
72}
73
74#[derive(Debug, Clone)]
75enum DisplaySegment {
76    Literal(String),
77    Field(String),
78}
79
80impl DisplayTemplate {
81    pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
82        if source.is_empty() {
83            return Err("template is empty (would render every line as nothing)".to_string());
84        }
85        let mut segments: Vec<DisplaySegment> = Vec::new();
86        let mut buf = String::new();
87        let mut chars = source.chars().peekable();
88        while let Some(c) = chars.next() {
89            match c {
90                '\\' => match chars.next() {
91                    Some('<') => buf.push('<'),
92                    Some('\\') => buf.push('\\'),
93                    Some(other) => {
94                        // Unknown escape: keep both bytes literally so users
95                        // don't have to escape every backslash in regex-like
96                        // strings.
97                        buf.push('\\');
98                        buf.push(other);
99                    }
100                    None => return Err("template ends with a lone `\\`".to_string()),
101                },
102                '<' => {
103                    if !buf.is_empty() {
104                        segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
105                    }
106                    let mut name = String::new();
107                    let mut closed = false;
108                    while let Some(&nc) = chars.peek() {
109                        chars.next();
110                        if nc == '>' { closed = true; break; }
111                        name.push(nc);
112                    }
113                    if !closed {
114                        return Err(format!("unterminated `<` (expected `<{name}>`)"));
115                    }
116                    if name.is_empty() {
117                        return Err("empty field reference `<>`".to_string());
118                    }
119                    if !field_names.iter().any(|n| n == &name) {
120                        return Err(format!(
121                            "unknown field `{name}` (available: {})",
122                            field_names.join(", ")
123                        ));
124                    }
125                    segments.push(DisplaySegment::Field(name));
126                }
127                _ => buf.push(c),
128            }
129        }
130        if !buf.is_empty() {
131            segments.push(DisplaySegment::Literal(buf));
132        }
133        Ok(Self { segments, source: source.to_string() })
134    }
135
136    /// Render the template against a captures-lookup closure. Returns the
137    /// rendered string. Missing fields render as empty.
138    pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
139        let mut out = String::new();
140        for seg in &self.segments {
141            match seg {
142                DisplaySegment::Literal(s) => out.push_str(s),
143                DisplaySegment::Field(name) => {
144                    if let Some(v) = lookup(name) { out.push_str(&v); }
145                }
146            }
147        }
148        out
149    }
150
151    pub fn source(&self) -> &str { &self.source }
152}
153
154/// Pairs a `DisplayTemplate` with the format's regex so callers can render
155/// any single line in one call. Owns its inputs so it's `Send`-friendly.
156#[derive(Debug, Clone)]
157pub struct DisplayRenderer {
158    template: DisplayTemplate,
159    regex: Regex,
160}
161
162impl DisplayRenderer {
163    pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
164        Self { template, regex }
165    }
166
167    pub fn template(&self) -> &DisplayTemplate { &self.template }
168
169    /// Render `line` (raw bytes) through the template. If the line doesn't
170    /// parse against the format regex, returns `None` — the caller decides
171    /// whether to fall back to the raw line, skip it, or show an error.
172    pub fn render_line(&self, line: &[u8]) -> Option<String> {
173        let s = std::str::from_utf8(line).ok()?;
174        let caps = self.regex.captures(s)?;
175        Some(self.template.render(|name| {
176            caps.name(name).map(|m| m.as_str().to_string())
177        }))
178    }
179}
180
181/// TOML schema for `~/.config/tess/formats.toml`:
182///
183/// ```toml
184/// [format.myapp]
185/// regex = "..."
186///
187/// [group.errorlog]
188/// format = "myapp"
189/// file = "/var/log/app.log"
190/// follow = true
191/// filter = ["level=ERROR"]
192/// ```
193#[derive(Debug, Deserialize)]
194struct UserConfig {
195    #[serde(default)]
196    format: HashMap<String, FormatEntry>,
197    #[serde(default)]
198    group: HashMap<String, GroupEntry>,
199}
200
201#[derive(Debug, Deserialize)]
202struct FormatEntry {
203    regex: String,
204    #[serde(default)]
205    display: Option<String>,
206}
207
208/// Raw group entry as deserialized from TOML. Promoted to `Group` after
209/// validation.
210#[derive(Debug, Deserialize, Default)]
211struct GroupEntry {
212    format: Option<String>,
213    file: Option<String>,
214    follow: Option<bool>,
215    tail: Option<usize>,
216    head: Option<usize>,
217    dim: Option<bool>,
218    line_numbers: Option<bool>,
219    chop: Option<bool>,
220    tab_width: Option<u8>,
221    #[serde(default)]
222    filter: Vec<String>,
223}
224
225/// A user-defined CLI shortcut. When `tess --<group_name>` appears in argv,
226/// the group's flags are expanded inline and remaining positionals become
227/// `--filter` arguments.
228#[derive(Debug, Clone, Default)]
229pub struct Group {
230    pub name: String,
231    pub format: Option<String>,
232    pub file: Option<String>,
233    pub follow: bool,
234    pub tail: Option<usize>,
235    pub head: Option<usize>,
236    pub dim: bool,
237    pub line_numbers: bool,
238    pub chop: bool,
239    pub tab_width: Option<u8>,
240    pub filter: Vec<String>,
241}
242
243/// Long-form names of every built-in clap flag. A group cannot reuse one of
244/// these names — it would shadow the real flag at expansion time.
245const RESERVED_LONG_FLAGS: &[&str] = &[
246    "format",
247    "filter",
248    "dim",
249    "head",
250    "tail",
251    "follow",
252    "LINE-NUMBERS",
253    "chop-long-lines",
254    "tab-width",
255    "list-formats",
256    "live",
257    "manual",
258    "examples",
259    "prettify",
260    "content-type",
261    "help",
262    "version",
263];
264
265/// Built-in formats compiled from this list of (name, pattern). Patterns use
266/// raw strings so backslashes don't need escaping.
267const BUILTINS: &[(&str, &str)] = &[
268    (
269        "apache-common",
270        r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
271    ),
272    (
273        "apache-combined",
274        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>[^"]*)"$"#,
275    ),
276    (
277        "nginx-combined",
278        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>[^"]*)"$"#,
279    ),
280];
281
282fn user_config_path() -> Option<PathBuf> {
283    std::env::var_os("HOME").map(|h| {
284        let mut p = PathBuf::from(h);
285        p.push(".config");
286        p.push("tess");
287        p.push("formats.toml");
288        p
289    })
290}
291
292fn load_user_config() -> Result<UserConfig, String> {
293    let Some(path) = user_config_path() else {
294        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
295    };
296    if !path.exists() {
297        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
298    }
299    let text = std::fs::read_to_string(&path)
300        .map_err(|e| format!("reading {}: {e}", path.display()))?;
301    toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))
302}
303
304fn load_user_formats() -> Result<HashMap<String, (String, Option<String>)>, String> {
305    let cfg = load_user_config()?;
306    Ok(cfg.format.into_iter().map(|(k, v)| (k, (v.regex, v.display))).collect())
307}
308
309/// Load all user-defined groups from `~/.config/tess/formats.toml`. Built-ins
310/// are not provided — groups are entirely user-defined. Validates that group
311/// names don't shadow built-in flag names.
312pub fn load_groups() -> Result<HashMap<String, Group>, String> {
313    let cfg = load_user_config()?;
314    let mut out = HashMap::with_capacity(cfg.group.len());
315    for (name, entry) in cfg.group {
316        if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
317            return Err(format!(
318                "group `{name}`: name collides with built-in --{name} flag"
319            ));
320        }
321        out.insert(
322            name.clone(),
323            Group {
324                name,
325                format: entry.format,
326                file: entry.file,
327                follow: entry.follow.unwrap_or(false),
328                tail: entry.tail,
329                head: entry.head,
330                dim: entry.dim.unwrap_or(false),
331                line_numbers: entry.line_numbers.unwrap_or(false),
332                chop: entry.chop.unwrap_or(false),
333                tab_width: entry.tab_width,
334                filter: entry.filter,
335            },
336        );
337    }
338    Ok(out)
339}
340
341/// Load all formats: built-ins first, then any in `~/.config/tess/formats.toml`
342/// (which override built-ins of the same name). Returns the compiled map keyed
343/// by format name.
344pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
345    let mut sources: HashMap<String, (String, Option<String>)> = HashMap::new();
346    for (name, pat) in BUILTINS {
347        sources.insert(name.to_string(), (pat.to_string(), None));
348    }
349    let user = load_user_formats()?;
350    for (name, src) in user {
351        sources.insert(name, src);
352    }
353    let mut compiled = HashMap::new();
354    for (name, (pat, display)) in sources {
355        let fmt = LogFormat::compile_with_display(&name, &pat, display.as_deref())?;
356        compiled.insert(name, fmt);
357    }
358    Ok(compiled)
359}
360
361/// Pre-process an argv vector before clap sees it. For every `--<name>`
362/// token that matches a defined group, expand the group's flags inline and
363/// switch into "filter mode" — bare positionals after the group token become
364/// `--filter <arg>` pairs. Group tokens before any flag still expand
365/// correctly; positionals before a group remain as-is.
366///
367/// CLI flags coming after the expansion override the group's values for
368/// `Option<T>` flags (clap takes the last occurrence) and add to repeatable
369/// flags like `--filter` (clap accumulates the `Vec<String>`).
370/// Long flags that take a separate value as the next argv token (e.g.
371/// `--tail 1000` rather than `--tail=1000`). Used by `expand_argv` so it
372/// doesn't mistake a flag's value for a positional.
373const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
374    "--format",
375    "--filter",
376    "--head",
377    "--tail",
378    "--tab-width",
379];
380
381pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
382    if argv.is_empty() {
383        return argv;
384    }
385    let mut out = Vec::with_capacity(argv.len() * 2);
386    let mut iter = argv.into_iter();
387    out.push(iter.next().unwrap()); // argv[0] = program name
388    let mut filter_mode = false;
389    let mut pass_next = false;
390    for arg in iter {
391        if pass_next {
392            pass_next = false;
393            out.push(arg);
394            continue;
395        }
396        if let Some(name) = arg.strip_prefix("--") {
397            // `--flag=value` is a single token: don't try to match groups
398            // against `flag=value`.
399            if !name.contains('=') {
400                if let Some(g) = groups.get(name) {
401                    expand_group(g, &mut out);
402                    filter_mode = true;
403                    continue;
404                }
405                if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
406                    // The next token is this flag's value; pass it through
407                    // even in filter mode.
408                    out.push(arg);
409                    pass_next = true;
410                    continue;
411                }
412            }
413        }
414        if filter_mode && !arg.starts_with('-') {
415            out.push("--filter".into());
416            out.push(arg);
417            continue;
418        }
419        out.push(arg);
420    }
421    out
422}
423
424fn expand_group(g: &Group, out: &mut Vec<String>) {
425    if let Some(format) = &g.format {
426        out.push("--format".into());
427        out.push(format.clone());
428    }
429    if g.follow {
430        out.push("--follow".into());
431    }
432    if let Some(t) = g.tail {
433        out.push("--tail".into());
434        out.push(t.to_string());
435    }
436    if let Some(h) = g.head {
437        out.push("--head".into());
438        out.push(h.to_string());
439    }
440    if g.dim {
441        out.push("--dim".into());
442    }
443    if g.line_numbers {
444        out.push("-N".into());
445    }
446    if g.chop {
447        out.push("-S".into());
448    }
449    if let Some(t) = g.tab_width {
450        out.push("--tab-width".into());
451        out.push(t.to_string());
452    }
453    for f in &g.filter {
454        out.push("--filter".into());
455        out.push(f.clone());
456    }
457    if let Some(file) = &g.file {
458        out.push(file.clone());
459    }
460}
461
462/// Print one line per format, with the named field list, to stdout. Used by
463/// `--list-formats`.
464pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
465    let mut names: Vec<&String> = formats.keys().collect();
466    names.sort();
467    for name in names {
468        let fmt = &formats[name];
469        let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
470        println!("{}: {}", name, fields.join(", "));
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use std::sync::Mutex;
478
479    /// Serializes tests that mutate the `HOME` env var; otherwise they
480    /// trample each other when cargo runs tests in parallel.
481    static HOME_LOCK: Mutex<()> = Mutex::new(());
482
483    #[test]
484    fn builtins_all_compile() {
485        for (name, pat) in BUILTINS {
486            LogFormat::compile(name, pat)
487                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
488        }
489    }
490
491    // ----- DisplayTemplate -----
492
493    fn fields() -> Vec<String> {
494        vec!["ts".into(), "level".into(), "msg".into()]
495    }
496
497    #[test]
498    fn display_template_compiles_basic() {
499        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
500        assert_eq!(t.source(), "[<ts>] <level> <msg>");
501    }
502
503    #[test]
504    fn display_template_renders_substitutions() {
505        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
506        let mut map = std::collections::HashMap::new();
507        map.insert("level".to_string(), "ERROR".to_string());
508        map.insert("msg".to_string(), "boom".to_string());
509        let out = t.render(|n| map.get(n).cloned());
510        assert_eq!(out, "ERROR: boom");
511    }
512
513    #[test]
514    fn display_template_missing_field_renders_empty() {
515        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
516        let mut map = std::collections::HashMap::new();
517        map.insert("level".to_string(), "ERROR".to_string());
518        // msg is absent
519        let out = t.render(|n| map.get(n).cloned());
520        assert_eq!(out, "ERROR:");
521    }
522
523    #[test]
524    fn display_template_escape_sequences() {
525        // Only `\<` and `\\` are recognized escapes; `>` is always literal
526        // (a stray `>` outside `<...>` is fine).
527        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
528        let mut map = std::collections::HashMap::new();
529        map.insert("level".to_string(), "X".to_string());
530        let out = t.render(|n| map.get(n).cloned());
531        assert_eq!(out, "<not a field> X");
532    }
533
534    #[test]
535    fn display_template_escape_backslash() {
536        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
537        let mut map = std::collections::HashMap::new();
538        map.insert("level".to_string(), "X".to_string());
539        let out = t.render(|n| map.get(n).cloned());
540        assert_eq!(out, r"a\b X");
541    }
542
543    #[test]
544    fn display_template_rejects_empty() {
545        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
546        assert!(err.contains("empty"), "{err}");
547    }
548
549    #[test]
550    fn display_template_rejects_unknown_field() {
551        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
552        assert!(err.contains("unknown field"), "{err}");
553    }
554
555    #[test]
556    fn display_template_rejects_unterminated() {
557        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
558        assert!(err.contains("unterminated"), "{err}");
559    }
560
561    #[test]
562    fn display_template_rejects_empty_ref() {
563        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
564        assert!(err.contains("empty field reference"), "{err}");
565    }
566
567    #[test]
568    fn apache_common_extracts_fields() {
569        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
570        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
571        let caps = fmt.regex.captures(line).expect("should match");
572        assert_eq!(&caps["ip"], "127.0.0.1");
573        assert_eq!(&caps["user"], "alice");
574        assert_eq!(&caps["method"], "GET");
575        assert_eq!(&caps["url"], "/index.html");
576        assert_eq!(&caps["status"], "200");
577        assert_eq!(&caps["size"], "2326");
578    }
579
580    #[test]
581    fn apache_combined_extracts_referer_and_agent() {
582        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
583        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""#;
584        let caps = fmt.regex.captures(line).expect("should match");
585        assert_eq!(&caps["status"], "401");
586        assert_eq!(&caps["url"], "/api/login");
587        assert_eq!(&caps["referer"], "https://example.com/");
588        assert_eq!(&caps["agent"], "Mozilla/5.0");
589    }
590
591    #[test]
592    fn field_names_listed_in_order() {
593        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
594        assert_eq!(
595            fmt.field_names,
596            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
597        );
598    }
599
600    #[test]
601    fn compile_rejects_regex_without_named_groups() {
602        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
603        assert!(err.contains("at least one named capture"), "{err}");
604    }
605
606    #[test]
607    fn compile_rejects_invalid_regex() {
608        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
609        assert!(err.contains("bad"), "{err}");
610    }
611
612    #[test]
613    fn load_groups_reads_user_config() {
614        let _g = HOME_LOCK.lock().unwrap();
615        let tmp = tempfile::tempdir().unwrap();
616        let cfg_dir = tmp.path().join(".config").join("tess");
617        std::fs::create_dir_all(&cfg_dir).unwrap();
618        std::fs::write(
619            cfg_dir.join("formats.toml"),
620            r#"
621[group.errorlog]
622format = "apache-combined"
623file = "/var/log/access.log"
624follow = true
625tail = 1000
626filter = ["status~^5"]
627
628[group.minimal]
629file = "/tmp/x.log"
630"#,
631        )
632        .unwrap();
633        let saved = std::env::var_os("HOME");
634        std::env::set_var("HOME", tmp.path());
635        let result = load_groups();
636        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
637        let groups = result.unwrap();
638        let err = &groups["errorlog"];
639        assert_eq!(err.format.as_deref(), Some("apache-combined"));
640        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
641        assert!(err.follow);
642        assert_eq!(err.tail, Some(1000));
643        assert_eq!(err.filter, vec!["status~^5".to_string()]);
644        let min = &groups["minimal"];
645        assert!(!min.follow);
646        assert!(min.tail.is_none());
647        assert_eq!(min.filter, Vec::<String>::new());
648    }
649
650    fn group(name: &str) -> Group {
651        Group { name: name.into(), ..Group::default() }
652    }
653
654    fn argv(parts: &[&str]) -> Vec<String> {
655        parts.iter().map(|s| s.to_string()).collect()
656    }
657
658    #[test]
659    fn expand_argv_passes_through_when_no_group_matches() {
660        let groups: HashMap<String, Group> = HashMap::new();
661        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
662        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
663    }
664
665    #[test]
666    fn expand_argv_inserts_group_flags_and_file() {
667        let mut groups: HashMap<String, Group> = HashMap::new();
668        groups.insert(
669            "errorlog".into(),
670            Group {
671                name: "errorlog".into(),
672                format: Some("apache-combined".into()),
673                file: Some("/var/log/access.log".into()),
674                follow: true,
675                tail: Some(1000),
676                filter: vec!["status~^5".into()],
677                ..Group::default()
678            },
679        );
680        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
681        assert_eq!(
682            out,
683            argv(&[
684                "tess",
685                "--format", "apache-combined",
686                "--follow",
687                "--tail", "1000",
688                "--filter", "status~^5",
689                "/var/log/access.log",
690            ])
691        );
692    }
693
694    #[test]
695    fn expand_argv_converts_positionals_to_filters_after_group() {
696        let mut groups: HashMap<String, Group> = HashMap::new();
697        groups.insert(
698            "errorlog".into(),
699            Group {
700                name: "errorlog".into(),
701                format: Some("apache-combined".into()),
702                file: Some("/log".into()),
703                ..Group::default()
704            },
705        );
706        let out = expand_argv(
707            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
708            &groups,
709        );
710        assert_eq!(
711            out,
712            argv(&[
713                "tess",
714                "--format", "apache-combined",
715                "/log",
716                "--filter", "msg~test",
717                "--filter", "url~/api/",
718            ])
719        );
720    }
721
722    #[test]
723    fn expand_argv_leaves_flags_alone_after_group() {
724        let mut groups: HashMap<String, Group> = HashMap::new();
725        groups.insert("errorlog".into(), group("errorlog"));
726        let out = expand_argv(
727            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
728            &groups,
729        );
730        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
731        assert_eq!(
732            out,
733            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
734        );
735    }
736
737    #[test]
738    fn expand_argv_user_flag_after_group_can_override_tail() {
739        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
740        // so user's 50 wins.
741        let mut groups: HashMap<String, Group> = HashMap::new();
742        groups.insert(
743            "errorlog".into(),
744            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
745        );
746        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
747        // --tail 1000 from group, then --tail 50 from user. Order preserved.
748        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
749        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
750        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
751        let pos_50 = out.iter().position(|x| x == "50").unwrap();
752        assert!(pos_1000 < pos_50, "user's value must come after group's");
753    }
754
755    #[test]
756    fn expand_argv_unknown_double_dash_passes_through() {
757        let groups: HashMap<String, Group> = HashMap::new();
758        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
759        assert_eq!(out, argv(&["tess", "--unknown"]));
760    }
761
762    #[test]
763    fn load_groups_rejects_reserved_name() {
764        let _g = HOME_LOCK.lock().unwrap();
765        let tmp = tempfile::tempdir().unwrap();
766        let cfg_dir = tmp.path().join(".config").join("tess");
767        std::fs::create_dir_all(&cfg_dir).unwrap();
768        std::fs::write(
769            cfg_dir.join("formats.toml"),
770            r#"
771[group.follow]
772file = "/x.log"
773"#,
774        )
775        .unwrap();
776        let saved = std::env::var_os("HOME");
777        std::env::set_var("HOME", tmp.path());
778        let result = load_groups();
779        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
780        let err = result.unwrap_err();
781        assert!(err.contains("collides with built-in --follow"), "{err}");
782    }
783
784    #[test]
785    fn user_config_overrides_builtin_via_load_all() {
786        let _g = HOME_LOCK.lock().unwrap();
787        // Use a temp HOME to avoid touching the real user's config.
788        let tmp = tempfile::tempdir().unwrap();
789        let cfg_dir = tmp.path().join(".config").join("tess");
790        std::fs::create_dir_all(&cfg_dir).unwrap();
791        let cfg_file = cfg_dir.join("formats.toml");
792        std::fs::write(
793            &cfg_file,
794            r#"
795[format.apache-common]
796regex = "^(?P<custom>\\S+)$"
797"#,
798        )
799        .unwrap();
800        // Save and replace HOME for the duration of this test.
801        let saved = std::env::var_os("HOME");
802        std::env::set_var("HOME", tmp.path());
803        let result = load_all();
804        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
805        let formats = result.unwrap();
806        let common = &formats["apache-common"];
807        assert_eq!(common.field_names, vec!["custom"], "user config should win");
808    }
809}