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
820 #[test]
821 fn builtins_all_compile() {
822 for (name, pat) in BUILTINS {
823 LogFormat::compile(name, pat)
824 .unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
825 }
826 }
827
828 fn fields() -> Vec<String> {
831 vec!["ts".into(), "level".into(), "msg".into()]
832 }
833
834 #[test]
835 fn display_template_compiles_basic() {
836 let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
837 assert_eq!(t.source(), "[<ts>] <level> <msg>");
838 }
839
840 #[test]
841 fn display_template_renders_substitutions() {
842 let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
843 let mut map = std::collections::HashMap::new();
844 map.insert("level".to_string(), "ERROR".to_string());
845 map.insert("msg".to_string(), "boom".to_string());
846 let out = t.render(|n| map.get(n).cloned());
847 assert_eq!(out, "ERROR: boom");
848 }
849
850 #[test]
851 fn display_template_missing_field_renders_empty() {
852 let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
853 let mut map = std::collections::HashMap::new();
854 map.insert("level".to_string(), "ERROR".to_string());
855 let out = t.render(|n| map.get(n).cloned());
857 assert_eq!(out, "ERROR:");
858 }
859
860 #[test]
861 fn display_template_escape_sequences() {
862 let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
865 let mut map = std::collections::HashMap::new();
866 map.insert("level".to_string(), "X".to_string());
867 let out = t.render(|n| map.get(n).cloned());
868 assert_eq!(out, "<not a field> X");
869 }
870
871 #[test]
872 fn display_template_escape_backslash() {
873 let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
874 let mut map = std::collections::HashMap::new();
875 map.insert("level".to_string(), "X".to_string());
876 let out = t.render(|n| map.get(n).cloned());
877 assert_eq!(out, r"a\b X");
878 }
879
880 #[test]
881 fn display_template_escape_e_emits_esc() {
882 let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
883 let mut map = std::collections::HashMap::new();
884 map.insert("level".to_string(), "X".to_string());
885 let out = t.render(|n| map.get(n).cloned());
886 assert_eq!(out, "\x1b[31mX\x1b[0m");
887 }
888
889 #[test]
890 fn display_template_escape_x1b_emits_esc() {
891 let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
892 let out = t.render(|_| Some("Y".to_string()));
893 assert_eq!(out, "\x1b[1mY");
894 }
895
896 #[test]
897 fn display_template_escape_octal_emits_esc() {
898 let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
899 let out = t.render(|_| Some("Z".to_string()));
900 assert_eq!(out, "\x1b[1mZ");
901 }
902
903 #[test]
904 fn display_template_escape_n_t_r() {
905 let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
906 let out = t.render(|_| Some("Q".to_string()));
907 assert_eq!(out, "\n\t\rQ");
908 }
909
910 #[test]
911 fn display_template_escape_unknown_preserves_backslash() {
912 let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
913 let out = t.render(|_| Some("Q".to_string()));
914 assert_eq!(out, r"\qQ");
915 }
916
917 #[test]
918 fn display_template_escape_x_incomplete_errors() {
919 let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
920 assert!(err.contains("incomplete"), "{err}");
921 }
922
923 #[test]
924 fn display_template_escape_invalid_hex_errors() {
925 let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
926 assert!(err.contains("invalid"), "{err}");
927 }
928
929 #[test]
930 fn display_template_rejects_empty() {
931 let err = DisplayTemplate::compile("", &fields()).unwrap_err();
932 assert!(err.contains("empty"), "{err}");
933 }
934
935 #[test]
936 fn display_template_rejects_unknown_field() {
937 let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
938 assert!(err.contains("unknown field"), "{err}");
939 }
940
941 #[test]
942 fn display_template_rejects_unterminated() {
943 let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
944 assert!(err.contains("unterminated"), "{err}");
945 }
946
947 #[test]
948 fn display_template_rejects_empty_ref() {
949 let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
950 assert!(err.contains("empty field reference"), "{err}");
951 }
952
953 #[test]
954 fn apache_common_extracts_fields() {
955 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
956 let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
957 let caps = fmt.regex.captures(line).expect("should match");
958 assert_eq!(&caps["ip"], "127.0.0.1");
959 assert_eq!(&caps["user"], "alice");
960 assert_eq!(&caps["method"], "GET");
961 assert_eq!(&caps["url"], "/index.html");
962 assert_eq!(&caps["status"], "200");
963 assert_eq!(&caps["size"], "2326");
964 }
965
966 #[test]
967 fn apache_combined_extracts_referer_and_agent() {
968 let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
969 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""#;
970 let caps = fmt.regex.captures(line).expect("should match");
971 assert_eq!(&caps["status"], "401");
972 assert_eq!(&caps["url"], "/api/login");
973 assert_eq!(&caps["referer"], "https://example.com/");
974 assert_eq!(&caps["agent"], "Mozilla/5.0");
975 }
976
977 #[test]
978 fn field_names_listed_in_order() {
979 let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
980 assert_eq!(
981 fmt.field_names,
982 vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
983 );
984 }
985
986 #[test]
987 fn compile_rejects_regex_without_named_groups() {
988 let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
989 assert!(err.contains("at least one named capture"), "{err}");
990 }
991
992 #[test]
993 fn compile_rejects_invalid_regex() {
994 let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
995 assert!(err.contains("bad"), "{err}");
996 }
997
998 #[test]
999 fn load_groups_reads_user_config() {
1000 let _g = crate::test_env::lock();
1001 let tmp = tempfile::tempdir().unwrap();
1002 let cfg_dir = tmp.path().join(".config").join("tess");
1003 std::fs::create_dir_all(&cfg_dir).unwrap();
1004 std::fs::write(
1005 cfg_dir.join("formats.toml"),
1006 r#"
1007[group.errorlog]
1008format = "apache-combined"
1009file = "/var/log/access.log"
1010follow = true
1011tail = 1000
1012filter = ["status~^5"]
1013display = "<status> <url>"
1014
1015[group.minimal]
1016file = "/tmp/x.log"
1017"#,
1018 )
1019 .unwrap();
1020 let saved = std::env::var_os("HOME");
1021 std::env::set_var("HOME", tmp.path());
1022 let result = load_groups();
1023 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1024 let groups = result.unwrap();
1025 let err = &groups["errorlog"];
1026 assert_eq!(err.format.as_deref(), Some("apache-combined"));
1027 assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
1028 assert!(err.follow);
1029 assert_eq!(err.tail, Some(1000));
1030 assert_eq!(err.filter, vec!["status~^5".to_string()]);
1031 assert_eq!(err.display.as_deref(), Some("<status> <url>"));
1032 let min = &groups["minimal"];
1033 assert!(!min.follow);
1034 assert!(min.tail.is_none());
1035 assert_eq!(min.filter, Vec::<String>::new());
1036 assert!(min.display.is_none());
1037 }
1038
1039 fn group(name: &str) -> Group {
1040 Group { name: name.into(), ..Group::default() }
1041 }
1042
1043 fn argv(parts: &[&str]) -> Vec<String> {
1044 parts.iter().map(|s| s.to_string()).collect()
1045 }
1046
1047 #[test]
1048 fn expand_argv_passes_through_when_no_group_matches() {
1049 let groups: HashMap<String, Group> = HashMap::new();
1050 let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
1051 assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
1052 }
1053
1054 #[test]
1055 fn expand_argv_inserts_group_flags_and_file() {
1056 let mut groups: HashMap<String, Group> = HashMap::new();
1057 groups.insert(
1058 "errorlog".into(),
1059 Group {
1060 name: "errorlog".into(),
1061 format: Some("apache-combined".into()),
1062 file: Some("/var/log/access.log".into()),
1063 follow: true,
1064 tail: Some(1000),
1065 filter: vec!["status~^5".into()],
1066 ..Group::default()
1067 },
1068 );
1069 let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
1070 assert_eq!(
1071 out,
1072 argv(&[
1073 "tess",
1074 "--format", "apache-combined",
1075 "--follow",
1076 "--tail", "1000",
1077 "--filter", "status~^5",
1078 "/var/log/access.log",
1079 ])
1080 );
1081 }
1082
1083 #[test]
1084 fn expand_argv_converts_positionals_to_filters_after_group() {
1085 let mut groups: HashMap<String, Group> = HashMap::new();
1086 groups.insert(
1087 "errorlog".into(),
1088 Group {
1089 name: "errorlog".into(),
1090 format: Some("apache-combined".into()),
1091 file: Some("/log".into()),
1092 ..Group::default()
1093 },
1094 );
1095 let out = expand_argv(
1096 argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
1097 &groups,
1098 );
1099 assert_eq!(
1100 out,
1101 argv(&[
1102 "tess",
1103 "--format", "apache-combined",
1104 "/log",
1105 "--filter", "msg~test",
1106 "--filter", "url~/api/",
1107 ])
1108 );
1109 }
1110
1111 #[test]
1112 fn expand_argv_leaves_flags_alone_after_group() {
1113 let mut groups: HashMap<String, Group> = HashMap::new();
1114 groups.insert("errorlog".into(), group("errorlog"));
1115 let out = expand_argv(
1116 argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
1117 &groups,
1118 );
1119 assert_eq!(
1121 out,
1122 argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
1123 );
1124 }
1125
1126 #[test]
1127 fn expand_argv_user_flag_after_group_can_override_tail() {
1128 let mut groups: HashMap<String, Group> = HashMap::new();
1131 groups.insert(
1132 "errorlog".into(),
1133 Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
1134 );
1135 let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
1136 assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
1138 assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
1139 let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
1140 let pos_50 = out.iter().position(|x| x == "50").unwrap();
1141 assert!(pos_1000 < pos_50, "user's value must come after group's");
1142 }
1143
1144 #[test]
1145 fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
1146 let mut groups: HashMap<String, Group> = HashMap::new();
1147 groups.insert("errorlog".into(), group("errorlog"));
1148 let out = expand_argv(
1149 argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
1150 &groups,
1151 );
1152 assert_eq!(
1154 out,
1155 argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
1156 );
1157 }
1158
1159 #[test]
1160 fn expand_argv_unknown_double_dash_passes_through() {
1161 let groups: HashMap<String, Group> = HashMap::new();
1162 let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
1163 assert_eq!(out, argv(&["tess", "--unknown"]));
1164 }
1165
1166 #[test]
1167 fn expand_argv_passes_display_template_through_after_group() {
1168 let mut groups: HashMap<String, Group> = HashMap::new();
1172 groups.insert("errorlog".into(), group("errorlog"));
1173 let out = expand_argv(
1174 argv(&["tess", "--errorlog", "--display", "<lvl>: <msg>", "lvl=ERROR"]),
1175 &groups,
1176 );
1177 assert_eq!(
1178 out,
1179 argv(&[
1180 "tess",
1181 "--display", "<lvl>: <msg>",
1182 "--filter", "lvl=ERROR",
1183 ])
1184 );
1185 }
1186
1187 #[test]
1188 fn expand_argv_passes_short_value_flag_through_after_group() {
1189 let mut groups: HashMap<String, Group> = HashMap::new();
1192 groups.insert("errorlog".into(), group("errorlog"));
1193 let out = expand_argv(
1194 argv(&["tess", "--errorlog", "-o", "out.txt", "lvl=ERROR"]),
1195 &groups,
1196 );
1197 assert_eq!(
1198 out,
1199 argv(&["tess", "-o", "out.txt", "--filter", "lvl=ERROR"])
1200 );
1201 }
1202
1203 #[test]
1204 fn expand_group_emits_display_when_set() {
1205 let g = Group {
1206 name: "errs".into(),
1207 format: Some("simple".into()),
1208 display: Some("<lvl>!! <msg>".into()),
1209 filter: vec!["lvl=ERROR".into()],
1210 ..Group::default()
1211 };
1212 let out = expand_argv(argv(&["tess", "--errs"]), &{
1213 let mut m = HashMap::new();
1214 m.insert("errs".to_string(), g);
1215 m
1216 });
1217 assert_eq!(
1218 out,
1219 argv(&[
1220 "tess",
1221 "--format", "simple",
1222 "--display", "<lvl>!! <msg>",
1223 "--filter", "lvl=ERROR",
1224 ])
1225 );
1226 }
1227
1228 #[test]
1229 fn expand_argv_cli_display_overrides_group_display() {
1230 let g = Group {
1233 name: "errs".into(),
1234 format: Some("simple".into()),
1235 display: Some("group-tmpl".into()),
1236 ..Group::default()
1237 };
1238 let out = expand_argv(argv(&["tess", "--errs", "--display", "cli-tmpl"]), &{
1239 let mut m = HashMap::new();
1240 m.insert("errs".to_string(), g);
1241 m
1242 });
1243 let pos_group = out.iter().position(|x| x == "group-tmpl").unwrap();
1244 let pos_cli = out.iter().position(|x| x == "cli-tmpl").unwrap();
1245 assert!(pos_group < pos_cli, "CLI display must come after group's so it wins");
1246 }
1247
1248 #[test]
1249 fn load_groups_rejects_reserved_name() {
1250 let _g = crate::test_env::lock();
1251 let tmp = tempfile::tempdir().unwrap();
1252 let cfg_dir = tmp.path().join(".config").join("tess");
1253 std::fs::create_dir_all(&cfg_dir).unwrap();
1254 std::fs::write(
1255 cfg_dir.join("formats.toml"),
1256 r#"
1257[group.follow]
1258file = "/x.log"
1259"#,
1260 )
1261 .unwrap();
1262 let saved = std::env::var_os("HOME");
1263 std::env::set_var("HOME", tmp.path());
1264 let result = load_groups();
1265 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1266 let err = result.unwrap_err();
1267 assert!(err.contains("collides with built-in --follow"), "{err}");
1268 }
1269
1270 #[test]
1271 fn user_config_overrides_builtin_via_load_all() {
1272 let _g = crate::test_env::lock();
1273 let tmp = tempfile::tempdir().unwrap();
1275 let cfg_dir = tmp.path().join(".config").join("tess");
1276 std::fs::create_dir_all(&cfg_dir).unwrap();
1277 let cfg_file = cfg_dir.join("formats.toml");
1278 std::fs::write(
1279 &cfg_file,
1280 r#"
1281[format.apache-common]
1282regex = "^(?P<custom>\\S+)$"
1283"#,
1284 )
1285 .unwrap();
1286 let saved = std::env::var_os("HOME");
1288 std::env::set_var("HOME", tmp.path());
1289 let result = load_all();
1290 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1291 let formats = result.unwrap();
1292 let common = &formats["apache-common"];
1293 assert_eq!(common.field_names, vec!["custom"], "user config should win");
1294 }
1295
1296 #[test]
1297 fn format_entry_parses_record_start() {
1298 let toml_text = r#"
1299 [format.myapp]
1300 regex = '^(?P<line>.*)$'
1301 record_start = '^\['
1302 "#;
1303 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1304 let entry = cfg.format.get("myapp").expect("myapp present");
1305 assert_eq!(entry.regex, "^(?P<line>.*)$");
1306 assert_eq!(entry.record_start.as_deref(), Some("^\\["));
1307 }
1308
1309 #[test]
1310 fn format_entry_record_start_optional() {
1311 let toml_text = r#"
1312 [format.myapp]
1313 regex = '^(?P<line>.*)$'
1314 "#;
1315 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1316 let entry = cfg.format.get("myapp").expect("myapp present");
1317 assert!(entry.record_start.is_none());
1318 }
1319
1320 #[test]
1321 fn layered_loader_local_overrides_global() {
1322 let _guard = crate::test_env::lock();
1323 let prev_home = std::env::var_os("HOME");
1324 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1325
1326 let home = tempfile::tempdir().unwrap();
1327 let global = tempfile::tempdir().unwrap();
1328
1329 std::env::set_var("HOME", home.path());
1330 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1331
1332 std::fs::write(
1333 global.path().join("formats.toml"),
1334 r#"
1335[format.shared]
1336regex = "^GLOBAL (?P<msg>.+)$"
1337
1338[format.both]
1339regex = "^GLOBAL_BOTH (?P<msg>.+)$"
1340"#,
1341 )
1342 .unwrap();
1343
1344 let cfg_dir = home.path().join(".config").join("tess");
1345 std::fs::create_dir_all(&cfg_dir).unwrap();
1346 std::fs::write(
1347 cfg_dir.join("formats.toml"),
1348 r#"
1349[format.both]
1350regex = "^LOCAL_BOTH (?P<msg>.+)$"
1351
1352[format.local-only]
1353regex = "^LOCAL (?P<msg>.+)$"
1354"#,
1355 )
1356 .unwrap();
1357
1358 let cfg = load_layered_config().unwrap();
1359
1360 assert!(cfg.global.format.contains_key("shared"));
1362 assert!(!cfg.local.format.contains_key("shared"));
1363
1364 assert_eq!(
1368 cfg.global.format.get("both").unwrap().regex,
1369 "^GLOBAL_BOTH (?P<msg>.+)$"
1370 );
1371 assert_eq!(
1372 cfg.local.format.get("both").unwrap().regex,
1373 "^LOCAL_BOTH (?P<msg>.+)$"
1374 );
1375
1376 assert!(cfg.local.format.contains_key("local-only"));
1378
1379 match prev_home {
1380 Some(v) => std::env::set_var("HOME", v),
1381 None => std::env::remove_var("HOME"),
1382 }
1383 match prev_global {
1384 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1385 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1386 }
1387 }
1388
1389 #[test]
1390 fn layered_loader_warns_on_bad_global_toml() {
1391 let _guard = crate::test_env::lock();
1392 let prev_home = std::env::var_os("HOME");
1393 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1394
1395 let home = tempfile::tempdir().unwrap();
1396 let global = tempfile::tempdir().unwrap();
1397
1398 std::env::set_var("HOME", home.path());
1399 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1400
1401 std::fs::write(
1402 global.path().join("formats.toml"),
1403 "this is not valid toml = = =",
1404 )
1405 .unwrap();
1406
1407 let cfg = load_layered_config().unwrap();
1409 assert!(cfg.global.format.is_empty());
1410 assert!(cfg.global.group.is_empty());
1411
1412 match prev_home {
1413 Some(v) => std::env::set_var("HOME", v),
1414 None => std::env::remove_var("HOME"),
1415 }
1416 match prev_global {
1417 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1418 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1419 }
1420 }
1421
1422 #[test]
1423 fn layered_loader_fails_on_bad_local_toml() {
1424 let _guard = crate::test_env::lock();
1425 let prev_home = std::env::var_os("HOME");
1426 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1427
1428 let home = tempfile::tempdir().unwrap();
1429 std::env::set_var("HOME", home.path());
1430 std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
1431
1432 let cfg_dir = home.path().join(".config").join("tess");
1433 std::fs::create_dir_all(&cfg_dir).unwrap();
1434 std::fs::write(
1435 cfg_dir.join("formats.toml"),
1436 "this is not valid toml = = =",
1437 )
1438 .unwrap();
1439
1440 let err = load_layered_config().unwrap_err();
1441 assert!(err.contains("formats.toml"), "got: {err}");
1442
1443 match prev_home {
1444 Some(v) => std::env::set_var("HOME", v),
1445 None => std::env::remove_var("HOME"),
1446 }
1447 match prev_global {
1448 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1449 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1450 }
1451 }
1452
1453 #[test]
1454 fn log_format_compile_full_with_record_start() {
1455 let fmt = LogFormat::compile_full(
1456 "test",
1457 r"^(?P<msg>.+)$",
1458 None,
1459 Some(r"^\["),
1460 None,
1461 ).expect("compile");
1462 assert!(fmt.record_start.is_some());
1463 assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
1464 assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
1465 }
1466
1467 #[test]
1468 fn log_format_compile_full_bad_record_start_errors() {
1469 let err = LogFormat::compile_full(
1470 "test",
1471 r"^(?P<msg>.+)$",
1472 None,
1473 Some(r"["), None,
1475 ).expect_err("should fail");
1476 assert!(err.contains("record_start"), "error mentions record_start: {err}");
1477 }
1478
1479 #[test]
1480 fn group_with_grep_field_deserializes() {
1481 let toml_text = r#"
1482 [group.errorlog]
1483 format = "app"
1484 grep = ["timeout", "deadlock"]
1485 "#;
1486 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1487 let entry = cfg.group.get("errorlog").expect("errorlog present");
1488 assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
1489 }
1490
1491 #[test]
1492 fn expand_argv_emits_group_grep_flags() {
1493 let mut groups = HashMap::new();
1494 groups.insert("errorlog".to_string(), Group {
1495 name: "errorlog".to_string(),
1496 grep: vec!["timeout".to_string(), "deadlock".to_string()],
1497 ..Default::default()
1498 });
1499 let out = expand_argv(
1500 argv(&["tess", "--errorlog", "logs.txt"]),
1501 &groups,
1502 );
1503 let joined = out.join(" ");
1504 assert!(joined.contains("--grep timeout"), "got: {joined}");
1505 assert!(joined.contains("--grep deadlock"), "got: {joined}");
1506 }
1507
1508 #[test]
1509 fn user_grep_after_group_accumulates() {
1510 let mut groups = HashMap::new();
1511 groups.insert("errorlog".to_string(), Group {
1512 name: "errorlog".to_string(),
1513 grep: vec!["timeout".to_string()],
1514 ..Default::default()
1515 });
1516 let out = expand_argv(
1517 argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
1518 &groups,
1519 );
1520 let joined = out.join(" ");
1521 assert!(joined.contains("--grep timeout"));
1522 assert!(joined.contains("--grep extra"));
1523 }
1524
1525 #[test]
1526 fn format_entry_parses_prompt() {
1527 let toml_text = r#"
1528 [format.myapp]
1529 regex = '^(?P<line>.*)$'
1530 prompt = '<label> <pct>%'
1531 "#;
1532 let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
1533 let entry = cfg.format.get("myapp").expect("myapp present");
1534 assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
1535 }
1536
1537 #[test]
1538 fn load_all_tags_source_correctly() {
1539 let _guard = crate::test_env::lock();
1540 let prev_home = std::env::var_os("HOME");
1541 let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
1542
1543 let home = tempfile::tempdir().unwrap();
1544 let global = tempfile::tempdir().unwrap();
1545
1546 std::env::set_var("HOME", home.path());
1547 std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
1548
1549 std::fs::write(
1550 global.path().join("formats.toml"),
1551 r#"
1552[format.global-only]
1553regex = "^G (?P<msg>.+)$"
1554
1555[format.both]
1556regex = "^GLOBAL (?P<msg>.+)$"
1557"#,
1558 )
1559 .unwrap();
1560
1561 let cfg_dir = home.path().join(".config").join("tess");
1562 std::fs::create_dir_all(&cfg_dir).unwrap();
1563 std::fs::write(
1564 cfg_dir.join("formats.toml"),
1565 r#"
1566[format.local-only]
1567regex = "^L (?P<msg>.+)$"
1568
1569[format.both]
1570regex = "^LOCAL (?P<msg>.+)$"
1571"#,
1572 )
1573 .unwrap();
1574
1575 let all = load_all().unwrap();
1576
1577 assert_eq!(
1579 all["apache-common"].source,
1580 crate::config_path::ConfigSource::Builtin
1581 );
1582 assert!(all["apache-common"].overrides.is_none());
1583
1584 assert_eq!(
1586 all["global-only"].source,
1587 crate::config_path::ConfigSource::Global
1588 );
1589 assert!(all["global-only"].overrides.is_none());
1590
1591 assert_eq!(
1593 all["local-only"].source,
1594 crate::config_path::ConfigSource::Local
1595 );
1596 assert!(all["local-only"].overrides.is_none());
1597
1598 assert_eq!(
1600 all["both"].source,
1601 crate::config_path::ConfigSource::Local
1602 );
1603 assert_eq!(
1604 all["both"].overrides,
1605 Some(crate::config_path::ConfigSource::Global)
1606 );
1607
1608 match prev_home {
1609 Some(v) => std::env::set_var("HOME", v),
1610 None => std::env::remove_var("HOME"),
1611 }
1612 match prev_global {
1613 Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
1614 None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
1615 }
1616 }
1617
1618 #[test]
1619 fn source_label_renders_correctly() {
1620 use crate::config_path::ConfigSource;
1621 assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
1622 assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
1623 assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
1624 assert_eq!(
1625 format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
1626 "[local, overrides global]"
1627 );
1628 assert_eq!(
1629 format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
1630 "[local, overrides built-in]"
1631 );
1632 assert_eq!(
1633 format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
1634 "[global, overrides built-in]"
1635 );
1636 }
1637
1638 #[test]
1639 fn load_groups_reads_or_conditions() {
1640 let _g = crate::test_env::lock();
1641 let tmp = tempfile::tempdir().unwrap();
1642 let cfg_dir = tmp.path().join(".config").join("tess");
1643 std::fs::create_dir_all(&cfg_dir).unwrap();
1644 std::fs::write(
1645 cfg_dir.join("formats.toml"),
1646 r#"
1647[group.intrusion]
1648format = "app"
1649or_filter = ["lvl=ERROR"]
1650or_grep = ["panic"]
1651
1652[group.intrusion.or.svc]
1653filter = ["status=403"]
1654grep = ["ssh", "sshd"]
1655"#,
1656 )
1657 .unwrap();
1658 let saved = std::env::var_os("HOME");
1659 std::env::set_var("HOME", tmp.path());
1660 let result = load_groups();
1661 if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1662 let groups = result.unwrap();
1663 let g = &groups["intrusion"];
1664 assert_eq!(g.or_filter, vec!["lvl=ERROR".to_string()]);
1665 assert_eq!(g.or_grep, vec!["panic".to_string()]);
1666 assert_eq!(g.or_named, vec![("svc".to_string(), vec!["status=403".to_string()], vec!["ssh".to_string(), "sshd".to_string()])]);
1667 }
1668
1669 #[test]
1670 fn expand_group_emits_or_conditions_in_marker_form() {
1671 let mut groups: HashMap<String, Group> = HashMap::new();
1672 groups.insert(
1673 "intrusion".into(),
1674 Group {
1675 name: "intrusion".into(),
1676 format: Some("app".into()),
1677 or_grep: vec!["panic".into()],
1678 or_named: vec![("svc".into(), vec!["status=403".into()], vec!["ssh".into()])],
1679 ..Group::default()
1680 },
1681 );
1682 let out = expand_argv(argv(&["tess", "--intrusion"]), &groups);
1683 assert_eq!(
1684 out,
1685 argv(&[
1686 "tess",
1687 "--format", "app",
1688 "--or-grep", "panic",
1689 "--or-group", "svc",
1690 "--or-filter", "status=403",
1691 "--or-grep", "ssh",
1692 ])
1693 );
1694 }
1695}