Skip to main content

freeswitch_log_parser/
attached.rs

1//! Compact contiguous storage for the raw continuation lines that follow a
2//! primary log entry.
3//!
4//! Replaces the historical `Vec<String>` shape, which on real production
5//! CHANNEL_DATA dumps (140+ attached lines per entry, tens of thousands of
6//! entries per rotated log file) paid one heap allocation per attached line
7//! plus capacity-doubling reallocations on the outer `Vec`. The new shape
8//! amortizes both into a single growing `String` buffer plus a `Vec<u32>`
9//! offset table — typically two allocations per entry regardless of line
10//! count, dominated by buffer doubling rather than per-element churn.
11//!
12//! Lines are stored end-to-end in `buf` separated by `\n`. The separator is
13//! never exposed to callers — [`AttachedLines::iter`] and
14//! [`AttachedLines::get`] return `&str` slices that exclude it.
15
16/// One entry's attached lines outgrew the `u32` offsets addressing them.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct AttachedOverflow;
19
20impl std::fmt::Display for AttachedOverflow {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_str("attached lines exceed the 4 GiB an entry can address")
23    }
24}
25
26impl std::error::Error for AttachedOverflow {}
27
28/// Compact storage for the raw continuation lines of a log entry.
29///
30/// Iteration yields each pushed line as `&str` in insertion order. The
31/// type is API-equivalent to a read-only `[String]` for the patterns used
32/// in this crate (`len`, `is_empty`, `iter`, `get`, indexed access via
33/// `get(i)`).
34#[derive(Debug, Default, Clone, PartialEq, Eq)]
35pub struct AttachedLines {
36    buf: String,
37    offsets: Vec<u32>,
38}
39
40impl AttachedLines {
41    /// Create an empty `AttachedLines` with no allocations.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Number of stored lines.
47    pub fn len(&self) -> usize {
48        self.offsets.len()
49    }
50
51    /// `true` when no lines have been pushed.
52    pub fn is_empty(&self) -> bool {
53        self.offsets.is_empty()
54    }
55
56    /// Append a line. The trailing `\n` separator is added internally and is
57    /// not part of the line returned by [`Self::get`] or [`Self::iter`].
58    ///
59    /// Fails once the buffer would outgrow the `u32` offsets that address it.
60    /// `mod_logfile`'s write budget bounds one physical line, not how many join
61    /// an entry, so nothing upstream caps this — the caller decides what to do,
62    /// and the line is not stored.
63    pub fn push(&mut self, line: &str) -> Result<(), AttachedOverflow> {
64        let start = u32::try_from(self.buf.len()).map_err(|_| AttachedOverflow)?;
65        self.offsets.push(start);
66        self.buf.push_str(line);
67        self.buf.push('\n');
68        Ok(())
69    }
70
71    /// Borrow the i-th line, or `None` if out of range.
72    pub fn get(&self, i: usize) -> Option<&str> {
73        let start = *self.offsets.get(i)? as usize;
74        let end = self
75            .offsets
76            .get(i + 1)
77            .map(|&o| o as usize - 1)
78            .unwrap_or_else(|| self.buf.len() - 1);
79        Some(&self.buf[start..end])
80    }
81
82    /// Iterate over the stored lines in insertion order.
83    pub fn iter(&self) -> AttachedLinesIter<'_> {
84        AttachedLinesIter {
85            lines: self,
86            pos: 0,
87        }
88    }
89}
90
91impl<'a> IntoIterator for &'a AttachedLines {
92    type Item = &'a str;
93    type IntoIter = AttachedLinesIter<'a>;
94
95    fn into_iter(self) -> Self::IntoIter {
96        self.iter()
97    }
98}
99
100/// Iterator over the lines of an [`AttachedLines`].
101#[derive(Debug, Clone)]
102pub struct AttachedLinesIter<'a> {
103    lines: &'a AttachedLines,
104    pos: usize,
105}
106
107impl<'a> Iterator for AttachedLinesIter<'a> {
108    type Item = &'a str;
109
110    fn next(&mut self) -> Option<&'a str> {
111        let line = self.lines.get(self.pos)?;
112        self.pos += 1;
113        Some(line)
114    }
115
116    fn size_hint(&self) -> (usize, Option<usize>) {
117        let remaining = self.lines.len() - self.pos;
118        (remaining, Some(remaining))
119    }
120}
121
122impl ExactSizeIterator for AttachedLinesIter<'_> {}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn empty_default() {
130        let a = AttachedLines::new();
131        assert_eq!(a.len(), 0);
132        assert!(a.is_empty());
133        assert!(a.get(0).is_none());
134        assert_eq!(a.iter().count(), 0);
135    }
136
137    #[test]
138    fn push_and_iterate_preserves_order_and_content() {
139        let mut a = AttachedLines::new();
140        a.push("first").expect("fits");
141        a.push("").expect("fits");
142        a.push("third line").expect("fits");
143        assert_eq!(a.len(), 3);
144        assert!(!a.is_empty());
145        assert_eq!(a.get(0), Some("first"));
146        assert_eq!(a.get(1), Some(""));
147        assert_eq!(a.get(2), Some("third line"));
148        assert!(a.get(3).is_none());
149        let collected: Vec<&str> = a.iter().collect();
150        assert_eq!(collected, vec!["first", "", "third line"]);
151    }
152
153    #[test]
154    fn intoiter_for_ref_works_in_for_loop() {
155        let mut a = AttachedLines::new();
156        a.push("a").expect("fits");
157        a.push("b").expect("fits");
158        let mut out = Vec::new();
159        for line in &a {
160            out.push(line.to_string());
161        }
162        assert_eq!(out, vec!["a".to_string(), "b".to_string()]);
163    }
164
165    #[test]
166    fn lines_with_embedded_separators_round_trip() {
167        // The parser never feeds embedded newlines today, but the API should not
168        // corrupt content that happens to contain them — the offset table
169        // delimits by index, not by scanning for '\n'.
170        let mut a = AttachedLines::new();
171        a.push("has\nnewline").expect("fits");
172        a.push("plain").expect("fits");
173        assert_eq!(a.get(0), Some("has\nnewline"));
174        assert_eq!(a.get(1), Some("plain"));
175    }
176
177    #[test]
178    fn allocation_pattern_is_logarithmic_not_per_line() {
179        // Push 200 typical CHANNEL_DATA variable lines. Buffer capacity should
180        // grow logarithmically (capacity-doubling), not 200 separate allocations.
181        let mut a = AttachedLines::new();
182        for i in 0..200 {
183            a.push(&format!(
184                "variable_some_long_name_{i}: [a typical value here]"
185            ))
186            .expect("fits");
187        }
188        assert_eq!(a.len(), 200);
189        // Round-trip check: every line readable in order.
190        for (i, line) in a.iter().enumerate() {
191            assert!(line.starts_with(&format!("variable_some_long_name_{i}")));
192        }
193    }
194}