Skip to main content

atuin_common/string/
bounded_buffer.rs

1use std::collections::VecDeque;
2use std::fmt;
3
4/// Controls how many bytes a [`BoundedBuffer`] can store.
5#[derive(Debug, Clone, Copy, Eq, PartialEq)]
6pub struct Limit {
7    /// How many bytes from the start of the buffer to keep.
8    pub start: usize,
9    /// How many bytes from the end of the buffer to keep.
10    pub end: usize,
11}
12
13impl Limit {
14    /// Split a total byte budget evenly across the start and end of the buffer.
15    ///
16    /// An odd byte goes to the end, so a budget of 1 keeps a single trailing byte.
17    #[must_use]
18    pub const fn split_evenly(total_bytes: usize) -> Self {
19        let start = total_bytes / 2;
20        Self {
21            start,
22            end: total_bytes - start,
23        }
24    }
25}
26
27/// A string buffer limited to a certain length.
28#[derive(Clone)]
29pub struct BoundedBuffer {
30    start: String,
31    /// Invariant: `end` is valid UTF-8.
32    end: VecDeque<u8>,
33    truncated: bool,
34    limit: Limit,
35}
36
37/// The finished contents of a [`BoundedBuffer`].
38///
39/// Returned by [`BoundedBuffer::take`].
40#[derive(Debug, Clone, Eq, PartialEq)]
41pub struct BufferContents {
42    /// The start portion of the buffer's contents.
43    ///
44    /// If `end` is [`None`], this contains the entire contents of the buffer.
45    pub start: String,
46
47    /// The end portion of the buffer's contents.
48    ///
49    /// If this is [`None`], the buffer didn't need any truncation, so the entire contents are in
50    /// `start`. If this is [`Some`], there is a middle portion that was discarded.
51    pub end: Option<String>,
52}
53
54impl BoundedBuffer {
55    /// Create a new [`BoundedBuffer`] with the given size limit.
56    #[must_use]
57    pub fn new(limit: Limit) -> Self {
58        Self {
59            start: String::new(),
60            end: VecDeque::new(),
61            truncated: false,
62            limit,
63        }
64    }
65
66    /// Return the buffer's contents and reset the buffer back to the initial state.
67    pub fn take(&mut self) -> BufferContents {
68        let start = std::mem::take(&mut self.start);
69
70        if std::mem::take(&mut self.truncated) {
71            let end = Vec::from(std::mem::take(&mut self.end));
72            if cfg!(debug_assertions) {
73                std::str::from_utf8(&end).expect("invalid utf-8: this is UB!");
74            }
75
76            #[allow(
77                unsafe_code,
78                reason = "unchecked conversion is much faster (O(1) vs O(n)) and is guaranteed by \
79                          this type's invariants; every place where `self.end` is modified is \
80                          accompanied by a justification for why the invariant is necessarily \
81                          upheld, and we perform an explicit check in debug mode for extra \
82                          assurance"
83            )]
84            // SAFETY: Guaranteed by invariant on `self.end`.
85            let end = unsafe { String::from_utf8_unchecked(end) };
86            BufferContents {
87                start,
88                end: Some(end),
89            }
90        } else {
91            let mut start = start.into_bytes();
92            start.extend(&self.end);
93            self.end.clear();
94
95            if cfg!(debug_assertions) {
96                std::str::from_utf8(&start).expect("invalid utf-8: this is UB!");
97            }
98
99            #[allow(
100                unsafe_code,
101                reason = "unchecked conversion is much faster (O(1) vs O(n)) and is guaranteed by \
102                          this type's invariants; every place where `self.end` is modified is \
103                          accompanied by a justification for why the invariant is necessarily \
104                          upheld, and we perform an explicit check in debug mode for extra \
105                          assurance"
106            )]
107            // SAFETY: `self.end` is valid UTF-8 by its invariant. `start` is the concatenation of
108            // valid UTF-8 (guaranteed because it came from a `String`) with `self.end`, which
109            // necessarily produces valid UTF-8.
110            let start = unsafe { String::from_utf8_unchecked(start) };
111            BufferContents { start, end: None }
112        }
113    }
114
115    pub fn clear(&mut self) {
116        self.start.clear();
117        self.end.clear();
118        self.truncated = false;
119    }
120}
121
122impl fmt::Write for BoundedBuffer {
123    fn write_str(&mut self, s: &str) -> fmt::Result {
124        let s = if self.truncated || !self.end.is_empty() {
125            s
126        } else {
127            let available = self.limit.start - self.start.len();
128            if available >= s.len() {
129                self.start.push_str(s);
130                return Ok(());
131            }
132            let (start, end) = s.split_at(s.floor_char_boundary(available));
133            self.start.push_str(start);
134            end
135        };
136
137        if s.len() > self.limit.end {
138            self.truncated = true;
139            // Maintains the invariant because an empty sequence of bytes is valid UTF-8.
140            self.end.clear();
141            let tail = &s[s.ceil_char_boundary(s.len() - self.limit.end)..];
142            // Maintains the UTF-8 invariant because we're pushing a UTF-8 string slice.
143            self.end.extend(tail.as_bytes());
144            return Ok(());
145        }
146
147        let keep = self.limit.end - s.len();
148        if self.end.len() > keep {
149            self.truncated = true;
150            let keep_start = self.end.len() - keep;
151
152            // Find the nearest char boundary at or after `keep_start`.
153            let offset = self.end.iter().skip(keep_start).take(char::MAX_LEN_UTF8).position(|&b| {
154                // There are three possibilities for the return value of `from_utf8`:
155                //
156                // * `Ok(())`: `b` is ASCII and thus a valid char boundary.
157                // * `Err(e)` where `e.error_len()` is `None`: `b` is the start of a multibyte UTF-8
158                //   sequence and thus a valid char boundary.
159                // * `Err(e)` where `e.error_len()` is `Some`: `b` is the middle of a multibyte
160                //   UTF-8 sequence and thus *not* a valid char boundary.
161                !matches!(std::str::from_utf8(&[b]), Err(e) if e.error_len().is_some())
162            });
163
164            // Round `keep_start` up to the nearest char boundary.
165            let keep_start = if let Some(offset) = offset {
166                keep_start + offset
167            } else {
168                self.end.len()
169            };
170
171            // Maintains the invariant because we calculated `keep_start` to be a valid char
172            // boundary.
173            self.end.drain(..keep_start);
174        }
175
176        // Maintains the invariant because `s` is a valid UTF-8 string slice, and appending valid
177        // UTF-8 to other valid UTF-8 (which `self.end` is due to the invariant) always results in
178        // valid UTF-8.
179        self.end.extend(s.as_bytes());
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use std::fmt::Write as _;
187
188    use proptest::prelude::*;
189    use rstest::{fixture, rstest};
190
191    use super::{BoundedBuffer, BufferContents, Limit};
192
193    const START: usize = 5;
194    const END: usize = 3;
195    const LIMIT: Limit = Limit {
196        start: START,
197        end: END,
198    };
199
200    /// A buffer that keeps five bytes from the start and three from the end, so eight bytes fit
201    /// whole and anything past that loses its middle.
202    #[fixture]
203    fn buffer(#[default(START)] start: usize, #[default(END)] end: usize) -> BoundedBuffer {
204        BoundedBuffer::new(Limit { start, end })
205    }
206
207    /// Feed `writes` into a fresh buffer with the given limit and take its contents.
208    fn filled(limit: Limit, writes: &[impl AsRef<str>]) -> BufferContents {
209        let mut buffer = BoundedBuffer::new(limit);
210        for write in writes {
211            buffer.write_str(write.as_ref()).expect("writing never fails");
212        }
213        buffer.take()
214    }
215
216    /// The contents as a caller sees them: the kept text, and whether a middle was dropped.
217    fn parts(contents: &BufferContents) -> (&str, Option<&str>) {
218        (&contents.start, contents.end.as_deref())
219    }
220
221    // -- Nothing dropped ------------------------------------------------------
222
223    #[rstest]
224    #[case::empty(&[], "")]
225    #[case::one_write(&["abc"], "abc")]
226    #[case::several_writes(&["ab", "cd"], "abcd")]
227    // The two halves of the limit are one budget: eight bytes fit even though `start` is five.
228    #[case::exactly_the_limit(&["abcdefgh"], "abcdefgh")]
229    #[case::exactly_the_limit_in_pieces(&["abcd", "efgh"], "abcdefgh")]
230    // Spilling into the end portion is not truncation on its own -- nothing was dropped yet.
231    #[case::spills_into_the_end_portion(&["abcdefg"], "abcdefg")]
232    fn keeps_everything_that_fits(#[case] writes: &[&str], #[case] expected: &str) {
233        let contents = filled(LIMIT, writes);
234        assert_eq!(
235            parts(&contents),
236            (expected, None),
237            "nothing was dropped, so it all belongs in `start`",
238        );
239    }
240
241    // -- The middle goes ------------------------------------------------------
242
243    #[rstest]
244    // One byte over: the start fills, the end keeps the last three, and `e` is what goes.
245    #[case::one_byte_over(&["abcdefghi"], "abcde", "ghi")]
246    #[case::far_over(&["abcdefghijklmnop"], "abcde", "nop")]
247    // The same content, however it is chopped up, yields the same answer.
248    #[case::overflows_part_way(&["abcdef", "ghi"], "abcde", "ghi")]
249    #[case::byte_at_a_time(&["a", "b", "c", "d", "e", "f", "g", "h", "i"], "abcde", "ghi")]
250    #[case::full_then_more(&["abcdefgh", "i"], "abcde", "ghi")]
251    fn keeps_the_start_and_the_end(
252        #[case] writes: &[&str],
253        #[case] start: &str,
254        #[case] end: &str,
255    ) {
256        let contents = filled(LIMIT, writes);
257        assert_eq!(parts(&contents), (start, Some(end)));
258    }
259
260    /// The regression this type exists for: one huge write must be capped just like many small
261    /// ones. Keeping `s[limit.end..]` instead of the last `limit.end` bytes let a single write
262    /// through almost whole.
263    #[rstest]
264    fn one_huge_write_is_capped_like_many_small_ones() {
265        let all = "x".repeat(10_000);
266
267        let at_once = filled(LIMIT, std::slice::from_ref(&all));
268        let in_pieces = filled(
269            LIMIT,
270            &all.as_bytes()
271                .chunks(7)
272                .map(|c| std::str::from_utf8(c).expect("ascii").to_string())
273                .collect::<Vec<_>>(),
274        );
275
276        assert_eq!(at_once, in_pieces);
277        assert_eq!(at_once.start.len(), START);
278        assert_eq!(at_once.end.as_deref().map(str::len), Some(END));
279    }
280
281    #[rstest]
282    fn the_end_portion_is_a_sliding_window(mut buffer: BoundedBuffer) {
283        // Once the start is full, every further write scrolls the end window along, always
284        // leaving the three most recent bytes. The first spill drops nothing, so it is not yet a
285        // split capture -- `take` joins the halves back up.
286        buffer.write_str("abcde").expect("writing never fails");
287        let expected = [
288            ("fgh", ("abcdefgh", None)),
289            ("ij", ("abcde", Some("hij"))),
290            ("klmn", ("abcde", Some("lmn"))),
291        ];
292        for (write, expected) in expected {
293            buffer.write_str(write).expect("writing never fails");
294            let contents = buffer.clone().take();
295            assert_eq!(parts(&contents), expected, "after writing {write:?}");
296        }
297    }
298
299    // -- Degenerate limits ----------------------------------------------------
300
301    #[rstest]
302    #[case::no_end_kept(Limit { start: 4, end: 0 }, "abcd", Some(""))]
303    #[case::no_start_kept(Limit { start: 0, end: 4 }, "", Some("wxyz"))]
304    #[case::nothing_kept(Limit { start: 0, end: 0 }, "", Some(""))]
305    fn zero_sided_limits(#[case] limit: Limit, #[case] start: &str, #[case] end: Option<&str>) {
306        let contents = filled(limit, &["abcdefghijklmnopqrstuvwxyz"]);
307        assert_eq!(parts(&contents), (start, end));
308    }
309
310    #[rstest]
311    fn a_zero_limit_keeps_nothing_but_still_reports_the_cut() {
312        let limit = Limit { start: 0, end: 0 };
313        assert_eq!(filled(limit, &[""]), BufferContents {
314            start: String::new(),
315            end: None
316        });
317        assert_eq!(filled(limit, &["a"]), BufferContents {
318            start: String::new(),
319            end: Some(String::new()),
320        });
321    }
322
323    // -- Characters stay whole ------------------------------------------------
324
325    #[rstest]
326    // A crab is four bytes, so it cannot fit in the two bytes left in `start`; it moves along
327    // rather than being cut in half.
328    #[case::does_not_fit_in_the_start(Limit { start: 6, end: 8 }, &["abcd", "🦀"], "abcd🦀", None)]
329    // Nor can half of one be kept at the front of the end window.
330    #[case::does_not_fit_in_the_end(Limit { start: 2, end: 3 }, &["ab", "🦀z"], "ab", Some("z"))]
331    #[case::fits_the_end_exactly(Limit { start: 2, end: 4 }, &["ab", "cd🦀"], "ab", Some("🦀"))]
332    fn never_splits_a_character(
333        #[case] limit: Limit,
334        #[case] writes: &[&str],
335        #[case] start: &str,
336        #[case] end: Option<&str>,
337    ) {
338        // That `start` and `end` are `String`s at all is half the assertion: a byte-wise cut
339        // would have made them invalid UTF-8, which the debug check inside `take` catches.
340        let contents = filled(limit, writes);
341        assert_eq!(parts(&contents), (start, end));
342    }
343
344    // -- take / clear ---------------------------------------------------------
345
346    #[rstest]
347    fn take_hands_over_the_contents_and_resets(mut buffer: BoundedBuffer) {
348        buffer.write_str("abcdefghi").expect("writing never fails");
349
350        let taken = buffer.take();
351        assert_eq!(parts(&taken), ("abcde", Some("ghi")));
352
353        // The original is empty again, keeps its limit, and accepts writes once more.
354        assert_eq!(buffer.take(), BufferContents {
355            start: String::new(),
356            end: None
357        });
358        buffer.write_str("xy").expect("writing never fails");
359        assert_eq!(parts(&buffer.take()), ("xy", None));
360    }
361
362    #[rstest]
363    fn clear_forgets_the_dropped_middle(mut buffer: BoundedBuffer) {
364        buffer.write_str("abcdefghi").expect("writing never fails");
365        buffer.clear();
366
367        buffer.write_str("xy").expect("writing never fails");
368        assert_eq!(
369            parts(&buffer.take()),
370            ("xy", None),
371            "a cleared buffer must not still claim its middle was dropped",
372        );
373    }
374
375    #[rstest]
376    fn writes_always_succeed(mut buffer: BoundedBuffer) {
377        // Unlike the old first-1-MiB-only buffer, this one never refuses input: it keeps
378        // consuming so that the *end* of a long-running command is still there at the finish.
379        for write in ["abcdefghij", "more", "and more"] {
380            assert!(buffer.write_str(write).is_ok(), "{write:?} was refused");
381        }
382        assert_eq!(parts(&buffer.take()), ("abcde", Some("ore"))); // codespell:ignore ore
383    }
384
385    // -- Properties -----------------------------------------------------------
386
387    prop_compose! {
388        /// Limits small enough that the writes below regularly overflow them.
389        fn limit_and_writes()(
390            start in 0usize..12,
391            end in 0usize..12,
392            writes in prop::collection::vec("(?s).{0,8}", 0..8),
393        ) -> (Limit, Vec<String>) {
394            (Limit { start, end }, writes)
395        }
396    }
397
398    proptest! {
399        /// The whole point of the type: whatever it is fed, it never outgrows its limit. Once a
400        /// middle has been dropped each half is bounded on its own; until then the two budgets
401        /// are one, because everything kept comes back joined in `start`.
402        #[rstest]
403        fn never_exceeds_the_limit((limit, writes) in limit_and_writes()) {
404            let contents = filled(limit, &writes);
405            if let Some(end) = &contents.end {
406                prop_assert!(contents.start.len() <= limit.start);
407                prop_assert!(end.len() <= limit.end);
408            } else {
409                prop_assert!(contents.start.len() <= limit.start + limit.end);
410            }
411        }
412
413        /// A middle is reported as dropped if and only if bytes really went missing. `end` being
414        /// `Some` is the only signal callers get, so it must never cry wolf and never stay quiet.
415        #[rstest]
416        fn reports_a_dropped_middle_exactly_when_data_was_dropped(
417            (limit, writes) in limit_and_writes(),
418        ) {
419            let all = writes.concat();
420            let contents = filled(limit, &writes);
421            let kept = contents.start.len() + contents.end.as_ref().map_or(0, String::len);
422            prop_assert_eq!(contents.end.is_some(), kept < all.len());
423        }
424
425        /// The bounds either side of that: what fits in the start half alone is never touched,
426        /// and what outgrows both halves together always loses something.
427        #[rstest]
428        fn the_limits_decide_whether_anything_is_dropped((limit, writes) in limit_and_writes()) {
429            let all = writes.concat();
430            let contents = filled(limit, &writes);
431            if all.len() <= limit.start {
432                prop_assert_eq!(contents.end, None);
433            } else if all.len() > limit.start + limit.end {
434                prop_assert!(contents.end.is_some());
435            }
436        }
437
438        /// What is kept really is the start and the end of what was written, cut on character
439        /// boundaries: prefix ++ suffix, with only the middle missing.
440        #[rstest]
441        fn keeps_a_prefix_and_a_suffix((limit, writes) in limit_and_writes()) {
442            let all = writes.concat();
443            let contents = filled(limit, &writes);
444            prop_assert!(all.starts_with(&contents.start));
445            if let Some(end) = &contents.end {
446                prop_assert!(all.ends_with(end.as_str()));
447                // The two halves never overlap, so what they show was really in the input.
448                prop_assert!(contents.start.len() + end.len() <= all.len());
449            } else {
450                prop_assert_eq!(&contents.start, &all);
451            }
452        }
453
454        /// Each half is as long as its limit allows, given that characters stay whole. A cut can
455        /// only be pulled back by less than one character's worth of bytes.
456        #[rstest]
457        fn keeps_as_much_as_it_can((limit, writes) in limit_and_writes()) {
458            let all = writes.concat();
459            let contents = filled(limit, &writes);
460            if let Some(end) = &contents.end {
461                prop_assert!(contents.start.len() + char::MAX_LEN_UTF8 > limit.start.min(all.len()));
462                prop_assert!(end.len() + char::MAX_LEN_UTF8 > limit.end.min(all.len()));
463            }
464        }
465
466        /// How the data is split across writes cannot change the result.
467        #[rstest]
468        fn chunking_does_not_matter((limit, writes) in limit_and_writes()) {
469            prop_assert_eq!(filled(limit, &writes), filled(limit, &[writes.concat()]));
470        }
471
472        /// Writing never fails, however much is written.
473        #[rstest]
474        fn writes_never_fail((limit, writes) in limit_and_writes()) {
475            let mut buffer = BoundedBuffer::new(limit);
476            for write in &writes {
477                prop_assert!(buffer.write_str(write).is_ok());
478            }
479        }
480
481        /// `take` leaves a buffer indistinguishable from a new one.
482        #[rstest]
483        fn take_resets_the_buffer((limit, writes) in limit_and_writes()) {
484            let mut buffer = BoundedBuffer::new(limit);
485            for write in &writes {
486                let _ = buffer.write_str(write);
487            }
488            let _ = buffer.take();
489
490            for write in &writes {
491                let _ = buffer.write_str(write);
492            }
493            prop_assert_eq!(buffer.take(), filled(limit, &writes));
494        }
495
496        /// So does `clear`.
497        #[rstest]
498        fn clear_resets_the_buffer((limit, writes) in limit_and_writes()) {
499            let mut buffer = BoundedBuffer::new(limit);
500            for write in &writes {
501                let _ = buffer.write_str(write);
502            }
503            buffer.clear();
504
505            for write in &writes {
506                let _ = buffer.write_str(write);
507            }
508            prop_assert_eq!(buffer.take(), filled(limit, &writes));
509        }
510    }
511}