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