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}
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#[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 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 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#[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 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#[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#[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#[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
257const 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
281const 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
335pub 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
367pub 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
396const 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()); 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 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 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
499pub 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 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 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 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 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 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 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 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 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 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 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"["), ).expect_err("should fail");
907 assert!(err.contains("record_start"), "error mentions record_start: {err}");
908 }
909}