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 pub prompt_style: Option<crate::ansi::Style>,
28}
29
30impl LogFormat {
31 pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
32 Self::compile_full(name, pattern, None, None, None)
33 }
34
35 pub fn compile_with_display(
36 name: &str,
37 pattern: &str,
38 display: Option<&str>,
39 ) -> Result<Self, String> {
40 Self::compile_full(name, pattern, display, None, None)
41 }
42
43 pub fn compile_full(
44 name: &str,
45 pattern: &str,
46 display: Option<&str>,
47 record_start: Option<&str>,
48 prompt: Option<&str>,
49 ) -> Result<Self, String> {
50 let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
51 let field_names: Vec<String> = regex
52 .capture_names()
53 .flatten()
54 .map(|s| s.to_string())
55 .collect();
56 if field_names.is_empty() {
57 return Err(format!(
58 "format `{name}`: regex must declare at least one named capture group"
59 ));
60 }
61 let display = display
62 .map(|s| {
63 DisplayTemplate::compile(s, &field_names)
64 .map_err(|e| format!("format `{name}`: display: {e}"))
65 })
66 .transpose()?;
67 let record_start = record_start
68 .map(|s| Regex::new(s).map_err(|e| format!("format `{name}`: record_start: {e}")))
69 .transpose()?;
70 let prompt = prompt
71 .map(|s| crate::prompt::ParsedPrompt::parse(s)
72 .map_err(|e| format!("format `{name}`: prompt: {e}")))
73 .transpose()?;
74 Ok(Self {
75 name: name.to_string(),
76 regex,
77 field_names,
78 display,
79 record_start,
80 prompt,
81 prompt_style: None,
82 })
83 }
84}
85
86#[derive(Debug, Clone)]
95pub struct DisplayTemplate {
96 segments: Vec<DisplaySegment>,
97 source: String,
98}
99
100#[derive(Debug, Clone)]
101enum DisplaySegment {
102 Literal(String),
103 Field(String),
104}
105
106impl DisplayTemplate {
107 pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
108 if source.is_empty() {
109 return Err("template is empty (would render every line as nothing)".to_string());
110 }
111 let mut segments: Vec<DisplaySegment> = Vec::new();
112 let mut buf = String::new();
113 let mut chars = source.chars().peekable();
114 while let Some(c) = chars.next() {
115 match c {
116 '\\' => match chars.next() {
117 Some('<') => buf.push('<'),
118 Some('\\') => buf.push('\\'),
119 Some('n') => buf.push('\n'),
120 Some('t') => buf.push('\t'),
121 Some('r') => buf.push('\r'),
122 Some('e') => buf.push('\x1b'),
123 Some('x') => {
124 let h1 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
125 let h2 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
126 let hex: String = [h1, h2].iter().collect();
127 let byte = u8::from_str_radix(&hex, 16)
128 .map_err(|_| format!("invalid `\\x{hex}` escape"))?;
129 buf.push(byte as char);
130 }
131 Some('0') => {
132 let d1 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
133 let d2 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
134 let oct: String = ['0', d1, d2].iter().collect();
135 let byte = u8::from_str_radix(&oct, 8)
136 .map_err(|_| format!("invalid `\\{oct}` escape"))?;
137 buf.push(byte as char);
138 }
139 Some(other) => {
140 buf.push('\\');
144 buf.push(other);
145 }
146 None => return Err("template ends with a lone `\\`".to_string()),
147 },
148 '<' => {
149 if !buf.is_empty() {
150 segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
151 }
152 let mut name = String::new();
153 let mut closed = false;
154 while let Some(&nc) = chars.peek() {
155 chars.next();
156 if nc == '>' { closed = true; break; }
157 name.push(nc);
158 }
159 if !closed {
160 return Err(format!("unterminated `<` (expected `<{name}>`)"));
161 }
162 if name.is_empty() {
163 return Err("empty field reference `<>`".to_string());
164 }
165 if !field_names.iter().any(|n| n == &name) {
166 return Err(format!(
167 "unknown field `{name}` (available: {})",
168 field_names.join(", ")
169 ));
170 }
171 segments.push(DisplaySegment::Field(name));
172 }
173 _ => buf.push(c),
174 }
175 }
176 if !buf.is_empty() {
177 segments.push(DisplaySegment::Literal(buf));
178 }
179 Ok(Self { segments, source: source.to_string() })
180 }
181
182 pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
185 let mut out = String::new();
186 for seg in &self.segments {
187 match seg {
188 DisplaySegment::Literal(s) => out.push_str(s),
189 DisplaySegment::Field(name) => {
190 if let Some(v) = lookup(name) { out.push_str(&v); }
191 }
192 }
193 }
194 out
195 }
196
197 pub fn source(&self) -> &str { &self.source }
198}
199
200#[derive(Debug, Clone)]
203pub struct DisplayRenderer {
204 template: DisplayTemplate,
205 regex: Regex,
206}
207
208impl DisplayRenderer {
209 pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
210 Self { template, regex }
211 }
212
213 pub fn template(&self) -> &DisplayTemplate { &self.template }
214
215 pub fn render_line(&self, line: &[u8]) -> Option<String> {
219 let s = std::str::from_utf8(line).ok()?;
220 let caps = self.regex.captures(s)?;
221 Some(self.template.render(|name| {
222 caps.name(name).map(|m| m.as_str().to_string())
223 }))
224 }
225}
226
227#[derive(Debug, Deserialize)]
240struct UserConfig {
241 #[serde(default)]
242 format: HashMap<String, FormatEntry>,
243 #[serde(default)]
244 group: HashMap<String, GroupEntry>,
245}
246
247#[derive(Debug, Deserialize)]
248struct FormatEntry {
249 regex: String,
250 #[serde(default)]
251 display: Option<String>,
252 #[serde(default)]
253 record_start: Option<String>,
254 #[serde(default)]
255 prompt: Option<String>,
256 #[serde(default)]
260 prompt_style: Option<String>,
261}
262
263#[derive(Debug, Deserialize, Default)]
266struct GroupEntry {
267 format: Option<String>,
268 file: Option<String>,
269 follow: Option<bool>,
270 tail: Option<usize>,
271 head: Option<usize>,
272 dim: Option<bool>,
273 line_numbers: Option<bool>,
274 chop: Option<bool>,
275 tab_width: Option<u8>,
276 #[serde(default)]
277 filter: Vec<String>,
278 #[serde(default)]
279 grep: Vec<String>,
280}
281
282#[derive(Debug, Clone, Default)]
286pub struct Group {
287 pub name: String,
288 pub format: Option<String>,
289 pub file: Option<String>,
290 pub follow: bool,
291 pub tail: Option<usize>,
292 pub head: Option<usize>,
293 pub dim: bool,
294 pub line_numbers: bool,
295 pub chop: bool,
296 pub tab_width: Option<u8>,
297 pub filter: Vec<String>,
298 pub grep: Vec<String>,
299}
300
301const RESERVED_LONG_FLAGS: &[&str] = &[
304 "format",
305 "filter",
306 "grep",
307 "dim",
308 "head",
309 "tail",
310 "follow",
311 "LINE-NUMBERS",
312 "chop-long-lines",
313 "tab-width",
314 "list-formats",
315 "live",
316 "manual",
317 "examples",
318 "prettify",
319 "content-type",
320 "help",
321 "version",
322 "record-start",
323 "hex",
324 "prompt",
325 "preprocess",
326 "no-preprocess",
327 "no-color",
328 "raw-control-chars",
329 "tag",
330 "tag-file",
331];
332
333const BUILTINS: &[(&str, &str)] = &[
336 (
337 "apache-common",
338 r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
339 ),
340 (
341 "apache-combined",
342 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>[^"]*)"$"#,
343 ),
344 (
345 "nginx-combined",
346 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>[^"]*)"$"#,
347 ),
348];
349
350fn user_config_path() -> Option<PathBuf> {
351 std::env::var_os("HOME").map(|h| {
352 let mut p = PathBuf::from(h);
353 p.push(".config");
354 p.push("tess");
355 p.push("formats.toml");
356 p
357 })
358}
359
360fn load_user_config() -> Result<UserConfig, String> {
361 let Some(path) = user_config_path() else {
362 return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
363 };
364 if !path.exists() {
365 return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
366 }
367 let text = std::fs::read_to_string(&path)
368 .map_err(|e| format!("reading {}: {e}", path.display()))?;
369 toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))
370}
371
372struct FormatSource {
373 regex: String,
374 display: Option<String>,
375 record_start: Option<String>,
376 prompt: Option<String>,
377 prompt_style: Option<String>,
378}
379
380fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
381 let cfg = load_user_config()?;
382 Ok(cfg.format.into_iter().map(|(k, v)| (k, FormatSource {
383 regex: v.regex,
384 display: v.display,
385 record_start: v.record_start,
386 prompt: v.prompt,
387 prompt_style: v.prompt_style,
388 })).collect())
389}
390
391pub fn load_groups() -> Result<HashMap<String, Group>, String> {
395 let cfg = load_user_config()?;
396 let mut out = HashMap::with_capacity(cfg.group.len());
397 for (name, entry) in cfg.group {
398 if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
399 return Err(format!(
400 "group `{name}`: name collides with built-in --{name} flag"
401 ));
402 }
403 out.insert(
404 name.clone(),
405 Group {
406 name,
407 format: entry.format,
408 file: entry.file,
409 follow: entry.follow.unwrap_or(false),
410 tail: entry.tail,
411 head: entry.head,
412 dim: entry.dim.unwrap_or(false),
413 line_numbers: entry.line_numbers.unwrap_or(false),
414 chop: entry.chop.unwrap_or(false),
415 tab_width: entry.tab_width,
416 filter: entry.filter,
417 grep: entry.grep,
418 },
419 );
420 }
421 Ok(out)
422}
423
424pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
428 let mut sources: HashMap<String, FormatSource> = HashMap::new();
429 for (name, pat) in BUILTINS {
430 sources.insert(name.to_string(), FormatSource {
431 regex: pat.to_string(),
432 display: None,
433 record_start: None,
434 prompt: None,
435 prompt_style: None,
436 });
437 }
438 let user = load_user_formats()?;
439 for (name, src) in user {
440 sources.insert(name, src);
441 }
442 let mut compiled = HashMap::new();
443 for (name, src) in sources {
444 let mut fmt = LogFormat::compile_full(
445 &name,
446 &src.regex,
447 src.display.as_deref(),
448 src.record_start.as_deref(),
449 src.prompt.as_deref(),
450 )?;
451 if let Some(spec) = src.prompt_style.as_deref() {
452 fmt.prompt_style = Some(
453 crate::style_spec::parse(spec)
454 .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
455 );
456 }
457 compiled.insert(name, fmt);
458 }
459 Ok(compiled)
460}
461
462const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
475 "--format",
476 "--filter",
477 "--grep",
478 "--head",
479 "--tail",
480 "--tab-width",
481 "--record-start",
482];
483
484pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
485 if argv.is_empty() {
486 return argv;
487 }
488 let mut out = Vec::with_capacity(argv.len() * 2);
489 let mut iter = argv.into_iter();
490 out.push(iter.next().unwrap()); let mut filter_mode = false;
492 let mut pass_next = false;
493 for arg in iter {
494 if pass_next {
495 pass_next = false;
496 out.push(arg);
497 continue;
498 }
499 if let Some(name) = arg.strip_prefix("--") {
500 if !name.contains('=') {
503 if let Some(g) = groups.get(name) {
504 expand_group(g, &mut out);
505 filter_mode = true;
506 continue;
507 }
508 if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
509 out.push(arg);
512 pass_next = true;
513 continue;
514 }
515 }
516 }
517 if filter_mode && !arg.starts_with('-') {
518 out.push("--filter".into());
519 out.push(arg);
520 continue;
521 }
522 out.push(arg);
523 }
524 out
525}
526
527fn expand_group(g: &Group, out: &mut Vec<String>) {
528 if let Some(format) = &g.format {
529 out.push("--format".into());
530 out.push(format.clone());
531 }
532 if g.follow {
533 out.push("--follow".into());
534 }
535 if let Some(t) = g.tail {
536 out.push("--tail".into());
537 out.push(t.to_string());
538 }
539 if let Some(h) = g.head {
540 out.push("--head".into());
541 out.push(h.to_string());
542 }
543 if g.dim {
544 out.push("--dim".into());
545 }
546 if g.line_numbers {
547 out.push("-N".into());
548 }
549 if g.chop {
550 out.push("-S".into());
551 }
552 if let Some(t) = g.tab_width {
553 out.push("--tab-width".into());
554 out.push(t.to_string());
555 }
556 for f in &g.filter {
557 out.push("--filter".into());
558 out.push(f.clone());
559 }
560 for g_pat in &g.grep {
561 out.push("--grep".into());
562 out.push(g_pat.clone());
563 }
564 if let Some(file) = &g.file {
565 out.push(file.clone());
566 }
567}
568
569pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
572 let mut names: Vec<&String> = formats.keys().collect();
573 names.sort();
574 for name in names {
575 let fmt = &formats[name];
576 let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
577 println!("{}: {}", name, fields.join(", "));
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use std::sync::Mutex;
585
586 static HOME_LOCK: Mutex<()> = Mutex::new(());
589
590 #[test]
591 fn builtins_all_compile() {
592 for (name, pat) in BUILTINS {
593 LogFormat::compile(name, pat)
594 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
595 }
596 }
597
598 fn fields() -> Vec<String> {
601 vec!["ts".into(), "level".into(), "msg".into()]
602 }
603
604 #[test]
605 fn display_template_compiles_basic() {
606 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
607 assert_eq!(t.source(), "[<ts>] <level> <msg>");
608 }
609
610 #[test]
611 fn display_template_renders_substitutions() {
612 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
613 let mut map = std::collections::HashMap::new();
614 map.insert("level".to_string(), "ERROR".to_string());
615 map.insert("msg".to_string(), "boom".to_string());
616 let out = t.render(|n| map.get(n).cloned());
617 assert_eq!(out, "ERROR: boom");
618 }
619
620 #[test]
621 fn display_template_missing_field_renders_empty() {
622 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
623 let mut map = std::collections::HashMap::new();
624 map.insert("level".to_string(), "ERROR".to_string());
625 let out = t.render(|n| map.get(n).cloned());
627 assert_eq!(out, "ERROR:");
628 }
629
630 #[test]
631 fn display_template_escape_sequences() {
632 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
635 let mut map = std::collections::HashMap::new();
636 map.insert("level".to_string(), "X".to_string());
637 let out = t.render(|n| map.get(n).cloned());
638 assert_eq!(out, "<not a field> X");
639 }
640
641 #[test]
642 fn display_template_escape_backslash() {
643 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
644 let mut map = std::collections::HashMap::new();
645 map.insert("level".to_string(), "X".to_string());
646 let out = t.render(|n| map.get(n).cloned());
647 assert_eq!(out, r"a\b X");
648 }
649
650 #[test]
651 fn display_template_escape_e_emits_esc() {
652 let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
653 let mut map = std::collections::HashMap::new();
654 map.insert("level".to_string(), "X".to_string());
655 let out = t.render(|n| map.get(n).cloned());
656 assert_eq!(out, "\x1b[31mX\x1b[0m");
657 }
658
659 #[test]
660 fn display_template_escape_x1b_emits_esc() {
661 let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
662 let out = t.render(|_| Some("Y".to_string()));
663 assert_eq!(out, "\x1b[1mY");
664 }
665
666 #[test]
667 fn display_template_escape_octal_emits_esc() {
668 let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
669 let out = t.render(|_| Some("Z".to_string()));
670 assert_eq!(out, "\x1b[1mZ");
671 }
672
673 #[test]
674 fn display_template_escape_n_t_r() {
675 let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
676 let out = t.render(|_| Some("Q".to_string()));
677 assert_eq!(out, "\n\t\rQ");
678 }
679
680 #[test]
681 fn display_template_escape_unknown_preserves_backslash() {
682 let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
683 let out = t.render(|_| Some("Q".to_string()));
684 assert_eq!(out, r"\qQ");
685 }
686
687 #[test]
688 fn display_template_escape_x_incomplete_errors() {
689 let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
690 assert!(err.contains("incomplete"), "{err}");
691 }
692
693 #[test]
694 fn display_template_escape_invalid_hex_errors() {
695 let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
696 assert!(err.contains("invalid"), "{err}");
697 }
698
699 #[test]
700 fn display_template_rejects_empty() {
701 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
702 assert!(err.contains("empty"), "{err}");
703 }
704
705 #[test]
706 fn display_template_rejects_unknown_field() {
707 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
708 assert!(err.contains("unknown field"), "{err}");
709 }
710
711 #[test]
712 fn display_template_rejects_unterminated() {
713 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
714 assert!(err.contains("unterminated"), "{err}");
715 }
716
717 #[test]
718 fn display_template_rejects_empty_ref() {
719 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
720 assert!(err.contains("empty field reference"), "{err}");
721 }
722
723 #[test]
724 fn apache_common_extracts_fields() {
725 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
726 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
727 let caps = fmt.regex.captures(line).expect("should match");
728 assert_eq!(&caps["ip"], "127.0.0.1");
729 assert_eq!(&caps["user"], "alice");
730 assert_eq!(&caps["method"], "GET");
731 assert_eq!(&caps["url"], "/index.html");
732 assert_eq!(&caps["status"], "200");
733 assert_eq!(&caps["size"], "2326");
734 }
735
736 #[test]
737 fn apache_combined_extracts_referer_and_agent() {
738 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
739 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""#;
740 let caps = fmt.regex.captures(line).expect("should match");
741 assert_eq!(&caps["status"], "401");
742 assert_eq!(&caps["url"], "/api/login");
743 assert_eq!(&caps["referer"], "https://example.com/");
744 assert_eq!(&caps["agent"], "Mozilla/5.0");
745 }
746
747 #[test]
748 fn field_names_listed_in_order() {
749 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
750 assert_eq!(
751 fmt.field_names,
752 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
753 );
754 }
755
756 #[test]
757 fn compile_rejects_regex_without_named_groups() {
758 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
759 assert!(err.contains("at least one named capture"), "{err}");
760 }
761
762 #[test]
763 fn compile_rejects_invalid_regex() {
764 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
765 assert!(err.contains("bad"), "{err}");
766 }
767
768 #[test]
769 fn load_groups_reads_user_config() {
770 let _g = HOME_LOCK.lock().unwrap();
771 let tmp = tempfile::tempdir().unwrap();
772 let cfg_dir = tmp.path().join(".config").join("tess");
773 std::fs::create_dir_all(&cfg_dir).unwrap();
774 std::fs::write(
775 cfg_dir.join("formats.toml"),
776 r#"
777[group.errorlog]
778format = "apache-combined"
779file = "/var/log/access.log"
780follow = true
781tail = 1000
782filter = ["status~^5"]
783
784[group.minimal]
785file = "/tmp/x.log"
786"#,
787 )
788 .unwrap();
789 let saved = std::env::var_os("HOME");
790 std::env::set_var("HOME", tmp.path());
791 let result = load_groups();
792 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
793 let groups = result.unwrap();
794 let err = &groups["errorlog"];
795 assert_eq!(err.format.as_deref(), Some("apache-combined"));
796 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
797 assert!(err.follow);
798 assert_eq!(err.tail, Some(1000));
799 assert_eq!(err.filter, vec!["status~^5".to_string()]);
800 let min = &groups["minimal"];
801 assert!(!min.follow);
802 assert!(min.tail.is_none());
803 assert_eq!(min.filter, Vec::<String>::new());
804 }
805
806 fn group(name: &str) -> Group {
807 Group { name: name.into(), ..Group::default() }
808 }
809
810 fn argv(parts: &[&str]) -> Vec<String> {
811 parts.iter().map(|s| s.to_string()).collect()
812 }
813
814 #[test]
815 fn expand_argv_passes_through_when_no_group_matches() {
816 let groups: HashMap<String, Group> = HashMap::new();
817 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
818 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
819 }
820
821 #[test]
822 fn expand_argv_inserts_group_flags_and_file() {
823 let mut groups: HashMap<String, Group> = HashMap::new();
824 groups.insert(
825 "errorlog".into(),
826 Group {
827 name: "errorlog".into(),
828 format: Some("apache-combined".into()),
829 file: Some("/var/log/access.log".into()),
830 follow: true,
831 tail: Some(1000),
832 filter: vec!["status~^5".into()],
833 ..Group::default()
834 },
835 );
836 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
837 assert_eq!(
838 out,
839 argv(&[
840 "tess",
841 "--format", "apache-combined",
842 "--follow",
843 "--tail", "1000",
844 "--filter", "status~^5",
845 "/var/log/access.log",
846 ])
847 );
848 }
849
850 #[test]
851 fn expand_argv_converts_positionals_to_filters_after_group() {
852 let mut groups: HashMap<String, Group> = HashMap::new();
853 groups.insert(
854 "errorlog".into(),
855 Group {
856 name: "errorlog".into(),
857 format: Some("apache-combined".into()),
858 file: Some("/log".into()),
859 ..Group::default()
860 },
861 );
862 let out = expand_argv(
863 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
864 &groups,
865 );
866 assert_eq!(
867 out,
868 argv(&[
869 "tess",
870 "--format", "apache-combined",
871 "/log",
872 "--filter", "msg~test",
873 "--filter", "url~/api/",
874 ])
875 );
876 }
877
878 #[test]
879 fn expand_argv_leaves_flags_alone_after_group() {
880 let mut groups: HashMap<String, Group> = HashMap::new();
881 groups.insert("errorlog".into(), group("errorlog"));
882 let out = expand_argv(
883 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
884 &groups,
885 );
886 assert_eq!(
888 out,
889 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
890 );
891 }
892
893 #[test]
894 fn expand_argv_user_flag_after_group_can_override_tail() {
895 let mut groups: HashMap<String, Group> = HashMap::new();
898 groups.insert(
899 "errorlog".into(),
900 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
901 );
902 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
903 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
905 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
906 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
907 let pos_50 = out.iter().position(|x| x == "50").unwrap();
908 assert!(pos_1000 < pos_50, "user's value must come after group's");
909 }
910
911 #[test]
912 fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
913 let mut groups: HashMap<String, Group> = HashMap::new();
914 groups.insert("errorlog".into(), group("errorlog"));
915 let out = expand_argv(
916 argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
917 &groups,
918 );
919 assert_eq!(
921 out,
922 argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
923 );
924 }
925
926 #[test]
927 fn expand_argv_unknown_double_dash_passes_through() {
928 let groups: HashMap<String, Group> = HashMap::new();
929 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
930 assert_eq!(out, argv(&["tess", "--unknown"]));
931 }
932
933 #[test]
934 fn load_groups_rejects_reserved_name() {
935 let _g = HOME_LOCK.lock().unwrap();
936 let tmp = tempfile::tempdir().unwrap();
937 let cfg_dir = tmp.path().join(".config").join("tess");
938 std::fs::create_dir_all(&cfg_dir).unwrap();
939 std::fs::write(
940 cfg_dir.join("formats.toml"),
941 r#"
942[group.follow]
943file = "/x.log"
944"#,
945 )
946 .unwrap();
947 let saved = std::env::var_os("HOME");
948 std::env::set_var("HOME", tmp.path());
949 let result = load_groups();
950 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
951 let err = result.unwrap_err();
952 assert!(err.contains("collides with built-in --follow"), "{err}");
953 }
954
955 #[test]
956 fn user_config_overrides_builtin_via_load_all() {
957 let _g = HOME_LOCK.lock().unwrap();
958 let tmp = tempfile::tempdir().unwrap();
960 let cfg_dir = tmp.path().join(".config").join("tess");
961 std::fs::create_dir_all(&cfg_dir).unwrap();
962 let cfg_file = cfg_dir.join("formats.toml");
963 std::fs::write(
964 &cfg_file,
965 r#"
966[format.apache-common]
967regex = "^(?P<custom>\\S+)$"
968"#,
969 )
970 .unwrap();
971 let saved = std::env::var_os("HOME");
973 std::env::set_var("HOME", tmp.path());
974 let result = load_all();
975 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
976 let formats = result.unwrap();
977 let common = &formats["apache-common"];
978 assert_eq!(common.field_names, vec!["custom"], "user config should win");
979 }
980
981 #[test]
982 fn format_entry_parses_record_start() {
983 let toml_text = r#"
984 [format.myapp]
985 regex = '^(?P<line>.*)$'
986 record_start = '^\['
987 "#;
988 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
989 let entry = cfg.format.get("myapp").expect("myapp present");
990 assert_eq!(entry.regex, "^(?P<line>.*)$");
991 assert_eq!(entry.record_start.as_deref(), Some("^\\["));
992 }
993
994 #[test]
995 fn format_entry_record_start_optional() {
996 let toml_text = r#"
997 [format.myapp]
998 regex = '^(?P<line>.*)$'
999 "#;
1000 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1001 let entry = cfg.format.get("myapp").expect("myapp present");
1002 assert!(entry.record_start.is_none());
1003 }
1004
1005 #[test]
1006 fn log_format_compile_full_with_record_start() {
1007 let fmt = LogFormat::compile_full(
1008 "test",
1009 r"^(?P<msg>.+)$",
1010 None,
1011 Some(r"^\["),
1012 None,
1013 ).expect("compile");
1014 assert!(fmt.record_start.is_some());
1015 assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1016 assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
1017 }
1018
1019 #[test]
1020 fn log_format_compile_full_bad_record_start_errors() {
1021 let err = LogFormat::compile_full(
1022 "test",
1023 r"^(?P<msg>.+)$",
1024 None,
1025 Some(r"["), None,
1027 ).expect_err("should fail");
1028 assert!(err.contains("record_start"), "error mentions record_start: {err}");
1029 }
1030
1031 #[test]
1032 fn group_with_grep_field_deserializes() {
1033 let toml_text = r#"
1034 [group.errorlog]
1035 format = "app"
1036 grep = ["timeout", "deadlock"]
1037 "#;
1038 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1039 let entry = cfg.group.get("errorlog").expect("errorlog present");
1040 assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1041 }
1042
1043 #[test]
1044 fn expand_argv_emits_group_grep_flags() {
1045 let mut groups = HashMap::new();
1046 groups.insert("errorlog".to_string(), Group {
1047 name: "errorlog".to_string(),
1048 grep: vec!["timeout".to_string(), "deadlock".to_string()],
1049 ..Default::default()
1050 });
1051 let out = expand_argv(
1052 argv(&["tess", "--errorlog", "logs.txt"]),
1053 &groups,
1054 );
1055 let joined = out.join(" ");
1056 assert!(joined.contains("--grep timeout"), "got: {joined}");
1057 assert!(joined.contains("--grep deadlock"), "got: {joined}");
1058 }
1059
1060 #[test]
1061 fn user_grep_after_group_accumulates() {
1062 let mut groups = HashMap::new();
1063 groups.insert("errorlog".to_string(), Group {
1064 name: "errorlog".to_string(),
1065 grep: vec!["timeout".to_string()],
1066 ..Default::default()
1067 });
1068 let out = expand_argv(
1069 argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1070 &groups,
1071 );
1072 let joined = out.join(" ");
1073 assert!(joined.contains("--grep timeout"));
1074 assert!(joined.contains("--grep extra"));
1075 }
1076
1077 #[test]
1078 fn format_entry_parses_prompt() {
1079 let toml_text = r#"
1080 [format.myapp]
1081 regex = '^(?P<line>.*)$'
1082 prompt = '<label> <pct>%'
1083 "#;
1084 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1085 let entry = cfg.format.get("myapp").expect("myapp present");
1086 assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1087 }
1088}