Skip to main content

logging/formatter/
mod.rs

1//! Pattern-based rendering of log records into strings.
2//!
3//! [`Formatter`] parses a pattern once into segments; the private `spec`
4//! module handles the per-field alignment/width mini-language (`-min.max`).
5
6mod spec;
7
8use crate::types::{Record, RecordExt};
9use spec::Spec;
10use std::collections::HashMap;
11
12/// A post-processing hook: receives the record and the fully rendered line and
13/// returns the final string (e.g. to add ANSI colors).
14type Transformer = Box<dyn Fn(&Record, &String) -> String + Send + Sync>;
15
16/// A registered field: a name plus a function that transforms the raw record
17/// value before it is padded/aligned (e.g. uppercasing a level).
18#[derive(Debug, Clone)]
19pub struct FieldFormat {
20    name: String,
21    resolve: fn(&str) -> &str,
22}
23impl FieldFormat {
24    fn new(name: String, resolve: fn(&str) -> &str) -> Self {
25        Self { name, resolve }
26    }
27}
28
29/// Renders a [`Record`] into a string according to a pattern.
30///
31/// The pattern is parsed once (at construction) into a list of [`Segment`]s, so
32/// [`format`](Formatter::format) does no parsing per call. Patterns mix literal
33/// text with field placeholders of the form `%(name)` optionally followed by a
34/// format spec (alignment/width), e.g. `"[%(level)-8] %(message)"`.
35pub struct Formatter {
36    fields: HashMap<String, FieldFormat>,
37    transformer: Option<Transformer>,
38    segments: Vec<Segment>,
39    /// The [`chrono`](https://docs.rs/chrono) `strftime` format used to render
40    /// the `timestamp` field (e.g. `"%Y-%m-%d %H:%M:%S"`).
41    pub time_format: &'static str,
42}
43
44/// One parsed piece of a pattern: either fixed literal text or a field
45/// placeholder with its parsed [`Spec`].
46#[derive(Debug)]
47enum Segment {
48    Literal(String),
49    Field { name: String, spec: Spec },
50}
51
52impl Formatter {
53    /// Creates a formatter from a pattern string, parsing it into segments now
54    /// so that [`format`](Formatter::format) is allocation-light per call.
55    pub fn new(pattern: &str) -> Self {
56        Self {
57            fields: HashMap::new(),
58            transformer: None,
59            segments: Self::parse(pattern),
60            time_format: "%Y-%m-%d %H:%M:%S",
61        }
62    }
63
64    /// Registers a transform for a field name. When a `%(name)` placeholder is
65    /// rendered, the raw record value is passed through `resolve` before
66    /// alignment/width is applied. Returns `&mut Self` for chaining.
67    pub fn add_field(&mut self, name: &str, resolve: fn(&str) -> &str) -> &mut Self {
68        self.fields.insert(
69            name.to_string(),
70            FieldFormat::new(name.to_string(), resolve),
71        );
72        self
73    }
74
75    /// Renders `record` into a string, substituting each `%(name)` placeholder
76    /// with the record's value (or `""` if absent), applying any registered
77    /// field transform and the placeholder's spec. A trailing newline is
78    /// appended. If a transformer is set, it post-processes the whole line.
79    pub fn format(&self, record: &Record) -> String {
80        let mut result = String::with_capacity(64);
81        for segment in self.segments.iter() {
82            match segment {
83                Segment::Literal(literal) => result.push_str(literal),
84                Segment::Field { name, spec } => {
85                    let raw = match self.fields.get(name) {
86                        Some(f) => (f.resolve)(record.get_or_default(f.name.as_str())),
87                        None => record.get_or_default(name),
88                    };
89                    result.push_str(&spec.apply(raw));
90                }
91            }
92        }
93        result.push('\n');
94        if let Some(transformer) = &self.transformer {
95            result = transformer(record, &result);
96        }
97        result
98    }
99
100    /// Installs a post-processing hook that receives the record and the fully
101    /// rendered line and returns the final string (e.g. to add ANSI colors).
102    pub fn set_transformer(
103        &mut self,
104        transformer: impl Fn(&Record, &String) -> String + Send + Sync + 'static,
105    ) {
106        self.transformer = Some(Box::new(move |record, result| transformer(record, result)));
107    }
108
109    /// Sets the [`chrono`](https://docs.rs/chrono) `strftime` format used to
110    /// render the `timestamp` field.
111    pub fn set_time_format(&mut self, time_format: &'static str) {
112        self.time_format = time_format;
113    }
114
115    /// Splits a pattern into [`Segment`]s. Walks a shrinking `&str`: a `%(name)`
116    /// run becomes a `Field` (with its spec parsed via [`Spec::parse`], which
117    /// reports how much of the trailing text was spec so the rest stays
118    /// literal), everything else becomes `Literal`. An unterminated `%(` is
119    /// treated as a literal.
120    fn parse(pattern: &str) -> Vec<Segment> {
121        let mut segments = Vec::new();
122        let mut rest = pattern;
123        while !rest.is_empty() {
124            if let Some(stripped) = rest.strip_prefix("%(") {
125                if let Some(close) = stripped.find(")") {
126                    let name = &stripped[..close];
127                    let tail = &stripped[close + 1..];
128
129                    let (spec, end) = Spec::parse(tail);
130
131                    segments.push(Segment::Field {
132                        name: name.to_string(),
133                        spec,
134                    });
135
136                    rest = &tail[end..];
137                } else {
138                    segments.push(Segment::Literal(rest.to_string()));
139                    break;
140                }
141            } else {
142                let end = rest.find("%(").unwrap_or(rest.len());
143                segments.push(Segment::Literal(rest[..end].to_string()));
144                rest = &rest[end..];
145            }
146        }
147
148        segments
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn substitutes_fields_and_literals() {
158        let formatter = Formatter::new("%(timestamp) - %(message)");
159        let record = HashMap::from([
160            ("timestamp", "2026-01-01 12:00:00"),
161            ("message", "Hello, world!"),
162        ]);
163        // format appends a trailing newline.
164        assert_eq!(
165            formatter.format(&record),
166            "2026-01-01 12:00:00 - Hello, world!\n"
167        );
168    }
169
170    #[test]
171    fn missing_field_renders_empty() {
172        let formatter = Formatter::new("[%(missing)]");
173        let record: Record = HashMap::new();
174        assert_eq!(formatter.format(&record), "[]\n");
175    }
176
177    #[test]
178    fn preserves_literal_immediately_after_spec() {
179        // Regression: the spec ("-7.4") must not swallow the following "]  ".
180        let formatter = Formatter::new("[%(name)-7.4]  %(message)");
181        let record = HashMap::from([("name", "ab"), ("message", "hi")]);
182        // name: truncate to 4 ("ab" unaffected), left-pad to 7 -> "ab     ";
183        // then literal "]  ", then message. The "]  " must survive the spec parse.
184        assert_eq!(formatter.format(&record), "[ab     ]  hi\n");
185    }
186
187    #[test]
188    fn right_aligns_and_truncates_via_spec() {
189        let formatter = Formatter::new("%(level)8.3");
190        let record = HashMap::from([("level", "WARNING")]);
191        // truncate to "WAR", right-align to width 8.
192        assert_eq!(formatter.format(&record), "     WAR\n");
193    }
194
195    #[test]
196    fn field_transform_is_applied_before_spec() {
197        let mut formatter = Formatter::new("%(level)");
198        formatter.add_field("level", |_| "INFO");
199        let record = HashMap::from([("level", "info")]);
200        assert_eq!(formatter.format(&record), "INFO\n");
201    }
202
203    #[test]
204    fn unterminated_placeholder_is_literal() {
205        let formatter = Formatter::new("oops %(name");
206        let record = HashMap::from([("name", "x")]);
207        assert_eq!(formatter.format(&record), "oops %(name\n");
208    }
209
210    #[test]
211    fn transformer_post_processes_line() {
212        let mut formatter = Formatter::new("%(message)");
213        formatter.set_transformer(|_record, line| format!(">> {}", line));
214        let record = HashMap::from([("message", "hi")]);
215        assert_eq!(formatter.format(&record), ">> hi\n");
216    }
217}