mod spec;
use crate::types::{Record, RecordExt};
use spec::Spec;
use std::collections::HashMap;
type Transformer = Box<dyn Fn(&Record, &String) -> String + Send + Sync>;
#[derive(Debug, Clone)]
pub struct FieldFormat {
name: String,
resolve: fn(&str) -> &str,
}
impl FieldFormat {
fn new(name: String, resolve: fn(&str) -> &str) -> Self {
Self { name, resolve }
}
}
pub struct Formatter {
fields: HashMap<String, FieldFormat>,
transformer: Option<Transformer>,
segments: Vec<Segment>,
pub time_format: &'static str,
}
#[derive(Debug)]
enum Segment {
Literal(String),
Field { name: String, spec: Spec },
}
impl Formatter {
pub fn new(pattern: &str) -> Self {
Self {
fields: HashMap::new(),
transformer: None,
segments: Self::parse(pattern),
time_format: "%Y-%m-%d %H:%M:%S",
}
}
pub fn add_field(&mut self, name: &str, resolve: fn(&str) -> &str) -> &mut Self {
self.fields.insert(
name.to_string(),
FieldFormat::new(name.to_string(), resolve),
);
self
}
pub fn format(&self, record: &Record) -> String {
let mut result = String::with_capacity(64);
for segment in self.segments.iter() {
match segment {
Segment::Literal(literal) => result.push_str(literal),
Segment::Field { name, spec } => {
let raw = match self.fields.get(name) {
Some(f) => (f.resolve)(record.get_or_default(f.name.as_str())),
None => record.get_or_default(name),
};
result.push_str(&spec.apply(raw));
}
}
}
result.push('\n');
if let Some(transformer) = &self.transformer {
result = transformer(record, &result);
}
result
}
pub fn set_transformer(
&mut self,
transformer: impl Fn(&Record, &String) -> String + Send + Sync + 'static,
) {
self.transformer = Some(Box::new(move |record, result| transformer(record, result)));
}
pub fn set_time_format(&mut self, time_format: &'static str) {
self.time_format = time_format;
}
fn parse(pattern: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut rest = pattern;
while !rest.is_empty() {
if let Some(stripped) = rest.strip_prefix("%(") {
if let Some(close) = stripped.find(")") {
let name = &stripped[..close];
let tail = &stripped[close + 1..];
let (spec, end) = Spec::parse(tail);
segments.push(Segment::Field {
name: name.to_string(),
spec,
});
rest = &tail[end..];
} else {
segments.push(Segment::Literal(rest.to_string()));
break;
}
} else {
let end = rest.find("%(").unwrap_or(rest.len());
segments.push(Segment::Literal(rest[..end].to_string()));
rest = &rest[end..];
}
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitutes_fields_and_literals() {
let formatter = Formatter::new("%(timestamp) - %(message)");
let record = HashMap::from([
("timestamp", "2026-01-01 12:00:00"),
("message", "Hello, world!"),
]);
assert_eq!(
formatter.format(&record),
"2026-01-01 12:00:00 - Hello, world!\n"
);
}
#[test]
fn missing_field_renders_empty() {
let formatter = Formatter::new("[%(missing)]");
let record: Record = HashMap::new();
assert_eq!(formatter.format(&record), "[]\n");
}
#[test]
fn preserves_literal_immediately_after_spec() {
let formatter = Formatter::new("[%(name)-7.4] %(message)");
let record = HashMap::from([("name", "ab"), ("message", "hi")]);
assert_eq!(formatter.format(&record), "[ab ] hi\n");
}
#[test]
fn right_aligns_and_truncates_via_spec() {
let formatter = Formatter::new("%(level)8.3");
let record = HashMap::from([("level", "WARNING")]);
assert_eq!(formatter.format(&record), " WAR\n");
}
#[test]
fn field_transform_is_applied_before_spec() {
let mut formatter = Formatter::new("%(level)");
formatter.add_field("level", |_| "INFO");
let record = HashMap::from([("level", "info")]);
assert_eq!(formatter.format(&record), "INFO\n");
}
#[test]
fn unterminated_placeholder_is_literal() {
let formatter = Formatter::new("oops %(name");
let record = HashMap::from([("name", "x")]);
assert_eq!(formatter.format(&record), "oops %(name\n");
}
#[test]
fn transformer_post_processes_line() {
let mut formatter = Formatter::new("%(message)");
formatter.set_transformer(|_record, line| format!(">> {}", line));
let record = HashMap::from([("message", "hi")]);
assert_eq!(formatter.format(&record), ">> hi\n");
}
}