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