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