Skip to main content

forge_engine/adapters/
cargo.rs

1use std::path::Path;
2
3use crate::config::ForgeConfig;
4use crate::exec::backend::*;
5
6/// ProjectAdapter trait — adapts Forge to specific project types.
7pub 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
22/// Cargo adapter — for Rust projects.
23pub 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
89/// Parse cargo fmt output.
90fn parse_fmt_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
91    let mut effects = Vec::new();
92
93    // Parse lines like "Diff in src/lib.rs at line 42:"
94    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
125/// Parse cargo clippy JSON output.
126fn parse_clippy_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
127    let mut effects = Vec::new();
128
129    // Clippy outputs JSON on stderr when --message-format=json is used
130    for line in stderr.lines() {
131        let line = line.trim();
132        if line.is_empty() || !line.starts_with('{') {
133            continue;
134        }
135
136        // Parse JSON, tolerating malformed input (CEA security concern)
137        let parsed: Result<serde_json::Value, _> = serde_json::from_str(line);
138        let value = match parsed {
139            Ok(v) => v,
140            Err(_) => continue, // skip malformed JSON gracefully
141        };
142
143        // Only process compiler-message reason
144        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        // Skip if no code
154        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        // Extract first span
174        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
226/// Parse cargo test output.
227fn parse_test_output(stdout: &str, stderr: &str, exit_code: i32) -> ParsedCheckOutput {
228    let mut effects = Vec::new();
229
230    // Try JSON format first (each line might be JSON)
231    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    // Fallback: parse text output for "test <name> ... FAILED"
264    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}