Skip to main content

cargo_context_core/collect/
errors.rs

1//! Compiler diagnostics capture via `cargo check --message-format=json`.
2//!
3//! We parse the JSON stream with `cargo_metadata::Message` rather than
4//! scraping stderr — that gives us stable span/line/column data regardless
5//! of rustc's human-readable formatting changes.
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum DiagLevel {
17    Error,
18    Warning,
19    Note,
20    Help,
21    Ice,
22    Other,
23}
24
25impl DiagLevel {
26    fn from_cargo(l: &cargo_metadata::diagnostic::DiagnosticLevel) -> Self {
27        use cargo_metadata::diagnostic::DiagnosticLevel as L;
28        match l {
29            L::Error => DiagLevel::Error,
30            L::Warning => DiagLevel::Warning,
31            L::Note => DiagLevel::Note,
32            L::Help => DiagLevel::Help,
33            L::Ice => DiagLevel::Ice,
34            L::FailureNote => DiagLevel::Note,
35            _ => DiagLevel::Other,
36        }
37    }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DiagSpan {
42    pub file: PathBuf,
43    pub line_start: usize,
44    pub line_end: usize,
45    pub col_start: usize,
46    pub col_end: usize,
47    pub is_primary: bool,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Diagnostic {
52    pub level: DiagLevel,
53    pub message: String,
54    pub code: Option<String>,
55    pub spans: Vec<DiagSpan>,
56    pub rendered: String,
57}
58
59impl Diagnostic {
60    /// File path of the primary span, if any.
61    pub fn primary_file(&self) -> Option<&Path> {
62        self.spans
63            .iter()
64            .find(|s| s.is_primary)
65            .map(|s| s.file.as_path())
66    }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Diagnostics {
71    pub success: bool,
72    pub diagnostics: Vec<Diagnostic>,
73}
74
75impl Diagnostics {
76    pub fn is_empty(&self) -> bool {
77        self.diagnostics.is_empty()
78    }
79
80    /// Files referenced by any diagnostic (primary spans only), deduplicated.
81    pub fn referenced_files(&self) -> Vec<PathBuf> {
82        let mut paths: Vec<PathBuf> = self
83            .diagnostics
84            .iter()
85            .flat_map(|d| d.spans.iter().filter(|s| s.is_primary))
86            .map(|s| s.file.clone())
87            .collect();
88        paths.sort();
89        paths.dedup();
90        paths
91    }
92
93    pub fn has_errors(&self) -> bool {
94        self.diagnostics
95            .iter()
96            .any(|d| d.level == DiagLevel::Error || d.level == DiagLevel::Ice)
97    }
98}
99
100/// Run `cargo check` and parse the diagnostic stream.
101///
102/// This always runs a fresh check; a follow-up revision will add a disk cache
103/// under `target/cargo-context/last-error.json` so repeat pack generations
104/// don't pay the compile cost.
105pub fn last_error(root: &Path) -> Result<Diagnostics> {
106    let output = Command::new("cargo")
107        .current_dir(root)
108        .args([
109            "check",
110            "--workspace",
111            "--all-targets",
112            "--message-format=json",
113            "--color=never",
114        ])
115        .output()
116        .map_err(|e| Error::Tool(format!("failed to spawn cargo: {e}")))?;
117
118    let stream = String::from_utf8_lossy(&output.stdout);
119    let diagnostics = parse_message_stream(&stream);
120
121    Ok(Diagnostics {
122        success: output.status.success(),
123        diagnostics,
124    })
125}
126
127/// Parse a `cargo check --message-format=json` stream. Public for tests and
128/// for consumers that have already captured the stream (e.g. a Cargo hook).
129pub fn parse_message_stream(stream: &str) -> Vec<Diagnostic> {
130    use cargo_metadata::Message;
131
132    let mut out = Vec::new();
133    for line in stream.lines() {
134        let trimmed = line.trim();
135        if trimmed.is_empty() || !trimmed.starts_with('{') {
136            continue;
137        }
138        let msg: Message = match serde_json::from_str(trimmed) {
139            Ok(m) => m,
140            Err(_) => continue,
141        };
142        if let Message::CompilerMessage(cm) = msg {
143            out.push(convert(cm.message));
144        }
145    }
146    out
147}
148
149fn convert(d: cargo_metadata::diagnostic::Diagnostic) -> Diagnostic {
150    let spans = d
151        .spans
152        .iter()
153        .map(|s| DiagSpan {
154            file: PathBuf::from(&s.file_name),
155            line_start: s.line_start,
156            line_end: s.line_end,
157            col_start: s.column_start,
158            col_end: s.column_end,
159            is_primary: s.is_primary,
160        })
161        .collect();
162    Diagnostic {
163        level: DiagLevel::from_cargo(&d.level),
164        message: d.message,
165        code: d.code.map(|c| c.code),
166        spans,
167        rendered: d.rendered.unwrap_or_default(),
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    const SAMPLE: &str = r#"{"reason":"compiler-message","package_id":"foo 0.1.0","manifest_path":"/tmp/foo/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"foo","src_path":"/tmp/foo/src/lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"message":{"message":"mismatched types","code":{"code":"E0308","explanation":null},"level":"error","spans":[{"file_name":"src/lib.rs","byte_start":10,"byte_end":20,"line_start":3,"line_end":3,"column_start":5,"column_end":12,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error[E0308]: mismatched types\n  --> src/lib.rs:3:5\n"}}
176{"reason":"build-finished","success":false}
177"#;
178
179    #[test]
180    fn parses_compiler_message_stream() {
181        let diags = parse_message_stream(SAMPLE);
182        assert_eq!(diags.len(), 1);
183        assert_eq!(diags[0].level, DiagLevel::Error);
184        assert_eq!(diags[0].code.as_deref(), Some("E0308"));
185        assert_eq!(diags[0].spans.len(), 1);
186        assert!(diags[0].spans[0].is_primary);
187    }
188
189    #[test]
190    fn referenced_files_deduplicates() {
191        let diags = parse_message_stream(SAMPLE);
192        let d = Diagnostics {
193            success: false,
194            diagnostics: diags,
195        };
196        let files = d.referenced_files();
197        assert_eq!(files, vec![PathBuf::from("src/lib.rs")]);
198    }
199
200    #[test]
201    fn ignores_non_message_lines() {
202        let noisy = "garbage\n{\"reason\":\"build-finished\",\"success\":true}\n";
203        assert!(parse_message_stream(noisy).is_empty());
204    }
205}