Skip to main content

grit_lib/
sideband_lines.rs

1//! Split raw side-band channel-2 chunks into typed remote progress lines.
2//!
3//! Git upload-pack multiplexes progress on side-band channel 2. Payload bytes
4//! use `\r` for in-place counter updates and `\n` for final lines; either
5//! delimiter may straddle pkt-line boundaries.
6
7/// One decoded fragment of remote progress text.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum RemoteLine {
10    /// In-place update (payload terminated with `\r`).
11    Update(String),
12    /// Final line (payload terminated with `\n`).
13    Line(String),
14}
15
16/// Incrementally decode channel-2 progress bytes into [`RemoteLine`] items.
17#[derive(Debug, Default)]
18pub struct SidebandLines {
19    pending: Vec<u8>,
20}
21
22impl SidebandLines {
23    /// Create an empty splitter.
24    #[must_use]
25    pub fn new() -> Self {
26        Self {
27            pending: Vec::new(),
28        }
29    }
30
31    /// Append `chunk` and invoke `emit` for each complete line decoded so far.
32    pub fn feed<F>(&mut self, chunk: &[u8], mut emit: F)
33    where
34        F: FnMut(RemoteLine),
35    {
36        if chunk.is_empty() {
37            return;
38        }
39        self.pending.extend_from_slice(chunk);
40        while let Some(i) = self.pending.iter().position(|&b| b == b'\r' || b == b'\n') {
41            let delim = self.pending[i];
42            let line = self.pending.drain(..i).collect::<Vec<_>>();
43            self.pending.drain(..1);
44            let text = String::from_utf8_lossy(&line).into_owned();
45            let item = if delim == b'\r' {
46                RemoteLine::Update(text)
47            } else {
48                RemoteLine::Line(text)
49            };
50            emit(item);
51        }
52    }
53
54    /// Emit bytes not yet terminated by `\r` or `\n` as one final [`RemoteLine::Line`].
55    pub fn finish<F>(&mut self, mut emit: F)
56    where
57        F: FnMut(RemoteLine),
58    {
59        if self.pending.is_empty() {
60            return;
61        }
62        let text = String::from_utf8_lossy(&self.pending).into_owned();
63        self.pending.clear();
64        if !text.is_empty() {
65            emit(RemoteLine::Line(text));
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn collect(chunks: &[&[u8]]) -> Vec<RemoteLine> {
75        let mut splitter = SidebandLines::new();
76        let mut out = Vec::new();
77        for chunk in chunks {
78            splitter.feed(*chunk, |line| out.push(line));
79        }
80        out
81    }
82
83    #[test]
84    fn cr_updates() {
85        assert_eq!(
86            collect(&[b"one\r two\r"]),
87            vec![
88                RemoteLine::Update("one".into()),
89                RemoteLine::Update(" two".into()),
90            ]
91        );
92    }
93
94    #[test]
95    fn lf_lines() {
96        assert_eq!(
97            collect(&[b"first\nsecond\n"]),
98            vec![
99                RemoteLine::Line("first".into()),
100                RemoteLine::Line("second".into()),
101            ]
102        );
103    }
104
105    #[test]
106    fn split_across_chunks() {
107        assert_eq!(
108            collect(&[b"hel", b"lo\rwor", b"ld\n"]),
109            vec![
110                RemoteLine::Update("hello".into()),
111                RemoteLine::Line("world".into()),
112            ]
113        );
114    }
115
116    #[test]
117    fn finish_emits_trailing_bytes_without_delimiter() {
118        let mut splitter = SidebandLines::new();
119        let mut out = Vec::new();
120        splitter.feed(b"no trailing newline", |line| out.push(line));
121        splitter.finish(|line| out.push(line));
122        assert_eq!(out, vec![RemoteLine::Line("no trailing newline".into())]);
123    }
124
125    #[test]
126    fn invalid_utf8_lossy() {
127        let mut splitter = SidebandLines::new();
128        let mut out = Vec::new();
129        splitter.feed(b"\xFF\xFEok\r", |line| out.push(line));
130        assert_eq!(out, vec![RemoteLine::Update("\u{FFFD}\u{FFFD}ok".into())]);
131    }
132}