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