1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6
7#[derive(Debug)]
10pub struct LogFormat {
11 pub name: String,
12 pub regex: Regex,
13 pub field_names: Vec<String>,
16 pub display: Option<DisplayTemplate>,
20 pub record_start: Option<Regex>,
21 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#[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 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 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#[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 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#[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#[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#[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
272const 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
304const 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
360pub 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
393pub 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
424const 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()); 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 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 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
531pub 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 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 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 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 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 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 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 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 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 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 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"["), 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}