Skip to main content

freeswitch_log_parser/
stamp.rs

1//! The `YYYY-MM-DD-HH-MM-SS` stamp form, which sorts lexicographically and so
2//! lets a caller order rotated files and window entries without a date library.
3
4use crate::mask::Mask;
5
6/// Width of the `YYYY-MM-DD-HH-MM-SS` stamp form.
7const STAMP_LEN: usize = 19;
8
9const STAMP_MASK: Mask = Mask {
10    len: STAMP_LEN,
11    separators: &[(4, b'-'), (7, b'-'), (10, b'-'), (13, b'-'), (16, b'-')],
12    filler: u8::is_ascii_digit,
13};
14
15/// The rotation stamp encoded in a `freeswitch.log.*` filename, or `None` for the
16/// active log and for any name that does not carry one.
17///
18/// Only the stamp is read; whatever logrotate appends after it (a sequence
19/// number, a compression suffix) is ignored.
20pub fn log_rotation_stamp(filename: &str) -> Option<&str> {
21    let rest = filename.strip_prefix("freeswitch.log.")?;
22    let candidate = rest.get(..STAMP_LEN)?;
23    STAMP_MASK
24        .matches_at(candidate.as_bytes(), 0)
25        .then_some(candidate)
26}
27
28/// Rewrite a log entry's `YYYY-MM-DD HH:MM:SS.ffffff` timestamp into the stamp
29/// form, dropping the sub-second part so it compares against a filename stamp.
30///
31/// Input too short to hold a full timestamp is normalized as best it can be
32/// rather than rejected, so a partially parsed entry still windows sanely.
33pub fn normalize_entry_timestamp(ts: &str) -> String {
34    // `get` rather than a byte slice: a caller can hand this any string, and a
35    // multibyte codepoint straddling either bound would panic on `&ts[..n]`.
36    if let (Some(date), Some(time)) = (ts.get(..10), ts.get(11..STAMP_LEN)) {
37        return format!("{date}-{}", time.replace(':', "-"));
38    }
39    loose_stamp(ts)
40}
41
42/// The stamp form of whatever `input` spells, however partial: the separators
43/// an operator is likely to type become `-`, and a trailing one is dropped.
44fn loose_stamp(input: &str) -> String {
45    let mut s = input.replace(['T', ':', ' '], "-");
46    while s.ends_with('-') {
47        s.pop();
48    }
49    s
50}
51
52/// Fill a partial stamp out to all six components, taking each missing one
53/// from `defaults`.
54fn pad_stamp(s: &str, defaults: [&str; 6]) -> String {
55    let parts: Vec<&str> = s.split('-').collect();
56    defaults
57        .iter()
58        .enumerate()
59        .map(|(i, default)| match parts.get(i) {
60            Some(p) if !p.is_empty() => *p,
61            _ => default,
62        })
63        .collect::<Vec<_>>()
64        .join("-")
65}
66
67/// The earliest stamp a partial date names — `"2026-03"` becomes
68/// `"2026-03-01-00-00-00"` — for use as an inclusive window start.
69pub fn stamp_lower_bound(input: &str) -> String {
70    pad_stamp(&loose_stamp(input), ["0000", "01", "01", "00", "00", "00"])
71}
72
73/// The latest stamp a partial date names — `"2026-03"` becomes
74/// `"2026-03-31-23-59-59"` — for use as an inclusive window end.
75///
76/// The day defaults to 31 whatever the month holds: the stamp is compared
77/// lexicographically, so a bound past the month's last day excludes nothing.
78pub fn stamp_upper_bound(input: &str) -> String {
79    pad_stamp(&loose_stamp(input), ["9999", "12", "31", "23", "59", "59"])
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn stamp_from_rotated_name() {
88        assert_eq!(
89            log_rotation_stamp("freeswitch.log.2026-03-08-16-52-07.1.xz"),
90            Some("2026-03-08-16-52-07"),
91        );
92        assert_eq!(
93            log_rotation_stamp("freeswitch.log.2026-03-08-16-52-07"),
94            Some("2026-03-08-16-52-07"),
95        );
96    }
97
98    #[test]
99    fn no_stamp_on_active_log_or_foreign_names() {
100        assert_eq!(log_rotation_stamp("freeswitch.log"), None);
101        assert_eq!(log_rotation_stamp("freeswitch.log.1.xz"), None);
102        assert_eq!(log_rotation_stamp("freeswitch.log.not-a-date-here!!"), None);
103        assert_eq!(log_rotation_stamp("other.log.2026-03-08-16-52-07"), None);
104    }
105
106    #[test]
107    fn stamp_rejects_multibyte_boundary() {
108        // `get(..19)` returns None rather than panicking mid-codepoint.
109        assert_eq!(
110            log_rotation_stamp("freeswitch.log.2026-03-08-16-52-é"),
111            None
112        );
113    }
114
115    #[test]
116    fn entry_timestamp_normalized() {
117        assert_eq!(
118            normalize_entry_timestamp("2026-03-08 16:52:07.123456"),
119            "2026-03-08-16-52-07",
120        );
121        assert_eq!(
122            normalize_entry_timestamp("2026-03-08 16:52:07"),
123            "2026-03-08-16-52-07",
124        );
125    }
126
127    #[test]
128    fn short_entry_timestamp_still_normalizes() {
129        assert_eq!(normalize_entry_timestamp("2026-03-08"), "2026-03-08");
130        assert_eq!(normalize_entry_timestamp("2026-03-08 "), "2026-03-08");
131        assert_eq!(normalize_entry_timestamp(""), "");
132    }
133
134    #[test]
135    fn multibyte_entry_timestamp_does_not_panic() {
136        // A codepoint straddling either slice bound would panic on a byte slice.
137        assert_eq!(
138            normalize_entry_timestamp("2026-03-08é16:52:07"),
139            "2026-03-08é16-52-07"
140        );
141        assert_eq!(
142            normalize_entry_timestamp("2026-03-0é 16:52:07"),
143            "2026-03-0é-16-52-07"
144        );
145        assert_eq!(normalize_entry_timestamp("ééééééééé"), "ééééééééé");
146    }
147
148    #[test]
149    fn bounds_fill_a_partial_date_from_both_ends() {
150        for (input, lower, upper) in [
151            ("2026", "2026-01-01-00-00-00", "2026-12-31-23-59-59"),
152            ("2026-03", "2026-03-01-00-00-00", "2026-03-31-23-59-59"),
153            ("2026-03-08", "2026-03-08-00-00-00", "2026-03-08-23-59-59"),
154            (
155                "2026-03-08T15:48",
156                "2026-03-08-15-48-00",
157                "2026-03-08-15-48-59",
158            ),
159            (
160                "2026-03-08 15:48:07",
161                "2026-03-08-15-48-07",
162                "2026-03-08-15-48-07",
163            ),
164        ] {
165            assert_eq!(stamp_lower_bound(input), lower, "lower for {input}");
166            assert_eq!(stamp_upper_bound(input), upper, "upper for {input}");
167        }
168    }
169
170    #[test]
171    fn an_empty_bound_spans_everything() {
172        assert!(stamp_lower_bound("") < stamp_upper_bound(""));
173        assert!(stamp_lower_bound("") < normalize_entry_timestamp("2026-03-08 16:52:07.123456"));
174    }
175
176    #[test]
177    fn normalized_forms_compare_lexicographically() {
178        let entry = normalize_entry_timestamp("2026-03-08 16:52:07.123456");
179        let earlier = log_rotation_stamp("freeswitch.log.2026-03-08-00-00-00.1.xz").unwrap();
180        let later = log_rotation_stamp("freeswitch.log.2026-03-09-00-00-00.1.xz").unwrap();
181        assert!(entry.as_str() > earlier);
182        assert!(entry.as_str() < later);
183    }
184}