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}
21
22impl LogFormat {
23 pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
24 Self::compile_with_display(name, pattern, None)
25 }
26
27 pub fn compile_with_display(
28 name: &str,
29 pattern: &str,
30 display: Option<&str>,
31 ) -> Result<Self, String> {
32 let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
33 let field_names: Vec<String> = regex
36 .capture_names()
37 .flatten()
38 .map(|s| s.to_string())
39 .collect();
40 if field_names.is_empty() {
41 return Err(format!(
42 "format `{name}`: regex must declare at least one named capture group"
43 ));
44 }
45 let display = display
46 .map(|s| {
47 DisplayTemplate::compile(s, &field_names)
48 .map_err(|e| format!("format `{name}`: display: {e}"))
49 })
50 .transpose()?;
51 Ok(Self {
52 name: name.to_string(),
53 regex,
54 field_names,
55 display,
56 })
57 }
58}
59
60#[derive(Debug, Clone)]
69pub struct DisplayTemplate {
70 segments: Vec<DisplaySegment>,
71 source: String,
72}
73
74#[derive(Debug, Clone)]
75enum DisplaySegment {
76 Literal(String),
77 Field(String),
78}
79
80impl DisplayTemplate {
81 pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
82 if source.is_empty() {
83 return Err("template is empty (would render every line as nothing)".to_string());
84 }
85 let mut segments: Vec<DisplaySegment> = Vec::new();
86 let mut buf = String::new();
87 let mut chars = source.chars().peekable();
88 while let Some(c) = chars.next() {
89 match c {
90 '\\' => match chars.next() {
91 Some('<') => buf.push('<'),
92 Some('\\') => buf.push('\\'),
93 Some(other) => {
94 buf.push('\\');
98 buf.push(other);
99 }
100 None => return Err("template ends with a lone `\\`".to_string()),
101 },
102 '<' => {
103 if !buf.is_empty() {
104 segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
105 }
106 let mut name = String::new();
107 let mut closed = false;
108 while let Some(&nc) = chars.peek() {
109 chars.next();
110 if nc == '>' { closed = true; break; }
111 name.push(nc);
112 }
113 if !closed {
114 return Err(format!("unterminated `<` (expected `<{name}>`)"));
115 }
116 if name.is_empty() {
117 return Err("empty field reference `<>`".to_string());
118 }
119 if !field_names.iter().any(|n| n == &name) {
120 return Err(format!(
121 "unknown field `{name}` (available: {})",
122 field_names.join(", ")
123 ));
124 }
125 segments.push(DisplaySegment::Field(name));
126 }
127 _ => buf.push(c),
128 }
129 }
130 if !buf.is_empty() {
131 segments.push(DisplaySegment::Literal(buf));
132 }
133 Ok(Self { segments, source: source.to_string() })
134 }
135
136 pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
139 let mut out = String::new();
140 for seg in &self.segments {
141 match seg {
142 DisplaySegment::Literal(s) => out.push_str(s),
143 DisplaySegment::Field(name) => {
144 if let Some(v) = lookup(name) { out.push_str(&v); }
145 }
146 }
147 }
148 out
149 }
150
151 pub fn source(&self) -> &str { &self.source }
152}
153
154#[derive(Debug, Clone)]
157pub struct DisplayRenderer {
158 template: DisplayTemplate,
159 regex: Regex,
160}
161
162impl DisplayRenderer {
163 pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
164 Self { template, regex }
165 }
166
167 pub fn template(&self) -> &DisplayTemplate { &self.template }
168
169 pub fn render_line(&self, line: &[u8]) -> Option<String> {
173 let s = std::str::from_utf8(line).ok()?;
174 let caps = self.regex.captures(s)?;
175 Some(self.template.render(|name| {
176 caps.name(name).map(|m| m.as_str().to_string())
177 }))
178 }
179}
180
181#[derive(Debug, Deserialize)]
194struct UserConfig {
195 #[serde(default)]
196 format: HashMap<String, FormatEntry>,
197 #[serde(default)]
198 group: HashMap<String, GroupEntry>,
199}
200
201#[derive(Debug, Deserialize)]
202struct FormatEntry {
203 regex: String,
204 #[serde(default)]
205 display: Option<String>,
206}
207
208#[derive(Debug, Deserialize, Default)]
211struct GroupEntry {
212 format: Option<String>,
213 file: Option<String>,
214 follow: Option<bool>,
215 tail: Option<usize>,
216 head: Option<usize>,
217 dim: Option<bool>,
218 line_numbers: Option<bool>,
219 chop: Option<bool>,
220 tab_width: Option<u8>,
221 #[serde(default)]
222 filter: Vec<String>,
223}
224
225#[derive(Debug, Clone, Default)]
229pub struct Group {
230 pub name: String,
231 pub format: Option<String>,
232 pub file: Option<String>,
233 pub follow: bool,
234 pub tail: Option<usize>,
235 pub head: Option<usize>,
236 pub dim: bool,
237 pub line_numbers: bool,
238 pub chop: bool,
239 pub tab_width: Option<u8>,
240 pub filter: Vec<String>,
241}
242
243const RESERVED_LONG_FLAGS: &[&str] = &[
246 "format",
247 "filter",
248 "dim",
249 "head",
250 "tail",
251 "follow",
252 "LINE-NUMBERS",
253 "chop-long-lines",
254 "tab-width",
255 "list-formats",
256 "live",
257 "manual",
258 "examples",
259 "prettify",
260 "content-type",
261 "help",
262 "version",
263];
264
265const BUILTINS: &[(&str, &str)] = &[
268 (
269 "apache-common",
270 r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
271 ),
272 (
273 "apache-combined",
274 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>[^"]*)"$"#,
275 ),
276 (
277 "nginx-combined",
278 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>[^"]*)"$"#,
279 ),
280];
281
282fn user_config_path() -> Option<PathBuf> {
283 std::env::var_os("HOME").map(|h| {
284 let mut p = PathBuf::from(h);
285 p.push(".config");
286 p.push("tess");
287 p.push("formats.toml");
288 p
289 })
290}
291
292fn load_user_config() -> Result<UserConfig, String> {
293 let Some(path) = user_config_path() else {
294 return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
295 };
296 if !path.exists() {
297 return Ok(UserConfig { format: HashMap::new(), group: HashMap::new() });
298 }
299 let text = std::fs::read_to_string(&path)
300 .map_err(|e| format!("reading {}: {e}", path.display()))?;
301 toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))
302}
303
304fn load_user_formats() -> Result<HashMap<String, (String, Option<String>)>, String> {
305 let cfg = load_user_config()?;
306 Ok(cfg.format.into_iter().map(|(k, v)| (k, (v.regex, v.display))).collect())
307}
308
309pub fn load_groups() -> Result<HashMap<String, Group>, String> {
313 let cfg = load_user_config()?;
314 let mut out = HashMap::with_capacity(cfg.group.len());
315 for (name, entry) in cfg.group {
316 if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
317 return Err(format!(
318 "group `{name}`: name collides with built-in --{name} flag"
319 ));
320 }
321 out.insert(
322 name.clone(),
323 Group {
324 name,
325 format: entry.format,
326 file: entry.file,
327 follow: entry.follow.unwrap_or(false),
328 tail: entry.tail,
329 head: entry.head,
330 dim: entry.dim.unwrap_or(false),
331 line_numbers: entry.line_numbers.unwrap_or(false),
332 chop: entry.chop.unwrap_or(false),
333 tab_width: entry.tab_width,
334 filter: entry.filter,
335 },
336 );
337 }
338 Ok(out)
339}
340
341pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
345 let mut sources: HashMap<String, (String, Option<String>)> = HashMap::new();
346 for (name, pat) in BUILTINS {
347 sources.insert(name.to_string(), (pat.to_string(), None));
348 }
349 let user = load_user_formats()?;
350 for (name, src) in user {
351 sources.insert(name, src);
352 }
353 let mut compiled = HashMap::new();
354 for (name, (pat, display)) in sources {
355 let fmt = LogFormat::compile_with_display(&name, &pat, display.as_deref())?;
356 compiled.insert(name, fmt);
357 }
358 Ok(compiled)
359}
360
361const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
374 "--format",
375 "--filter",
376 "--head",
377 "--tail",
378 "--tab-width",
379];
380
381pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
382 if argv.is_empty() {
383 return argv;
384 }
385 let mut out = Vec::with_capacity(argv.len() * 2);
386 let mut iter = argv.into_iter();
387 out.push(iter.next().unwrap()); let mut filter_mode = false;
389 let mut pass_next = false;
390 for arg in iter {
391 if pass_next {
392 pass_next = false;
393 out.push(arg);
394 continue;
395 }
396 if let Some(name) = arg.strip_prefix("--") {
397 if !name.contains('=') {
400 if let Some(g) = groups.get(name) {
401 expand_group(g, &mut out);
402 filter_mode = true;
403 continue;
404 }
405 if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
406 out.push(arg);
409 pass_next = true;
410 continue;
411 }
412 }
413 }
414 if filter_mode && !arg.starts_with('-') {
415 out.push("--filter".into());
416 out.push(arg);
417 continue;
418 }
419 out.push(arg);
420 }
421 out
422}
423
424fn expand_group(g: &Group, out: &mut Vec<String>) {
425 if let Some(format) = &g.format {
426 out.push("--format".into());
427 out.push(format.clone());
428 }
429 if g.follow {
430 out.push("--follow".into());
431 }
432 if let Some(t) = g.tail {
433 out.push("--tail".into());
434 out.push(t.to_string());
435 }
436 if let Some(h) = g.head {
437 out.push("--head".into());
438 out.push(h.to_string());
439 }
440 if g.dim {
441 out.push("--dim".into());
442 }
443 if g.line_numbers {
444 out.push("-N".into());
445 }
446 if g.chop {
447 out.push("-S".into());
448 }
449 if let Some(t) = g.tab_width {
450 out.push("--tab-width".into());
451 out.push(t.to_string());
452 }
453 for f in &g.filter {
454 out.push("--filter".into());
455 out.push(f.clone());
456 }
457 if let Some(file) = &g.file {
458 out.push(file.clone());
459 }
460}
461
462pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
465 let mut names: Vec<&String> = formats.keys().collect();
466 names.sort();
467 for name in names {
468 let fmt = &formats[name];
469 let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
470 println!("{}: {}", name, fields.join(", "));
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::sync::Mutex;
478
479 static HOME_LOCK: Mutex<()> = Mutex::new(());
482
483 #[test]
484 fn builtins_all_compile() {
485 for (name, pat) in BUILTINS {
486 LogFormat::compile(name, pat)
487 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
488 }
489 }
490
491 fn fields() -> Vec<String> {
494 vec!["ts".into(), "level".into(), "msg".into()]
495 }
496
497 #[test]
498 fn display_template_compiles_basic() {
499 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
500 assert_eq!(t.source(), "[<ts>] <level> <msg>");
501 }
502
503 #[test]
504 fn display_template_renders_substitutions() {
505 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
506 let mut map = std::collections::HashMap::new();
507 map.insert("level".to_string(), "ERROR".to_string());
508 map.insert("msg".to_string(), "boom".to_string());
509 let out = t.render(|n| map.get(n).cloned());
510 assert_eq!(out, "ERROR: boom");
511 }
512
513 #[test]
514 fn display_template_missing_field_renders_empty() {
515 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
516 let mut map = std::collections::HashMap::new();
517 map.insert("level".to_string(), "ERROR".to_string());
518 let out = t.render(|n| map.get(n).cloned());
520 assert_eq!(out, "ERROR:");
521 }
522
523 #[test]
524 fn display_template_escape_sequences() {
525 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
528 let mut map = std::collections::HashMap::new();
529 map.insert("level".to_string(), "X".to_string());
530 let out = t.render(|n| map.get(n).cloned());
531 assert_eq!(out, "<not a field> X");
532 }
533
534 #[test]
535 fn display_template_escape_backslash() {
536 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
537 let mut map = std::collections::HashMap::new();
538 map.insert("level".to_string(), "X".to_string());
539 let out = t.render(|n| map.get(n).cloned());
540 assert_eq!(out, r"a\b X");
541 }
542
543 #[test]
544 fn display_template_rejects_empty() {
545 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
546 assert!(err.contains("empty"), "{err}");
547 }
548
549 #[test]
550 fn display_template_rejects_unknown_field() {
551 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
552 assert!(err.contains("unknown field"), "{err}");
553 }
554
555 #[test]
556 fn display_template_rejects_unterminated() {
557 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
558 assert!(err.contains("unterminated"), "{err}");
559 }
560
561 #[test]
562 fn display_template_rejects_empty_ref() {
563 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
564 assert!(err.contains("empty field reference"), "{err}");
565 }
566
567 #[test]
568 fn apache_common_extracts_fields() {
569 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
570 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
571 let caps = fmt.regex.captures(line).expect("should match");
572 assert_eq!(&caps["ip"], "127.0.0.1");
573 assert_eq!(&caps["user"], "alice");
574 assert_eq!(&caps["method"], "GET");
575 assert_eq!(&caps["url"], "/index.html");
576 assert_eq!(&caps["status"], "200");
577 assert_eq!(&caps["size"], "2326");
578 }
579
580 #[test]
581 fn apache_combined_extracts_referer_and_agent() {
582 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
583 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""#;
584 let caps = fmt.regex.captures(line).expect("should match");
585 assert_eq!(&caps["status"], "401");
586 assert_eq!(&caps["url"], "/api/login");
587 assert_eq!(&caps["referer"], "https://example.com/");
588 assert_eq!(&caps["agent"], "Mozilla/5.0");
589 }
590
591 #[test]
592 fn field_names_listed_in_order() {
593 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
594 assert_eq!(
595 fmt.field_names,
596 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
597 );
598 }
599
600 #[test]
601 fn compile_rejects_regex_without_named_groups() {
602 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
603 assert!(err.contains("at least one named capture"), "{err}");
604 }
605
606 #[test]
607 fn compile_rejects_invalid_regex() {
608 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
609 assert!(err.contains("bad"), "{err}");
610 }
611
612 #[test]
613 fn load_groups_reads_user_config() {
614 let _g = HOME_LOCK.lock().unwrap();
615 let tmp = tempfile::tempdir().unwrap();
616 let cfg_dir = tmp.path().join(".config").join("tess");
617 std::fs::create_dir_all(&cfg_dir).unwrap();
618 std::fs::write(
619 cfg_dir.join("formats.toml"),
620 r#"
621[group.errorlog]
622format = "apache-combined"
623file = "/var/log/access.log"
624follow = true
625tail = 1000
626filter = ["status~^5"]
627
628[group.minimal]
629file = "/tmp/x.log"
630"#,
631 )
632 .unwrap();
633 let saved = std::env::var_os("HOME");
634 std::env::set_var("HOME", tmp.path());
635 let result = load_groups();
636 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
637 let groups = result.unwrap();
638 let err = &groups["errorlog"];
639 assert_eq!(err.format.as_deref(), Some("apache-combined"));
640 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
641 assert!(err.follow);
642 assert_eq!(err.tail, Some(1000));
643 assert_eq!(err.filter, vec!["status~^5".to_string()]);
644 let min = &groups["minimal"];
645 assert!(!min.follow);
646 assert!(min.tail.is_none());
647 assert_eq!(min.filter, Vec::<String>::new());
648 }
649
650 fn group(name: &str) -> Group {
651 Group { name: name.into(), ..Group::default() }
652 }
653
654 fn argv(parts: &[&str]) -> Vec<String> {
655 parts.iter().map(|s| s.to_string()).collect()
656 }
657
658 #[test]
659 fn expand_argv_passes_through_when_no_group_matches() {
660 let groups: HashMap<String, Group> = HashMap::new();
661 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
662 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
663 }
664
665 #[test]
666 fn expand_argv_inserts_group_flags_and_file() {
667 let mut groups: HashMap<String, Group> = HashMap::new();
668 groups.insert(
669 "errorlog".into(),
670 Group {
671 name: "errorlog".into(),
672 format: Some("apache-combined".into()),
673 file: Some("/var/log/access.log".into()),
674 follow: true,
675 tail: Some(1000),
676 filter: vec!["status~^5".into()],
677 ..Group::default()
678 },
679 );
680 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
681 assert_eq!(
682 out,
683 argv(&[
684 "tess",
685 "--format", "apache-combined",
686 "--follow",
687 "--tail", "1000",
688 "--filter", "status~^5",
689 "/var/log/access.log",
690 ])
691 );
692 }
693
694 #[test]
695 fn expand_argv_converts_positionals_to_filters_after_group() {
696 let mut groups: HashMap<String, Group> = HashMap::new();
697 groups.insert(
698 "errorlog".into(),
699 Group {
700 name: "errorlog".into(),
701 format: Some("apache-combined".into()),
702 file: Some("/log".into()),
703 ..Group::default()
704 },
705 );
706 let out = expand_argv(
707 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
708 &groups,
709 );
710 assert_eq!(
711 out,
712 argv(&[
713 "tess",
714 "--format", "apache-combined",
715 "/log",
716 "--filter", "msg~test",
717 "--filter", "url~/api/",
718 ])
719 );
720 }
721
722 #[test]
723 fn expand_argv_leaves_flags_alone_after_group() {
724 let mut groups: HashMap<String, Group> = HashMap::new();
725 groups.insert("errorlog".into(), group("errorlog"));
726 let out = expand_argv(
727 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
728 &groups,
729 );
730 assert_eq!(
732 out,
733 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
734 );
735 }
736
737 #[test]
738 fn expand_argv_user_flag_after_group_can_override_tail() {
739 let mut groups: HashMap<String, Group> = HashMap::new();
742 groups.insert(
743 "errorlog".into(),
744 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
745 );
746 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
747 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
749 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
750 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
751 let pos_50 = out.iter().position(|x| x == "50").unwrap();
752 assert!(pos_1000 < pos_50, "user's value must come after group's");
753 }
754
755 #[test]
756 fn expand_argv_unknown_double_dash_passes_through() {
757 let groups: HashMap<String, Group> = HashMap::new();
758 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
759 assert_eq!(out, argv(&["tess", "--unknown"]));
760 }
761
762 #[test]
763 fn load_groups_rejects_reserved_name() {
764 let _g = HOME_LOCK.lock().unwrap();
765 let tmp = tempfile::tempdir().unwrap();
766 let cfg_dir = tmp.path().join(".config").join("tess");
767 std::fs::create_dir_all(&cfg_dir).unwrap();
768 std::fs::write(
769 cfg_dir.join("formats.toml"),
770 r#"
771[group.follow]
772file = "/x.log"
773"#,
774 )
775 .unwrap();
776 let saved = std::env::var_os("HOME");
777 std::env::set_var("HOME", tmp.path());
778 let result = load_groups();
779 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
780 let err = result.unwrap_err();
781 assert!(err.contains("collides with built-in --follow"), "{err}");
782 }
783
784 #[test]
785 fn user_config_overrides_builtin_via_load_all() {
786 let _g = HOME_LOCK.lock().unwrap();
787 let tmp = tempfile::tempdir().unwrap();
789 let cfg_dir = tmp.path().join(".config").join("tess");
790 std::fs::create_dir_all(&cfg_dir).unwrap();
791 let cfg_file = cfg_dir.join("formats.toml");
792 std::fs::write(
793 &cfg_file,
794 r#"
795[format.apache-common]
796regex = "^(?P<custom>\\S+)$"
797"#,
798 )
799 .unwrap();
800 let saved = std::env::var_os("HOME");
802 std::env::set_var("HOME", tmp.path());
803 let result = load_all();
804 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
805 let formats = result.unwrap();
806 let common = &formats["apache-common"];
807 assert_eq!(common.field_names, vec!["custom"], "user config should win");
808 }
809}