Skip to main content

termesh_tasks/
output.rs

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}