Skip to main content

nros_log/
buffer.rs

1//! Call-site formatting buffer for the `nros_*!` macros.
2//!
3//! Wraps a `heapless::String<N>` where `N` is the `NROS_LOG_BUFFER_SIZE`
4//! knob (issue 1037; builtin 256, resolved by `build.rs` on the RFC-0049
5//! ladder — it replaced the `buffer-size-<N>` features). Overflow truncates
6//! and appends `…` rather than dropping the record.
7
8use core::fmt::{self, Write};
9
10/// Returns the configured capacity in bytes.
11#[must_use]
12pub const fn format_buffer_capacity() -> usize {
13    crate::config::BUFFER_SIZE
14}
15
16const CAPACITY: usize = format_buffer_capacity();
17
18/// Stack-resident UTF-8 buffer for one log record's formatted body.
19///
20/// `Write` impl truncates + appends a single `…` (3 bytes) on
21/// overflow; subsequent writes drop silently. The truncated content
22/// is still valid UTF-8 (`…` is 3 bytes; we shrink first to make
23/// room rather than splitting a multi-byte sequence).
24pub struct FormatBuffer {
25    inner: heapless::String<CAPACITY>,
26    truncated: bool,
27}
28
29impl FormatBuffer {
30    /// Empty buffer at the configured capacity.
31    #[must_use]
32    pub fn new() -> Self {
33        Self {
34            inner: heapless::String::new(),
35            truncated: false,
36        }
37    }
38
39    /// Borrow the current contents as `&str`.
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        self.inner.as_str()
43    }
44
45    /// Whether the formatter saw an overflow on at least one write.
46    #[must_use]
47    pub fn truncated(&self) -> bool {
48        self.truncated
49    }
50}
51
52impl Default for FormatBuffer {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl Write for FormatBuffer {
59    fn write_str(&mut self, s: &str) -> fmt::Result {
60        if self.truncated {
61            return Ok(());
62        }
63        match self.inner.push_str(s) {
64            Ok(()) => Ok(()),
65            Err(()) => {
66                self.truncated = true;
67                // Reserve 3 bytes for the ellipsis. heapless::String
68                // exposes capacity / len; shrink without splitting a
69                // multi-byte UTF-8 boundary.
70                let target_len = CAPACITY.saturating_sub(3);
71                // Keep as much of THIS write as fits before the ellipsis
72                // (phase-488). It used to be dropped whole, so a message whose
73                // long argument came second logged as its first few bytes:
74                // `"{} {}"` with a long string printed `6…`.
75                let room = target_len.saturating_sub(self.inner.len());
76                let mut cut = s.len().min(room);
77                while cut > 0 && !s.is_char_boundary(cut) {
78                    cut -= 1;
79                }
80                let _ = self.inner.push_str(&s[..cut]);
81                // `is_char_boundary`, not a test of the byte BEFORE the cut:
82                // that one stopped on a lead byte and split the character
83                // (heapless's `truncate` panics on it).
84                let mut trunc_at = self.inner.len().min(target_len);
85                while trunc_at > 0 && !self.inner.as_str().is_char_boundary(trunc_at) {
86                    trunc_at -= 1;
87                }
88                self.inner.truncate(trunc_at);
89                let _ = self.inner.push('\u{2026}');
90                Ok(())
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use core::fmt::Write;
100
101    #[test]
102    fn small_payload_fits() {
103        let mut b = FormatBuffer::new();
104        write!(b, "hello {}", 42).unwrap();
105        assert_eq!(b.as_str(), "hello 42");
106        assert!(!b.truncated());
107    }
108
109    #[test]
110    fn overflow_truncates_and_appends_ellipsis() {
111        let mut b = FormatBuffer::new();
112        let pad = "x".repeat(CAPACITY * 2);
113        write!(b, "{}", pad).unwrap();
114        assert!(b.truncated());
115        assert!(b.as_str().ends_with('\u{2026}'));
116        assert!(b.as_str().len() <= CAPACITY);
117    }
118
119    /// phase-488 — an overflowing write keeps the part that fits. Before,
120    /// the whole write was dropped, so this logged `6…`.
121    #[test]
122    fn an_overflowing_write_keeps_what_fits() {
123        let mut b = FormatBuffer::new();
124        let pad = "y".repeat(CAPACITY * 2);
125        write!(b, "{} {}", 6, pad).unwrap();
126        assert!(b.truncated());
127        assert!(b.as_str().starts_with("6 yyy"), "got {:?}", b.as_str());
128        assert!(b.as_str().ends_with('\u{2026}'));
129        assert!(b.as_str().len() <= CAPACITY);
130        assert!(
131            b.as_str().len() >= CAPACITY - 3,
132            "fills the buffer: {}",
133            b.as_str().len()
134        );
135    }
136
137    /// A multi-byte character that does not fit is not split.
138    #[test]
139    fn an_overflowing_write_does_not_split_a_character() {
140        let mut b = FormatBuffer::new();
141        let pad = "\u{00e9}".repeat(CAPACITY);
142        write!(b, "x{}", pad).unwrap();
143        assert!(b.as_str().ends_with('\u{2026}'));
144        assert!(b.as_str().len() <= CAPACITY);
145    }
146
147    #[test]
148    fn capacity_matches_feature_default() {
149        // Default feature set selects 256.
150        assert_eq!(format_buffer_capacity(), 256);
151    }
152}