use std::collections::HashMap;
use std::path::PathBuf;
use regex::Regex;
use serde::Deserialize;
use crate::config_path;
#[derive(Debug)]
pub struct LogFormat {
pub name: String,
pub regex: Regex,
pub field_names: Vec<String>,
pub display: Option<DisplayTemplate>,
pub record_start: Option<Regex>,
pub prompt: Option<crate::prompt::ParsedPrompt>,
pub prompt_style: Option<crate::ansi::Style>,
pub(crate) source: crate::config_path::ConfigSource,
pub(crate) overrides: Option<crate::config_path::ConfigSource>,
}
impl LogFormat {
pub fn compile(name: &str, pattern: &str) -> Result<Self, String> {
Self::compile_full(name, pattern, None, None, None)
}
pub fn compile_with_display(
name: &str,
pattern: &str,
display: Option<&str>,
) -> Result<Self, String> {
Self::compile_full(name, pattern, display, None, None)
}
pub fn compile_full(
name: &str,
pattern: &str,
display: Option<&str>,
record_start: Option<&str>,
prompt: Option<&str>,
) -> Result<Self, String> {
let regex = Regex::new(pattern).map_err(|e| format!("format `{name}`: {e}"))?;
let field_names: Vec<String> = regex
.capture_names()
.flatten()
.map(|s| s.to_string())
.collect();
if field_names.is_empty() {
return Err(format!(
"format `{name}`: regex must declare at least one named capture group"
));
}
let display = display
.map(|s| {
DisplayTemplate::compile(s, &field_names)
.map_err(|e| format!("format `{name}`: display: {e}"))
})
.transpose()?;
let record_start = record_start
.map(|s| Regex::new(s).map_err(|e| format!("format `{name}`: record_start: {e}")))
.transpose()?;
let prompt = prompt
.map(|s| crate::prompt::ParsedPrompt::parse(s)
.map_err(|e| format!("format `{name}`: prompt: {e}")))
.transpose()?;
Ok(Self {
name: name.to_string(),
regex,
field_names,
display,
record_start,
prompt,
prompt_style: None,
source: crate::config_path::ConfigSource::Builtin,
overrides: None,
})
}
}
#[derive(Debug, Clone)]
pub struct DisplayTemplate {
segments: Vec<DisplaySegment>,
source: String,
}
#[derive(Debug, Clone)]
enum DisplaySegment {
Literal(String),
Field(String),
}
impl DisplayTemplate {
pub fn compile(source: &str, field_names: &[String]) -> Result<Self, String> {
if source.is_empty() {
return Err("template is empty (would render every line as nothing)".to_string());
}
let mut segments: Vec<DisplaySegment> = Vec::new();
let mut buf = String::new();
let mut chars = source.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => match chars.next() {
Some('<') => buf.push('<'),
Some('\\') => buf.push('\\'),
Some('n') => buf.push('\n'),
Some('t') => buf.push('\t'),
Some('r') => buf.push('\r'),
Some('e') => buf.push('\x1b'),
Some('x') => {
let h1 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
let h2 = chars.next().ok_or_else(|| "incomplete `\\xHH` escape".to_string())?;
let hex: String = [h1, h2].iter().collect();
let byte = u8::from_str_radix(&hex, 16)
.map_err(|_| format!("invalid `\\x{hex}` escape"))?;
buf.push(byte as char);
}
Some('0') => {
let d1 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
let d2 = chars.next().ok_or_else(|| "incomplete `\\NNN` escape".to_string())?;
let oct: String = ['0', d1, d2].iter().collect();
let byte = u8::from_str_radix(&oct, 8)
.map_err(|_| format!("invalid `\\{oct}` escape"))?;
buf.push(byte as char);
}
Some(other) => {
buf.push('\\');
buf.push(other);
}
None => return Err("template ends with a lone `\\`".to_string()),
},
'<' => {
if !buf.is_empty() {
segments.push(DisplaySegment::Literal(std::mem::take(&mut buf)));
}
let mut name = String::new();
let mut closed = false;
while let Some(&nc) = chars.peek() {
chars.next();
if nc == '>' { closed = true; break; }
name.push(nc);
}
if !closed {
return Err(format!("unterminated `<` (expected `<{name}>`)"));
}
if name.is_empty() {
return Err("empty field reference `<>`".to_string());
}
if !field_names.iter().any(|n| n == &name) {
return Err(format!(
"unknown field `{name}` (available: {})",
field_names.join(", ")
));
}
segments.push(DisplaySegment::Field(name));
}
_ => buf.push(c),
}
}
if !buf.is_empty() {
segments.push(DisplaySegment::Literal(buf));
}
Ok(Self { segments, source: source.to_string() })
}
pub fn render(&self, lookup: impl Fn(&str) -> Option<String>) -> String {
let mut out = String::new();
for seg in &self.segments {
match seg {
DisplaySegment::Literal(s) => out.push_str(s),
DisplaySegment::Field(name) => {
if let Some(v) = lookup(name) { out.push_str(&v); }
}
}
}
out
}
pub fn source(&self) -> &str { &self.source }
}
#[derive(Debug, Clone)]
pub struct DisplayRenderer {
template: DisplayTemplate,
regex: Regex,
}
impl DisplayRenderer {
pub fn new(template: DisplayTemplate, regex: Regex) -> Self {
Self { template, regex }
}
pub fn template(&self) -> &DisplayTemplate { &self.template }
pub fn render_line(&self, line: &[u8]) -> Option<String> {
let s = std::str::from_utf8(line).ok()?;
let caps = self.regex.captures(s)?;
Some(self.template.render(|name| {
caps.name(name).map(|m| m.as_str().to_string())
}))
}
}
#[derive(Debug, Default, Deserialize)]
struct UserConfig {
#[serde(default)]
format: HashMap<String, FormatEntry>,
#[serde(default)]
group: HashMap<String, GroupEntry>,
}
#[derive(Debug, Deserialize)]
struct FormatEntry {
regex: String,
#[serde(default)]
display: Option<String>,
#[serde(default)]
record_start: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
prompt_style: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct GroupEntry {
format: Option<String>,
file: Option<String>,
follow: Option<bool>,
tail: Option<usize>,
head: Option<usize>,
dim: Option<bool>,
line_numbers: Option<bool>,
chop: Option<bool>,
tab_width: Option<u8>,
#[serde(default)]
filter: Vec<String>,
#[serde(default)]
grep: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Group {
pub name: String,
pub format: Option<String>,
pub file: Option<String>,
pub follow: bool,
pub tail: Option<usize>,
pub head: Option<usize>,
pub dim: bool,
pub line_numbers: bool,
pub chop: bool,
pub tab_width: Option<u8>,
pub filter: Vec<String>,
pub grep: Vec<String>,
#[allow(dead_code)]
pub(crate) source: crate::config_path::ConfigSource,
#[allow(dead_code)]
pub(crate) overrides: Option<crate::config_path::ConfigSource>,
}
const RESERVED_LONG_FLAGS: &[&str] = &[
"format",
"filter",
"grep",
"dim",
"head",
"tail",
"follow",
"LINE-NUMBERS",
"chop-long-lines",
"tab-width",
"list-formats",
"live",
"manual",
"examples",
"prettify",
"content-type",
"help",
"version",
"record-start",
"hex",
"prompt",
"preprocess",
"no-preprocess",
"no-color",
"raw-control-chars",
"tag",
"tag-file",
];
const BUILTINS: &[(&str, &str)] = &[
(
"apache-common",
r#"^(?P<ip>\S+) \S+ (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\S+)$"#,
),
(
"apache-combined",
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>[^"]*)"$"#,
),
(
"nginx-combined",
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>[^"]*)"$"#,
),
];
fn formats_path_in(dir: &std::path::Path) -> PathBuf {
dir.join("formats.toml")
}
#[derive(Debug, Default)]
struct LayeredConfig {
global: UserConfig,
local: UserConfig,
}
fn read_formats_toml(path: &std::path::Path) -> Result<UserConfig, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
toml::from_str(&text)
.map_err(|e| format!("parsing {}: {e}", path.display()))
}
fn load_layered_config() -> Result<LayeredConfig, String> {
let mut layered = LayeredConfig::default();
if let Some(dir) = config_path::global_config_dir() {
let path = formats_path_in(&dir);
if path.exists() {
match read_formats_toml(&path) {
Ok(cfg) => layered.global = cfg,
Err(e) => eprintln!(
"tess: warning: {e}; ignoring global config"
),
}
}
}
if let Some(dir) = config_path::user_config_dir() {
let path = formats_path_in(&dir);
if path.exists() {
layered.local = read_formats_toml(&path)?;
}
}
Ok(layered)
}
struct FormatSource {
regex: String,
display: Option<String>,
record_start: Option<String>,
prompt: Option<String>,
prompt_style: Option<String>,
source: crate::config_path::ConfigSource,
overrides: Option<crate::config_path::ConfigSource>,
}
fn load_user_formats() -> Result<HashMap<String, FormatSource>, String> {
let cfg = load_layered_config()?;
let mut out: HashMap<String, FormatSource> = HashMap::new();
for (k, v) in cfg.global.format {
out.insert(k, FormatSource {
regex: v.regex,
display: v.display,
record_start: v.record_start,
prompt: v.prompt,
prompt_style: v.prompt_style,
source: crate::config_path::ConfigSource::Global,
overrides: None,
});
}
for (k, v) in cfg.local.format {
let overrides = out.get(&k).map(|prev| prev.source);
out.insert(k, FormatSource {
regex: v.regex,
display: v.display,
record_start: v.record_start,
prompt: v.prompt,
prompt_style: v.prompt_style,
source: crate::config_path::ConfigSource::Local,
overrides,
});
}
Ok(out)
}
pub fn load_groups() -> Result<HashMap<String, Group>, String> {
let cfg = load_layered_config()?;
struct StagedGroup {
entry: GroupEntry,
source: crate::config_path::ConfigSource,
overrides: Option<crate::config_path::ConfigSource>,
}
let mut staged: HashMap<String, StagedGroup> = HashMap::new();
for (k, v) in cfg.global.group {
staged.insert(k, StagedGroup {
entry: v,
source: crate::config_path::ConfigSource::Global,
overrides: None,
});
}
for (k, v) in cfg.local.group {
let overrides = staged.get(&k).map(|prev| prev.source);
staged.insert(k, StagedGroup {
entry: v,
source: crate::config_path::ConfigSource::Local,
overrides,
});
}
let mut out = HashMap::with_capacity(staged.len());
for (name, sg) in staged {
if RESERVED_LONG_FLAGS.contains(&name.as_str()) {
return Err(format!(
"group `{name}`: name collides with built-in --{name} flag"
));
}
out.insert(
name.clone(),
Group {
name,
format: sg.entry.format,
file: sg.entry.file,
follow: sg.entry.follow.unwrap_or(false),
tail: sg.entry.tail,
head: sg.entry.head,
dim: sg.entry.dim.unwrap_or(false),
line_numbers: sg.entry.line_numbers.unwrap_or(false),
chop: sg.entry.chop.unwrap_or(false),
tab_width: sg.entry.tab_width,
filter: sg.entry.filter,
grep: sg.entry.grep,
source: sg.source,
overrides: sg.overrides,
},
);
}
Ok(out)
}
pub fn load_all() -> Result<HashMap<String, LogFormat>, String> {
let mut sources: HashMap<String, FormatSource> = HashMap::new();
for (name, pat) in BUILTINS {
sources.insert(name.to_string(), FormatSource {
regex: pat.to_string(),
display: None,
record_start: None,
prompt: None,
prompt_style: None,
source: crate::config_path::ConfigSource::Builtin,
overrides: None,
});
}
let user = load_user_formats()?;
for (name, mut src) in user {
if src.overrides.is_none() && sources.contains_key(&name) {
src.overrides = Some(crate::config_path::ConfigSource::Builtin);
}
sources.insert(name, src);
}
let mut compiled = HashMap::new();
for (name, src) in sources {
let mut fmt = LogFormat::compile_full(
&name,
&src.regex,
src.display.as_deref(),
src.record_start.as_deref(),
src.prompt.as_deref(),
)?;
if let Some(spec) = src.prompt_style.as_deref() {
fmt.prompt_style = Some(
crate::style_spec::parse(spec)
.map_err(|e| format!("format `{name}`: prompt_style: {e}"))?,
);
}
fmt.source = src.source;
fmt.overrides = src.overrides;
compiled.insert(name, fmt);
}
Ok(compiled)
}
const VALUE_TAKING_LONG_FLAGS: &[&str] = &[
"--format",
"--filter",
"--grep",
"--head",
"--tail",
"--tab-width",
"--record-start",
];
pub fn expand_argv(argv: Vec<String>, groups: &HashMap<String, Group>) -> Vec<String> {
if argv.is_empty() {
return argv;
}
let mut out = Vec::with_capacity(argv.len() * 2);
let mut iter = argv.into_iter();
out.push(iter.next().unwrap()); let mut filter_mode = false;
let mut pass_next = false;
for arg in iter {
if pass_next {
pass_next = false;
out.push(arg);
continue;
}
if let Some(name) = arg.strip_prefix("--") {
if !name.contains('=') {
if let Some(g) = groups.get(name) {
expand_group(g, &mut out);
filter_mode = true;
continue;
}
if VALUE_TAKING_LONG_FLAGS.contains(&arg.as_str()) {
out.push(arg);
pass_next = true;
continue;
}
}
}
if filter_mode && !arg.starts_with('-') {
out.push("--filter".into());
out.push(arg);
continue;
}
out.push(arg);
}
out
}
fn expand_group(g: &Group, out: &mut Vec<String>) {
if let Some(format) = &g.format {
out.push("--format".into());
out.push(format.clone());
}
if g.follow {
out.push("--follow".into());
}
if let Some(t) = g.tail {
out.push("--tail".into());
out.push(t.to_string());
}
if let Some(h) = g.head {
out.push("--head".into());
out.push(h.to_string());
}
if g.dim {
out.push("--dim".into());
}
if g.line_numbers {
out.push("-N".into());
}
if g.chop {
out.push("-S".into());
}
if let Some(t) = g.tab_width {
out.push("--tab-width".into());
out.push(t.to_string());
}
for f in &g.filter {
out.push("--filter".into());
out.push(f.clone());
}
for g_pat in &g.grep {
out.push("--grep".into());
out.push(g_pat.clone());
}
if let Some(file) = &g.file {
out.push(file.clone());
}
}
fn format_source_label(
source: crate::config_path::ConfigSource,
overrides: Option<crate::config_path::ConfigSource>,
) -> String {
use crate::config_path::ConfigSource::*;
let layer = match source {
Builtin => "built-in",
Global => "global",
Local => "local",
};
match overrides {
None => format!("[{layer}]"),
Some(Builtin) => format!("[{layer}, overrides built-in]"),
Some(Global) => format!("[{layer}, overrides global]"),
Some(Local) => format!("[{layer}, overrides local]"),
}
}
pub fn print_format_list(formats: &HashMap<String, LogFormat>) {
let mut names: Vec<&String> = formats.keys().collect();
names.sort();
let name_width = names.iter().map(|n| n.len()).max().unwrap_or(0);
for name in names {
let fmt = &formats[name];
let fields: Vec<&str> = fmt.field_names.iter().map(|s| s.as_str()).collect();
let label = format_source_label(fmt.source, fmt.overrides);
println!(
"{:<width$} {} {}",
name,
label,
fields.join(", "),
width = name_width
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static HOME_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn builtins_all_compile() {
for (name, pat) in BUILTINS {
LogFormat::compile(name, pat)
.unwrap_or_else(|e| panic!("built-in {name} should compile: {e}"));
}
}
fn fields() -> Vec<String> {
vec!["ts".into(), "level".into(), "msg".into()]
}
#[test]
fn display_template_compiles_basic() {
let t = DisplayTemplate::compile("[<ts>] <level> <msg>", &fields()).unwrap();
assert_eq!(t.source(), "[<ts>] <level> <msg>");
}
#[test]
fn display_template_renders_substitutions() {
let t = DisplayTemplate::compile("<level>: <msg>", &fields()).unwrap();
let mut map = std::collections::HashMap::new();
map.insert("level".to_string(), "ERROR".to_string());
map.insert("msg".to_string(), "boom".to_string());
let out = t.render(|n| map.get(n).cloned());
assert_eq!(out, "ERROR: boom");
}
#[test]
fn display_template_missing_field_renders_empty() {
let t = DisplayTemplate::compile("<level>:<msg>", &fields()).unwrap();
let mut map = std::collections::HashMap::new();
map.insert("level".to_string(), "ERROR".to_string());
let out = t.render(|n| map.get(n).cloned());
assert_eq!(out, "ERROR:");
}
#[test]
fn display_template_escape_sequences() {
let t = DisplayTemplate::compile(r"\<not a field> <level>", &fields()).unwrap();
let mut map = std::collections::HashMap::new();
map.insert("level".to_string(), "X".to_string());
let out = t.render(|n| map.get(n).cloned());
assert_eq!(out, "<not a field> X");
}
#[test]
fn display_template_escape_backslash() {
let t = DisplayTemplate::compile(r"a\\b <level>", &fields()).unwrap();
let mut map = std::collections::HashMap::new();
map.insert("level".to_string(), "X".to_string());
let out = t.render(|n| map.get(n).cloned());
assert_eq!(out, r"a\b X");
}
#[test]
fn display_template_escape_e_emits_esc() {
let t = DisplayTemplate::compile(r"\e[31m<level>\e[0m", &fields()).unwrap();
let mut map = std::collections::HashMap::new();
map.insert("level".to_string(), "X".to_string());
let out = t.render(|n| map.get(n).cloned());
assert_eq!(out, "\x1b[31mX\x1b[0m");
}
#[test]
fn display_template_escape_x1b_emits_esc() {
let t = DisplayTemplate::compile(r"\x1b[1m<level>", &fields()).unwrap();
let out = t.render(|_| Some("Y".to_string()));
assert_eq!(out, "\x1b[1mY");
}
#[test]
fn display_template_escape_octal_emits_esc() {
let t = DisplayTemplate::compile(r"\033[1m<level>", &fields()).unwrap();
let out = t.render(|_| Some("Z".to_string()));
assert_eq!(out, "\x1b[1mZ");
}
#[test]
fn display_template_escape_n_t_r() {
let t = DisplayTemplate::compile(r"\n\t\r<level>", &fields()).unwrap();
let out = t.render(|_| Some("Q".to_string()));
assert_eq!(out, "\n\t\rQ");
}
#[test]
fn display_template_escape_unknown_preserves_backslash() {
let t = DisplayTemplate::compile(r"\q<level>", &fields()).unwrap();
let out = t.render(|_| Some("Q".to_string()));
assert_eq!(out, r"\qQ");
}
#[test]
fn display_template_escape_x_incomplete_errors() {
let err = DisplayTemplate::compile(r"\x1", &fields()).unwrap_err();
assert!(err.contains("incomplete"), "{err}");
}
#[test]
fn display_template_escape_invalid_hex_errors() {
let err = DisplayTemplate::compile(r"\xZZ", &fields()).unwrap_err();
assert!(err.contains("invalid"), "{err}");
}
#[test]
fn display_template_rejects_empty() {
let err = DisplayTemplate::compile("", &fields()).unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn display_template_rejects_unknown_field() {
let err = DisplayTemplate::compile("<bogus>", &fields()).unwrap_err();
assert!(err.contains("unknown field"), "{err}");
}
#[test]
fn display_template_rejects_unterminated() {
let err = DisplayTemplate::compile("<level", &fields()).unwrap_err();
assert!(err.contains("unterminated"), "{err}");
}
#[test]
fn display_template_rejects_empty_ref() {
let err = DisplayTemplate::compile("<>", &fields()).unwrap_err();
assert!(err.contains("empty field reference"), "{err}");
}
#[test]
fn apache_common_extracts_fields() {
let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
let line = r#"127.0.0.1 - alice [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326"#;
let caps = fmt.regex.captures(line).expect("should match");
assert_eq!(&caps["ip"], "127.0.0.1");
assert_eq!(&caps["user"], "alice");
assert_eq!(&caps["method"], "GET");
assert_eq!(&caps["url"], "/index.html");
assert_eq!(&caps["status"], "200");
assert_eq!(&caps["size"], "2326");
}
#[test]
fn apache_combined_extracts_referer_and_agent() {
let fmt = LogFormat::compile("apache-combined", BUILTINS[1].1).unwrap();
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""#;
let caps = fmt.regex.captures(line).expect("should match");
assert_eq!(&caps["status"], "401");
assert_eq!(&caps["url"], "/api/login");
assert_eq!(&caps["referer"], "https://example.com/");
assert_eq!(&caps["agent"], "Mozilla/5.0");
}
#[test]
fn field_names_listed_in_order() {
let fmt = LogFormat::compile("apache-common", BUILTINS[0].1).unwrap();
assert_eq!(
fmt.field_names,
vec!["ip", "user", "time", "method", "url", "protocol", "status", "size"]
);
}
#[test]
fn compile_rejects_regex_without_named_groups() {
let err = LogFormat::compile("bare", r"^\d+$").unwrap_err();
assert!(err.contains("at least one named capture"), "{err}");
}
#[test]
fn compile_rejects_invalid_regex() {
let err = LogFormat::compile("bad", r"(?P<x>[").unwrap_err();
assert!(err.contains("bad"), "{err}");
}
#[test]
fn load_groups_reads_user_config() {
let _g = HOME_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let cfg_dir = tmp.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("formats.toml"),
r#"
[group.errorlog]
format = "apache-combined"
file = "/var/log/access.log"
follow = true
tail = 1000
filter = ["status~^5"]
[group.minimal]
file = "/tmp/x.log"
"#,
)
.unwrap();
let saved = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let result = load_groups();
if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
let groups = result.unwrap();
let err = &groups["errorlog"];
assert_eq!(err.format.as_deref(), Some("apache-combined"));
assert_eq!(err.file.as_deref(), Some("/var/log/access.log"));
assert!(err.follow);
assert_eq!(err.tail, Some(1000));
assert_eq!(err.filter, vec!["status~^5".to_string()]);
let min = &groups["minimal"];
assert!(!min.follow);
assert!(min.tail.is_none());
assert_eq!(min.filter, Vec::<String>::new());
}
fn group(name: &str) -> Group {
Group { name: name.into(), ..Group::default() }
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn expand_argv_passes_through_when_no_group_matches() {
let groups: HashMap<String, Group> = HashMap::new();
let out = expand_argv(argv(&["tess", "-f", "log.txt"]), &groups);
assert_eq!(out, argv(&["tess", "-f", "log.txt"]));
}
#[test]
fn expand_argv_inserts_group_flags_and_file() {
let mut groups: HashMap<String, Group> = HashMap::new();
groups.insert(
"errorlog".into(),
Group {
name: "errorlog".into(),
format: Some("apache-combined".into()),
file: Some("/var/log/access.log".into()),
follow: true,
tail: Some(1000),
filter: vec!["status~^5".into()],
..Group::default()
},
);
let out = expand_argv(argv(&["tess", "--errorlog"]), &groups);
assert_eq!(
out,
argv(&[
"tess",
"--format", "apache-combined",
"--follow",
"--tail", "1000",
"--filter", "status~^5",
"/var/log/access.log",
])
);
}
#[test]
fn expand_argv_converts_positionals_to_filters_after_group() {
let mut groups: HashMap<String, Group> = HashMap::new();
groups.insert(
"errorlog".into(),
Group {
name: "errorlog".into(),
format: Some("apache-combined".into()),
file: Some("/log".into()),
..Group::default()
},
);
let out = expand_argv(
argv(&["tess", "--errorlog", "msg~test", "url~/api/"]),
&groups,
);
assert_eq!(
out,
argv(&[
"tess",
"--format", "apache-combined",
"/log",
"--filter", "msg~test",
"--filter", "url~/api/",
])
);
}
#[test]
fn expand_argv_leaves_flags_alone_after_group() {
let mut groups: HashMap<String, Group> = HashMap::new();
groups.insert("errorlog".into(), group("errorlog"));
let out = expand_argv(
argv(&["tess", "--errorlog", "--tail", "50", "msg=hi"]),
&groups,
);
assert_eq!(
out,
argv(&["tess", "--tail", "50", "--filter", "msg=hi"])
);
}
#[test]
fn expand_argv_user_flag_after_group_can_override_tail() {
let mut groups: HashMap<String, Group> = HashMap::new();
groups.insert(
"errorlog".into(),
Group { name: "errorlog".into(), tail: Some(1000), ..Group::default() },
);
let out = expand_argv(argv(&["tess", "--errorlog", "--tail", "50"]), &groups);
assert!(out.windows(2).any(|w| w == ["--tail", "1000"]));
assert!(out.windows(2).any(|w| w == ["--tail", "50"]));
let pos_1000 = out.iter().position(|x| x == "1000").unwrap();
let pos_50 = out.iter().position(|x| x == "50").unwrap();
assert!(pos_1000 < pos_50, "user's value must come after group's");
}
#[test]
fn expand_argv_treats_grep_value_as_flag_arg_not_filter() {
let mut groups: HashMap<String, Group> = HashMap::new();
groups.insert("errorlog".into(), group("errorlog"));
let out = expand_argv(
argv(&["tess", "--errorlog", "--grep", "timeout", "msg=hi"]),
&groups,
);
assert_eq!(
out,
argv(&["tess", "--grep", "timeout", "--filter", "msg=hi"])
);
}
#[test]
fn expand_argv_unknown_double_dash_passes_through() {
let groups: HashMap<String, Group> = HashMap::new();
let out = expand_argv(argv(&["tess", "--unknown"]), &groups);
assert_eq!(out, argv(&["tess", "--unknown"]));
}
#[test]
fn load_groups_rejects_reserved_name() {
let _g = HOME_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let cfg_dir = tmp.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("formats.toml"),
r#"
[group.follow]
file = "/x.log"
"#,
)
.unwrap();
let saved = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let result = load_groups();
if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
let err = result.unwrap_err();
assert!(err.contains("collides with built-in --follow"), "{err}");
}
#[test]
fn user_config_overrides_builtin_via_load_all() {
let _g = HOME_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let cfg_dir = tmp.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_file = cfg_dir.join("formats.toml");
std::fs::write(
&cfg_file,
r#"
[format.apache-common]
regex = "^(?P<custom>\\S+)$"
"#,
)
.unwrap();
let saved = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let result = load_all();
if let Some(h) = saved { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
let formats = result.unwrap();
let common = &formats["apache-common"];
assert_eq!(common.field_names, vec!["custom"], "user config should win");
}
#[test]
fn format_entry_parses_record_start() {
let toml_text = r#"
[format.myapp]
regex = '^(?P<line>.*)$'
record_start = '^\['
"#;
let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
let entry = cfg.format.get("myapp").expect("myapp present");
assert_eq!(entry.regex, "^(?P<line>.*)$");
assert_eq!(entry.record_start.as_deref(), Some("^\\["));
}
#[test]
fn format_entry_record_start_optional() {
let toml_text = r#"
[format.myapp]
regex = '^(?P<line>.*)$'
"#;
let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
let entry = cfg.format.get("myapp").expect("myapp present");
assert!(entry.record_start.is_none());
}
#[test]
fn layered_loader_local_overrides_global() {
let _guard = HOME_LOCK.lock().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
let home = tempfile::tempdir().unwrap();
let global = tempfile::tempdir().unwrap();
std::env::set_var("HOME", home.path());
std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
std::fs::write(
global.path().join("formats.toml"),
r#"
[format.shared]
regex = "^GLOBAL (?P<msg>.+)$"
[format.both]
regex = "^GLOBAL_BOTH (?P<msg>.+)$"
"#,
)
.unwrap();
let cfg_dir = home.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("formats.toml"),
r#"
[format.both]
regex = "^LOCAL_BOTH (?P<msg>.+)$"
[format.local-only]
regex = "^LOCAL (?P<msg>.+)$"
"#,
)
.unwrap();
let cfg = load_layered_config().unwrap();
assert!(cfg.global.format.contains_key("shared"));
assert!(!cfg.local.format.contains_key("shared"));
assert_eq!(
cfg.global.format.get("both").unwrap().regex,
"^GLOBAL_BOTH (?P<msg>.+)$"
);
assert_eq!(
cfg.local.format.get("both").unwrap().regex,
"^LOCAL_BOTH (?P<msg>.+)$"
);
assert!(cfg.local.format.contains_key("local-only"));
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_global {
Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
}
}
#[test]
fn layered_loader_warns_on_bad_global_toml() {
let _guard = HOME_LOCK.lock().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
let home = tempfile::tempdir().unwrap();
let global = tempfile::tempdir().unwrap();
std::env::set_var("HOME", home.path());
std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
std::fs::write(
global.path().join("formats.toml"),
"this is not valid toml = = =",
)
.unwrap();
let cfg = load_layered_config().unwrap();
assert!(cfg.global.format.is_empty());
assert!(cfg.global.group.is_empty());
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_global {
Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
}
}
#[test]
fn layered_loader_fails_on_bad_local_toml() {
let _guard = HOME_LOCK.lock().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
let home = tempfile::tempdir().unwrap();
std::env::set_var("HOME", home.path());
std::env::remove_var("TESS_GLOBAL_CONFIG_DIR");
let cfg_dir = home.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("formats.toml"),
"this is not valid toml = = =",
)
.unwrap();
let err = load_layered_config().unwrap_err();
assert!(err.contains("formats.toml"), "got: {err}");
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_global {
Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
}
}
#[test]
fn log_format_compile_full_with_record_start() {
let fmt = LogFormat::compile_full(
"test",
r"^(?P<msg>.+)$",
None,
Some(r"^\["),
None,
).expect("compile");
assert!(fmt.record_start.is_some());
assert!(fmt.record_start.as_ref().unwrap().is_match("[2026-05-15"));
assert!(!fmt.record_start.as_ref().unwrap().is_match(" continuation"));
}
#[test]
fn log_format_compile_full_bad_record_start_errors() {
let err = LogFormat::compile_full(
"test",
r"^(?P<msg>.+)$",
None,
Some(r"["), None,
).expect_err("should fail");
assert!(err.contains("record_start"), "error mentions record_start: {err}");
}
#[test]
fn group_with_grep_field_deserializes() {
let toml_text = r#"
[group.errorlog]
format = "app"
grep = ["timeout", "deadlock"]
"#;
let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
let entry = cfg.group.get("errorlog").expect("errorlog present");
assert_eq!(entry.grep, vec!["timeout".to_string(), "deadlock".to_string()]);
}
#[test]
fn expand_argv_emits_group_grep_flags() {
let mut groups = HashMap::new();
groups.insert("errorlog".to_string(), Group {
name: "errorlog".to_string(),
grep: vec!["timeout".to_string(), "deadlock".to_string()],
..Default::default()
});
let out = expand_argv(
argv(&["tess", "--errorlog", "logs.txt"]),
&groups,
);
let joined = out.join(" ");
assert!(joined.contains("--grep timeout"), "got: {joined}");
assert!(joined.contains("--grep deadlock"), "got: {joined}");
}
#[test]
fn user_grep_after_group_accumulates() {
let mut groups = HashMap::new();
groups.insert("errorlog".to_string(), Group {
name: "errorlog".to_string(),
grep: vec!["timeout".to_string()],
..Default::default()
});
let out = expand_argv(
argv(&["tess", "--errorlog", "--grep", "extra", "logs.txt"]),
&groups,
);
let joined = out.join(" ");
assert!(joined.contains("--grep timeout"));
assert!(joined.contains("--grep extra"));
}
#[test]
fn format_entry_parses_prompt() {
let toml_text = r#"
[format.myapp]
regex = '^(?P<line>.*)$'
prompt = '<label> <pct>%'
"#;
let cfg: UserConfig = toml::from_str(toml_text).expect("parse");
let entry = cfg.format.get("myapp").expect("myapp present");
assert_eq!(entry.prompt.as_deref(), Some("<label> <pct>%"));
}
#[test]
fn load_all_tags_source_correctly() {
let _guard = HOME_LOCK.lock().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
let home = tempfile::tempdir().unwrap();
let global = tempfile::tempdir().unwrap();
std::env::set_var("HOME", home.path());
std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
std::fs::write(
global.path().join("formats.toml"),
r#"
[format.global-only]
regex = "^G (?P<msg>.+)$"
[format.both]
regex = "^GLOBAL (?P<msg>.+)$"
"#,
)
.unwrap();
let cfg_dir = home.path().join(".config").join("tess");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("formats.toml"),
r#"
[format.local-only]
regex = "^L (?P<msg>.+)$"
[format.both]
regex = "^LOCAL (?P<msg>.+)$"
"#,
)
.unwrap();
let all = load_all().unwrap();
assert_eq!(
all["apache-common"].source,
crate::config_path::ConfigSource::Builtin
);
assert!(all["apache-common"].overrides.is_none());
assert_eq!(
all["global-only"].source,
crate::config_path::ConfigSource::Global
);
assert!(all["global-only"].overrides.is_none());
assert_eq!(
all["local-only"].source,
crate::config_path::ConfigSource::Local
);
assert!(all["local-only"].overrides.is_none());
assert_eq!(
all["both"].source,
crate::config_path::ConfigSource::Local
);
assert_eq!(
all["both"].overrides,
Some(crate::config_path::ConfigSource::Global)
);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_global {
Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
}
}
#[test]
fn source_label_renders_correctly() {
use crate::config_path::ConfigSource;
assert_eq!(format_source_label(ConfigSource::Builtin, None), "[built-in]");
assert_eq!(format_source_label(ConfigSource::Global, None), "[global]");
assert_eq!(format_source_label(ConfigSource::Local, None), "[local]");
assert_eq!(
format_source_label(ConfigSource::Local, Some(ConfigSource::Global)),
"[local, overrides global]"
);
assert_eq!(
format_source_label(ConfigSource::Local, Some(ConfigSource::Builtin)),
"[local, overrides built-in]"
);
assert_eq!(
format_source_label(ConfigSource::Global, Some(ConfigSource::Builtin)),
"[global, overrides built-in]"
);
}
}