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    "no-color",
299    "raw-control-chars",
300    "tag",
301    "tag-file",
302];
303
304/// Built-in formats compiled from this list of (name, pattern). Patterns use
305/// raw strings so backslashes don't need escaping.
306const BUILTINS: &[(&str, &str)] = &[
307    (
308        "apache-common",
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+)$"#,
310    ),
311    (
312        "apache-combined",
313        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>[^"]*)"$"#,
314    ),
315    (
316        "nginx-combined",
317        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>[^"]*)"$"#,
318    ),
319];
320
321fn user_config_path() -> Option<PathBuf> {
322    std::env::var_os("HOME").map(|h| {
323        let mut p = PathBuf::from(h);
324        p.push(".config");
325        p.push("tess");
326        p.push("formats.toml");
327        p
328    })
329}
330
331fn load_user_config() -> Result<UserConfig, String> {
332    let Some(path) = user_config_path() else {
333        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
334    };
335    if !path.exists() {
336        return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
337    }
338    let text = std::fs::read_to_string(&path)
339        .map_err(|e| format!("reading {}: {e}", path.display()))?;
340    toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))
341}
342
343struct FormatSource {
344    regex: String,
345    display: Option<String>,
346    record_start: Option<String>,
347    prompt: Option<String>,
348}
349
350fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
351    let cfg = load_user_config()?;
352    Ok(cfg.format.into_iter().map(|(k, v)| (k, FormatSource {
353        regex: v.regex,
354        display: v.display,
355        record_start: v.record_start,
356        prompt: v.prompt,
357    })).collect())
358}
359
360/// Load all user-defined groups from `~/.config/tess/formats.toml`. Built-ins
361/// are not provided — groups are entirely user-defined. Validates that group
362/// names don't shadow built-in flag names.
363pub fn load_groups() -> Result<HashMap<String, Group>, String> {
364    let cfg = load_user_config()?;
365    let mut out = HashMap::with_capacity(cfg.group.len());
366    for (name, entry) in cfg.group {
367        if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
368            return Err(format!(
369                "group `{name}`: name collides with built-in --{name} flag"
370            ));
371        }
372        out.insert(
373            name.clone(),
374            Group {
375                name,
376                format: entry.format,
377                file: entry.file,
378                follow: entry.follow.unwrap_or(false),
379                tail: entry.tail,
380                head: entry.head,
381                dim: entry.dim.unwrap_or(false),
382                line_numbers: entry.line_numbers.unwrap_or(false),
383                chop: entry.chop.unwrap_or(false),
384                tab_width: entry.tab_width,
385                filter: entry.filter,
386                grep: entry.grep,
387            },
388        );
389    }
390    Ok(out)
391}
392
393/// Load all formats: built-ins first, then any in `~/.config/tess/formats.toml`
394/// (which override built-ins of the same name). Returns the compiled map keyed
395/// by format name.
396pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
397    let mut sources: HashMap<String, FormatSource> = HashMap::new();
398    for (name, pat) in BUILTINS {
399        sources.insert(name.to_string(), FormatSource {
400            regex: pat.to_string(),
401            display: None,
402            record_start: None,
403            prompt: None,
404        });
405    }
406    let user = load_user_formats()?;
407    for (name, src) in user {
408        sources.insert(name, src);
409    }
410    let mut compiled = HashMap::new();
411    for (name, src) in sources {
412        let fmt = LogFormat::compile_full(
413            &name,
414            &src.regex,
415            src.display.as_deref(),
416            src.record_start.as_deref(),
417            src.prompt.as_deref(),
418        )?;
419        compiled.insert(name, fmt);
420    }
421    Ok(compiled)
422}
423
424/// Pre-process an argv vector before clap sees it. For every `--<name>`
425/// token that matches a defined group, expand the group's flags inline and
426/// switch into "filter mode" — bare positionals after the group token become
427/// `--filter <arg>` pairs. Group tokens before any flag still expand
428/// correctly; positionals before a group remain as-is.
429///
430/// CLI flags coming after the expansion override the group's values for
431/// `Option<T>` flags (clap takes the last occurrence) and add to repeatable
432/// flags like `--filter` (clap accumulates the `Vec<String>`).
433/// Long flags that take a separate value as the next argv token (e.g.
434/// `--tail 1000` rather than `--tail=1000`). Used by `expand_argv` so it
435/// doesn't mistake a flag's value for a positional.
436const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
437    "--format",
438    "--filter",
439    "--grep",
440    "--head",
441    "--tail",
442    "--tab-width",
443    "--record-start",
444];
445
446pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
447    if argv.is_empty() {
448        return argv;
449    }
450    let mut out = Vec::with_capacity(argv.len() * 2);
451    let mut iter = argv.into_iter();
452    out.push(iter.next().unwrap()); // argv[0] = program name
453    let mut filter_mode = false;
454    let mut pass_next = false;
455    for arg in iter {
456        if pass_next {
457            pass_next = false;
458            out.push(arg);
459            continue;
460        }
461        if let Some(name) = arg.strip_prefix("--") {
462            // `--flag=value` is a single token: don't try to match groups
463            // against `flag=value`.
464            if !name.contains('=') {
465                if let Some(g) = groups.get(name) {
466                    expand_group(g, &mut out);
467                    filter_mode = true;
468                    continue;
469                }
470                if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
471                    // The next token is this flag's value; pass it through
472                    // even in filter mode.
473                    out.push(arg);
474                    pass_next = true;
475                    continue;
476                }
477            }
478        }
479        if filter_mode && !arg.starts_with('-') {
480            out.push("--filter".into());
481            out.push(arg);
482            continue;
483        }
484        out.push(arg);
485    }
486    out
487}
488
489fn expand_group(g: &Group, out: &mut Vec<String>) {
490    if let Some(format) = &g.format {
491        out.push("--format".into());
492        out.push(format.clone());
493    }
494    if g.follow {
495        out.push("--follow".into());
496    }
497    if let Some(t) = g.tail {
498        out.push("--tail".into());
499        out.push(t.to_string());
500    }
501    if let Some(h) = g.head {
502        out.push("--head".into());
503        out.push(h.to_string());
504    }
505    if g.dim {
506        out.push("--dim".into());
507    }
508    if g.line_numbers {
509        out.push("-N".into());
510    }
511    if g.chop {
512        out.push("-S".into());
513    }
514    if let Some(t) = g.tab_width {
515        out.push("--tab-width".into());
516        out.push(t.to_string());
517    }
518    for f in &g.filter {
519        out.push("--filter".into());
520        out.push(f.clone());
521    }
522    for g_pat in &g.grep {
523        out.push("--grep".into());
524        out.push(g_pat.clone());
525    }
526    if let Some(file) = &g.file {
527        out.push(file.clone());
528    }
529}
530
531/// Print one line per format, with the named field list, to stdout. Used by
532/// `--list-formats`.
533pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
534    let mut names: Vec<&String> = formats.keys().collect();
535    names.sort();
536    for name in names {
537        let fmt = &formats[name];
538        let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
539        println!("{}: {}", name, fields.join(", "));
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::sync::Mutex;
547
548    /// Serializes tests that mutate the `HOME` env var; otherwise they
549    /// trample each other when cargo runs tests in parallel.
550    static HOME_LOCK: Mutex<()> = Mutex::new(());
551
552    #[test]
553    fn builtins_all_compile() {
554        for (name, pat) in BUILTINS {
555            LogFormat::compile(name, pat)
556                .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
557        }
558    }
559
560    // ----- DisplayTemplate -----
561
562    fn fields() -> Vec<String> {
563        vec!["ts".into(), "level".into(), "msg".into()]
564    }
565
566    #[test]
567    fn display_template_compiles_basic() {
568        let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
569        assert_eq!(t.source(), "[<ts>] <level> <msg>");
570    }
571
572    #[test]
573    fn display_template_renders_substitutions() {
574        let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
575        let mut map = std::collections::HashMap::new();
576        map.insert("level".to_string(), "ERROR".to_string());
577        map.insert("msg".to_string(), "boom".to_string());
578        let out = t.render(|n| map.get(n).cloned());
579        assert_eq!(out, "ERROR: boom");
580    }
581
582    #[test]
583    fn display_template_missing_field_renders_empty() {
584        let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
585        let mut map = std::collections::HashMap::new();
586        map.insert("level".to_string(), "ERROR".to_string());
587        // msg is absent
588        let out = t.render(|n| map.get(n).cloned());
589        assert_eq!(out, "ERROR:");
590    }
591
592    #[test]
593    fn display_template_escape_sequences() {
594        // Only `\<` and `\\` are recognized escapes; `>` is always literal
595        // (a stray `>` outside `<...>` is fine).
596        let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
597        let mut map = std::collections::HashMap::new();
598        map.insert("level".to_string(), "X".to_string());
599        let out = t.render(|n| map.get(n).cloned());
600        assert_eq!(out, "<not a field> X");
601    }
602
603    #[test]
604    fn display_template_escape_backslash() {
605        let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
606        let mut map = std::collections::HashMap::new();
607        map.insert("level".to_string(), "X".to_string());
608        let out = t.render(|n| map.get(n).cloned());
609        assert_eq!(out, r"a\b X");
610    }
611
612    #[test]
613    fn display_template_rejects_empty() {
614        let err = DisplayTemplate::compile("", &fields()).unwrap_err();
615        assert!(err.contains("empty"), "{err}");
616    }
617
618    #[test]
619    fn display_template_rejects_unknown_field() {
620        let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
621        assert!(err.contains("unknown field"), "{err}");
622    }
623
624    #[test]
625    fn display_template_rejects_unterminated() {
626        let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
627        assert!(err.contains("unterminated"), "{err}");
628    }
629
630    #[test]
631    fn display_template_rejects_empty_ref() {
632        let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
633        assert!(err.contains("empty field reference"), "{err}");
634    }
635
636    #[test]
637    fn apache_common_extracts_fields() {
638        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
639        let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
640        let caps = fmt.regex.captures(line).expect("should match");
641        assert_eq!(&caps["ip"], "127.0.0.1");
642        assert_eq!(&caps["user"], "alice");
643        assert_eq!(&caps["method"], "GET");
644        assert_eq!(&caps["url"], "/index.html");
645        assert_eq!(&caps["status"], "200");
646        assert_eq!(&caps["size"], "2326");
647    }
648
649    #[test]
650    fn apache_combined_extracts_referer_and_agent() {
651        let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
652        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""#;
653        let caps = fmt.regex.captures(line).expect("should match");
654        assert_eq!(&caps["status"], "401");
655        assert_eq!(&caps["url"], "/api/login");
656        assert_eq!(&caps["referer"], "https://example.com/");
657        assert_eq!(&caps["agent"], "Mozilla/5.0");
658    }
659
660    #[test]
661    fn field_names_listed_in_order() {
662        let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
663        assert_eq!(
664            fmt.field_names,
665            vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
666        );
667    }
668
669    #[test]
670    fn compile_rejects_regex_without_named_groups() {
671        let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
672        assert!(err.contains("at least one named capture"), "{err}");
673    }
674
675    #[test]
676    fn compile_rejects_invalid_regex() {
677        let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
678        assert!(err.contains("bad"), "{err}");
679    }
680
681    #[test]
682    fn load_groups_reads_user_config() {
683        let _g = HOME_LOCK.lock().unwrap();
684        let tmp = tempfile::tempdir().unwrap();
685        let cfg_dir = tmp.path().join(".config").join("tess");
686        std::fs::create_dir_all(&cfg_dir).unwrap();
687        std::fs::write(
688            cfg_dir.join("formats.toml"),
689            r#"
690[group.errorlog]
691format = "apache-combined"
692file = "/var/log/access.log"
693follow = true
694tail = 1000
695filter = ["status~^5"]
696
697[group.minimal]
698file = "/tmp/x.log"
699"#,
700        )
701        .unwrap();
702        let saved = std::env::var_os("HOME");
703        std::env::set_var("HOME", tmp.path());
704        let result = load_groups();
705        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
706        let groups = result.unwrap();
707        let err = &groups["errorlog"];
708        assert_eq!(err.format.as_deref(), Some("apache-combined"));
709        assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
710        assert!(err.follow);
711        assert_eq!(err.tail, Some(1000));
712        assert_eq!(err.filter, vec!["status~^5".to_string()]);
713        let min = &groups["minimal"];
714        assert!(!min.follow);
715        assert!(min.tail.is_none());
716        assert_eq!(min.filter, Vec::<String>::new());
717    }
718
719    fn group(name: &str) -> Group {
720        Group { name: name.into(), ..Group::default() }
721    }
722
723    fn argv(parts: &[&str]) -> Vec<String> {
724        parts.iter().map(|s| s.to_string()).collect()
725    }
726
727    #[test]
728    fn expand_argv_passes_through_when_no_group_matches() {
729        let groups: HashMap<String, Group> = HashMap::new();
730        let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
731        assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
732    }
733
734    #[test]
735    fn expand_argv_inserts_group_flags_and_file() {
736        let mut groups: HashMap<String, Group> = HashMap::new();
737        groups.insert(
738            "errorlog".into(),
739            Group {
740                name: "errorlog".into(),
741                format: Some("apache-combined".into()),
742                file: Some("/var/log/access.log".into()),
743                follow: true,
744                tail: Some(1000),
745                filter: vec!["status~^5".into()],
746                ..Group::default()
747            },
748        );
749        let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
750        assert_eq!(
751            out,
752            argv(&[
753                "tess",
754                "--format", "apache-combined",
755                "--follow",
756                "--tail", "1000",
757                "--filter", "status~^5",
758                "/var/log/access.log",
759            ])
760        );
761    }
762
763    #[test]
764    fn expand_argv_converts_positionals_to_filters_after_group() {
765        let mut groups: HashMap<String, Group> = HashMap::new();
766        groups.insert(
767            "errorlog".into(),
768            Group {
769                name: "errorlog".into(),
770                format: Some("apache-combined".into()),
771                file: Some("/log".into()),
772                ..Group::default()
773            },
774        );
775        let out = expand_argv(
776            argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
777            &groups,
778        );
779        assert_eq!(
780            out,
781            argv(&[
782                "tess",
783                "--format", "apache-combined",
784                "/log",
785                "--filter", "msg~test",
786                "--filter", "url~/api/",
787            ])
788        );
789    }
790
791    #[test]
792    fn expand_argv_leaves_flags_alone_after_group() {
793        let mut groups: HashMap<String, Group> = HashMap::new();
794        groups.insert("errorlog".into(), group("errorlog"));
795        let out = expand_argv(
796            argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
797            &groups,
798        );
799        // Group is empty so no insertion; --tail 50 stays; "msg=hi" becomes a filter.
800        assert_eq!(
801            out,
802            argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
803        );
804    }
805
806    #[test]
807    fn expand_argv_user_flag_after_group_can_override_tail() {
808        // Group sets tail=1000, user passes --tail 50 after; clap takes last,
809        // so user's 50 wins.
810        let mut groups: HashMap<String, Group> = HashMap::new();
811        groups.insert(
812            "errorlog".into(),
813            Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
814        );
815        let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
816        // --tail 1000 from group, then --tail 50 from user. Order preserved.
817        assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
818        assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
819        let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
820        let pos_50 = out.iter().position(|x| x == "50").unwrap();
821        assert!(pos_1000 < pos_50, "user's value must come after group's");
822    }
823
824    #[test]
825    fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
826        let mut groups: HashMap<String, Group> = HashMap::new();
827        groups.insert("errorlog".into(), group("errorlog"));
828        let out = expand_argv(
829            argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
830            &groups,
831        );
832        // `timeout` is --grep's value, not a positional → not converted to --filter.
833        assert_eq!(
834            out,
835            argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
836        );
837    }
838
839    #[test]
840    fn expand_argv_unknown_double_dash_passes_through() {
841        let groups: HashMap<String, Group> = HashMap::new();
842        let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
843        assert_eq!(out, argv(&["tess", "--unknown"]));
844    }
845
846    #[test]
847    fn load_groups_rejects_reserved_name() {
848        let _g = HOME_LOCK.lock().unwrap();
849        let tmp = tempfile::tempdir().unwrap();
850        let cfg_dir = tmp.path().join(".config").join("tess");
851        std::fs::create_dir_all(&cfg_dir).unwrap();
852        std::fs::write(
853            cfg_dir.join("formats.toml"),
854            r#"
855[group.follow]
856file = "/x.log"
857"#,
858        )
859        .unwrap();
860        let saved = std::env::var_os("HOME");
861        std::env::set_var("HOME", tmp.path());
862        let result = load_groups();
863        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
864        let err = result.unwrap_err();
865        assert!(err.contains("collides with built-in --follow"), "{err}");
866    }
867
868    #[test]
869    fn user_config_overrides_builtin_via_load_all() {
870        let _g = HOME_LOCK.lock().unwrap();
871        // Use a temp HOME to avoid touching the real user's config.
872        let tmp = tempfile::tempdir().unwrap();
873        let cfg_dir = tmp.path().join(".config").join("tess");
874        std::fs::create_dir_all(&cfg_dir).unwrap();
875        let cfg_file = cfg_dir.join("formats.toml");
876        std::fs::write(
877            &cfg_file,
878            r#"
879[format.apache-common]
880regex = "^(?P<custom>\\S+)$"
881"#,
882        )
883        .unwrap();
884        // Save and replace HOME for the duration of this test.
885        let saved = std::env::var_os("HOME");
886        std::env::set_var("HOME", tmp.path());
887        let result = load_all();
888        if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
889        let formats = result.unwrap();
890        let common = &formats["apache-common"];
891        assert_eq!(common.field_names, vec!["custom"], "user config should win");
892    }
893
894    #[test]
895    fn format_entry_parses_record_start() {
896        let toml_text = r#"
897            [format.myapp]
898            regex = '^(?P<line>.*)$'
899            record_start = '^\['
900        "#;
901        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
902        let entry = cfg.format.get("myapp").expect("myapp present");
903        assert_eq!(entry.regex, "^(?P<line>.*)$");
904        assert_eq!(entry.record_start.as_deref(), Some("^\\["));
905    }
906
907    #[test]
908    fn format_entry_record_start_optional() {
909        let toml_text = r#"
910            [format.myapp]
911            regex = '^(?P<line>.*)$'
912        "#;
913        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
914        let entry = cfg.format.get("myapp").expect("myapp present");
915        assert!(entry.record_start.is_none());
916    }
917
918    #[test]
919    fn log_format_compile_full_with_record_start() {
920        let fmt = LogFormat::compile_full(
921            "test",
922            r"^(?P<msg>.+)$",
923            None,
924            Some(r"^\["),
925            None,
926        ).expect("compile");
927        assert!(fmt.record_start.is_some());
928        assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
929        assert!(!fmt.record_start.as_ref().unwrap().is_match("  continuation"));
930    }
931
932    #[test]
933    fn log_format_compile_full_bad_record_start_errors() {
934        let err = LogFormat::compile_full(
935            "test",
936            r"^(?P<msg>.+)$",
937            None,
938            Some(r"["),  // unclosed bracket
939            None,
940        ).expect_err("should fail");
941        assert!(err.contains("record_start"), "error mentions record_start: {err}");
942    }
943
944    #[test]
945    fn group_with_grep_field_deserializes() {
946        let toml_text = r#"
947            [group.errorlog]
948            format = "app"
949            grep = ["timeout", "deadlock"]
950        "#;
951        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
952        let entry = cfg.group.get("errorlog").expect("errorlog present");
953        assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
954    }
955
956    #[test]
957    fn expand_argv_emits_group_grep_flags() {
958        let mut groups = HashMap::new();
959        groups.insert("errorlog".to_string(), Group {
960            name: "errorlog".to_string(),
961            grep: vec!["timeout".to_string(), "deadlock".to_string()],
962            ..Default::default()
963        });
964        let out = expand_argv(
965            argv(&["tess", "--errorlog", "logs.txt"]),
966            &groups,
967        );
968        let joined = out.join(" ");
969        assert!(joined.contains("--grep timeout"), "got: {joined}");
970        assert!(joined.contains("--grep deadlock"), "got: {joined}");
971    }
972
973    #[test]
974    fn user_grep_after_group_accumulates() {
975        let mut groups = HashMap::new();
976        groups.insert("errorlog".to_string(), Group {
977            name: "errorlog".to_string(),
978            grep: vec!["timeout".to_string()],
979            ..Default::default()
980        });
981        let out = expand_argv(
982            argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
983            &groups,
984        );
985        let joined = out.join(" ");
986        assert!(joined.contains("--grep timeout"));
987        assert!(joined.contains("--grep extra"));
988    }
989
990    #[test]
991    fn format_entry_parses_prompt() {
992        let toml_text = r#"
993            [format.myapp]
994            regex = '^(?P<line>.*)$'
995            prompt = '<label> <pct>%'
996        "#;
997        let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
998        let entry = cfg.format.get("myapp").expect("myapp present");
999        assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1000    }
1001}