1use std::path::Path;
2
3use crate::config::ForgeConfig;
4use crate::exec::backend::*;
5
6pub trait ProjectAdapter: Send + Sync {
8 fn detect(workspace: &Path) -> bool
9 where
10 Self: Sized;
11 fn name(&self) -> &str;
12 fn check_commands(&self, config: &ForgeConfig) -> Vec<CheckCommand>;
13 fn parse_check_output(
14 &self,
15 cmd: &CheckCommand,
16 stdout: &str,
17 stderr: &str,
18 exit_code: i32,
19 ) -> ParsedCheckOutput;
20}
21
22pub struct CargoAdapter;
24
25impl ProjectAdapter for CargoAdapter {
26 fn detect(workspace: &Path) -> bool {
27 workspace.join("Cargo.toml").exists()
28 }
29
30 fn name(&self) -> &str {
31 "cargo"
32 }
33
34 fn check_commands(&self, _config: &ForgeConfig) -> Vec<CheckCommand> {
35 vec![
36 CheckCommand {
37 kind: CheckKind::Fmt,
38 program: "cargo".to_string(),
39 args: vec![
40 "fmt".to_string(),
41 "--all".to_string(),
42 "--".to_string(),
43 "--check".to_string(),
44 ],
45 env: vec![],
46 },
47 CheckCommand {
48 kind: CheckKind::Clippy,
49 program: "cargo".to_string(),
50 args: vec![
51 "clippy".to_string(),
52 "--all-targets".to_string(),
53 "--all-features".to_string(),
54 "--message-format=json".to_string(),
55 "--".to_string(),
56 "-D".to_string(),
57 "warnings".to_string(),
58 ],
59 env: vec![],
60 },
61 CheckCommand {
62 kind: CheckKind::Test,
63 program: "cargo".to_string(),
64 args: vec![
65 "test".to_string(),
66 "--all".to_string(),
67 "--all-features".to_string(),
68 ],
69 env: vec![],
70 },
71 ]
72 }
73
74 fn parse_check_output(
75 &self,
76 cmd: &CheckCommand,
77 stdout: &str,
78 stderr: &str,
79 exit_code: i32,
80 ) -> ParsedCheckOutput {
81 match cmd.kind {
82 CheckKind::Fmt => parse_fmt_output(stdout, stderr, exit_code),
83 CheckKind::Clippy => parse_clippy_output(stdout, stderr, exit_code),
84 CheckKind::Test => parse_test_output(stdout, stderr, exit_code),
85 }
86 }
87}
88
89fn parse_fmt_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
91 let mut effects = Vec::new();
92
93 for line in stdout.lines().chain(stderr.lines()) {
95 if line.starts_with("Diff in ") {
96 let parts: Vec<&str> = line.split_whitespace().collect();
97 if parts.len() >= 3 {
98 let file = parts[2].trim_end_matches(':');
99 effects.push(LocatedEffect {
100 file: Some(std::path::PathBuf::from(file)),
101 line: None,
102 col: None,
103 message: line.to_string(),
104 sig: EffectSignature {
105 check_kind: "fmt".to_string(),
106 outcome: "fail".to_string(),
107 severity: "warning".to_string(),
108 message_class: file.to_string(),
109 line_offset_from_edit: None,
110 },
111 });
112 }
113 }
114 }
115
116 ParsedCheckOutput {
117 check_kind: CheckKind::Fmt,
118 exit_code,
119 effects,
120 raw_stdout: stdout.to_string(),
121 raw_stderr: stderr.to_string(),
122 }
123}
124
125fn parse_clippy_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
127 let mut effects = Vec::new();
128
129 for line in stderr.lines() {
131 let line = line.trim();
132 if line.is_empty() || !line.starts_with('{') {
133 continue;
134 }
135
136 let parsed: Result<serde_json::Value, _> = serde_json::from_str(line);
138 let value = match parsed {
139 Ok(v) => v,
140 Err(_) => continue, };
142
143 if value.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
145 continue;
146 }
147
148 let message = match value.get("message") {
149 Some(m) => m,
150 None => continue,
151 };
152
153 let code = match message
155 .get("code")
156 .and_then(|c| c.get("code"))
157 .and_then(|c| c.as_str())
158 {
159 Some(c) => c,
160 None => continue,
161 };
162
163 let level = message
164 .get("level")
165 .and_then(|l| l.as_str())
166 .unwrap_or("warning");
167
168 let msg_text = message
169 .get("message")
170 .and_then(|m| m.as_str())
171 .unwrap_or("");
172
173 let spans = message.get("spans").and_then(|s| s.as_array());
175 let (file, line_num, col_num) = if let Some(spans) = spans {
176 if let Some(first) = spans.first() {
177 let f = first
178 .get("file_name")
179 .and_then(|f| f.as_str())
180 .map(std::path::PathBuf::from);
181 let l = first
182 .get("line_start")
183 .and_then(|l| l.as_u64())
184 .map(|l| l as u32);
185 let c = first
186 .get("column_start")
187 .and_then(|c| c.as_u64())
188 .map(|c| c as u32);
189 (f, l, c)
190 } else {
191 (None, None, None)
192 }
193 } else {
194 (None, None, None)
195 };
196
197 let severity = match level {
198 "error" => "error",
199 _ => "warning",
200 };
201
202 effects.push(LocatedEffect {
203 file,
204 line: line_num,
205 col: col_num,
206 message: msg_text.to_string(),
207 sig: EffectSignature {
208 check_kind: "clippy".to_string(),
209 outcome: "fail".to_string(),
210 severity: severity.to_string(),
211 message_class: code.to_string(),
212 line_offset_from_edit: None,
213 },
214 });
215 }
216
217 ParsedCheckOutput {
218 check_kind: CheckKind::Clippy,
219 exit_code,
220 effects,
221 raw_stdout: stdout.to_string(),
222 raw_stderr: stderr.to_string(),
223 }
224}
225
226fn parse_test_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
228 let mut effects = Vec::new();
229
230 let mut found_json = false;
232 for line in stdout.lines() {
233 let line = line.trim();
234 if line.is_empty() || !line.starts_with('{') {
235 continue;
236 }
237 if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
238 if value.get("type").and_then(|t| t.as_str()) == Some("test")
239 && value.get("event").and_then(|e| e.as_str()) == Some("failed")
240 {
241 let name = value
242 .get("name")
243 .and_then(|n| n.as_str())
244 .unwrap_or("unknown");
245 effects.push(LocatedEffect {
246 file: None,
247 line: None,
248 col: None,
249 message: format!("test {name} FAILED"),
250 sig: EffectSignature {
251 check_kind: "test".to_string(),
252 outcome: "fail".to_string(),
253 severity: "test_fail".to_string(),
254 message_class: name.to_string(),
255 line_offset_from_edit: None,
256 },
257 });
258 found_json = true;
259 }
260 }
261 }
262
263 if !found_json {
265 let re = regex::Regex::new(r"test\s+(\S+)\s+\.\.\.\s+FAILED").ok();
266 for line in stdout.lines().chain(stderr.lines()) {
267 if let Some(re) = &re {
268 if let Some(caps) = re.captures(line) {
269 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("unknown");
270 effects.push(LocatedEffect {
271 file: None,
272 line: None,
273 col: None,
274 message: line.to_string(),
275 sig: EffectSignature {
276 check_kind: "test".to_string(),
277 outcome: "fail".to_string(),
278 severity: "test_fail".to_string(),
279 message_class: name.to_string(),
280 line_offset_from_edit: None,
281 },
282 });
283 }
284 }
285 }
286 }
287
288 ParsedCheckOutput {
289 check_kind: CheckKind::Test,
290 exit_code,
291 effects,
292 raw_stdout: stdout.to_string(),
293 raw_stderr: stderr.to_string(),
294 }
295}