Skip to main content

agentmux_srv/backend/storage/filestore/
lines.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The transcript line rule, shared by the `output.idx` indexer and the
5//! per-file line counter (Phase 5a-2,
6//! SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7).
7//!
8//! A line is the bytes between two `\n` (the last one may be unterminated).
9//! It is counted unless it is blank: `from_utf8_lossy(line).trim()` is
10//! empty, so `\r` (CRLF) and Unicode whitespace are blank, and invalid UTF-8
11//! is not. This is the reader's addressing (`String::lines()` filtered on
12//! `!trim().is_empty()`); line `k` of a file is the `k`-th counted line.
13//! Both users call [`is_blank_line`], so they can't drift apart.
14
15/// Whether `line` (without its `\n`) is blank under the reader's rule.
16pub(crate) fn is_blank_line(line: &[u8]) -> bool {
17    // Fast path for ASCII, nearly every transcript line: the ASCII characters
18    // `char::is_whitespace` (and so `str::trim`) accepts. Unlike
19    // `u8::is_ascii_whitespace`, that includes U+000B (vertical tab).
20    if line.is_ascii() {
21        return line.iter().all(|&b| matches!(b, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r'));
22    }
23    String::from_utf8_lossy(line).trim().is_empty()
24}
25
26/// Where a file's line counter stands after appending bytes.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) struct LineCount {
29    /// Counted lines, including an unterminated last line if it is not blank.
30    pub lines: u64,
31    /// Byte offset where the last (possibly unterminated) line starts: just
32    /// past the last `\n`, or 0. Equal to the file size when the file ends
33    /// with `\n` or is empty.
34    pub tail_start: u64,
35}
36
37/// The counter after appending `data` at byte offset `at` to a file whose
38/// counter was `before`. `open_tail` is the file's bytes from
39/// `before.tail_start` to `at` — the unterminated last line, empty when the
40/// file ends with `\n` — because appended bytes continue that line and can
41/// change whether it is blank.
42pub(crate) fn count_append(before: LineCount, open_tail: &[u8], at: u64, data: &[u8]) -> LineCount {
43    debug_assert_eq!(before.tail_start + open_tail.len() as u64, at);
44    let old_tail_counted = !open_tail.is_empty() && !is_blank_line(open_tail);
45    let mut lines = before.lines.saturating_sub(u64::from(old_tail_counted));
46
47    let Some(first_nl) = data.iter().position(|&b| b == b'\n') else {
48        // No newline: the open line just grows.
49        let mut line = Vec::with_capacity(open_tail.len() + data.len());
50        line.extend_from_slice(open_tail);
51        line.extend_from_slice(data);
52        lines += u64::from(!line.is_empty() && !is_blank_line(&line));
53        return LineCount { lines, tail_start: before.tail_start };
54    };
55
56    // The open line, completed by the bytes up to the first newline.
57    let mut first = Vec::with_capacity(open_tail.len() + first_nl);
58    first.extend_from_slice(open_tail);
59    first.extend_from_slice(&data[..first_nl]);
60    lines += u64::from(!is_blank_line(&first));
61
62    // Every later segment: complete lines, then a possibly empty remainder.
63    let rest = &data[first_nl + 1..];
64    let mut segments = rest.split(|&b| b == b'\n');
65    let remainder = segments.next_back().unwrap_or(&[]);
66    lines += segments.filter(|l| !is_blank_line(l)).count() as u64;
67    lines += u64::from(!remainder.is_empty() && !is_blank_line(remainder));
68
69    let last_nl = data.iter().rposition(|&b| b == b'\n').expect("data has a newline");
70    LineCount { lines, tail_start: at + last_nl as u64 + 1 }
71}
72
73/// The counter for a whole file's content.
74pub(crate) fn count_all(data: &[u8]) -> LineCount {
75    count_append(LineCount { lines: 0, tail_start: 0 }, &[], 0, data)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use proptest::prelude::*;
82
83    /// The reader's addressing, written the way the read path does it.
84    fn reader_count(data: &[u8]) -> u64 {
85        String::from_utf8_lossy(data).lines().filter(|l| !l.trim().is_empty()).count() as u64
86    }
87
88    #[test]
89    fn blank_rule_matches_trim() {
90        for (line, blank) in [
91            (&b""[..], true),
92            (b" \t", true),
93            (b"\r", true),
94            (b"\x0b", true),
95            (b"\x0c", true),
96            ("\u{3000}".as_bytes(), true),
97            ("\u{a0}".as_bytes(), true),
98            (b"x", false),
99            (b" {} ", false),
100            (b"\xff", false),
101            (b"\xe3\x80", false), // a truncated U+3000 decodes to U+FFFD
102        ] {
103            assert_eq!(is_blank_line(line), blank, "{line:?}");
104            assert_eq!(is_blank_line(line), String::from_utf8_lossy(line).trim().is_empty(), "{line:?}");
105        }
106    }
107
108    #[test]
109    fn appends_that_complete_or_extend_an_open_line() {
110        let c = count_all(b"a\n  ");
111        assert_eq!(c, LineCount { lines: 1, tail_start: 2 });
112        // The blank open line becomes a counted line.
113        let c = count_append(c, b"  ", 4, b"b");
114        assert_eq!(c, LineCount { lines: 2, tail_start: 2 });
115        // Appending to a counted open line doesn't count it twice.
116        let c = count_append(c, b"  b", 5, b"c\n\n \nd\n");
117        assert_eq!(c, LineCount { lines: 3, tail_start: 12 });
118        assert_eq!(c.lines, reader_count(b"a\n  bc\n\n \nd\n"));
119    }
120
121    fn arb_bytes() -> impl Strategy<Value = Vec<u8>> {
122        // Weighted towards the bytes the rule cares about.
123        let byte = prop_oneof![
124            4 => Just(b'\n'),
125            2 => Just(b' '),
126            1 => Just(b'\r'),
127            1 => Just(b'\t'),
128            1 => Just(0x0bu8),
129            1 => Just(0xe3u8), // lead byte of U+3000
130            1 => Just(0x80u8),
131            1 => Just(0xffu8),
132            6 => b'a'..=b'z',
133        ];
134        prop::collection::vec(byte, 0..40)
135    }
136
137    proptest! {
138        /// Appending in any chunks gives the reader's count of the whole.
139        #[test]
140        fn chunked_count_equals_the_readers_count(chunks in prop::collection::vec(arb_bytes(), 0..12)) {
141            let mut file: Vec<u8> = Vec::new();
142            let mut c = LineCount { lines: 0, tail_start: 0 };
143            for chunk in &chunks {
144                let open = file[c.tail_start as usize..].to_vec();
145                c = count_append(c, &open, file.len() as u64, chunk);
146                file.extend_from_slice(chunk);
147                prop_assert_eq!(c.lines, reader_count(&file), "after {:?}", file);
148                let expected_tail = file.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1) as u64;
149                prop_assert_eq!(c.tail_start, expected_tail);
150            }
151            prop_assert_eq!(count_all(&file), c);
152        }
153    }
154}