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/// Compact storage for the raw continuation lines of a log entry.
17///
18/// Iteration yields each pushed line as `&str` in insertion order. The
19/// type is API-equivalent to a read-only `[String]` for the patterns used
20/// in this crate (`len`, `is_empty`, `iter`, `get`, indexed access via
21/// `get(i)`).
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct AttachedLines {
24    buf: String,
25    offsets: Vec<u32>,
26}
27
28impl AttachedLines {
29    /// Create an empty `AttachedLines` with no allocations.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Number of stored lines.
35    pub fn len(&self) -> usize {
36        self.offsets.len()
37    }
38
39    /// `true` when no lines have been pushed.
40    pub fn is_empty(&self) -> bool {
41        self.offsets.is_empty()
42    }
43
44    /// Append a line. The trailing `\n` separator is added internally and is
45    /// not part of the line returned by [`Self::get`] or [`Self::iter`].
46    ///
47    /// Panics if the buffer would exceed 4 GiB — `u32` offsets can address up
48    /// to that, and a single log entry larger than that is structurally
49    /// impossible under `mod_logfile`'s 2 KiB-per-physical-line budget.
50    pub fn push(&mut self, line: &str) {
51        let start = u32::try_from(self.buf.len()).expect("attached buffer exceeded 4 GiB");
52        self.offsets.push(start);
53        self.buf.push_str(line);
54        self.buf.push('\n');
55    }
56
57    /// Borrow the i-th line, or `None` if out of range.
58    pub fn get(&self, i: usize) -> Option<&str> {
59        let start = *self.offsets.get(i)? as usize;
60        let end = self
61            .offsets
62            .get(i + 1)
63            .map(|&o| o as usize - 1)
64            .unwrap_or_else(|| self.buf.len() - 1);
65        Some(&self.buf[start..end])
66    }
67
68    /// Iterate over the stored lines in insertion order.
69    pub fn iter(&self) -> AttachedLinesIter<'_> {
70        AttachedLinesIter {
71            lines: self,
72            pos: 0,
73        }
74    }
75}
76
77impl<'a> IntoIterator for &'a AttachedLines {
78    type Item = &'a str;
79    type IntoIter = AttachedLinesIter<'a>;
80
81    fn into_iter(self) -> Self::IntoIter {
82        self.iter()
83    }
84}
85
86/// Iterator over the lines of an [`AttachedLines`].
87#[derive(Debug, Clone)]
88pub struct AttachedLinesIter<'a> {
89    lines: &'a AttachedLines,
90    pos: usize,
91}
92
93impl<'a> Iterator for AttachedLinesIter<'a> {
94    type Item = &'a str;
95
96    fn next(&mut self) -> Option<&'a str> {
97        let line = self.lines.get(self.pos)?;
98        self.pos += 1;
99        Some(line)
100    }
101
102    fn size_hint(&self) -> (usize, Option<usize>) {
103        let remaining = self.lines.len() - self.pos;
104        (remaining, Some(remaining))
105    }
106}
107
108impl ExactSizeIterator for AttachedLinesIter<'_> {}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn empty_default() {
116        let a = AttachedLines::new();
117        assert_eq!(a.len(), 0);
118        assert!(a.is_empty());
119        assert!(a.get(0).is_none());
120        assert_eq!(a.iter().count(), 0);
121    }
122
123    #[test]
124    fn push_and_iterate_preserves_order_and_content() {
125        let mut a = AttachedLines::new();
126        a.push("first");
127        a.push("");
128        a.push("third line");
129        assert_eq!(a.len(), 3);
130        assert!(!a.is_empty());
131        assert_eq!(a.get(0), Some("first"));
132        assert_eq!(a.get(1), Some(""));
133        assert_eq!(a.get(2), Some("third line"));
134        assert!(a.get(3).is_none());
135        let collected: Vec<&str> = a.iter().collect();
136        assert_eq!(collected, vec!["first", "", "third line"]);
137    }
138
139    #[test]
140    fn intoiter_for_ref_works_in_for_loop() {
141        let mut a = AttachedLines::new();
142        a.push("a");
143        a.push("b");
144        let mut out = Vec::new();
145        for line in &a {
146            out.push(line.to_string());
147        }
148        assert_eq!(out, vec!["a".to_string(), "b".to_string()]);
149    }
150
151    #[test]
152    fn lines_with_embedded_separators_round_trip() {
153        // The parser never feeds embedded newlines today, but the API should not
154        // corrupt content that happens to contain them — the offset table
155        // delimits by index, not by scanning for '\n'.
156        let mut a = AttachedLines::new();
157        a.push("has\nnewline");
158        a.push("plain");
159        assert_eq!(a.get(0), Some("has\nnewline"));
160        assert_eq!(a.get(1), Some("plain"));
161    }
162
163    #[test]
164    fn allocation_pattern_is_logarithmic_not_per_line() {
165        // Push 200 typical CHANNEL_DATA variable lines. Buffer capacity should
166        // grow logarithmically (capacity-doubling), not 200 separate allocations.
167        let mut a = AttachedLines::new();
168        for i in 0..200 {
169            a.push(&format!(
170                "variable_some_long_name_{i}: [a typical value here]"
171            ));
172        }
173        assert_eq!(a.len(), 200);
174        // Round-trip check: every line readable in order.
175        for (i, line) in a.iter().enumerate() {
176            assert!(line.starts_with(&format!("variable_some_long_name_{i}")));
177        }
178    }
179}