1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6
7use crate::config_path;
8
9#[derive(Debug)]
12pub struct LogFormat {
13 pub name: String,
14 pub regex: Regex,
15 pub field_names: Vec<String>,
18 pub display: Option<DisplayTemplate>,
22 pub record_start: Option<Regex>,
23 pub prompt: Option<crate::prompt::ParsedPrompt>,
27 pub prompt_style: Option<crate::ansi::Style>,
30 pub(crate) source: crate::config_path::ConfigSource,
31 pub(crate) overrides: Option<crate::config_path::ConfigSource>,
32}
33
34impl LogFormat {
35 pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
36 Self::compile_full(name, pattern, None, None, None)
37 }
38
39 pub fn compile_with_display(
40 name: &str,
41 pattern: &str,
42 display: Option<&str>,
43 ) -> Result<Self, String> {
44 Self::compile_full(name, pattern, display, None, None)
45 }
46
47 pub fn compile_full(
48 name: &str,
49 pattern: &str,
50 display: Option<&str>,
51 record_start: Option<&str>,
52 prompt: Option<&str>,
53 ) -> Result<Self, String> {
54 let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
55 let field_names: Vec<String> = regex
56 .capture_names()
57 .flatten()
58 .map(|s| s.to_string())
59 .collect();
60 if field_names.is_empty() {
61 return Err(format!(
62 "format `{name}`: regex must declare at least one named capture group"
63 ));
64 }
65 let display = display
66 .map(|s| {
67 DisplayTemplate::compile(s, &field_names)
68 .map_err(|e| format!("format `{name}`: display: {e}"))
69 })
70 .transpose()?;
71 let record_start = record_start
72 .map(|s| Regex::new(s).map_err(|e| format!("format `{name}`: record_start: {e}")))
73 .transpose()?;
74 let prompt = prompt
75 .map(|s| crate::prompt::ParsedPrompt::parse(s)
76 .map_err(|e| format!("format `{name}`: prompt: {e}")))
77 .transpose()?;
78 Ok(Self {
79 name: name.to_string(),
80 regex,
81 field_names,
82 display,
83 record_start,
84 prompt,
85 prompt_style: None,
86 source: crate::config_path::ConfigSource::Builtin,
87 overrides: None,
88 })
89 }
90}
91
92#[derive(Debug, Clone)]
101pub struct DisplayTemplate {
102 segments: Vec<DisplaySegment>,
103 source: String,
104}
105
106#[derive(Debug, Clone)]
107enum DisplaySegment {
108 Literal(String),
109 Field(String),
110}
111
112impl DisplayTemplate {
113 pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
114 if source.is_empty() {
115 return Err("template is empty (would render every line as nothing)".to_string());
116 }
117 let mut segments: Vec<DisplaySegment> = Vec::new();
118 let mut buf = String::new();
119 let mut chars = source.chars().peekable();
120 while let Some(c) = chars.next() {
121 match c {
122 '\\' => match chars.next() {
123 Some('<') => buf.push('<'),
124 Some('\\') => buf.push('\\'),
125 Some('n') => buf.push('\n'),
126 Some('t') => buf.push('\t'),
127 Some('r') => buf.push('\r'),
128 Some('e') => buf.push('\x1b'),
129 Some('x') => {
130 let h1 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
131 let h2 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
132 let hex: String = [h1, h2].iter().collect();
133 let byte = u8::from_str_radix(&hex, 16)
134 .map_err(|_| format!("invalid `\\x{hex}` escape"))?;
135 buf.push(byte as char);
136 }
137 Some('0') => {
138 let d1 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
139 let d2 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
140 let oct: String = ['0', d1, d2].iter().collect();
141 let byte = u8::from_str_radix(&oct, 8)
142 .map_err(|_| format!("invalid `\\{oct}` escape"))?;
143 buf.push(byte as char);
144 }
145 Some(other) => {
146 buf.push('\\');
150 buf.push(other);
151 }
152 None => return Err("template ends with a lone `\\`".to_string()),
153 },
154 '<' => {
155 if !buf.is_empty() {
156 segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
157 }
158 let mut name = String::new();
159 let mut closed = false;
160 while let Some(&nc) = chars.peek() {
161 chars.next();
162 if nc == '>' { closed = true; break; }
163 name.push(nc);
164 }
165 if !closed {
166 return Err(format!("unterminated `<` (expected `<{name}>`)"));
167 }
168 if name.is_empty() {
169 return Err("empty field reference `<>`".to_string());
170 }
171 if !field_names.iter().any(|n| n == &name) {
172 return Err(format!(
173 "unknown field `{name}` (available: {})",
174 field_names.join(", ")
175 ));
176 }
177 segments.push(DisplaySegment::Field(name));
178 }
179 _ => buf.push(c),
180 }
181 }
182 if !buf.is_empty() {
183 segments.push(DisplaySegment::Literal(buf));
184 }
185 Ok(Self { segments, source: source.to_string() })
186 }
187
188 pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
191 let mut out = String::new();
192 for seg in &self.segments {
193 match seg {
194 DisplaySegment::Literal(s) => out.push_str(s),
195 DisplaySegment::Field(name) => {
196 if let Some(v) = lookup(name) { out.push_str(&v); }
197 }
198 }
199 }
200 out
201 }
202
203 pub fn source(&self) -> &str { &self.source }
204}
205
206#[derive(Debug, Clone)]
209pub struct DisplayRenderer {
210 template: DisplayTemplate,
211 regex: Regex,
212}
213
214impl DisplayRenderer {
215 pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
216 Self { template, regex }
217 }
218
219 pub fn template(&self) -> &DisplayTemplate { &self.template }
220
221 pub fn render_line(&self, line: &[u8]) -> Option<String> {
225 let s = std::str::from_utf8(line).ok()?;
226 let caps = self.regex.captures(s)?;
227 Some(self.template.render(|name| {
228 caps.name(name).map(|m| m.as_str().to_string())
229 }))
230 }
231}
232
233#[derive(Debug, Default, Deserialize)]
246struct UserConfig {
247 #[serde(default)]
248 format: HashMap<String, FormatEntry>,
249 #[serde(default)]
250 group: HashMap<String, GroupEntry>,
251}
252
253#[derive(Debug, Deserialize)]
254struct FormatEntry {
255 regex: String,
256 #[serde(default)]
257 display: Option<String>,
258 #[serde(default)]
259 record_start: Option<String>,
260 #[serde(default)]
261 prompt: Option<String>,
262 #[serde(default)]
266 prompt_style: Option<String>,
267}
268
269#[derive(Debug, Deserialize, Default)]
272struct GroupEntry {
273 format: Option<String>,
274 file: Option<String>,
275 follow: Option<bool>,
276 tail: Option<usize>,
277 head: Option<usize>,
278 dim: Option<bool>,
279 line_numbers: Option<bool>,
280 chop: Option<bool>,
281 tab_width: Option<u8>,
282 #[serde(default)]
283 filter: Vec<String>,
284 #[serde(default)]
285 grep: Vec<String>,
286}
287
288#[derive(Debug, Clone, Default)]
292pub struct Group {
293 pub name: String,
294 pub format: Option<String>,
295 pub file: Option<String>,
296 pub follow: bool,
297 pub tail: Option<usize>,
298 pub head: Option<usize>,
299 pub dim: bool,
300 pub line_numbers: bool,
301 pub chop: bool,
302 pub tab_width: Option<u8>,
303 pub filter: Vec<String>,
304 pub grep: Vec<String>,
305 pub(crate) source: crate::config_path::ConfigSource,
306 pub(crate) overrides: Option<crate::config_path::ConfigSource>,
307}
308
309const RESERVED_LONG_FLAGS: &[&str] = &[
312 "format",
313 "filter",
314 "grep",
315 "dim",
316 "head",
317 "tail",
318 "follow",
319 "LINE-NUMBERS",
320 "chop-long-lines",
321 "tab-width",
322 "list-formats",
323 "live",
324 "manual",
325 "examples",
326 "prettify",
327 "content-type",
328 "help",
329 "version",
330 "record-start",
331 "hex",
332 "prompt",
333 "preprocess",
334 "no-preprocess",
335 "no-color",
336 "raw-control-chars",
337 "tag",
338 "tag-file",
339];
340
341const BUILTINS: &[(&str, &str)] = &[
344 (
345 "apache-common",
346 r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
347 ),
348 (
349 "apache-combined",
350 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>[^"]*)"$"#,
351 ),
352 (
353 "nginx-combined",
354 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>[^"]*)"$"#,
355 ),
356];
357
358fn formats_path_in(dir: &std::path::Path) -> PathBuf {
359 dir.join("formats.toml")
360}
361
362#[derive(Debug, Default)]
365struct LayeredConfig {
366 global: UserConfig,
367 local: UserConfig,
368}
369
370fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
371 let text = std::fs::read_to_string(path)
372 .map_err(|e| format!("reading {}: {e}", path.display()))?;
373 toml::from_str(&text)
374 .map_err(|e| format!("parsing {}: {e}", path.display()))
375}
376
377fn load_layered_config() -> Result<LayeredConfig, String> {
378 let mut layered = LayeredConfig::default();
379
380 if let Some(dir) = config_path::global_config_dir() {
382 let path = formats_path_in(&dir);
383 if path.exists() {
384 match read_formats_toml(&path) {
385 Ok(cfg) => layered.global = cfg,
386 Err(e) => eprintln!(
387 "tess: warning: {e}; ignoring global config"
388 ),
389 }
390 }
391 }
392
393 if let Some(dir) = config_path::user_config_dir() {
395 let path = formats_path_in(&dir);
396 if path.exists() {
397 layered.local = read_formats_toml(&path)?;
398 }
399 }
400
401 Ok(layered)
402}
403
404struct FormatSource {
405 regex: String,
406 display: Option<String>,
407 record_start: Option<String>,
408 prompt: Option<String>,
409 prompt_style: Option<String>,
410 source: crate::config_path::ConfigSource,
411 overrides: Option<crate::config_path::ConfigSource>,
412}
413
414fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
415 let cfg = load_layered_config()?;
416 let mut out: HashMap<String, FormatSource> = HashMap::new();
417 for (k, v) in cfg.global.format {
418 out.insert(k, FormatSource {
419 regex: v.regex,
420 display: v.display,
421 record_start: v.record_start,
422 prompt: v.prompt,
423 prompt_style: v.prompt_style,
424 source: crate::config_path::ConfigSource::Global,
425 overrides: None,
426 });
427 }
428 for (k, v) in cfg.local.format {
429 let overrides = out.get(&k).map(|prev| prev.source);
430 out.insert(k, FormatSource {
431 regex: v.regex,
432 display: v.display,
433 record_start: v.record_start,
434 prompt: v.prompt,
435 prompt_style: v.prompt_style,
436 source: crate::config_path::ConfigSource::Local,
437 overrides,
438 });
439 }
440 Ok(out)
441}
442
443pub fn load_groups() -> Result<HashMap<String, Group>, String> {
447 let cfg = load_layered_config()?;
448
449 struct StagedGroup {
450 entry: GroupEntry,
451 source: crate::config_path::ConfigSource,
452 overrides: Option<crate::config_path::ConfigSource>,
453 }
454
455 let mut staged: HashMap<String, StagedGroup> = HashMap::new();
456 for (k, v) in cfg.global.group {
457 staged.insert(k, StagedGroup {
458 entry: v,
459 source: crate::config_path::ConfigSource::Global,
460 overrides: None,
461 });
462 }
463 for (k, v) in cfg.local.group {
464 let overrides = staged.get(&k).map(|prev| prev.source);
465 staged.insert(k, StagedGroup {
466 entry: v,
467 source: crate::config_path::ConfigSource::Local,
468 overrides,
469 });
470 }
471
472 let mut out = HashMap::with_capacity(staged.len());
473 for (name, sg) in staged {
474 if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
475 return Err(format!(
476 "group `{name}`: name collides with built-in --{name} flag"
477 ));
478 }
479 out.insert(
480 name.clone(),
481 Group {
482 name,
483 format: sg.entry.format,
484 file: sg.entry.file,
485 follow: sg.entry.follow.unwrap_or(false),
486 tail: sg.entry.tail,
487 head: sg.entry.head,
488 dim: sg.entry.dim.unwrap_or(false),
489 line_numbers: sg.entry.line_numbers.unwrap_or(false),
490 chop: sg.entry.chop.unwrap_or(false),
491 tab_width: sg.entry.tab_width,
492 filter: sg.entry.filter,
493 grep: sg.entry.grep,
494 source: sg.source,
495 overrides: sg.overrides,
496 },
497 );
498 }
499 Ok(out)
500}
501
502pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
506 let mut sources: HashMap<String, FormatSource> = HashMap::new();
507 for (name, pat) in BUILTINS {
508 sources.insert(name.to_string(), FormatSource {
509 regex: pat.to_string(),
510 display: None,
511 record_start: None,
512 prompt: None,
513 prompt_style: None,
514 source: crate::config_path::ConfigSource::Builtin,
515 overrides: None,
516 });
517 }
518 let user = load_user_formats()?;
519 for (name, mut src) in user {
520 if src.overrides.is_none() && sources.contains_key(&name) {
524 src.overrides = Some(crate::config_path::ConfigSource::Builtin);
525 }
526 sources.insert(name, src);
527 }
528 let mut compiled = HashMap::new();
529 for (name, src) in sources {
530 let mut fmt = LogFormat::compile_full(
531 &name,
532 &src.regex,
533 src.display.as_deref(),
534 src.record_start.as_deref(),
535 src.prompt.as_deref(),
536 )?;
537 if let Some(spec) = src.prompt_style.as_deref() {
538 fmt.prompt_style = Some(
539 crate::style_spec::parse(spec)
540 .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
541 );
542 }
543 fmt.source = src.source;
544 fmt.overrides = src.overrides;
545 compiled.insert(name, fmt);
546 }
547 Ok(compiled)
548}
549
550const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
563 "--format",
564 "--filter",
565 "--grep",
566 "--head",
567 "--tail",
568 "--tab-width",
569 "--record-start",
570];
571
572pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
573 if argv.is_empty() {
574 return argv;
575 }
576 let mut out = Vec::with_capacity(argv.len() * 2);
577 let mut iter = argv.into_iter();
578 out.push(iter.next().unwrap()); let mut filter_mode = false;
580 let mut pass_next = false;
581 for arg in iter {
582 if pass_next {
583 pass_next = false;
584 out.push(arg);
585 continue;
586 }
587 if let Some(name) = arg.strip_prefix("--") {
588 if !name.contains('=') {
591 if let Some(g) = groups.get(name) {
592 expand_group(g, &mut out);
593 filter_mode = true;
594 continue;
595 }
596 if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
597 out.push(arg);
600 pass_next = true;
601 continue;
602 }
603 }
604 }
605 if filter_mode && !arg.starts_with('-') {
606 out.push("--filter".into());
607 out.push(arg);
608 continue;
609 }
610 out.push(arg);
611 }
612 out
613}
614
615fn expand_group(g: &Group, out: &mut Vec<String>) {
616 if let Some(format) = &g.format {
617 out.push("--format".into());
618 out.push(format.clone());
619 }
620 if g.follow {
621 out.push("--follow".into());
622 }
623 if let Some(t) = g.tail {
624 out.push("--tail".into());
625 out.push(t.to_string());
626 }
627 if let Some(h) = g.head {
628 out.push("--head".into());
629 out.push(h.to_string());
630 }
631 if g.dim {
632 out.push("--dim".into());
633 }
634 if g.line_numbers {
635 out.push("-N".into());
636 }
637 if g.chop {
638 out.push("-S".into());
639 }
640 if let Some(t) = g.tab_width {
641 out.push("--tab-width".into());
642 out.push(t.to_string());
643 }
644 for f in &g.filter {
645 out.push("--filter".into());
646 out.push(f.clone());
647 }
648 for g_pat in &g.grep {
649 out.push("--grep".into());
650 out.push(g_pat.clone());
651 }
652 if let Some(file) = &g.file {
653 out.push(file.clone());
654 }
655}
656
657fn format_source_label(
660 source: crate::config_path::ConfigSource,
661 overrides: Option<crate::config_path::ConfigSource>,
662) -> String {
663 use crate::config_path::ConfigSource::*;
664 let layer = match source {
665 Builtin => "built-in",
666 Global => "global",
667 Local => "local",
668 };
669 match overrides {
670 None => format!("[{layer}]"),
671 Some(Builtin) => format!("[{layer}, overrides built-in]"),
672 Some(Global) => format!("[{layer}, overrides global]"),
673 Some(Local) => format!("[{layer}, overrides local]"),
676 }
677}
678
679pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
682 let mut names: Vec<&String> = formats.keys().collect();
683 names.sort();
684
685 let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
687
688 for name in names {
689 let fmt = &formats[name];
690 let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
691 let label = format_source_label(fmt.source, fmt.overrides);
692 println!(
693 "{:<width$} {} {}",
694 name,
695 label,
696 fields.join(", "),
697 width = name_width
698 );
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use std::sync::Mutex;
706
707 static HOME_LOCK: Mutex<()> = Mutex::new(());
710
711 #[test]
712 fn builtins_all_compile() {
713 for (name, pat) in BUILTINS {
714 LogFormat::compile(name, pat)
715 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
716 }
717 }
718
719 fn fields() -> Vec<String> {
722 vec!["ts".into(), "level".into(), "msg".into()]
723 }
724
725 #[test]
726 fn display_template_compiles_basic() {
727 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
728 assert_eq!(t.source(), "[<ts>] <level> <msg>");
729 }
730
731 #[test]
732 fn display_template_renders_substitutions() {
733 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
734 let mut map = std::collections::HashMap::new();
735 map.insert("level".to_string(), "ERROR".to_string());
736 map.insert("msg".to_string(), "boom".to_string());
737 let out = t.render(|n| map.get(n).cloned());
738 assert_eq!(out, "ERROR: boom");
739 }
740
741 #[test]
742 fn display_template_missing_field_renders_empty() {
743 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
744 let mut map = std::collections::HashMap::new();
745 map.insert("level".to_string(), "ERROR".to_string());
746 let out = t.render(|n| map.get(n).cloned());
748 assert_eq!(out, "ERROR:");
749 }
750
751 #[test]
752 fn display_template_escape_sequences() {
753 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
756 let mut map = std::collections::HashMap::new();
757 map.insert("level".to_string(), "X".to_string());
758 let out = t.render(|n| map.get(n).cloned());
759 assert_eq!(out, "<not a field> X");
760 }
761
762 #[test]
763 fn display_template_escape_backslash() {
764 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
765 let mut map = std::collections::HashMap::new();
766 map.insert("level".to_string(), "X".to_string());
767 let out = t.render(|n| map.get(n).cloned());
768 assert_eq!(out, r"a\b X");
769 }
770
771 #[test]
772 fn display_template_escape_e_emits_esc() {
773 let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
774 let mut map = std::collections::HashMap::new();
775 map.insert("level".to_string(), "X".to_string());
776 let out = t.render(|n| map.get(n).cloned());
777 assert_eq!(out, "\x1b[31mX\x1b[0m");
778 }
779
780 #[test]
781 fn display_template_escape_x1b_emits_esc() {
782 let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
783 let out = t.render(|_| Some("Y".to_string()));
784 assert_eq!(out, "\x1b[1mY");
785 }
786
787 #[test]
788 fn display_template_escape_octal_emits_esc() {
789 let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
790 let out = t.render(|_| Some("Z".to_string()));
791 assert_eq!(out, "\x1b[1mZ");
792 }
793
794 #[test]
795 fn display_template_escape_n_t_r() {
796 let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
797 let out = t.render(|_| Some("Q".to_string()));
798 assert_eq!(out, "\n\t\rQ");
799 }
800
801 #[test]
802 fn display_template_escape_unknown_preserves_backslash() {
803 let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
804 let out = t.render(|_| Some("Q".to_string()));
805 assert_eq!(out, r"\qQ");
806 }
807
808 #[test]
809 fn display_template_escape_x_incomplete_errors() {
810 let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
811 assert!(err.contains("incomplete"), "{err}");
812 }
813
814 #[test]
815 fn display_template_escape_invalid_hex_errors() {
816 let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
817 assert!(err.contains("invalid"), "{err}");
818 }
819
820 #[test]
821 fn display_template_rejects_empty() {
822 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
823 assert!(err.contains("empty"), "{err}");
824 }
825
826 #[test]
827 fn display_template_rejects_unknown_field() {
828 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
829 assert!(err.contains("unknown field"), "{err}");
830 }
831
832 #[test]
833 fn display_template_rejects_unterminated() {
834 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
835 assert!(err.contains("unterminated"), "{err}");
836 }
837
838 #[test]
839 fn display_template_rejects_empty_ref() {
840 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
841 assert!(err.contains("empty field reference"), "{err}");
842 }
843
844 #[test]
845 fn apache_common_extracts_fields() {
846 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
847 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
848 let caps = fmt.regex.captures(line).expect("should match");
849 assert_eq!(&caps["ip"], "127.0.0.1");
850 assert_eq!(&caps["user"], "alice");
851 assert_eq!(&caps["method"], "GET");
852 assert_eq!(&caps["url"], "/index.html");
853 assert_eq!(&caps["status"], "200");
854 assert_eq!(&caps["size"], "2326");
855 }
856
857 #[test]
858 fn apache_combined_extracts_referer_and_agent() {
859 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
860 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""#;
861 let caps = fmt.regex.captures(line).expect("should match");
862 assert_eq!(&caps["status"], "401");
863 assert_eq!(&caps["url"], "/api/login");
864 assert_eq!(&caps["referer"], "https://example.com/");
865 assert_eq!(&caps["agent"], "Mozilla/5.0");
866 }
867
868 #[test]
869 fn field_names_listed_in_order() {
870 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
871 assert_eq!(
872 fmt.field_names,
873 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
874 );
875 }
876
877 #[test]
878 fn compile_rejects_regex_without_named_groups() {
879 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
880 assert!(err.contains("at least one named capture"), "{err}");
881 }
882
883 #[test]
884 fn compile_rejects_invalid_regex() {
885 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
886 assert!(err.contains("bad"), "{err}");
887 }
888
889 #[test]
890 fn load_groups_reads_user_config() {
891 let _g = HOME_LOCK.lock().unwrap();
892 let tmp = tempfile::tempdir().unwrap();
893 let cfg_dir = tmp.path().join(".config").join("tess");
894 std::fs::create_dir_all(&cfg_dir).unwrap();
895 std::fs::write(
896 cfg_dir.join("formats.toml"),
897 r#"
898[group.errorlog]
899format = "apache-combined"
900file = "/var/log/access.log"
901follow = true
902tail = 1000
903filter = ["status~^5"]
904
905[group.minimal]
906file = "/tmp/x.log"
907"#,
908 )
909 .unwrap();
910 let saved = std::env::var_os("HOME");
911 std::env::set_var("HOME", tmp.path());
912 let result = load_groups();
913 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
914 let groups = result.unwrap();
915 let err = &groups["errorlog"];
916 assert_eq!(err.format.as_deref(), Some("apache-combined"));
917 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
918 assert!(err.follow);
919 assert_eq!(err.tail, Some(1000));
920 assert_eq!(err.filter, vec!["status~^5".to_string()]);
921 let min = &groups["minimal"];
922 assert!(!min.follow);
923 assert!(min.tail.is_none());
924 assert_eq!(min.filter, Vec::<String>::new());
925 }
926
927 fn group(name: &str) -> Group {
928 Group { name: name.into(), ..Group::default() }
929 }
930
931 fn argv(parts: &[&str]) -> Vec<String> {
932 parts.iter().map(|s| s.to_string()).collect()
933 }
934
935 #[test]
936 fn expand_argv_passes_through_when_no_group_matches() {
937 let groups: HashMap<String, Group> = HashMap::new();
938 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
939 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
940 }
941
942 #[test]
943 fn expand_argv_inserts_group_flags_and_file() {
944 let mut groups: HashMap<String, Group> = HashMap::new();
945 groups.insert(
946 "errorlog".into(),
947 Group {
948 name: "errorlog".into(),
949 format: Some("apache-combined".into()),
950 file: Some("/var/log/access.log".into()),
951 follow: true,
952 tail: Some(1000),
953 filter: vec!["status~^5".into()],
954 ..Group::default()
955 },
956 );
957 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
958 assert_eq!(
959 out,
960 argv(&[
961 "tess",
962 "--format", "apache-combined",
963 "--follow",
964 "--tail", "1000",
965 "--filter", "status~^5",
966 "/var/log/access.log",
967 ])
968 );
969 }
970
971 #[test]
972 fn expand_argv_converts_positionals_to_filters_after_group() {
973 let mut groups: HashMap<String, Group> = HashMap::new();
974 groups.insert(
975 "errorlog".into(),
976 Group {
977 name: "errorlog".into(),
978 format: Some("apache-combined".into()),
979 file: Some("/log".into()),
980 ..Group::default()
981 },
982 );
983 let out = expand_argv(
984 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
985 &groups,
986 );
987 assert_eq!(
988 out,
989 argv(&[
990 "tess",
991 "--format", "apache-combined",
992 "/log",
993 "--filter", "msg~test",
994 "--filter", "url~/api/",
995 ])
996 );
997 }
998
999 #[test]
1000 fn expand_argv_leaves_flags_alone_after_group() {
1001 let mut groups: HashMap<String, Group> = HashMap::new();
1002 groups.insert("errorlog".into(), group("errorlog"));
1003 let out = expand_argv(
1004 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1005 &groups,
1006 );
1007 assert_eq!(
1009 out,
1010 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1011 );
1012 }
1013
1014 #[test]
1015 fn expand_argv_user_flag_after_group_can_override_tail() {
1016 let mut groups: HashMap<String, Group> = HashMap::new();
1019 groups.insert(
1020 "errorlog".into(),
1021 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1022 );
1023 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1024 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1026 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1027 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1028 let pos_50 = out.iter().position(|x| x == "50").unwrap();
1029 assert!(pos_1000 < pos_50, "user's value must come after group's");
1030 }
1031
1032 #[test]
1033 fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1034 let mut groups: HashMap<String, Group> = HashMap::new();
1035 groups.insert("errorlog".into(), group("errorlog"));
1036 let out = expand_argv(
1037 argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1038 &groups,
1039 );
1040 assert_eq!(
1042 out,
1043 argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1044 );
1045 }
1046
1047 #[test]
1048 fn expand_argv_unknown_double_dash_passes_through() {
1049 let groups: HashMap<String, Group> = HashMap::new();
1050 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1051 assert_eq!(out, argv(&["tess", "--unknown"]));
1052 }
1053
1054 #[test]
1055 fn load_groups_rejects_reserved_name() {
1056 let _g = HOME_LOCK.lock().unwrap();
1057 let tmp = tempfile::tempdir().unwrap();
1058 let cfg_dir = tmp.path().join(".config").join("tess");
1059 std::fs::create_dir_all(&cfg_dir).unwrap();
1060 std::fs::write(
1061 cfg_dir.join("formats.toml"),
1062 r#"
1063[group.follow]
1064file = "/x.log"
1065"#,
1066 )
1067 .unwrap();
1068 let saved = std::env::var_os("HOME");
1069 std::env::set_var("HOME", tmp.path());
1070 let result = load_groups();
1071 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1072 let err = result.unwrap_err();
1073 assert!(err.contains("collides with built-in --follow"), "{err}");
1074 }
1075
1076 #[test]
1077 fn user_config_overrides_builtin_via_load_all() {
1078 let _g = HOME_LOCK.lock().unwrap();
1079 let tmp = tempfile::tempdir().unwrap();
1081 let cfg_dir = tmp.path().join(".config").join("tess");
1082 std::fs::create_dir_all(&cfg_dir).unwrap();
1083 let cfg_file = cfg_dir.join("formats.toml");
1084 std::fs::write(
1085 &cfg_file,
1086 r#"
1087[format.apache-common]
1088regex = "^(?P<custom>\\S+)$"
1089"#,
1090 )
1091 .unwrap();
1092 let saved = std::env::var_os("HOME");
1094 std::env::set_var("HOME", tmp.path());
1095 let result = load_all();
1096 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1097 let formats = result.unwrap();
1098 let common = &formats["apache-common"];
1099 assert_eq!(common.field_names, vec!["custom"], "user config should win");
1100 }
1101
1102 #[test]
1103 fn format_entry_parses_record_start() {
1104 let toml_text = r#"
1105 [format.myapp]
1106 regex = '^(?P<line>.*)$'
1107 record_start = '^\['
1108 "#;
1109 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1110 let entry = cfg.format.get("myapp").expect("myapp present");
1111 assert_eq!(entry.regex, "^(?P<line>.*)$");
1112 assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1113 }
1114
1115 #[test]
1116 fn format_entry_record_start_optional() {
1117 let toml_text = r#"
1118 [format.myapp]
1119 regex = '^(?P<line>.*)$'
1120 "#;
1121 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1122 let entry = cfg.format.get("myapp").expect("myapp present");
1123 assert!(entry.record_start.is_none());
1124 }
1125
1126 #[test]
1127 fn layered_loader_local_overrides_global() {
1128 let _guard = HOME_LOCK.lock().unwrap();
1129 let prev_home = std::env::var_os("HOME");
1130 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1131
1132 let home = tempfile::tempdir().unwrap();
1133 let global = tempfile::tempdir().unwrap();
1134
1135 std::env::set_var("HOME", home.path());
1136 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1137
1138 std::fs::write(
1139 global.path().join("formats.toml"),
1140 r#"
1141[format.shared]
1142regex = "^GLOBAL (?P<msg>.+)$"
1143
1144[format.both]
1145regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1146"#,
1147 )
1148 .unwrap();
1149
1150 let cfg_dir = home.path().join(".config").join("tess");
1151 std::fs::create_dir_all(&cfg_dir).unwrap();
1152 std::fs::write(
1153 cfg_dir.join("formats.toml"),
1154 r#"
1155[format.both]
1156regex = "^LOCAL_BOTH (?P<msg>.+)$"
1157
1158[format.local-only]
1159regex = "^LOCAL (?P<msg>.+)$"
1160"#,
1161 )
1162 .unwrap();
1163
1164 let cfg = load_layered_config().unwrap();
1165
1166 assert!(cfg.global.format.contains_key("shared"));
1168 assert!(!cfg.local.format.contains_key("shared"));
1169
1170 assert_eq!(
1174 cfg.global.format.get("both").unwrap().regex,
1175 "^GLOBAL_BOTH (?P<msg>.+)$"
1176 );
1177 assert_eq!(
1178 cfg.local.format.get("both").unwrap().regex,
1179 "^LOCAL_BOTH (?P<msg>.+)$"
1180 );
1181
1182 assert!(cfg.local.format.contains_key("local-only"));
1184
1185 match prev_home {
1186 Some(v) => std::env::set_var("HOME", v),
1187 None => std::env::remove_var("HOME"),
1188 }
1189 match prev_global {
1190 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1191 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1192 }
1193 }
1194
1195 #[test]
1196 fn layered_loader_warns_on_bad_global_toml() {
1197 let _guard = HOME_LOCK.lock().unwrap();
1198 let prev_home = std::env::var_os("HOME");
1199 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1200
1201 let home = tempfile::tempdir().unwrap();
1202 let global = tempfile::tempdir().unwrap();
1203
1204 std::env::set_var("HOME", home.path());
1205 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1206
1207 std::fs::write(
1208 global.path().join("formats.toml"),
1209 "this is not valid toml = = =",
1210 )
1211 .unwrap();
1212
1213 let cfg = load_layered_config().unwrap();
1215 assert!(cfg.global.format.is_empty());
1216 assert!(cfg.global.group.is_empty());
1217
1218 match prev_home {
1219 Some(v) => std::env::set_var("HOME", v),
1220 None => std::env::remove_var("HOME"),
1221 }
1222 match prev_global {
1223 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1224 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1225 }
1226 }
1227
1228 #[test]
1229 fn layered_loader_fails_on_bad_local_toml() {
1230 let _guard = HOME_LOCK.lock().unwrap();
1231 let prev_home = std::env::var_os("HOME");
1232 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1233
1234 let home = tempfile::tempdir().unwrap();
1235 std::env::set_var("HOME", home.path());
1236 std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1237
1238 let cfg_dir = home.path().join(".config").join("tess");
1239 std::fs::create_dir_all(&cfg_dir).unwrap();
1240 std::fs::write(
1241 cfg_dir.join("formats.toml"),
1242 "this is not valid toml = = =",
1243 )
1244 .unwrap();
1245
1246 let err = load_layered_config().unwrap_err();
1247 assert!(err.contains("formats.toml"), "got: {err}");
1248
1249 match prev_home {
1250 Some(v) => std::env::set_var("HOME", v),
1251 None => std::env::remove_var("HOME"),
1252 }
1253 match prev_global {
1254 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1255 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1256 }
1257 }
1258
1259 #[test]
1260 fn log_format_compile_full_with_record_start() {
1261 let fmt = LogFormat::compile_full(
1262 "test",
1263 r"^(?P<msg>.+)$",
1264 None,
1265 Some(r"^\["),
1266 None,
1267 ).expect("compile");
1268 assert!(fmt.record_start.is_some());
1269 assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1270 assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
1271 }
1272
1273 #[test]
1274 fn log_format_compile_full_bad_record_start_errors() {
1275 let err = LogFormat::compile_full(
1276 "test",
1277 r"^(?P<msg>.+)$",
1278 None,
1279 Some(r"["), None,
1281 ).expect_err("should fail");
1282 assert!(err.contains("record_start"), "error mentions record_start: {err}");
1283 }
1284
1285 #[test]
1286 fn group_with_grep_field_deserializes() {
1287 let toml_text = r#"
1288 [group.errorlog]
1289 format = "app"
1290 grep = ["timeout", "deadlock"]
1291 "#;
1292 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1293 let entry = cfg.group.get("errorlog").expect("errorlog present");
1294 assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1295 }
1296
1297 #[test]
1298 fn expand_argv_emits_group_grep_flags() {
1299 let mut groups = HashMap::new();
1300 groups.insert("errorlog".to_string(), Group {
1301 name: "errorlog".to_string(),
1302 grep: vec!["timeout".to_string(), "deadlock".to_string()],
1303 ..Default::default()
1304 });
1305 let out = expand_argv(
1306 argv(&["tess", "--errorlog", "logs.txt"]),
1307 &groups,
1308 );
1309 let joined = out.join(" ");
1310 assert!(joined.contains("--grep timeout"), "got: {joined}");
1311 assert!(joined.contains("--grep deadlock"), "got: {joined}");
1312 }
1313
1314 #[test]
1315 fn user_grep_after_group_accumulates() {
1316 let mut groups = HashMap::new();
1317 groups.insert("errorlog".to_string(), Group {
1318 name: "errorlog".to_string(),
1319 grep: vec!["timeout".to_string()],
1320 ..Default::default()
1321 });
1322 let out = expand_argv(
1323 argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1324 &groups,
1325 );
1326 let joined = out.join(" ");
1327 assert!(joined.contains("--grep timeout"));
1328 assert!(joined.contains("--grep extra"));
1329 }
1330
1331 #[test]
1332 fn format_entry_parses_prompt() {
1333 let toml_text = r#"
1334 [format.myapp]
1335 regex = '^(?P<line>.*)$'
1336 prompt = '<label> <pct>%'
1337 "#;
1338 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1339 let entry = cfg.format.get("myapp").expect("myapp present");
1340 assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1341 }
1342
1343 #[test]
1344 fn load_all_tags_source_correctly() {
1345 let _guard = HOME_LOCK.lock().unwrap();
1346 let prev_home = std::env::var_os("HOME");
1347 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1348
1349 let home = tempfile::tempdir().unwrap();
1350 let global = tempfile::tempdir().unwrap();
1351
1352 std::env::set_var("HOME", home.path());
1353 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1354
1355 std::fs::write(
1356 global.path().join("formats.toml"),
1357 r#"
1358[format.global-only]
1359regex = "^G (?P<msg>.+)$"
1360
1361[format.both]
1362regex = "^GLOBAL (?P<msg>.+)$"
1363"#,
1364 )
1365 .unwrap();
1366
1367 let cfg_dir = home.path().join(".config").join("tess");
1368 std::fs::create_dir_all(&cfg_dir).unwrap();
1369 std::fs::write(
1370 cfg_dir.join("formats.toml"),
1371 r#"
1372[format.local-only]
1373regex = "^L (?P<msg>.+)$"
1374
1375[format.both]
1376regex = "^LOCAL (?P<msg>.+)$"
1377"#,
1378 )
1379 .unwrap();
1380
1381 let all = load_all().unwrap();
1382
1383 assert_eq!(
1385 all["apache-common"].source,
1386 crate::config_path::ConfigSource::Builtin
1387 );
1388 assert!(all["apache-common"].overrides.is_none());
1389
1390 assert_eq!(
1392 all["global-only"].source,
1393 crate::config_path::ConfigSource::Global
1394 );
1395 assert!(all["global-only"].overrides.is_none());
1396
1397 assert_eq!(
1399 all["local-only"].source,
1400 crate::config_path::ConfigSource::Local
1401 );
1402 assert!(all["local-only"].overrides.is_none());
1403
1404 assert_eq!(
1406 all["both"].source,
1407 crate::config_path::ConfigSource::Local
1408 );
1409 assert_eq!(
1410 all["both"].overrides,
1411 Some(crate::config_path::ConfigSource::Global)
1412 );
1413
1414 match prev_home {
1415 Some(v) => std::env::set_var("HOME", v),
1416 None => std::env::remove_var("HOME"),
1417 }
1418 match prev_global {
1419 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1420 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1421 }
1422 }
1423
1424 #[test]
1425 fn source_label_renders_correctly() {
1426 use crate::config_path::ConfigSource;
1427 assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1428 assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1429 assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1430 assert_eq!(
1431 format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1432 "[local, overrides global]"
1433 );
1434 assert_eq!(
1435 format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1436 "[local, overrides built-in]"
1437 );
1438 assert_eq!(
1439 format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1440 "[global, overrides built-in]"
1441 );
1442 }
1443}