agentmux_srv/backend/storage/filestore/
lines.rs1pub(crate) fn is_blank_line(line: &[u8]) -> bool {
17 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) struct LineCount {
29 pub lines: u64,
31 pub tail_start: u64,
35}
36
37pub(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 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 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 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
73pub(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 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), ] {
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 let c = count_append(c, b" ", 4, b"b");
114 assert_eq!(c, LineCount { lines: 2, tail_start: 2 });
115 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 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), 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 #[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}