Skip to main content

forgekit_core/build/
mod.rs

1use std::path::Path;
2use std::time::Duration;
3
4use crate::diagnostic::{Diagnostic, DiagnosticParser};
5use crate::error::{ForgeError, Result};
6#[derive(Debug, Clone)]
7pub struct BuildOutput {
8    pub success: bool,
9    pub diagnostics: Vec<Diagnostic>,
10    pub duration: Duration,
11    pub stdout: String,
12    pub stderr: String,
13}
14
15impl BuildOutput {
16    pub fn ok(stdout: String, stderr: String, duration: Duration) -> Self {
17        Self {
18            success: true,
19            diagnostics: Vec::new(),
20            duration,
21            stdout,
22            stderr,
23        }
24    }
25
26    pub fn fail(
27        diagnostics: Vec<Diagnostic>,
28        stdout: String,
29        stderr: String,
30        duration: Duration,
31    ) -> Self {
32        Self {
33            success: false,
34            diagnostics,
35            duration,
36            stdout,
37            stderr,
38        }
39    }
40
41    pub fn errors(&self) -> Vec<&Diagnostic> {
42        self.diagnostics
43            .iter()
44            .filter(|d| matches!(d.severity, crate::diagnostic::DiagnosticSeverity::Error))
45            .collect()
46    }
47
48    pub fn warnings(&self) -> Vec<&Diagnostic> {
49        self.diagnostics
50            .iter()
51            .filter(|d| matches!(d.severity, crate::diagnostic::DiagnosticSeverity::Warning))
52            .collect()
53    }
54}
55
56pub struct BuildModule {
57    system: Box<dyn BuildSystem>,
58}
59
60impl BuildModule {
61    pub fn new(system: Box<dyn BuildSystem>) -> Self {
62        Self { system }
63    }
64
65    pub fn detect(project_root: &Path) -> Option<Self> {
66        detect_build_system(project_root).map(Self::new)
67    }
68
69    pub fn system_name(&self) -> &str {
70        self.system.name()
71    }
72
73    pub async fn check(&self, project_root: &Path) -> Result<BuildOutput> {
74        self.system.check(project_root).await
75    }
76
77    pub async fn build(&self, project_root: &Path) -> Result<BuildOutput> {
78        self.system.build(project_root).await
79    }
80
81    pub async fn test(&self, project_root: &Path) -> Result<BuildOutput> {
82        self.system.test(project_root).await
83    }
84
85    pub async fn clean(&self, project_root: &Path) -> Result<BuildOutput> {
86        self.system.clean(project_root).await
87    }
88}
89
90pub fn detect_build_system(project_root: &Path) -> Option<Box<dyn BuildSystem>> {
91    let systems: Vec<Box<dyn BuildSystem>> = vec![
92        Box::new(CargoBuildSystem),
93        Box::new(GoBuildSystem),
94        Box::new(NpmBuildSystem),
95        Box::new(MakeBuildSystem),
96    ];
97
98    systems.into_iter().find(|sys| sys.detect(project_root))
99}
100
101#[async_trait::async_trait]
102pub trait BuildSystem: Send + Sync {
103    fn name(&self) -> &str;
104    fn detect(&self, project_root: &Path) -> bool;
105    async fn check(&self, project_root: &Path) -> Result<BuildOutput>;
106    async fn build(&self, project_root: &Path) -> Result<BuildOutput>;
107    async fn test(&self, project_root: &Path) -> Result<BuildOutput>;
108    async fn clean(&self, project_root: &Path) -> Result<BuildOutput>;
109}
110
111async fn run_command(
112    program: &str,
113    args: &[&str],
114    working_dir: &Path,
115    parser: &dyn DiagnosticParser,
116) -> Result<BuildOutput> {
117    let start = std::time::Instant::now();
118    let output = tokio::process::Command::new(program)
119        .args(args)
120        .current_dir(working_dir)
121        .output()
122        .await
123        .map_err(|e| {
124            ForgeError::ToolError(format!(
125                "Failed to run {} {}: {}",
126                program,
127                args.join(" "),
128                e
129            ))
130        })?;
131
132    let duration = start.elapsed();
133    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
134    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
135    let diagnostics = parser.parse(&stdout, &stderr);
136
137    Ok(BuildOutput {
138        success: output.status.success(),
139        diagnostics,
140        duration,
141        stdout,
142        stderr,
143    })
144}
145
146pub struct CargoBuildSystem;
147
148#[async_trait::async_trait]
149impl BuildSystem for CargoBuildSystem {
150    fn name(&self) -> &str {
151        "cargo"
152    }
153
154    fn detect(&self, project_root: &Path) -> bool {
155        project_root.join("Cargo.toml").exists()
156    }
157
158    async fn check(&self, project_root: &Path) -> Result<BuildOutput> {
159        let parser = crate::diagnostic::CargoDiagnosticParser;
160        run_command(
161            "cargo",
162            &["check", "--message-format=json"],
163            project_root,
164            &parser,
165        )
166        .await
167    }
168
169    async fn build(&self, project_root: &Path) -> Result<BuildOutput> {
170        let parser = crate::diagnostic::CargoDiagnosticParser;
171        run_command("cargo", &["build"], project_root, &parser).await
172    }
173
174    async fn test(&self, project_root: &Path) -> Result<BuildOutput> {
175        let parser = crate::diagnostic::CargoDiagnosticParser;
176        run_command("cargo", &["test"], project_root, &parser).await
177    }
178
179    async fn clean(&self, project_root: &Path) -> Result<BuildOutput> {
180        let parser = crate::diagnostic::GenericDiagnosticParser {
181            tool_name: "cargo".to_string(),
182        };
183        run_command("cargo", &["clean"], project_root, &parser).await
184    }
185}
186
187pub struct GoBuildSystem;
188
189#[async_trait::async_trait]
190impl BuildSystem for GoBuildSystem {
191    fn name(&self) -> &str {
192        "go"
193    }
194
195    fn detect(&self, project_root: &Path) -> bool {
196        project_root.join("go.mod").exists()
197    }
198
199    async fn check(&self, project_root: &Path) -> Result<BuildOutput> {
200        let parser = crate::diagnostic::GoDiagnosticParser;
201        run_command("go", &["vet", "./..."], project_root, &parser).await
202    }
203
204    async fn build(&self, project_root: &Path) -> Result<BuildOutput> {
205        let parser = crate::diagnostic::GoDiagnosticParser;
206        run_command("go", &["build", "./..."], project_root, &parser).await
207    }
208
209    async fn test(&self, project_root: &Path) -> Result<BuildOutput> {
210        let parser = crate::diagnostic::GoDiagnosticParser;
211        run_command("go", &["test", "./..."], project_root, &parser).await
212    }
213
214    async fn clean(&self, project_root: &Path) -> Result<BuildOutput> {
215        let parser = crate::diagnostic::GenericDiagnosticParser {
216            tool_name: "go".to_string(),
217        };
218        run_command("go", &["clean", "-cache"], project_root, &parser).await
219    }
220}
221
222pub struct NpmBuildSystem;
223
224#[async_trait::async_trait]
225impl BuildSystem for NpmBuildSystem {
226    fn name(&self) -> &str {
227        "npm"
228    }
229
230    fn detect(&self, project_root: &Path) -> bool {
231        project_root.join("package.json").exists()
232    }
233
234    async fn check(&self, project_root: &Path) -> Result<BuildOutput> {
235        let parser = crate::diagnostic::GenericDiagnosticParser {
236            tool_name: "npm".to_string(),
237        };
238        run_command("npm", &["run", "check"], project_root, &parser).await
239    }
240
241    async fn build(&self, project_root: &Path) -> Result<BuildOutput> {
242        let parser = crate::diagnostic::GenericDiagnosticParser {
243            tool_name: "npm".to_string(),
244        };
245        run_command("npm", &["run", "build"], project_root, &parser).await
246    }
247
248    async fn test(&self, project_root: &Path) -> Result<BuildOutput> {
249        let parser = crate::diagnostic::GenericDiagnosticParser {
250            tool_name: "npm".to_string(),
251        };
252        run_command("npm", &["test"], project_root, &parser).await
253    }
254
255    async fn clean(&self, project_root: &Path) -> Result<BuildOutput> {
256        let parser = crate::diagnostic::GenericDiagnosticParser {
257            tool_name: "npm".to_string(),
258        };
259        run_command("npm", &["run", "clean"], project_root, &parser).await
260    }
261}
262
263pub struct MakeBuildSystem;
264
265#[async_trait::async_trait]
266impl BuildSystem for MakeBuildSystem {
267    fn name(&self) -> &str {
268        "make"
269    }
270
271    fn detect(&self, project_root: &Path) -> bool {
272        project_root.join("Makefile").exists() || project_root.join("makefile").exists()
273    }
274
275    async fn check(&self, project_root: &Path) -> Result<BuildOutput> {
276        let parser = crate::diagnostic::GenericDiagnosticParser {
277            tool_name: "make".to_string(),
278        };
279        run_command("make", &["check"], project_root, &parser).await
280    }
281
282    async fn build(&self, project_root: &Path) -> Result<BuildOutput> {
283        let parser = crate::diagnostic::GenericDiagnosticParser {
284            tool_name: "make".to_string(),
285        };
286        run_command("make", &[], project_root, &parser).await
287    }
288
289    async fn test(&self, project_root: &Path) -> Result<BuildOutput> {
290        let parser = crate::diagnostic::GenericDiagnosticParser {
291            tool_name: "make".to_string(),
292        };
293        run_command("make", &["test"], project_root, &parser).await
294    }
295
296    async fn clean(&self, project_root: &Path) -> Result<BuildOutput> {
297        let parser = crate::diagnostic::GenericDiagnosticParser {
298            tool_name: "make".to_string(),
299        };
300        run_command("make", &["clean"], project_root, &parser).await
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_detect_cargo() {
310        let temp = tempfile::tempdir().unwrap();
311        std::fs::write(
312            temp.path().join("Cargo.toml"),
313            "[package]\nname = \"test\"\n",
314        )
315        .unwrap();
316        let sys = detect_build_system(temp.path()).unwrap();
317        assert_eq!(sys.name(), "cargo");
318    }
319
320    #[test]
321    fn test_detect_go() {
322        let temp = tempfile::tempdir().unwrap();
323        std::fs::write(temp.path().join("go.mod"), "module test\n").unwrap();
324        let sys = detect_build_system(temp.path()).unwrap();
325        assert_eq!(sys.name(), "go");
326    }
327
328    #[test]
329    fn test_detect_npm() {
330        let temp = tempfile::tempdir().unwrap();
331        std::fs::write(temp.path().join("package.json"), "{}").unwrap();
332        let sys = detect_build_system(temp.path()).unwrap();
333        assert_eq!(sys.name(), "npm");
334    }
335
336    #[test]
337    fn test_detect_make() {
338        let temp = tempfile::tempdir().unwrap();
339        std::fs::write(temp.path().join("Makefile"), "all:\n\techo hello\n").unwrap();
340        let sys = detect_build_system(temp.path()).unwrap();
341        assert_eq!(sys.name(), "make");
342    }
343
344    #[test]
345    fn test_detect_makefile_lowercase() {
346        let temp = tempfile::tempdir().unwrap();
347        std::fs::write(temp.path().join("makefile"), "all:\n\techo hello\n").unwrap();
348        let sys = detect_build_system(temp.path()).unwrap();
349        assert_eq!(sys.name(), "make");
350    }
351
352    #[test]
353    fn test_detect_nothing() {
354        let temp = tempfile::tempdir().unwrap();
355        assert!(detect_build_system(temp.path()).is_none());
356    }
357
358    #[test]
359    fn test_detect_cargo_priority_over_make() {
360        let temp = tempfile::tempdir().unwrap();
361        std::fs::write(
362            temp.path().join("Cargo.toml"),
363            "[package]\nname = \"test\"\n",
364        )
365        .unwrap();
366        std::fs::write(temp.path().join("Makefile"), "all:\n\techo hello\n").unwrap();
367        let sys = detect_build_system(temp.path()).unwrap();
368        assert_eq!(sys.name(), "cargo");
369    }
370
371    #[test]
372    fn test_build_output_ok() {
373        let out = BuildOutput::ok(
374            "done".to_string(),
375            String::new(),
376            Duration::from_millis(100),
377        );
378        assert!(out.success);
379        assert!(out.diagnostics.is_empty());
380    }
381
382    #[test]
383    fn test_build_output_fail() {
384        let out = BuildOutput::fail(
385            vec![Diagnostic::error("broken")],
386            String::new(),
387            "error".to_string(),
388            Duration::from_millis(50),
389        );
390        assert!(!out.success);
391        assert_eq!(out.errors().len(), 1);
392    }
393
394    #[test]
395    fn test_build_output_errors_warnings() {
396        let diags = vec![
397            Diagnostic::error("e1"),
398            Diagnostic::warning("w1"),
399            Diagnostic::error("e2"),
400            Diagnostic::warning("w2"),
401        ];
402        let out = BuildOutput::fail(diags, String::new(), String::new(), Duration::ZERO);
403        assert_eq!(out.errors().len(), 2);
404        assert_eq!(out.warnings().len(), 2);
405    }
406
407    #[tokio::test]
408    async fn test_cargo_check_on_forge() {
409        let forge_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
410        let sys = CargoBuildSystem;
411        if !sys.detect(&forge_root) {
412            return;
413        }
414        let out = sys.check(&forge_root).await.unwrap();
415        assert!(out.success, "forge should pass cargo check: {}", out.stderr);
416    }
417
418    #[test]
419    fn test_build_module_detect() {
420        let temp = tempfile::tempdir().unwrap();
421        std::fs::write(
422            temp.path().join("Cargo.toml"),
423            "[package]\nname = \"test\"\n",
424        )
425        .unwrap();
426        let module = BuildModule::detect(temp.path()).unwrap();
427        assert_eq!(module.system_name(), "cargo");
428    }
429}