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 #[allow(dead_code)]
309 pub(crate) source: crate::config_path::ConfigSource,
310 #[allow(dead_code)]
311 pub(crate) overrides: Option<crate::config_path::ConfigSource>,
312}
313
314const RESERVED_LONG_FLAGS: &[&str] = &[
317 "format",
318 "filter",
319 "grep",
320 "dim",
321 "head",
322 "tail",
323 "follow",
324 "LINE-NUMBERS",
325 "chop-long-lines",
326 "tab-width",
327 "list-formats",
328 "live",
329 "manual",
330 "examples",
331 "prettify",
332 "content-type",
333 "help",
334 "version",
335 "record-start",
336 "hex",
337 "prompt",
338 "preprocess",
339 "no-preprocess",
340 "no-color",
341 "raw-control-chars",
342 "tag",
343 "tag-file",
344];
345
346const BUILTINS: &[(&str, &str)] = &[
349 (
350 "apache-common",
351 r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
352 ),
353 (
354 "apache-combined",
355 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>[^"]*)"$"#,
356 ),
357 (
358 "nginx-combined",
359 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>[^"]*)"$"#,
360 ),
361];
362
363fn formats_path_in(dir: &std::path::Path) -> PathBuf {
364 dir.join("formats.toml")
365}
366
367#[derive(Debug, Default)]
370struct LayeredConfig {
371 global: UserConfig,
372 local: UserConfig,
373}
374
375fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
376 let text = std::fs::read_to_string(path)
377 .map_err(|e| format!("reading {}: {e}", path.display()))?;
378 toml::from_str(&text)
379 .map_err(|e| format!("parsing {}: {e}", path.display()))
380}
381
382fn load_layered_config() -> Result<LayeredConfig, String> {
383 let mut layered = LayeredConfig::default();
384
385 if let Some(dir) = config_path::global_config_dir() {
387 let path = formats_path_in(&dir);
388 if path.exists() {
389 match read_formats_toml(&path) {
390 Ok(cfg) => layered.global = cfg,
391 Err(e) => eprintln!(
392 "tess: warning: {e}; ignoring global config"
393 ),
394 }
395 }
396 }
397
398 if let Some(dir) = config_path::user_config_dir() {
400 let path = formats_path_in(&dir);
401 if path.exists() {
402 layered.local = read_formats_toml(&path)?;
403 }
404 }
405
406 Ok(layered)
407}
408
409struct FormatSource {
410 regex: String,
411 display: Option<String>,
412 record_start: Option<String>,
413 prompt: Option<String>,
414 prompt_style: Option<String>,
415 source: crate::config_path::ConfigSource,
416 overrides: Option<crate::config_path::ConfigSource>,
417}
418
419fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
420 let cfg = load_layered_config()?;
421 let mut out: HashMap<String, FormatSource> = HashMap::new();
422 for (k, v) in cfg.global.format {
423 out.insert(k, FormatSource {
424 regex: v.regex,
425 display: v.display,
426 record_start: v.record_start,
427 prompt: v.prompt,
428 prompt_style: v.prompt_style,
429 source: crate::config_path::ConfigSource::Global,
430 overrides: None,
431 });
432 }
433 for (k, v) in cfg.local.format {
434 let overrides = out.get(&k).map(|prev| prev.source);
435 out.insert(k, FormatSource {
436 regex: v.regex,
437 display: v.display,
438 record_start: v.record_start,
439 prompt: v.prompt,
440 prompt_style: v.prompt_style,
441 source: crate::config_path::ConfigSource::Local,
442 overrides,
443 });
444 }
445 Ok(out)
446}
447
448pub fn load_groups() -> Result<HashMap<String, Group>, String> {
452 let cfg = load_layered_config()?;
453
454 struct StagedGroup {
455 entry: GroupEntry,
456 source: crate::config_path::ConfigSource,
457 overrides: Option<crate::config_path::ConfigSource>,
458 }
459
460 let mut staged: HashMap<String, StagedGroup> = HashMap::new();
461 for (k, v) in cfg.global.group {
462 staged.insert(k, StagedGroup {
463 entry: v,
464 source: crate::config_path::ConfigSource::Global,
465 overrides: None,
466 });
467 }
468 for (k, v) in cfg.local.group {
469 let overrides = staged.get(&k).map(|prev| prev.source);
470 staged.insert(k, StagedGroup {
471 entry: v,
472 source: crate::config_path::ConfigSource::Local,
473 overrides,
474 });
475 }
476
477 let mut out = HashMap::with_capacity(staged.len());
478 for (name, sg) in staged {
479 if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
480 return Err(format!(
481 "group `{name}`: name collides with built-in --{name} flag"
482 ));
483 }
484 out.insert(
485 name.clone(),
486 Group {
487 name,
488 format: sg.entry.format,
489 file: sg.entry.file,
490 follow: sg.entry.follow.unwrap_or(false),
491 tail: sg.entry.tail,
492 head: sg.entry.head,
493 dim: sg.entry.dim.unwrap_or(false),
494 line_numbers: sg.entry.line_numbers.unwrap_or(false),
495 chop: sg.entry.chop.unwrap_or(false),
496 tab_width: sg.entry.tab_width,
497 filter: sg.entry.filter,
498 grep: sg.entry.grep,
499 source: sg.source,
500 overrides: sg.overrides,
501 },
502 );
503 }
504 Ok(out)
505}
506
507pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
511 let mut sources: HashMap<String, FormatSource> = HashMap::new();
512 for (name, pat) in BUILTINS {
513 sources.insert(name.to_string(), FormatSource {
514 regex: pat.to_string(),
515 display: None,
516 record_start: None,
517 prompt: None,
518 prompt_style: None,
519 source: crate::config_path::ConfigSource::Builtin,
520 overrides: None,
521 });
522 }
523 let user = load_user_formats()?;
524 for (name, mut src) in user {
525 if src.overrides.is_none() && sources.contains_key(&name) {
529 src.overrides = Some(crate::config_path::ConfigSource::Builtin);
530 }
531 sources.insert(name, src);
532 }
533 let mut compiled = HashMap::new();
534 for (name, src) in sources {
535 let mut fmt = LogFormat::compile_full(
536 &name,
537 &src.regex,
538 src.display.as_deref(),
539 src.record_start.as_deref(),
540 src.prompt.as_deref(),
541 )?;
542 if let Some(spec) = src.prompt_style.as_deref() {
543 fmt.prompt_style = Some(
544 crate::style_spec::parse(spec)
545 .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
546 );
547 }
548 fmt.source = src.source;
549 fmt.overrides = src.overrides;
550 compiled.insert(name, fmt);
551 }
552 Ok(compiled)
553}
554
555const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
568 "--format",
569 "--filter",
570 "--grep",
571 "--head",
572 "--tail",
573 "--tab-width",
574 "--record-start",
575];
576
577pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
578 if argv.is_empty() {
579 return argv;
580 }
581 let mut out = Vec::with_capacity(argv.len() * 2);
582 let mut iter = argv.into_iter();
583 out.push(iter.next().unwrap()); let mut filter_mode = false;
585 let mut pass_next = false;
586 for arg in iter {
587 if pass_next {
588 pass_next = false;
589 out.push(arg);
590 continue;
591 }
592 if let Some(name) = arg.strip_prefix("--") {
593 if !name.contains('=') {
596 if let Some(g) = groups.get(name) {
597 expand_group(g, &mut out);
598 filter_mode = true;
599 continue;
600 }
601 if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
602 out.push(arg);
605 pass_next = true;
606 continue;
607 }
608 }
609 }
610 if filter_mode && !arg.starts_with('-') {
611 out.push("--filter".into());
612 out.push(arg);
613 continue;
614 }
615 out.push(arg);
616 }
617 out
618}
619
620fn expand_group(g: &Group, out: &mut Vec<String>) {
621 if let Some(format) = &g.format {
622 out.push("--format".into());
623 out.push(format.clone());
624 }
625 if g.follow {
626 out.push("--follow".into());
627 }
628 if let Some(t) = g.tail {
629 out.push("--tail".into());
630 out.push(t.to_string());
631 }
632 if let Some(h) = g.head {
633 out.push("--head".into());
634 out.push(h.to_string());
635 }
636 if g.dim {
637 out.push("--dim".into());
638 }
639 if g.line_numbers {
640 out.push("-N".into());
641 }
642 if g.chop {
643 out.push("-S".into());
644 }
645 if let Some(t) = g.tab_width {
646 out.push("--tab-width".into());
647 out.push(t.to_string());
648 }
649 for f in &g.filter {
650 out.push("--filter".into());
651 out.push(f.clone());
652 }
653 for g_pat in &g.grep {
654 out.push("--grep".into());
655 out.push(g_pat.clone());
656 }
657 if let Some(file) = &g.file {
658 out.push(file.clone());
659 }
660}
661
662fn format_source_label(
665 source: crate::config_path::ConfigSource,
666 overrides: Option<crate::config_path::ConfigSource>,
667) -> String {
668 use crate::config_path::ConfigSource::*;
669 let layer = match source {
670 Builtin => "built-in",
671 Global => "global",
672 Local => "local",
673 };
674 match overrides {
675 None => format!("[{layer}]"),
676 Some(Builtin) => format!("[{layer}, overrides built-in]"),
677 Some(Global) => format!("[{layer}, overrides global]"),
678 Some(Local) => format!("[{layer}, overrides local]"),
681 }
682}
683
684pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
687 let mut names: Vec<&String> = formats.keys().collect();
688 names.sort();
689
690 let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
692
693 for name in names {
694 let fmt = &formats[name];
695 let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
696 let label = format_source_label(fmt.source, fmt.overrides);
697 println!(
698 "{:<width$} {} {}",
699 name,
700 label,
701 fields.join(", "),
702 width = name_width
703 );
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use std::sync::Mutex;
711
712 static HOME_LOCK: Mutex<()> = Mutex::new(());
715
716 #[test]
717 fn builtins_all_compile() {
718 for (name, pat) in BUILTINS {
719 LogFormat::compile(name, pat)
720 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
721 }
722 }
723
724 fn fields() -> Vec<String> {
727 vec!["ts".into(), "level".into(), "msg".into()]
728 }
729
730 #[test]
731 fn display_template_compiles_basic() {
732 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
733 assert_eq!(t.source(), "[<ts>] <level> <msg>");
734 }
735
736 #[test]
737 fn display_template_renders_substitutions() {
738 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
739 let mut map = std::collections::HashMap::new();
740 map.insert("level".to_string(), "ERROR".to_string());
741 map.insert("msg".to_string(), "boom".to_string());
742 let out = t.render(|n| map.get(n).cloned());
743 assert_eq!(out, "ERROR: boom");
744 }
745
746 #[test]
747 fn display_template_missing_field_renders_empty() {
748 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
749 let mut map = std::collections::HashMap::new();
750 map.insert("level".to_string(), "ERROR".to_string());
751 let out = t.render(|n| map.get(n).cloned());
753 assert_eq!(out, "ERROR:");
754 }
755
756 #[test]
757 fn display_template_escape_sequences() {
758 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
761 let mut map = std::collections::HashMap::new();
762 map.insert("level".to_string(), "X".to_string());
763 let out = t.render(|n| map.get(n).cloned());
764 assert_eq!(out, "<not a field> X");
765 }
766
767 #[test]
768 fn display_template_escape_backslash() {
769 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
770 let mut map = std::collections::HashMap::new();
771 map.insert("level".to_string(), "X".to_string());
772 let out = t.render(|n| map.get(n).cloned());
773 assert_eq!(out, r"a\b X");
774 }
775
776 #[test]
777 fn display_template_escape_e_emits_esc() {
778 let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
779 let mut map = std::collections::HashMap::new();
780 map.insert("level".to_string(), "X".to_string());
781 let out = t.render(|n| map.get(n).cloned());
782 assert_eq!(out, "\x1b[31mX\x1b[0m");
783 }
784
785 #[test]
786 fn display_template_escape_x1b_emits_esc() {
787 let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
788 let out = t.render(|_| Some("Y".to_string()));
789 assert_eq!(out, "\x1b[1mY");
790 }
791
792 #[test]
793 fn display_template_escape_octal_emits_esc() {
794 let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
795 let out = t.render(|_| Some("Z".to_string()));
796 assert_eq!(out, "\x1b[1mZ");
797 }
798
799 #[test]
800 fn display_template_escape_n_t_r() {
801 let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
802 let out = t.render(|_| Some("Q".to_string()));
803 assert_eq!(out, "\n\t\rQ");
804 }
805
806 #[test]
807 fn display_template_escape_unknown_preserves_backslash() {
808 let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
809 let out = t.render(|_| Some("Q".to_string()));
810 assert_eq!(out, r"\qQ");
811 }
812
813 #[test]
814 fn display_template_escape_x_incomplete_errors() {
815 let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
816 assert!(err.contains("incomplete"), "{err}");
817 }
818
819 #[test]
820 fn display_template_escape_invalid_hex_errors() {
821 let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
822 assert!(err.contains("invalid"), "{err}");
823 }
824
825 #[test]
826 fn display_template_rejects_empty() {
827 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
828 assert!(err.contains("empty"), "{err}");
829 }
830
831 #[test]
832 fn display_template_rejects_unknown_field() {
833 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
834 assert!(err.contains("unknown field"), "{err}");
835 }
836
837 #[test]
838 fn display_template_rejects_unterminated() {
839 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
840 assert!(err.contains("unterminated"), "{err}");
841 }
842
843 #[test]
844 fn display_template_rejects_empty_ref() {
845 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
846 assert!(err.contains("empty field reference"), "{err}");
847 }
848
849 #[test]
850 fn apache_common_extracts_fields() {
851 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
852 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
853 let caps = fmt.regex.captures(line).expect("should match");
854 assert_eq!(&caps["ip"], "127.0.0.1");
855 assert_eq!(&caps["user"], "alice");
856 assert_eq!(&caps["method"], "GET");
857 assert_eq!(&caps["url"], "/index.html");
858 assert_eq!(&caps["status"], "200");
859 assert_eq!(&caps["size"], "2326");
860 }
861
862 #[test]
863 fn apache_combined_extracts_referer_and_agent() {
864 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
865 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""#;
866 let caps = fmt.regex.captures(line).expect("should match");
867 assert_eq!(&caps["status"], "401");
868 assert_eq!(&caps["url"], "/api/login");
869 assert_eq!(&caps["referer"], "https://example.com/");
870 assert_eq!(&caps["agent"], "Mozilla/5.0");
871 }
872
873 #[test]
874 fn field_names_listed_in_order() {
875 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
876 assert_eq!(
877 fmt.field_names,
878 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
879 );
880 }
881
882 #[test]
883 fn compile_rejects_regex_without_named_groups() {
884 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
885 assert!(err.contains("at least one named capture"), "{err}");
886 }
887
888 #[test]
889 fn compile_rejects_invalid_regex() {
890 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
891 assert!(err.contains("bad"), "{err}");
892 }
893
894 #[test]
895 fn load_groups_reads_user_config() {
896 let _g = HOME_LOCK.lock().unwrap();
897 let tmp = tempfile::tempdir().unwrap();
898 let cfg_dir = tmp.path().join(".config").join("tess");
899 std::fs::create_dir_all(&cfg_dir).unwrap();
900 std::fs::write(
901 cfg_dir.join("formats.toml"),
902 r#"
903[group.errorlog]
904format = "apache-combined"
905file = "/var/log/access.log"
906follow = true
907tail = 1000
908filter = ["status~^5"]
909
910[group.minimal]
911file = "/tmp/x.log"
912"#,
913 )
914 .unwrap();
915 let saved = std::env::var_os("HOME");
916 std::env::set_var("HOME", tmp.path());
917 let result = load_groups();
918 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
919 let groups = result.unwrap();
920 let err = &groups["errorlog"];
921 assert_eq!(err.format.as_deref(), Some("apache-combined"));
922 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
923 assert!(err.follow);
924 assert_eq!(err.tail, Some(1000));
925 assert_eq!(err.filter, vec!["status~^5".to_string()]);
926 let min = &groups["minimal"];
927 assert!(!min.follow);
928 assert!(min.tail.is_none());
929 assert_eq!(min.filter, Vec::<String>::new());
930 }
931
932 fn group(name: &str) -> Group {
933 Group { name: name.into(), ..Group::default() }
934 }
935
936 fn argv(parts: &[&str]) -> Vec<String> {
937 parts.iter().map(|s| s.to_string()).collect()
938 }
939
940 #[test]
941 fn expand_argv_passes_through_when_no_group_matches() {
942 let groups: HashMap<String, Group> = HashMap::new();
943 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
944 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
945 }
946
947 #[test]
948 fn expand_argv_inserts_group_flags_and_file() {
949 let mut groups: HashMap<String, Group> = HashMap::new();
950 groups.insert(
951 "errorlog".into(),
952 Group {
953 name: "errorlog".into(),
954 format: Some("apache-combined".into()),
955 file: Some("/var/log/access.log".into()),
956 follow: true,
957 tail: Some(1000),
958 filter: vec!["status~^5".into()],
959 ..Group::default()
960 },
961 );
962 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
963 assert_eq!(
964 out,
965 argv(&[
966 "tess",
967 "--format", "apache-combined",
968 "--follow",
969 "--tail", "1000",
970 "--filter", "status~^5",
971 "/var/log/access.log",
972 ])
973 );
974 }
975
976 #[test]
977 fn expand_argv_converts_positionals_to_filters_after_group() {
978 let mut groups: HashMap<String, Group> = HashMap::new();
979 groups.insert(
980 "errorlog".into(),
981 Group {
982 name: "errorlog".into(),
983 format: Some("apache-combined".into()),
984 file: Some("/log".into()),
985 ..Group::default()
986 },
987 );
988 let out = expand_argv(
989 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
990 &groups,
991 );
992 assert_eq!(
993 out,
994 argv(&[
995 "tess",
996 "--format", "apache-combined",
997 "/log",
998 "--filter", "msg~test",
999 "--filter", "url~/api/",
1000 ])
1001 );
1002 }
1003
1004 #[test]
1005 fn expand_argv_leaves_flags_alone_after_group() {
1006 let mut groups: HashMap<String, Group> = HashMap::new();
1007 groups.insert("errorlog".into(), group("errorlog"));
1008 let out = expand_argv(
1009 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1010 &groups,
1011 );
1012 assert_eq!(
1014 out,
1015 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1016 );
1017 }
1018
1019 #[test]
1020 fn expand_argv_user_flag_after_group_can_override_tail() {
1021 let mut groups: HashMap<String, Group> = HashMap::new();
1024 groups.insert(
1025 "errorlog".into(),
1026 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1027 );
1028 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1029 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1031 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1032 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1033 let pos_50 = out.iter().position(|x| x == "50").unwrap();
1034 assert!(pos_1000 < pos_50, "user's value must come after group's");
1035 }
1036
1037 #[test]
1038 fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1039 let mut groups: HashMap<String, Group> = HashMap::new();
1040 groups.insert("errorlog".into(), group("errorlog"));
1041 let out = expand_argv(
1042 argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1043 &groups,
1044 );
1045 assert_eq!(
1047 out,
1048 argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1049 );
1050 }
1051
1052 #[test]
1053 fn expand_argv_unknown_double_dash_passes_through() {
1054 let groups: HashMap<String, Group> = HashMap::new();
1055 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1056 assert_eq!(out, argv(&["tess", "--unknown"]));
1057 }
1058
1059 #[test]
1060 fn load_groups_rejects_reserved_name() {
1061 let _g = HOME_LOCK.lock().unwrap();
1062 let tmp = tempfile::tempdir().unwrap();
1063 let cfg_dir = tmp.path().join(".config").join("tess");
1064 std::fs::create_dir_all(&cfg_dir).unwrap();
1065 std::fs::write(
1066 cfg_dir.join("formats.toml"),
1067 r#"
1068[group.follow]
1069file = "/x.log"
1070"#,
1071 )
1072 .unwrap();
1073 let saved = std::env::var_os("HOME");
1074 std::env::set_var("HOME", tmp.path());
1075 let result = load_groups();
1076 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1077 let err = result.unwrap_err();
1078 assert!(err.contains("collides with built-in --follow"), "{err}");
1079 }
1080
1081 #[test]
1082 fn user_config_overrides_builtin_via_load_all() {
1083 let _g = HOME_LOCK.lock().unwrap();
1084 let tmp = tempfile::tempdir().unwrap();
1086 let cfg_dir = tmp.path().join(".config").join("tess");
1087 std::fs::create_dir_all(&cfg_dir).unwrap();
1088 let cfg_file = cfg_dir.join("formats.toml");
1089 std::fs::write(
1090 &cfg_file,
1091 r#"
1092[format.apache-common]
1093regex = "^(?P<custom>\\S+)$"
1094"#,
1095 )
1096 .unwrap();
1097 let saved = std::env::var_os("HOME");
1099 std::env::set_var("HOME", tmp.path());
1100 let result = load_all();
1101 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1102 let formats = result.unwrap();
1103 let common = &formats["apache-common"];
1104 assert_eq!(common.field_names, vec!["custom"], "user config should win");
1105 }
1106
1107 #[test]
1108 fn format_entry_parses_record_start() {
1109 let toml_text = r#"
1110 [format.myapp]
1111 regex = '^(?P<line>.*)$'
1112 record_start = '^\['
1113 "#;
1114 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1115 let entry = cfg.format.get("myapp").expect("myapp present");
1116 assert_eq!(entry.regex, "^(?P<line>.*)$");
1117 assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1118 }
1119
1120 #[test]
1121 fn format_entry_record_start_optional() {
1122 let toml_text = r#"
1123 [format.myapp]
1124 regex = '^(?P<line>.*)$'
1125 "#;
1126 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1127 let entry = cfg.format.get("myapp").expect("myapp present");
1128 assert!(entry.record_start.is_none());
1129 }
1130
1131 #[test]
1132 fn layered_loader_local_overrides_global() {
1133 let _guard = HOME_LOCK.lock().unwrap();
1134 let prev_home = std::env::var_os("HOME");
1135 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1136
1137 let home = tempfile::tempdir().unwrap();
1138 let global = tempfile::tempdir().unwrap();
1139
1140 std::env::set_var("HOME", home.path());
1141 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1142
1143 std::fs::write(
1144 global.path().join("formats.toml"),
1145 r#"
1146[format.shared]
1147regex = "^GLOBAL (?P<msg>.+)$"
1148
1149[format.both]
1150regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1151"#,
1152 )
1153 .unwrap();
1154
1155 let cfg_dir = home.path().join(".config").join("tess");
1156 std::fs::create_dir_all(&cfg_dir).unwrap();
1157 std::fs::write(
1158 cfg_dir.join("formats.toml"),
1159 r#"
1160[format.both]
1161regex = "^LOCAL_BOTH (?P<msg>.+)$"
1162
1163[format.local-only]
1164regex = "^LOCAL (?P<msg>.+)$"
1165"#,
1166 )
1167 .unwrap();
1168
1169 let cfg = load_layered_config().unwrap();
1170
1171 assert!(cfg.global.format.contains_key("shared"));
1173 assert!(!cfg.local.format.contains_key("shared"));
1174
1175 assert_eq!(
1179 cfg.global.format.get("both").unwrap().regex,
1180 "^GLOBAL_BOTH (?P<msg>.+)$"
1181 );
1182 assert_eq!(
1183 cfg.local.format.get("both").unwrap().regex,
1184 "^LOCAL_BOTH (?P<msg>.+)$"
1185 );
1186
1187 assert!(cfg.local.format.contains_key("local-only"));
1189
1190 match prev_home {
1191 Some(v) => std::env::set_var("HOME", v),
1192 None => std::env::remove_var("HOME"),
1193 }
1194 match prev_global {
1195 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1196 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1197 }
1198 }
1199
1200 #[test]
1201 fn layered_loader_warns_on_bad_global_toml() {
1202 let _guard = HOME_LOCK.lock().unwrap();
1203 let prev_home = std::env::var_os("HOME");
1204 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1205
1206 let home = tempfile::tempdir().unwrap();
1207 let global = tempfile::tempdir().unwrap();
1208
1209 std::env::set_var("HOME", home.path());
1210 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1211
1212 std::fs::write(
1213 global.path().join("formats.toml"),
1214 "this is not valid toml = = =",
1215 )
1216 .unwrap();
1217
1218 let cfg = load_layered_config().unwrap();
1220 assert!(cfg.global.format.is_empty());
1221 assert!(cfg.global.group.is_empty());
1222
1223 match prev_home {
1224 Some(v) => std::env::set_var("HOME", v),
1225 None => std::env::remove_var("HOME"),
1226 }
1227 match prev_global {
1228 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1229 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1230 }
1231 }
1232
1233 #[test]
1234 fn layered_loader_fails_on_bad_local_toml() {
1235 let _guard = HOME_LOCK.lock().unwrap();
1236 let prev_home = std::env::var_os("HOME");
1237 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1238
1239 let home = tempfile::tempdir().unwrap();
1240 std::env::set_var("HOME", home.path());
1241 std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1242
1243 let cfg_dir = home.path().join(".config").join("tess");
1244 std::fs::create_dir_all(&cfg_dir).unwrap();
1245 std::fs::write(
1246 cfg_dir.join("formats.toml"),
1247 "this is not valid toml = = =",
1248 )
1249 .unwrap();
1250
1251 let err = load_layered_config().unwrap_err();
1252 assert!(err.contains("formats.toml"), "got: {err}");
1253
1254 match prev_home {
1255 Some(v) => std::env::set_var("HOME", v),
1256 None => std::env::remove_var("HOME"),
1257 }
1258 match prev_global {
1259 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1260 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1261 }
1262 }
1263
1264 #[test]
1265 fn log_format_compile_full_with_record_start() {
1266 let fmt = LogFormat::compile_full(
1267 "test",
1268 r"^(?P<msg>.+)$",
1269 None,
1270 Some(r"^\["),
1271 None,
1272 ).expect("compile");
1273 assert!(fmt.record_start.is_some());
1274 assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1275 assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
1276 }
1277
1278 #[test]
1279 fn log_format_compile_full_bad_record_start_errors() {
1280 let err = LogFormat::compile_full(
1281 "test",
1282 r"^(?P<msg>.+)$",
1283 None,
1284 Some(r"["), None,
1286 ).expect_err("should fail");
1287 assert!(err.contains("record_start"), "error mentions record_start: {err}");
1288 }
1289
1290 #[test]
1291 fn group_with_grep_field_deserializes() {
1292 let toml_text = r#"
1293 [group.errorlog]
1294 format = "app"
1295 grep = ["timeout", "deadlock"]
1296 "#;
1297 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1298 let entry = cfg.group.get("errorlog").expect("errorlog present");
1299 assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1300 }
1301
1302 #[test]
1303 fn expand_argv_emits_group_grep_flags() {
1304 let mut groups = HashMap::new();
1305 groups.insert("errorlog".to_string(), Group {
1306 name: "errorlog".to_string(),
1307 grep: vec!["timeout".to_string(), "deadlock".to_string()],
1308 ..Default::default()
1309 });
1310 let out = expand_argv(
1311 argv(&["tess", "--errorlog", "logs.txt"]),
1312 &groups,
1313 );
1314 let joined = out.join(" ");
1315 assert!(joined.contains("--grep timeout"), "got: {joined}");
1316 assert!(joined.contains("--grep deadlock"), "got: {joined}");
1317 }
1318
1319 #[test]
1320 fn user_grep_after_group_accumulates() {
1321 let mut groups = HashMap::new();
1322 groups.insert("errorlog".to_string(), Group {
1323 name: "errorlog".to_string(),
1324 grep: vec!["timeout".to_string()],
1325 ..Default::default()
1326 });
1327 let out = expand_argv(
1328 argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1329 &groups,
1330 );
1331 let joined = out.join(" ");
1332 assert!(joined.contains("--grep timeout"));
1333 assert!(joined.contains("--grep extra"));
1334 }
1335
1336 #[test]
1337 fn format_entry_parses_prompt() {
1338 let toml_text = r#"
1339 [format.myapp]
1340 regex = '^(?P<line>.*)$'
1341 prompt = '<label> <pct>%'
1342 "#;
1343 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1344 let entry = cfg.format.get("myapp").expect("myapp present");
1345 assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1346 }
1347
1348 #[test]
1349 fn load_all_tags_source_correctly() {
1350 let _guard = HOME_LOCK.lock().unwrap();
1351 let prev_home = std::env::var_os("HOME");
1352 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1353
1354 let home = tempfile::tempdir().unwrap();
1355 let global = tempfile::tempdir().unwrap();
1356
1357 std::env::set_var("HOME", home.path());
1358 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1359
1360 std::fs::write(
1361 global.path().join("formats.toml"),
1362 r#"
1363[format.global-only]
1364regex = "^G (?P<msg>.+)$"
1365
1366[format.both]
1367regex = "^GLOBAL (?P<msg>.+)$"
1368"#,
1369 )
1370 .unwrap();
1371
1372 let cfg_dir = home.path().join(".config").join("tess");
1373 std::fs::create_dir_all(&cfg_dir).unwrap();
1374 std::fs::write(
1375 cfg_dir.join("formats.toml"),
1376 r#"
1377[format.local-only]
1378regex = "^L (?P<msg>.+)$"
1379
1380[format.both]
1381regex = "^LOCAL (?P<msg>.+)$"
1382"#,
1383 )
1384 .unwrap();
1385
1386 let all = load_all().unwrap();
1387
1388 assert_eq!(
1390 all["apache-common"].source,
1391 crate::config_path::ConfigSource::Builtin
1392 );
1393 assert!(all["apache-common"].overrides.is_none());
1394
1395 assert_eq!(
1397 all["global-only"].source,
1398 crate::config_path::ConfigSource::Global
1399 );
1400 assert!(all["global-only"].overrides.is_none());
1401
1402 assert_eq!(
1404 all["local-only"].source,
1405 crate::config_path::ConfigSource::Local
1406 );
1407 assert!(all["local-only"].overrides.is_none());
1408
1409 assert_eq!(
1411 all["both"].source,
1412 crate::config_path::ConfigSource::Local
1413 );
1414 assert_eq!(
1415 all["both"].overrides,
1416 Some(crate::config_path::ConfigSource::Global)
1417 );
1418
1419 match prev_home {
1420 Some(v) => std::env::set_var("HOME", v),
1421 None => std::env::remove_var("HOME"),
1422 }
1423 match prev_global {
1424 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1425 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1426 }
1427 }
1428
1429 #[test]
1430 fn source_label_renders_correctly() {
1431 use crate::config_path::ConfigSource;
1432 assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1433 assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1434 assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1435 assert_eq!(
1436 format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1437 "[local, overrides global]"
1438 );
1439 assert_eq!(
1440 format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1441 "[local, overrides built-in]"
1442 );
1443 assert_eq!(
1444 format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1445 "[global, overrides built-in]"
1446 );
1447 }
1448}