1use facet::Facet;
4
5use crate::{ConvertError, Converted, Details, Diagnostic, DiagnosticSummary, Level};
6
7#[derive(Facet)]
8struct Line {
9 reason: String,
10 #[facet(default)]
11 message: Option<Message>,
12}
13
14#[derive(Facet)]
15struct Message {
16 level: String,
17 #[facet(rename = "message")]
18 text: String,
19 #[facet(default)]
20 code: Option<Code>,
21 #[facet(default)]
22 spans: Vec<Span>,
23 #[facet(default)]
24 rendered: Option<String>,
25}
26
27#[derive(Facet)]
28struct Code {
29 code: String,
30}
31
32#[derive(Facet)]
33struct Span {
34 file_name: String,
35 line_start: u64,
36 column_start: u64,
37 is_primary: bool,
38}
39
40pub(crate) fn convert(bytes: &[u8]) -> Result<Converted, ConvertError> {
52 let text = str::from_utf8(bytes)?;
53 let mut items = Vec::new();
54 let mut rendered = Vec::new();
55 for (index, line) in text.lines().enumerate() {
56 if line.trim().is_empty() {
57 continue;
58 }
59 let parsed: Line = facet_json::from_str(line).map_err(|source| ConvertError::Json {
60 line: index.saturating_add(1),
61 source: Box::new(source),
62 })?;
63 let Some(message) = parsed
64 .message
65 .filter(|_| parsed.reason == "compiler-message")
66 else {
67 continue;
68 };
69 if let Some(output) = &message.rendered {
70 rendered.push(output.clone());
71 }
72 if !is_summary(&message) {
73 items.push(diagnostic(message));
74 }
75 }
76 let count = |level| items.iter().filter(|item| item.level == level).count() as u64;
77 Ok(Converted {
78 details: Details::Diagnostics {
79 summary: DiagnosticSummary {
80 errors: count(Level::Error),
81 warnings: count(Level::Warning),
82 },
83 items,
84 },
85 terminal: rendered.concat(),
86 })
87}
88
89fn is_summary(message: &Message) -> bool {
90 let bare = message.code.is_none() && message.spans.is_empty();
91 bare && (message.text.starts_with("aborting due to") || message.text.contains(" emitted"))
92}
93
94fn diagnostic(message: Message) -> Diagnostic {
95 let level = match message.level.as_str() {
96 "error" => Level::Error,
97 "warning" => Level::Warning,
98 _ => Level::Note,
99 };
100 let span = message.spans.into_iter().find(|span| span.is_primary);
101 Diagnostic {
102 level,
103 message: message.text,
104 file: span.as_ref().map(|span| span.file_name.clone()),
105 line: span.as_ref().map(|span| span.line_start),
106 column: span.as_ref().map(|span| span.column_start),
107 code: message.code.map(|code| code.code),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::convert;
114 use crate::{ConvertError, Details, Level};
115
116 const ARTIFACT: &str = r#"{"reason":"compiler-artifact","package_id":"a 0.1.0","target":{"name":"a"},"fresh":true}"#;
117 const WARNING: &str = r#"{"reason":"compiler-message","package_id":"a 0.1.0","message":{"message":"unused variable: `x`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","line_start":3,"line_end":3,"column_start":9,"column_end":10,"is_primary":true,"text":[]},{"file_name":"src/other.rs","line_start":1,"column_start":1,"is_primary":false}],"children":[],"rendered":"warning: unused variable: `x`\n --> src/lib.rs:3:9\n"}}"#;
118 const SUMMARY: &str = r#"{"reason":"compiler-message","message":{"message":"1 warning emitted","code":null,"level":"warning","spans":[],"children":[],"rendered":"warning: 1 warning emitted\n"}}"#;
119 const FINISHED: &str = r#"{"reason":"build-finished","success":true}"#;
120
121 #[test]
122 fn empty_output_passes_with_zero_counts() {
123 let converted = convert(b"").unwrap();
124 let Details::Diagnostics { summary, items } = converted.details else {
125 panic!("expected diagnostics");
126 };
127 assert_eq!(items.len(), 0);
128 assert_eq!((summary.errors, summary.warnings), (0, 0));
129 assert_eq!(converted.terminal, "");
130 }
131
132 #[test]
133 fn a_warning_keeps_code_and_primary_span() {
134 let input = format!("{ARTIFACT}\n{WARNING}\n{SUMMARY}\n{FINISHED}\n");
135 let converted = convert(input.as_bytes()).unwrap();
136 let Details::Diagnostics { summary, items } = converted.details else {
137 panic!("expected diagnostics");
138 };
139 assert_eq!((summary.errors, summary.warnings), (0, 1));
140 assert_eq!(items.len(), 1);
141 let item = &items[0];
142 assert_eq!(item.level, Level::Warning);
143 assert_eq!(item.message, "unused variable: `x`");
144 assert_eq!(item.code.as_deref(), Some("unused_variables"));
145 assert_eq!(item.file.as_deref(), Some("src/lib.rs"));
146 assert_eq!((item.line, item.column), (Some(3), Some(9)));
147 assert!(converted.terminal.starts_with("warning: unused variable"));
148 assert!(converted.terminal.ends_with("1 warning emitted\n"));
149 }
150
151 #[test]
152 fn other_levels_are_notes_and_errors_count() {
153 let error = r#"{"reason":"compiler-message","message":{"message":"boom","level":"error","spans":[]}}"#;
154 let note = r#"{"reason":"compiler-message","message":{"message":"look","level":"help","spans":[]}}"#;
155 let input = format!("{error}\n{note}\n");
156 let Details::Diagnostics { summary, items } = convert(input.as_bytes()).unwrap().details
157 else {
158 panic!("expected diagnostics");
159 };
160 assert_eq!((summary.errors, summary.warnings), (1, 0));
161 assert_eq!(items[1].level, Level::Note);
162 assert_eq!(items[0].file, None);
163 }
164
165 #[test]
166 fn a_line_that_is_not_json_is_an_error() {
167 let input = format!("{ARTIFACT}\nnot json\n");
168 assert!(matches!(
169 convert(input.as_bytes()),
170 Err(ConvertError::Json { line: 2, source: _ })
171 ));
172 }
173}