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, Clone)]
273struct OrSubGroup {
274 #[serde(default)]
275 filter: Vec<String>,
276 #[serde(default)]
277 grep: Vec<String>,
278}
279
280#[derive(Debug, Deserialize, Default)]
283struct GroupEntry {
284 format: Option<String>,
285 file: Option<String>,
286 follow: Option<bool>,
287 tail: Option<usize>,
288 head: Option<usize>,
289 dim: Option<bool>,
290 line_numbers: Option<bool>,
291 chop: Option<bool>,
292 tab_width: Option<u8>,
293 display: Option<String>,
294 #[serde(default)]
295 filter: Vec<String>,
296 #[serde(default)]
297 grep: Vec<String>,
298 #[serde(default)]
299 or_filter: Vec<String>,
300 #[serde(default)]
301 or_grep: Vec<String>,
302 #[serde(default)]
303 or: std::collections::HashMap<String, OrSubGroup>,
304}
305
306#[derive(Debug, Clone, Default)]
310pub struct Group {
311 pub name: String,
312 pub format: Option<String>,
313 pub file: Option<String>,
314 pub follow: bool,
315 pub tail: Option<usize>,
316 pub head: Option<usize>,
317 pub dim: bool,
318 pub line_numbers: bool,
319 pub chop: bool,
320 pub tab_width: Option<u8>,
321 pub display: Option<String>,
326 pub filter: Vec<String>,
327 pub grep: Vec<String>,
328 pub or_filter: Vec<String>,
331 pub or_grep: Vec<String>,
332 pub or_named: Vec<(String, Vec<String>, Vec<String>)>,
335 #[allow(dead_code)]
339 pub(crate) source: crate::config_path::ConfigSource,
340 #[allow(dead_code)]
341 pub(crate) overrides: Option<crate::config_path::ConfigSource>,
342}
343
344const RESERVED_LONG_FLAGS: &[&str] = &[
347 "format",
348 "filter",
349 "grep",
350 "dim",
351 "head",
352 "tail",
353 "follow",
354 "LINE-NUMBERS",
355 "chop-long-lines",
356 "tab-width",
357 "list-formats",
358 "live",
359 "manual",
360 "examples",
361 "prettify",
362 "content-type",
363 "help",
364 "version",
365 "record-start",
366 "hex",
367 "prompt",
368 "display",
369 "or-filter",
370 "or-grep",
371 "or-group",
372 "preprocess",
373 "no-preprocess",
374 "no-color",
375 "raw-control-chars",
376 "tag",
377 "tag-file",
378];
379
380const BUILTINS: &[(&str, &str)] = &[
383 (
384 "apache-common",
385 r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
386 ),
387 (
388 "apache-combined",
389 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>[^"]*)"$"#,
390 ),
391 (
392 "nginx-combined",
393 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>[^"]*)"$"#,
394 ),
395];
396
397fn formats_path_in(dir: &std::path::Path) -> PathBuf {
398 dir.join("formats.toml")
399}
400
401#[derive(Debug, Default)]
404struct LayeredConfig {
405 global: UserConfig,
406 local: UserConfig,
407}
408
409fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
410 let text = std::fs::read_to_string(path)
411 .map_err(|e| format!("reading {}: {e}", path.display()))?;
412 toml::from_str(&text)
413 .map_err(|e| format!("parsing {}: {e}", path.display()))
414}
415
416fn load_layered_config() -> Result<LayeredConfig, String> {
417 let mut layered = LayeredConfig::default();
418
419 if let Some(dir) = config_path::global_config_dir() {
421 let path = formats_path_in(&dir);
422 if path.exists() {
423 match read_formats_toml(&path) {
424 Ok(cfg) => layered.global = cfg,
425 Err(e) => eprintln!(
426 "tess: warning: {e}; ignoring global config"
427 ),
428 }
429 }
430 }
431
432 if let Some(dir) = config_path::user_config_dir() {
434 let path = formats_path_in(&dir);
435 if path.exists() {
436 layered.local = read_formats_toml(&path)?;
437 }
438 }
439
440 Ok(layered)
441}
442
443struct FormatSource {
444 regex: String,
445 display: Option<String>,
446 record_start: Option<String>,
447 prompt: Option<String>,
448 prompt_style: Option<String>,
449 source: crate::config_path::ConfigSource,
450 overrides: Option<crate::config_path::ConfigSource>,
451}
452
453fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
454 let cfg = load_layered_config()?;
455 let mut out: HashMap<String, FormatSource> = HashMap::new();
456 for (k, v) in cfg.global.format {
457 out.insert(k, FormatSource {
458 regex: v.regex,
459 display: v.display,
460 record_start: v.record_start,
461 prompt: v.prompt,
462 prompt_style: v.prompt_style,
463 source: crate::config_path::ConfigSource::Global,
464 overrides: None,
465 });
466 }
467 for (k, v) in cfg.local.format {
468 let overrides = out.get(&k).map(|prev| prev.source);
469 out.insert(k, FormatSource {
470 regex: v.regex,
471 display: v.display,
472 record_start: v.record_start,
473 prompt: v.prompt,
474 prompt_style: v.prompt_style,
475 source: crate::config_path::ConfigSource::Local,
476 overrides,
477 });
478 }
479 Ok(out)
480}
481
482pub fn load_groups() -> Result<HashMap<String, Group>, String> {
486 let cfg = load_layered_config()?;
487
488 struct StagedGroup {
489 entry: GroupEntry,
490 source: crate::config_path::ConfigSource,
491 overrides: Option<crate::config_path::ConfigSource>,
492 }
493
494 let mut staged: HashMap<String, StagedGroup> = HashMap::new();
495 for (k, v) in cfg.global.group {
496 staged.insert(k, StagedGroup {
497 entry: v,
498 source: crate::config_path::ConfigSource::Global,
499 overrides: None,
500 });
501 }
502 for (k, v) in cfg.local.group {
503 let overrides = staged.get(&k).map(|prev| prev.source);
504 staged.insert(k, StagedGroup {
505 entry: v,
506 source: crate::config_path::ConfigSource::Local,
507 overrides,
508 });
509 }
510
511 let mut out = HashMap::with_capacity(staged.len());
512 for (name, sg) in staged {
513 if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
514 return Err(format!(
515 "group `{name}`: name collides with built-in --{name} flag"
516 ));
517 }
518 out.insert(
519 name.clone(),
520 Group {
521 name,
522 format: sg.entry.format,
523 file: sg.entry.file,
524 follow: sg.entry.follow.unwrap_or(false),
525 tail: sg.entry.tail,
526 head: sg.entry.head,
527 dim: sg.entry.dim.unwrap_or(false),
528 line_numbers: sg.entry.line_numbers.unwrap_or(false),
529 chop: sg.entry.chop.unwrap_or(false),
530 tab_width: sg.entry.tab_width,
531 display: sg.entry.display,
532 filter: sg.entry.filter,
533 grep: sg.entry.grep,
534 or_filter: sg.entry.or_filter,
535 or_grep: sg.entry.or_grep,
536 or_named: {
537 let mut v: Vec<(String, Vec<String>, Vec<String>)> = sg
538 .entry
539 .or
540 .into_iter()
541 .map(|(name, sub)| (name, sub.filter, sub.grep))
542 .collect();
543 v.sort_by(|a, b| a.0.cmp(&b.0)); v
545 },
546 source: sg.source,
547 overrides: sg.overrides,
548 },
549 );
550 }
551 Ok(out)
552}
553
554pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
558 let mut sources: HashMap<String, FormatSource> = HashMap::new();
559 for (name, pat) in BUILTINS {
560 sources.insert(name.to_string(), FormatSource {
561 regex: pat.to_string(),
562 display: None,
563 record_start: None,
564 prompt: None,
565 prompt_style: None,
566 source: crate::config_path::ConfigSource::Builtin,
567 overrides: None,
568 });
569 }
570 let user = load_user_formats()?;
571 for (name, mut src) in user {
572 if src.overrides.is_none() && sources.contains_key(&name) {
576 src.overrides = Some(crate::config_path::ConfigSource::Builtin);
577 }
578 sources.insert(name, src);
579 }
580 let mut compiled = HashMap::new();
581 for (name, src) in sources {
582 let mut fmt = LogFormat::compile_full(
583 &name,
584 &src.regex,
585 src.display.as_deref(),
586 src.record_start.as_deref(),
587 src.prompt.as_deref(),
588 )?;
589 if let Some(spec) = src.prompt_style.as_deref() {
590 fmt.prompt_style = Some(
591 crate::style_spec::parse(spec)
592 .map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
593 );
594 }
595 fmt.source = src.source;
596 fmt.overrides = src.overrides;
597 compiled.insert(name, fmt);
598 }
599 Ok(compiled)
600}
601
602const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
617 "--content-type",
618 "--display",
619 "--filter",
620 "--format",
621 "--grep",
622 "--head",
623 "--header",
624 "--hex-group",
625 "--image-width",
626 "--or-filter",
627 "--or-grep",
628 "--or-group",
629 "--output",
630 "--preprocess",
631 "--prompt",
632 "--prompt-style",
633 "--record-start",
634 "--rscroll",
635 "--status-style",
636 "--tab-width",
637 "--tag",
638 "--tag-file",
639 "--tail",
640 "--truecolor",
641 "--window",
642];
643
644const VALUE_TAKING_SHORT_FLAGS: &[&str] = &[
649 "-o",
650 "-z",
651 "-t",
652 "-T",
653];
654
655pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
656 if argv.is_empty() {
657 return argv;
658 }
659 let mut out = Vec::with_capacity(argv.len() * 2);
660 let mut iter = argv.into_iter();
661 out.push(iter.next().unwrap()); let mut filter_mode = false;
663 let mut pass_next = false;
664 for arg in iter {
665 if pass_next {
666 pass_next = false;
667 out.push(arg);
668 continue;
669 }
670 if let Some(name) = arg.strip_prefix("--") {
671 if !name.contains('=') {
674 if let Some(g) = groups.get(name) {
675 expand_group(g, &mut out);
676 filter_mode = true;
677 continue;
678 }
679 }
680 }
681 if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str())
687 || VALUE_TAKING_SHORT_FLAGS.contains(&arg.as_str())
688 {
689 out.push(arg);
690 pass_next = true;
691 continue;
692 }
693 if filter_mode && !arg.starts_with('-') {
694 out.push("--filter".into());
695 out.push(arg);
696 continue;
697 }
698 out.push(arg);
699 }
700 out
701}
702
703fn expand_group(g: &Group, out: &mut Vec<String>) {
704 if let Some(format) = &g.format {
705 out.push("--format".into());
706 out.push(format.clone());
707 }
708 if let Some(display) = &g.display {
709 out.push("--display".into());
710 out.push(display.clone());
711 }
712 if g.follow {
713 out.push("--follow".into());
714 }
715 if let Some(t) = g.tail {
716 out.push("--tail".into());
717 out.push(t.to_string());
718 }
719 if let Some(h) = g.head {
720 out.push("--head".into());
721 out.push(h.to_string());
722 }
723 if g.dim {
724 out.push("--dim".into());
725 }
726 if g.line_numbers {
727 out.push("-N".into());
728 }
729 if g.chop {
730 out.push("-S".into());
731 }
732 if let Some(t) = g.tab_width {
733 out.push("--tab-width".into());
734 out.push(t.to_string());
735 }
736 for f in &g.filter {
737 out.push("--filter".into());
738 out.push(f.clone());
739 }
740 for g_pat in &g.grep {
741 out.push("--grep".into());
742 out.push(g_pat.clone());
743 }
744 for f in &g.or_filter {
746 out.push("--or-filter".into());
747 out.push(f.clone());
748 }
749 for p in &g.or_grep {
750 out.push("--or-grep".into());
751 out.push(p.clone());
752 }
753 for (name, filters, greps) in &g.or_named {
755 out.push("--or-group".into());
756 out.push(name.clone());
757 for f in filters {
758 out.push("--or-filter".into());
759 out.push(f.clone());
760 }
761 for p in greps {
762 out.push("--or-grep".into());
763 out.push(p.clone());
764 }
765 }
766 if let Some(file) = &g.file {
767 out.push(file.clone());
768 }
769}
770
771fn format_source_label(
774 source: crate::config_path::ConfigSource,
775 overrides: Option<crate::config_path::ConfigSource>,
776) -> String {
777 use crate::config_path::ConfigSource::*;
778 let layer = match source {
779 Builtin => "built-in",
780 Global => "global",
781 Local => "local",
782 };
783 match overrides {
784 None => format!("[{layer}]"),
785 Some(Builtin) => format!("[{layer}, overrides built-in]"),
786 Some(Global) => format!("[{layer}, overrides global]"),
787 Some(Local) => format!("[{layer}, overrides local]"),
790 }
791}
792
793pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
796 let mut names: Vec<&String> = formats.keys().collect();
797 names.sort();
798
799 let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
801
802 for name in names {
803 let fmt = &formats[name];
804 let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
805 let label = format_source_label(fmt.source, fmt.overrides);
806 println!(
807 "{:<width$} {} {}",
808 name,
809 label,
810 fields.join(", "),
811 width = name_width
812 );
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use std::sync::Mutex;
820
821 static HOME_LOCK: Mutex<()> = Mutex::new(());
824
825 #[test]
826 fn builtins_all_compile() {
827 for (name, pat) in BUILTINS {
828 LogFormat::compile(name, pat)
829 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
830 }
831 }
832
833 fn fields() -> Vec<String> {
836 vec!["ts".into(), "level".into(), "msg".into()]
837 }
838
839 #[test]
840 fn display_template_compiles_basic() {
841 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
842 assert_eq!(t.source(), "[<ts>] <level> <msg>");
843 }
844
845 #[test]
846 fn display_template_renders_substitutions() {
847 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
848 let mut map = std::collections::HashMap::new();
849 map.insert("level".to_string(), "ERROR".to_string());
850 map.insert("msg".to_string(), "boom".to_string());
851 let out = t.render(|n| map.get(n).cloned());
852 assert_eq!(out, "ERROR: boom");
853 }
854
855 #[test]
856 fn display_template_missing_field_renders_empty() {
857 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
858 let mut map = std::collections::HashMap::new();
859 map.insert("level".to_string(), "ERROR".to_string());
860 let out = t.render(|n| map.get(n).cloned());
862 assert_eq!(out, "ERROR:");
863 }
864
865 #[test]
866 fn display_template_escape_sequences() {
867 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
870 let mut map = std::collections::HashMap::new();
871 map.insert("level".to_string(), "X".to_string());
872 let out = t.render(|n| map.get(n).cloned());
873 assert_eq!(out, "<not a field> X");
874 }
875
876 #[test]
877 fn display_template_escape_backslash() {
878 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
879 let mut map = std::collections::HashMap::new();
880 map.insert("level".to_string(), "X".to_string());
881 let out = t.render(|n| map.get(n).cloned());
882 assert_eq!(out, r"a\b X");
883 }
884
885 #[test]
886 fn display_template_escape_e_emits_esc() {
887 let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
888 let mut map = std::collections::HashMap::new();
889 map.insert("level".to_string(), "X".to_string());
890 let out = t.render(|n| map.get(n).cloned());
891 assert_eq!(out, "\x1b[31mX\x1b[0m");
892 }
893
894 #[test]
895 fn display_template_escape_x1b_emits_esc() {
896 let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
897 let out = t.render(|_| Some("Y".to_string()));
898 assert_eq!(out, "\x1b[1mY");
899 }
900
901 #[test]
902 fn display_template_escape_octal_emits_esc() {
903 let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
904 let out = t.render(|_| Some("Z".to_string()));
905 assert_eq!(out, "\x1b[1mZ");
906 }
907
908 #[test]
909 fn display_template_escape_n_t_r() {
910 let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
911 let out = t.render(|_| Some("Q".to_string()));
912 assert_eq!(out, "\n\t\rQ");
913 }
914
915 #[test]
916 fn display_template_escape_unknown_preserves_backslash() {
917 let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
918 let out = t.render(|_| Some("Q".to_string()));
919 assert_eq!(out, r"\qQ");
920 }
921
922 #[test]
923 fn display_template_escape_x_incomplete_errors() {
924 let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
925 assert!(err.contains("incomplete"), "{err}");
926 }
927
928 #[test]
929 fn display_template_escape_invalid_hex_errors() {
930 let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
931 assert!(err.contains("invalid"), "{err}");
932 }
933
934 #[test]
935 fn display_template_rejects_empty() {
936 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
937 assert!(err.contains("empty"), "{err}");
938 }
939
940 #[test]
941 fn display_template_rejects_unknown_field() {
942 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
943 assert!(err.contains("unknown field"), "{err}");
944 }
945
946 #[test]
947 fn display_template_rejects_unterminated() {
948 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
949 assert!(err.contains("unterminated"), "{err}");
950 }
951
952 #[test]
953 fn display_template_rejects_empty_ref() {
954 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
955 assert!(err.contains("empty field reference"), "{err}");
956 }
957
958 #[test]
959 fn apache_common_extracts_fields() {
960 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
961 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
962 let caps = fmt.regex.captures(line).expect("should match");
963 assert_eq!(&caps["ip"], "127.0.0.1");
964 assert_eq!(&caps["user"], "alice");
965 assert_eq!(&caps["method"], "GET");
966 assert_eq!(&caps["url"], "/index.html");
967 assert_eq!(&caps["status"], "200");
968 assert_eq!(&caps["size"], "2326");
969 }
970
971 #[test]
972 fn apache_combined_extracts_referer_and_agent() {
973 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
974 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""#;
975 let caps = fmt.regex.captures(line).expect("should match");
976 assert_eq!(&caps["status"], "401");
977 assert_eq!(&caps["url"], "/api/login");
978 assert_eq!(&caps["referer"], "https://example.com/");
979 assert_eq!(&caps["agent"], "Mozilla/5.0");
980 }
981
982 #[test]
983 fn field_names_listed_in_order() {
984 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
985 assert_eq!(
986 fmt.field_names,
987 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
988 );
989 }
990
991 #[test]
992 fn compile_rejects_regex_without_named_groups() {
993 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
994 assert!(err.contains("at least one named capture"), "{err}");
995 }
996
997 #[test]
998 fn compile_rejects_invalid_regex() {
999 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
1000 assert!(err.contains("bad"), "{err}");
1001 }
1002
1003 #[test]
1004 fn load_groups_reads_user_config() {
1005 let _g = HOME_LOCK.lock().unwrap();
1006 let tmp = tempfile::tempdir().unwrap();
1007 let cfg_dir = tmp.path().join(".config").join("tess");
1008 std::fs::create_dir_all(&cfg_dir).unwrap();
1009 std::fs::write(
1010 cfg_dir.join("formats.toml"),
1011 r#"
1012[group.errorlog]
1013format = "apache-combined"
1014file = "/var/log/access.log"
1015follow = true
1016tail = 1000
1017filter = ["status~^5"]
1018display = "<status> <url>"
1019
1020[group.minimal]
1021file = "/tmp/x.log"
1022"#,
1023 )
1024 .unwrap();
1025 let saved = std::env::var_os("HOME");
1026 std::env::set_var("HOME", tmp.path());
1027 let result = load_groups();
1028 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1029 let groups = result.unwrap();
1030 let err = &groups["errorlog"];
1031 assert_eq!(err.format.as_deref(), Some("apache-combined"));
1032 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
1033 assert!(err.follow);
1034 assert_eq!(err.tail, Some(1000));
1035 assert_eq!(err.filter, vec!["status~^5".to_string()]);
1036 assert_eq!(err.display.as_deref(), Some("<status> <url>"));
1037 let min = &groups["minimal"];
1038 assert!(!min.follow);
1039 assert!(min.tail.is_none());
1040 assert_eq!(min.filter, Vec::<String>::new());
1041 assert!(min.display.is_none());
1042 }
1043
1044 fn group(name: &str) -> Group {
1045 Group { name: name.into(), ..Group::default() }
1046 }
1047
1048 fn argv(parts: &[&str]) -> Vec<String> {
1049 parts.iter().map(|s| s.to_string()).collect()
1050 }
1051
1052 #[test]
1053 fn expand_argv_passes_through_when_no_group_matches() {
1054 let groups: HashMap<String, Group> = HashMap::new();
1055 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
1056 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
1057 }
1058
1059 #[test]
1060 fn expand_argv_inserts_group_flags_and_file() {
1061 let mut groups: HashMap<String, Group> = HashMap::new();
1062 groups.insert(
1063 "errorlog".into(),
1064 Group {
1065 name: "errorlog".into(),
1066 format: Some("apache-combined".into()),
1067 file: Some("/var/log/access.log".into()),
1068 follow: true,
1069 tail: Some(1000),
1070 filter: vec!["status~^5".into()],
1071 ..Group::default()
1072 },
1073 );
1074 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
1075 assert_eq!(
1076 out,
1077 argv(&[
1078 "tess",
1079 "--format", "apache-combined",
1080 "--follow",
1081 "--tail", "1000",
1082 "--filter", "status~^5",
1083 "/var/log/access.log",
1084 ])
1085 );
1086 }
1087
1088 #[test]
1089 fn expand_argv_converts_positionals_to_filters_after_group() {
1090 let mut groups: HashMap<String, Group> = HashMap::new();
1091 groups.insert(
1092 "errorlog".into(),
1093 Group {
1094 name: "errorlog".into(),
1095 format: Some("apache-combined".into()),
1096 file: Some("/log".into()),
1097 ..Group::default()
1098 },
1099 );
1100 let out = expand_argv(
1101 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
1102 &groups,
1103 );
1104 assert_eq!(
1105 out,
1106 argv(&[
1107 "tess",
1108 "--format", "apache-combined",
1109 "/log",
1110 "--filter", "msg~test",
1111 "--filter", "url~/api/",
1112 ])
1113 );
1114 }
1115
1116 #[test]
1117 fn expand_argv_leaves_flags_alone_after_group() {
1118 let mut groups: HashMap<String, Group> = HashMap::new();
1119 groups.insert("errorlog".into(), group("errorlog"));
1120 let out = expand_argv(
1121 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1122 &groups,
1123 );
1124 assert_eq!(
1126 out,
1127 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1128 );
1129 }
1130
1131 #[test]
1132 fn expand_argv_user_flag_after_group_can_override_tail() {
1133 let mut groups: HashMap<String, Group> = HashMap::new();
1136 groups.insert(
1137 "errorlog".into(),
1138 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1139 );
1140 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1141 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1143 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1144 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1145 let pos_50 = out.iter().position(|x| x == "50").unwrap();
1146 assert!(pos_1000 < pos_50, "user's value must come after group's");
1147 }
1148
1149 #[test]
1150 fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1151 let mut groups: HashMap<String, Group> = HashMap::new();
1152 groups.insert("errorlog".into(), group("errorlog"));
1153 let out = expand_argv(
1154 argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1155 &groups,
1156 );
1157 assert_eq!(
1159 out,
1160 argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1161 );
1162 }
1163
1164 #[test]
1165 fn expand_argv_unknown_double_dash_passes_through() {
1166 let groups: HashMap<String, Group> = HashMap::new();
1167 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1168 assert_eq!(out, argv(&["tess", "--unknown"]));
1169 }
1170
1171 #[test]
1172 fn expand_argv_passes_display_template_through_after_group() {
1173 let mut groups: HashMap<String, Group> = HashMap::new();
1177 groups.insert("errorlog".into(), group("errorlog"));
1178 let out = expand_argv(
1179 argv(&["tess", "--errorlog", "--display", "<lvl>: <msg>", "lvl=ERROR"]),
1180 &groups,
1181 );
1182 assert_eq!(
1183 out,
1184 argv(&[
1185 "tess",
1186 "--display", "<lvl>: <msg>",
1187 "--filter", "lvl=ERROR",
1188 ])
1189 );
1190 }
1191
1192 #[test]
1193 fn expand_argv_passes_short_value_flag_through_after_group() {
1194 let mut groups: HashMap<String, Group> = HashMap::new();
1197 groups.insert("errorlog".into(), group("errorlog"));
1198 let out = expand_argv(
1199 argv(&["tess", "--errorlog", "-o", "out.txt", "lvl=ERROR"]),
1200 &groups,
1201 );
1202 assert_eq!(
1203 out,
1204 argv(&["tess", "-o", "out.txt", "--filter", "lvl=ERROR"])
1205 );
1206 }
1207
1208 #[test]
1209 fn expand_group_emits_display_when_set() {
1210 let g = Group {
1211 name: "errs".into(),
1212 format: Some("simple".into()),
1213 display: Some("<lvl>!! <msg>".into()),
1214 filter: vec!["lvl=ERROR".into()],
1215 ..Group::default()
1216 };
1217 let out = expand_argv(argv(&["tess", "--errs"]), &{
1218 let mut m = HashMap::new();
1219 m.insert("errs".to_string(), g);
1220 m
1221 });
1222 assert_eq!(
1223 out,
1224 argv(&[
1225 "tess",
1226 "--format", "simple",
1227 "--display", "<lvl>!! <msg>",
1228 "--filter", "lvl=ERROR",
1229 ])
1230 );
1231 }
1232
1233 #[test]
1234 fn expand_argv_cli_display_overrides_group_display() {
1235 let g = Group {
1238 name: "errs".into(),
1239 format: Some("simple".into()),
1240 display: Some("group-tmpl".into()),
1241 ..Group::default()
1242 };
1243 let out = expand_argv(argv(&["tess", "--errs", "--display", "cli-tmpl"]), &{
1244 let mut m = HashMap::new();
1245 m.insert("errs".to_string(), g);
1246 m
1247 });
1248 let pos_group = out.iter().position(|x| x == "group-tmpl").unwrap();
1249 let pos_cli = out.iter().position(|x| x == "cli-tmpl").unwrap();
1250 assert!(pos_group < pos_cli, "CLI display must come after group's so it wins");
1251 }
1252
1253 #[test]
1254 fn load_groups_rejects_reserved_name() {
1255 let _g = HOME_LOCK.lock().unwrap();
1256 let tmp = tempfile::tempdir().unwrap();
1257 let cfg_dir = tmp.path().join(".config").join("tess");
1258 std::fs::create_dir_all(&cfg_dir).unwrap();
1259 std::fs::write(
1260 cfg_dir.join("formats.toml"),
1261 r#"
1262[group.follow]
1263file = "/x.log"
1264"#,
1265 )
1266 .unwrap();
1267 let saved = std::env::var_os("HOME");
1268 std::env::set_var("HOME", tmp.path());
1269 let result = load_groups();
1270 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1271 let err = result.unwrap_err();
1272 assert!(err.contains("collides with built-in --follow"), "{err}");
1273 }
1274
1275 #[test]
1276 fn user_config_overrides_builtin_via_load_all() {
1277 let _g = HOME_LOCK.lock().unwrap();
1278 let tmp = tempfile::tempdir().unwrap();
1280 let cfg_dir = tmp.path().join(".config").join("tess");
1281 std::fs::create_dir_all(&cfg_dir).unwrap();
1282 let cfg_file = cfg_dir.join("formats.toml");
1283 std::fs::write(
1284 &cfg_file,
1285 r#"
1286[format.apache-common]
1287regex = "^(?P<custom>\\S+)$"
1288"#,
1289 )
1290 .unwrap();
1291 let saved = std::env::var_os("HOME");
1293 std::env::set_var("HOME", tmp.path());
1294 let result = load_all();
1295 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1296 let formats = result.unwrap();
1297 let common = &formats["apache-common"];
1298 assert_eq!(common.field_names, vec!["custom"], "user config should win");
1299 }
1300
1301 #[test]
1302 fn format_entry_parses_record_start() {
1303 let toml_text = r#"
1304 [format.myapp]
1305 regex = '^(?P<line>.*)$'
1306 record_start = '^\['
1307 "#;
1308 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1309 let entry = cfg.format.get("myapp").expect("myapp present");
1310 assert_eq!(entry.regex, "^(?P<line>.*)$");
1311 assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1312 }
1313
1314 #[test]
1315 fn format_entry_record_start_optional() {
1316 let toml_text = r#"
1317 [format.myapp]
1318 regex = '^(?P<line>.*)$'
1319 "#;
1320 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1321 let entry = cfg.format.get("myapp").expect("myapp present");
1322 assert!(entry.record_start.is_none());
1323 }
1324
1325 #[test]
1326 fn layered_loader_local_overrides_global() {
1327 let _guard = HOME_LOCK.lock().unwrap();
1328 let prev_home = std::env::var_os("HOME");
1329 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1330
1331 let home = tempfile::tempdir().unwrap();
1332 let global = tempfile::tempdir().unwrap();
1333
1334 std::env::set_var("HOME", home.path());
1335 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1336
1337 std::fs::write(
1338 global.path().join("formats.toml"),
1339 r#"
1340[format.shared]
1341regex = "^GLOBAL (?P<msg>.+)$"
1342
1343[format.both]
1344regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1345"#,
1346 )
1347 .unwrap();
1348
1349 let cfg_dir = home.path().join(".config").join("tess");
1350 std::fs::create_dir_all(&cfg_dir).unwrap();
1351 std::fs::write(
1352 cfg_dir.join("formats.toml"),
1353 r#"
1354[format.both]
1355regex = "^LOCAL_BOTH (?P<msg>.+)$"
1356
1357[format.local-only]
1358regex = "^LOCAL (?P<msg>.+)$"
1359"#,
1360 )
1361 .unwrap();
1362
1363 let cfg = load_layered_config().unwrap();
1364
1365 assert!(cfg.global.format.contains_key("shared"));
1367 assert!(!cfg.local.format.contains_key("shared"));
1368
1369 assert_eq!(
1373 cfg.global.format.get("both").unwrap().regex,
1374 "^GLOBAL_BOTH (?P<msg>.+)$"
1375 );
1376 assert_eq!(
1377 cfg.local.format.get("both").unwrap().regex,
1378 "^LOCAL_BOTH (?P<msg>.+)$"
1379 );
1380
1381 assert!(cfg.local.format.contains_key("local-only"));
1383
1384 match prev_home {
1385 Some(v) => std::env::set_var("HOME", v),
1386 None => std::env::remove_var("HOME"),
1387 }
1388 match prev_global {
1389 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1390 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1391 }
1392 }
1393
1394 #[test]
1395 fn layered_loader_warns_on_bad_global_toml() {
1396 let _guard = HOME_LOCK.lock().unwrap();
1397 let prev_home = std::env::var_os("HOME");
1398 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1399
1400 let home = tempfile::tempdir().unwrap();
1401 let global = tempfile::tempdir().unwrap();
1402
1403 std::env::set_var("HOME", home.path());
1404 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1405
1406 std::fs::write(
1407 global.path().join("formats.toml"),
1408 "this is not valid toml = = =",
1409 )
1410 .unwrap();
1411
1412 let cfg = load_layered_config().unwrap();
1414 assert!(cfg.global.format.is_empty());
1415 assert!(cfg.global.group.is_empty());
1416
1417 match prev_home {
1418 Some(v) => std::env::set_var("HOME", v),
1419 None => std::env::remove_var("HOME"),
1420 }
1421 match prev_global {
1422 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1423 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1424 }
1425 }
1426
1427 #[test]
1428 fn layered_loader_fails_on_bad_local_toml() {
1429 let _guard = HOME_LOCK.lock().unwrap();
1430 let prev_home = std::env::var_os("HOME");
1431 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1432
1433 let home = tempfile::tempdir().unwrap();
1434 std::env::set_var("HOME", home.path());
1435 std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1436
1437 let cfg_dir = home.path().join(".config").join("tess");
1438 std::fs::create_dir_all(&cfg_dir).unwrap();
1439 std::fs::write(
1440 cfg_dir.join("formats.toml"),
1441 "this is not valid toml = = =",
1442 )
1443 .unwrap();
1444
1445 let err = load_layered_config().unwrap_err();
1446 assert!(err.contains("formats.toml"), "got: {err}");
1447
1448 match prev_home {
1449 Some(v) => std::env::set_var("HOME", v),
1450 None => std::env::remove_var("HOME"),
1451 }
1452 match prev_global {
1453 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1454 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1455 }
1456 }
1457
1458 #[test]
1459 fn log_format_compile_full_with_record_start() {
1460 let fmt = LogFormat::compile_full(
1461 "test",
1462 r"^(?P<msg>.+)$",
1463 None,
1464 Some(r"^\["),
1465 None,
1466 ).expect("compile");
1467 assert!(fmt.record_start.is_some());
1468 assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1469 assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
1470 }
1471
1472 #[test]
1473 fn log_format_compile_full_bad_record_start_errors() {
1474 let err = LogFormat::compile_full(
1475 "test",
1476 r"^(?P<msg>.+)$",
1477 None,
1478 Some(r"["), None,
1480 ).expect_err("should fail");
1481 assert!(err.contains("record_start"), "error mentions record_start: {err}");
1482 }
1483
1484 #[test]
1485 fn group_with_grep_field_deserializes() {
1486 let toml_text = r#"
1487 [group.errorlog]
1488 format = "app"
1489 grep = ["timeout", "deadlock"]
1490 "#;
1491 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1492 let entry = cfg.group.get("errorlog").expect("errorlog present");
1493 assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1494 }
1495
1496 #[test]
1497 fn expand_argv_emits_group_grep_flags() {
1498 let mut groups = HashMap::new();
1499 groups.insert("errorlog".to_string(), Group {
1500 name: "errorlog".to_string(),
1501 grep: vec!["timeout".to_string(), "deadlock".to_string()],
1502 ..Default::default()
1503 });
1504 let out = expand_argv(
1505 argv(&["tess", "--errorlog", "logs.txt"]),
1506 &groups,
1507 );
1508 let joined = out.join(" ");
1509 assert!(joined.contains("--grep timeout"), "got: {joined}");
1510 assert!(joined.contains("--grep deadlock"), "got: {joined}");
1511 }
1512
1513 #[test]
1514 fn user_grep_after_group_accumulates() {
1515 let mut groups = HashMap::new();
1516 groups.insert("errorlog".to_string(), Group {
1517 name: "errorlog".to_string(),
1518 grep: vec!["timeout".to_string()],
1519 ..Default::default()
1520 });
1521 let out = expand_argv(
1522 argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1523 &groups,
1524 );
1525 let joined = out.join(" ");
1526 assert!(joined.contains("--grep timeout"));
1527 assert!(joined.contains("--grep extra"));
1528 }
1529
1530 #[test]
1531 fn format_entry_parses_prompt() {
1532 let toml_text = r#"
1533 [format.myapp]
1534 regex = '^(?P<line>.*)$'
1535 prompt = '<label> <pct>%'
1536 "#;
1537 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1538 let entry = cfg.format.get("myapp").expect("myapp present");
1539 assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1540 }
1541
1542 #[test]
1543 fn load_all_tags_source_correctly() {
1544 let _guard = HOME_LOCK.lock().unwrap();
1545 let prev_home = std::env::var_os("HOME");
1546 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1547
1548 let home = tempfile::tempdir().unwrap();
1549 let global = tempfile::tempdir().unwrap();
1550
1551 std::env::set_var("HOME", home.path());
1552 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1553
1554 std::fs::write(
1555 global.path().join("formats.toml"),
1556 r#"
1557[format.global-only]
1558regex = "^G (?P<msg>.+)$"
1559
1560[format.both]
1561regex = "^GLOBAL (?P<msg>.+)$"
1562"#,
1563 )
1564 .unwrap();
1565
1566 let cfg_dir = home.path().join(".config").join("tess");
1567 std::fs::create_dir_all(&cfg_dir).unwrap();
1568 std::fs::write(
1569 cfg_dir.join("formats.toml"),
1570 r#"
1571[format.local-only]
1572regex = "^L (?P<msg>.+)$"
1573
1574[format.both]
1575regex = "^LOCAL (?P<msg>.+)$"
1576"#,
1577 )
1578 .unwrap();
1579
1580 let all = load_all().unwrap();
1581
1582 assert_eq!(
1584 all["apache-common"].source,
1585 crate::config_path::ConfigSource::Builtin
1586 );
1587 assert!(all["apache-common"].overrides.is_none());
1588
1589 assert_eq!(
1591 all["global-only"].source,
1592 crate::config_path::ConfigSource::Global
1593 );
1594 assert!(all["global-only"].overrides.is_none());
1595
1596 assert_eq!(
1598 all["local-only"].source,
1599 crate::config_path::ConfigSource::Local
1600 );
1601 assert!(all["local-only"].overrides.is_none());
1602
1603 assert_eq!(
1605 all["both"].source,
1606 crate::config_path::ConfigSource::Local
1607 );
1608 assert_eq!(
1609 all["both"].overrides,
1610 Some(crate::config_path::ConfigSource::Global)
1611 );
1612
1613 match prev_home {
1614 Some(v) => std::env::set_var("HOME", v),
1615 None => std::env::remove_var("HOME"),
1616 }
1617 match prev_global {
1618 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1619 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1620 }
1621 }
1622
1623 #[test]
1624 fn source_label_renders_correctly() {
1625 use crate::config_path::ConfigSource;
1626 assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1627 assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1628 assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1629 assert_eq!(
1630 format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1631 "[local, overrides global]"
1632 );
1633 assert_eq!(
1634 format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1635 "[local, overrides built-in]"
1636 );
1637 assert_eq!(
1638 format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1639 "[global, overrides built-in]"
1640 );
1641 }
1642
1643 #[test]
1644 fn load_groups_reads_or_conditions() {
1645 let _g = HOME_LOCK.lock().unwrap();
1646 let tmp = tempfile::tempdir().unwrap();
1647 let cfg_dir = tmp.path().join(".config").join("tess");
1648 std::fs::create_dir_all(&cfg_dir).unwrap();
1649 std::fs::write(
1650 cfg_dir.join("formats.toml"),
1651 r#"
1652[group.intrusion]
1653format = "app"
1654or_filter = ["lvl=ERROR"]
1655or_grep = ["panic"]
1656
1657[group.intrusion.or.svc]
1658filter = ["status=403"]
1659grep = ["ssh", "sshd"]
1660"#,
1661 )
1662 .unwrap();
1663 let saved = std::env::var_os("HOME");
1664 std::env::set_var("HOME", tmp.path());
1665 let result = load_groups();
1666 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1667 let groups = result.unwrap();
1668 let g = &groups["intrusion"];
1669 assert_eq!(g.or_filter, vec!["lvl=ERROR".to_string()]);
1670 assert_eq!(g.or_grep, vec!["panic".to_string()]);
1671 assert_eq!(g.or_named, vec![("svc".to_string(), vec!["status=403".to_string()], vec!["ssh".to_string(), "sshd".to_string()])]);
1672 }
1673
1674 #[test]
1675 fn expand_group_emits_or_conditions_in_marker_form() {
1676 let mut groups: HashMap<String, Group> = HashMap::new();
1677 groups.insert(
1678 "intrusion".into(),
1679 Group {
1680 name: "intrusion".into(),
1681 format: Some("app".into()),
1682 or_grep: vec!["panic".into()],
1683 or_named: vec![("svc".into(), vec!["status=403".into()], vec!["ssh".into()])],
1684 ..Group::default()
1685 },
1686 );
1687 let out = expand_argv(argv(&["tess", "--intrusion"]), &groups);
1688 assert_eq!(
1689 out,
1690 argv(&[
1691 "tess",
1692 "--format", "app",
1693 "--or-grep", "panic",
1694 "--or-group", "svc",
1695 "--or-filter", "status=403",
1696 "--or-grep", "ssh",
1697 ])
1698 );
1699 }
1700}