Skip to main content

croc_sidecar/
parser.rs

1use std::pin::Pin;
2use std::task::{Context, Poll};
3use tokio::io::{AsyncBufRead, AsyncRead, BufReader, ReadBuf};
4
5use crate::Result;
6use crate::event::{CrocEvent, FileInfo, Progress};
7
8/// A `croc` parser that can serialize outputs from the provided `reader`.
9pub struct CrocParser<R: AsyncBufRead + Unpin> {
10    /// The buffered reader used to read output.
11    reader: BufReader<R>,
12    /// Tracks whether the current transfer is a receiving operation.
13    is_receiving: bool,
14    /// Internal buffer for reading lines.
15    buf: Vec<u8>,
16    /// The last parsed line.
17    last_line: String,
18}
19
20impl<R: AsyncBufRead + Unpin> CrocParser<R> {
21    /// Creates a new croc output parser for the provided reader.
22    pub fn new(inner: R) -> Self {
23        let reader = BufReader::new(inner);
24        Self {
25            reader,
26            is_receiving: false,
27            buf: Vec::new(),
28            last_line: String::new(),
29        }
30    }
31
32    /// Polls the next event available in the output
33    pub fn poll_next_event(&mut self, cx: &mut Context<'_>) -> Poll<Result<CrocEvent>> {
34        loop {
35            let mut byte = [0u8; 1];
36            let mut read_buf = ReadBuf::new(&mut byte);
37            match Pin::new(&mut self.reader).poll_read(cx, &mut read_buf) {
38                Poll::Ready(Ok(())) => {
39                    if read_buf.filled().is_empty() {
40                        if !self.buf.is_empty() {
41                            let raw_line = String::from_utf8_lossy(&self.buf).to_string();
42                            self.buf.clear();
43                            let line = Self::strip_ansi(&raw_line);
44                            if !line.trim().is_empty() && line != self.last_line {
45                                self.last_line = line.clone();
46                                if let Some(event) = self.parse_line(&line) {
47                                    return Poll::Ready(Ok(event));
48                                }
49                            }
50                        }
51                        if self.last_line != "DONE" {
52                            self.last_line = "DONE".to_string();
53                            return Poll::Ready(Ok(CrocEvent::Done));
54                        }
55                        return Poll::Ready(Ok(CrocEvent::EOF));
56                    }
57                    let b = byte[0];
58                    if b == b'\r' || b == b'\n' {
59                        if self.buf.is_empty() {
60                            continue;
61                        }
62                        let raw_line = String::from_utf8_lossy(&self.buf).to_string();
63                        self.buf.clear();
64                        let line = Self::strip_ansi(&raw_line);
65                        if line.trim().is_empty() || line == self.last_line {
66                            continue;
67                        }
68                        self.last_line = line.clone();
69                        if let Some(event) = self.parse_line(&line) {
70                            return Poll::Ready(Ok(event));
71                        }
72                    } else {
73                        self.buf.push(b);
74                    }
75                }
76                Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())),
77                Poll::Pending => return Poll::Pending,
78            }
79        }
80    }
81
82    /// Parses a single line of output from croc and returns the corresponding `CrocEvent`.
83    fn parse_line(&mut self, line: &str) -> Option<CrocEvent> {
84        let line = line.trim();
85
86        if Self::should_ignore(line) {
87            return None;
88        }
89
90        if let Some(event) = self.parse_hashing(line) {
91            return Some(event);
92        }
93
94        if let Some(event) = self.parse_info(line) {
95            return Some(event);
96        }
97
98        if let Some(event) = self.parse_code(line) {
99            return Some(event);
100        }
101
102        if let Some(event) = self.parse_state_change(line) {
103            return Some(event);
104        }
105
106        if let Some(event) = self.parse_progress(line) {
107            return Some(event);
108        }
109
110        Some(CrocEvent::Unknown(line.to_string()))
111    }
112
113    /// Determines if a specific line of output from `croc` should be completely ignored.
114    fn should_ignore(line: &str) -> bool {
115        line == "On the other computer run:"
116            || line == "(For Windows)"
117            || line == "(For Linux/macOS)"
118            || line.starts_with("croc ")
119            || line.starts_with("CROC_SECRET=")
120    }
121
122    /// Attempts to parse a line containing progress for a "Hashing" operation.
123    fn parse_hashing(&self, line: &str) -> Option<CrocEvent> {
124        if !line.starts_with("Hashing ") {
125            return None;
126        }
127
128        let percent_idx = line.find("% |")?;
129        let left = line[8..percent_idx].trim();
130        let last_space = left.rfind(' ')?;
131
132        let file_name = left[..last_space].trim().to_string();
133        let percent_str = left[last_space..].trim();
134        let percentage = percent_str.parse::<u8>().ok()?;
135
136        let (bytes_sent, bytes_total, speed) = Self::parse_metadata(line);
137
138        Some(CrocEvent::Hashing(Progress {
139            file_name,
140            percentage,
141            bytes_sent,
142            bytes_total,
143            speed,
144        }))
145    }
146
147    /// Attempts to parse lines describing file transfer info, updating the receiving state.
148    fn parse_info(&mut self, line: &str) -> Option<CrocEvent> {
149        if line.starts_with("Sending '") {
150            let end_quote = line[9..].find('\'')?;
151            let end_quote_idx = 9 + end_quote;
152            let name = line[9..end_quote_idx].to_string();
153
154            let start_paren = line[end_quote_idx..].find('(')?;
155            let start_paren_idx = end_quote_idx + start_paren + 1;
156            let end_paren = line[start_paren_idx..].find(')')?;
157
158            let size_str = line[start_paren_idx..start_paren_idx + end_paren].to_string();
159            let size = Self::parse_bytes(&size_str).unwrap_or(0);
160            self.is_receiving = false;
161            return Some(CrocEvent::SendingInfo(FileInfo { name, size }));
162        }
163
164        if line.starts_with("Receiving '") {
165            let end_quote = line[11..].find('\'')?;
166            let end_quote_idx = 11 + end_quote;
167            let name = line[11..end_quote_idx].to_string();
168
169            let start_paren = line[end_quote_idx..].find('(')?;
170            let start_paren_idx = end_quote_idx + start_paren + 1;
171            let end_paren = line[start_paren_idx..].find(')')?;
172
173            let size_str = line[start_paren_idx..start_paren_idx + end_paren].to_string();
174            let size = Self::parse_bytes(&size_str).unwrap_or(0);
175            self.is_receiving = true;
176            return Some(CrocEvent::ReceivingInfo(FileInfo { name, size }));
177        }
178
179        None
180    }
181
182    /// Attempts to parse a line indicating the connection code is ready.
183    fn parse_code(&self, line: &str) -> Option<CrocEvent> {
184        if line.starts_with("Code is: ") {
185            let code = line[9..].trim().to_string();
186            return Some(CrocEvent::CodeGenerated(code));
187        }
188        None
189    }
190
191    /// Attempts to parse lines that toggle the receiving/sending state without other direct info.
192    fn parse_state_change(&mut self, line: &str) -> Option<CrocEvent> {
193        if line.starts_with("Receiving (") {
194            self.is_receiving = true;
195            if let Some(relay) = Self::parse_relay_address(line, "<-") {
196                return Some(CrocEvent::ReceivingFrom(relay));
197            }
198            return Some(CrocEvent::Unknown(line.to_string()));
199        } else if line.starts_with("Sending (") {
200            self.is_receiving = false;
201            if let Some(relay) = Self::parse_relay_address(line, "->") {
202                return Some(CrocEvent::SendingTo(relay));
203            }
204            return Some(CrocEvent::Unknown(line.to_string()));
205        }
206        None
207    }
208
209    /// Attempts to extract a relay address from a state change line using the given delimiter (`<-` or `->`).
210    fn parse_relay_address(line: &str, delimiter: &str) -> Option<crate::croc::Relay> {
211        let start = line.find(delimiter)?;
212        let end = line.rfind(')')?;
213        let ip_port = &line[start + 2..end];
214        let addr = ip_port.parse::<std::net::SocketAddr>().ok()?;
215        Some(crate::croc::Relay::new(addr.ip(), addr.port()))
216    }
217
218    /// Attempts to parse file transfer progress updates.
219    fn parse_progress(&self, line: &str) -> Option<CrocEvent> {
220        let percent_idx = line.find("% |")?;
221        let left = line[..percent_idx].trim();
222        let last_space = left.rfind(' ')?;
223
224        let file_name = left[..last_space].trim().to_string();
225        let percent_str = left[last_space..].trim();
226        let percentage = percent_str.parse::<u8>().ok()?;
227
228        let (bytes_sent, bytes_total, speed) = Self::parse_metadata(line);
229
230        let progress = Progress {
231            file_name,
232            percentage,
233            bytes_sent,
234            bytes_total,
235            speed,
236        };
237
238        if self.is_receiving {
239            Some(CrocEvent::Receiving(progress))
240        } else {
241            Some(CrocEvent::Sending(progress))
242        }
243    }
244
245    /// Extracts extra progress metadata (bytes sent, bytes total, transfer speed) from trailing parentheses.
246    fn parse_metadata(line: &str) -> (Option<u64>, Option<u64>, Option<f64>) {
247        let paren_idx = match line.rfind('(') {
248            Some(idx) => idx,
249            None => return (None, None, None),
250        };
251
252        let end_paren = line[paren_idx..]
253            .find(')')
254            .unwrap_or(line.len() - paren_idx);
255        let meta_str = line[paren_idx + 1..paren_idx + end_paren].trim();
256        let parts: Vec<&str> = meta_str.splitn(2, ',').collect();
257
258        if parts.len() == 1 && (parts[0].trim().ends_with("/s") || parts[0].trim().ends_with("ps"))
259        {
260            return (None, None, Self::parse_speed(parts[0].trim()));
261        }
262
263        let (bytes_sent, bytes_total, mut speed) = Self::parse_bytes_part(parts[0].trim());
264
265        if parts.len() > 1 {
266            speed = Self::parse_speed(parts[1].trim());
267        }
268
269        (bytes_sent, bytes_total, speed)
270    }
271
272    /// Attempts to extract byte progress and optional speed from a metadata chunk.
273    fn parse_bytes_part(bytes_part: &str) -> (Option<u64>, Option<u64>, Option<f64>) {
274        if let Some(slash_idx) = bytes_part.find('/') {
275            let sent_str = bytes_part[..slash_idx].trim();
276            let total_str = bytes_part[slash_idx + 1..].trim();
277
278            if total_str.starts_with('s') {
279                (None, None, Self::parse_speed(bytes_part))
280            } else {
281                let (sent, total) = Self::parse_fractional_bytes(sent_str, total_str);
282                (sent, total, None)
283            }
284        } else {
285            (Self::parse_bytes(bytes_part), None, None)
286        }
287    }
288
289    /// Parses a fraction like `37 / 268 MB` into absolute byte counts.
290    fn parse_fractional_bytes(sent_str: &str, total_str: &str) -> (Option<u64>, Option<u64>) {
291        let total_unit = if let Some(idx) = total_str.find(|c: char| c.is_alphabetic()) {
292            &total_str[idx..]
293        } else {
294            ""
295        };
296
297        let sent_has_unit = sent_str.contains(|c: char| c.is_alphabetic());
298        let sent_to_parse = if !sent_has_unit && !total_unit.is_empty() {
299            format!("{} {}", sent_str, total_unit)
300        } else {
301            sent_str.to_string()
302        };
303
304        (
305            Self::parse_bytes(&sent_to_parse),
306            Self::parse_bytes(total_str),
307        )
308    }
309
310    /// Converts a string unit (like KB, MB, GB) into its corresponding byte multiplier.
311    fn parse_unit_multiplier(unit: &str) -> f64 {
312        match unit.trim().to_uppercase().as_str() {
313            "B" | "" => 1.0,
314            "KB" | "K" => 1024.0,
315            "MB" | "M" => 1024.0 * 1024.0,
316            "GB" | "G" => 1024.0 * 1024.0 * 1024.0,
317            "TB" | "T" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
318            _ => 1.0,
319        }
320    }
321
322    /// Strips ANSI escape sequences from a string to ensure clean parsing.
323    fn strip_ansi(s: &str) -> String {
324        let mut result = String::with_capacity(s.len());
325        let mut in_ansi = false;
326        for c in s.chars() {
327            if c == '\x1b' {
328                in_ansi = true;
329            } else if in_ansi {
330                if c.is_ascii_alphabetic() {
331                    in_ansi = false;
332                }
333            } else {
334                result.push(c);
335            }
336        }
337        result
338    }
339
340    /// Attempts to parse a string containing a byte count and an optional unit into a raw `u64` representing bytes.
341    fn parse_bytes(s: &str) -> Option<u64> {
342        let s = s.trim();
343        let (num_str, unit) = if let Some(idx) = s.find(|c: char| c.is_alphabetic()) {
344            (&s[..idx], &s[idx..])
345        } else {
346            (s, "")
347        };
348
349        let val: f64 = num_str.trim().parse().ok()?;
350        let multiplier = Self::parse_unit_multiplier(unit);
351        Some((val * multiplier) as u64)
352    }
353
354    /// Attempts to parse a string containing a transfer speed and an optional unit into a raw `f64`.
355    fn parse_speed(s: &str) -> Option<f64> {
356        let s = s.trim().trim_end_matches("/s").trim_end_matches("ps");
357        let (num_str, unit) = if let Some(idx) = s.find(|c: char| c.is_alphabetic()) {
358            (&s[..idx], &s[idx..])
359        } else {
360            (s, "")
361        };
362
363        let val: f64 = num_str.trim().parse().ok()?;
364        let multiplier = Self::parse_unit_multiplier(unit);
365        Some(val * multiplier)
366    }
367}