1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4use termesh_core::{Problem, ProblemSeverity};
5
6use crate::{DecodedTaskOutput, TaskOutputDecoder};
7
8pub struct CargoOutputDecoder {
9 cwd: PathBuf,
10 pending: Vec<u8>,
11}
12
13impl CargoOutputDecoder {
14 pub fn new(cwd: PathBuf) -> Self {
15 Self { cwd, pending: Vec::new() }
16 }
17
18 fn decode_line(&self, line: &[u8]) -> DecodedTaskOutput {
19 let Ok(text) = std::str::from_utf8(line) else {
20 return DecodedTaskOutput { display: line.to_vec(), problems: Vec::new() };
21 };
22 let json_text = text.strip_suffix('\n').unwrap_or(text);
23 let json_text = json_text.strip_suffix('\r').unwrap_or(json_text);
24 let Ok(value) = serde_json::from_str::<Value>(json_text) else {
25 return fallback_line(&self.cwd, line);
26 };
27 match value.get("reason").and_then(Value::as_str) {
28 Some("compiler-message") => compiler_message(&self.cwd, &value),
29 Some("compiler-artifact" | "build-script-executed" | "build-finished") => {
30 DecodedTaskOutput::default()
31 }
32 _ => DecodedTaskOutput { display: line.to_vec(), problems: Vec::new() },
33 }
34 }
35}
36
37impl TaskOutputDecoder for CargoOutputDecoder {
38 fn push(&mut self, bytes: &[u8]) -> DecodedTaskOutput {
39 self.pending.extend_from_slice(bytes);
40 let mut decoded = DecodedTaskOutput::default();
41 while let Some(end) = self.pending.iter().position(|byte| *byte == b'\n') {
42 let line: Vec<u8> = self.pending.drain(..=end).collect();
43 merge(&mut decoded, self.decode_line(&line));
44 }
45 decoded
46 }
47
48 fn finish(&mut self) -> DecodedTaskOutput {
49 if self.pending.is_empty() {
50 return DecodedTaskOutput::default();
51 }
52 let line = std::mem::take(&mut self.pending);
53 self.decode_line(&line)
54 }
55}
56
57fn compiler_message(cwd: &Path, value: &Value) -> DecodedTaskOutput {
58 let Some(message) = value.get("message") else { return DecodedTaskOutput::default() };
59 let display = message
60 .get("rendered")
61 .and_then(Value::as_str)
62 .map(str::as_bytes)
63 .unwrap_or_default()
64 .to_vec();
65 let problem = message
66 .get("spans")
67 .and_then(Value::as_array)
68 .and_then(|spans| {
69 spans.iter().find(|span| span.get("is_primary") == Some(&Value::Bool(true)))
70 })
71 .and_then(|span| problem_from_span(cwd, message, span));
72 DecodedTaskOutput { display, problems: problem.into_iter().collect() }
73}
74
75fn problem_from_span(cwd: &Path, message: &Value, span: &Value) -> Option<Problem> {
76 let raw_path = span.get("file_name")?.as_str()?;
77 let path = PathBuf::from(raw_path);
78 let path = if path.is_absolute() { path } else { cwd.join(path) };
79 let level = message.get("level").and_then(Value::as_str).unwrap_or("warning");
80 Some(Problem {
81 path,
82 line: usize::try_from(span.get("line_start")?.as_u64()?).ok()?,
83 column: usize::try_from(span.get("column_start")?.as_u64()?).ok()?,
84 severity: if level == "error" { ProblemSeverity::Error } else { ProblemSeverity::Warning },
85 message: message.get("message")?.as_str()?.to_owned(),
86 })
87}
88
89fn fallback_line(cwd: &Path, line: &[u8]) -> DecodedTaskOutput {
90 let plain = strip_ansi(&String::from_utf8_lossy(line));
91 let problems = panic_problem(cwd, &plain).into_iter().collect();
92 DecodedTaskOutput { display: line.to_vec(), problems }
93}
94
95fn panic_problem(cwd: &Path, line: &str) -> Option<Problem> {
96 let location = line.split("panicked at ").nth(1)?.split_whitespace().next()?;
97 let location = location
98 .trim_matches(|character| matches!(character, '\'' | '"' | ','))
99 .trim_end_matches(':');
100 let mut parts = location.rsplitn(3, ':');
101 let column = parts.next()?.trim_end_matches(':').parse().ok()?;
102 let line_number = parts.next()?.parse().ok()?;
103 let raw_path = parts.next()?;
104 let path = PathBuf::from(raw_path);
105 Some(Problem {
106 path: if path.is_absolute() { path } else { cwd.join(path) },
107 line: line_number,
108 column,
109 severity: ProblemSeverity::Error,
110 message: line.trim().to_owned(),
111 })
112}
113
114fn strip_ansi(input: &str) -> String {
115 let mut output = String::new();
116 let mut chars = input.chars().peekable();
117 while let Some(character) = chars.next() {
118 if character == '\u{1b}' && chars.peek() == Some(&'[') {
119 chars.next();
120 for next in chars.by_ref() {
121 if ('@'..='~').contains(&next) {
122 break;
123 }
124 }
125 } else {
126 output.push(character);
127 }
128 }
129 output
130}
131
132fn merge(target: &mut DecodedTaskOutput, source: DecodedTaskOutput) {
133 target.display.extend(source.display);
134 target.problems.extend(source.problems);
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn compiler_json_split_across_chunks_becomes_rendered_output_and_problem() {
143 let json = r#"{"reason":"compiler-message","message":{"rendered":"error[E0425]\n","level":"error","message":"cannot find value","spans":[{"file_name":"src/lib.rs","line_start":12,"column_start":5,"is_primary":true}]}}
144"#;
145 let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
146 let split = json.len() / 2;
147 assert!(decoder.push(&json.as_bytes()[..split]).display.is_empty());
148 let output = decoder.push(&json.as_bytes()[split..]);
149 assert_eq!(output.display, b"error[E0425]\n");
150 assert_eq!((output.problems[0].line, output.problems[0].column), (12, 5));
151 }
152
153 #[test]
154 fn artifacts_are_hidden_but_test_output_passes_through() {
155 let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
156 assert!(decoder.push(b"{\"reason\":\"compiler-artifact\"}\n").display.is_empty());
157 assert_eq!(decoder.push(b"test result: ok\n").display, b"test result: ok\n");
158 }
159
160 #[test]
161 fn ansi_panic_location_becomes_a_problem_without_changing_display() {
162 let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
163 let line = b"\x1b[31mthread 'case' panicked at src/lib.rs:12:5:\x1b[0m\n";
164 let output = decoder.push(line);
165 assert_eq!(output.display, line);
166 assert_eq!(output.problems[0].path, Path::new("/p/src/lib.rs"));
167 assert_eq!((output.problems[0].line, output.problems[0].column), (12, 5));
168 }
169
170 #[test]
171 fn incomplete_non_json_line_is_flushed_verbatim() {
172 let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
173 assert!(decoder.push(b"partial").display.is_empty());
174 assert_eq!(decoder.finish().display, b"partial");
175 }
176}