ognibuild/buildsystems/
node.rs

1use crate::buildsystem::{BuildSystem, DependencyCategory, Error};
2use crate::dependencies::node::NodePackageDependency;
3use crate::dependencies::BinaryDependency;
4use crate::dependency::Dependency;
5use crate::installer::{Error as InstallerError, InstallationScope, Installer};
6use crate::session::Session;
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11#[derive(Debug)]
12#[allow(dead_code)]
13/// Node.js build system.
14///
15/// Handles Node.js projects with a package.json file.
16pub struct Node {
17    path: PathBuf,
18    package: NodePackage,
19}
20
21#[derive(Debug, Deserialize)]
22struct NodePackage {
23    #[serde(default)]
24    dependencies: HashMap<String, String>,
25    #[serde(rename = "devDependencies", default)]
26    dev_dependencies: HashMap<String, String>,
27    #[serde(default)]
28    scripts: HashMap<String, String>,
29}
30
31impl Node {
32    /// Create a new Node build system with the specified path to package.json.
33    pub fn new(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
34        let package_path = path.join("package.json");
35
36        let package_content = std::fs::read_to_string(&package_path)?;
37
38        let package: NodePackage = serde_json::from_str(&package_content)?;
39
40        Ok(Self { path, package })
41    }
42
43    fn setup(&self, session: &dyn Session, installer: &dyn Installer) -> Result<(), Error> {
44        let binary_req = BinaryDependency::new("npm");
45        if !binary_req.present(session) {
46            installer.install(&binary_req, InstallationScope::Global)?;
47        }
48        Ok(())
49    }
50
51    /// Probe a directory for a Node.js build system.
52    ///
53    /// Returns a Node build system if a package.json file is found.
54    pub fn probe(path: &std::path::Path) -> Option<Box<dyn BuildSystem>> {
55        let package_json_path = path.join("package.json");
56        if package_json_path.exists() {
57            log::debug!("Found package.json, attempting to parse as node package.");
58            match Self::new(path.to_path_buf()) {
59                Ok(node_system) => return Some(Box::new(node_system)),
60                Err(e) => {
61                    log::debug!("Failed to parse package.json: {}", e);
62                    return None;
63                }
64            }
65        }
66        None
67    }
68}
69
70impl BuildSystem for Node {
71    fn get_declared_dependencies(
72        &self,
73        _session: &dyn Session,
74        _fixers: Option<&[&dyn crate::fix_build::BuildFixer<InstallerError>]>,
75    ) -> Result<Vec<(DependencyCategory, Box<dyn Dependency>)>, Error> {
76        let mut dependencies: Vec<(DependencyCategory, Box<dyn Dependency>)> = vec![];
77
78        for (name, _version) in self.package.dependencies.iter() {
79            // TODO(jelmer): Look at version
80            dependencies.push((
81                DependencyCategory::Universal,
82                Box::new(NodePackageDependency::new(name)),
83            ));
84        }
85
86        for (name, _version) in self.package.dev_dependencies.iter() {
87            // TODO(jelmer): Look at version
88            dependencies.push((
89                DependencyCategory::Build,
90                Box::new(NodePackageDependency::new(name)),
91            ));
92        }
93
94        Ok(dependencies)
95    }
96
97    fn name(&self) -> &str {
98        "node"
99    }
100
101    fn dist(
102        &self,
103        session: &dyn Session,
104        installer: &dyn crate::installer::Installer,
105        target_directory: &std::path::Path,
106        quiet: bool,
107    ) -> Result<std::ffi::OsString, crate::buildsystem::Error> {
108        self.setup(session, installer)?;
109        let dc = crate::dist_catcher::DistCatcher::new(vec![
110            session.external_path(std::path::Path::new("."))
111        ]);
112        session
113            .command(vec!["npm", "pack"])
114            .quiet(quiet)
115            .run_detecting_problems()?;
116        Ok(dc.copy_single(target_directory).unwrap().unwrap())
117    }
118
119    fn test(
120        &self,
121        session: &dyn crate::session::Session,
122        installer: &dyn crate::installer::Installer,
123    ) -> Result<(), crate::buildsystem::Error> {
124        self.setup(session, installer)?;
125        if let Some(test_script) = self.package.scripts.get("test") {
126            session
127                .command(vec!["bash", "-c", test_script])
128                .run_detecting_problems()?;
129        } else {
130            log::info!("No test command defined in package.json");
131        }
132        Ok(())
133    }
134
135    fn build(
136        &self,
137        session: &dyn crate::session::Session,
138        installer: &dyn crate::installer::Installer,
139    ) -> Result<(), crate::buildsystem::Error> {
140        self.setup(session, installer)?;
141        if let Some(build_script) = self.package.scripts.get("build") {
142            session
143                .command(vec!["bash", "-c", build_script])
144                .run_detecting_problems()?;
145        } else {
146            log::info!("No build command defined in package.json");
147        }
148        Ok(())
149    }
150
151    fn clean(
152        &self,
153        session: &dyn crate::session::Session,
154        installer: &dyn crate::installer::Installer,
155    ) -> Result<(), crate::buildsystem::Error> {
156        self.setup(session, installer)?;
157        if let Some(clean_script) = self.package.scripts.get("clean") {
158            session
159                .command(vec!["bash", "-c", clean_script])
160                .run_detecting_problems()?;
161        } else {
162            log::info!("No clean command defined in package.json");
163        }
164        Ok(())
165    }
166
167    fn install(
168        &self,
169        _session: &dyn crate::session::Session,
170        _installer: &dyn crate::installer::Installer,
171        _install_target: &crate::buildsystem::InstallTarget,
172    ) -> Result<(), crate::buildsystem::Error> {
173        Err(Error::Unimplemented)
174    }
175
176    fn get_declared_outputs(
177        &self,
178        _session: &dyn crate::session::Session,
179        _fixers: Option<&[&dyn crate::fix_build::BuildFixer<crate::installer::Error>]>,
180    ) -> Result<Vec<Box<dyn crate::output::Output>>, crate::buildsystem::Error> {
181        Err(Error::Unimplemented)
182    }
183
184    fn as_any(&self) -> &dyn std::any::Any {
185        self
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use tempfile::TempDir;
193
194    #[test]
195    fn test_node_detection_minimal_package() {
196        let temp_dir = TempDir::new().unwrap();
197        let project_dir = temp_dir.path();
198
199        // Create minimal package.json
200        std::fs::write(
201            project_dir.join("package.json"),
202            r#"{"name": "test-package", "version": "1.0.0"}"#,
203        )
204        .unwrap();
205
206        let result = Node::probe(project_dir);
207
208        match result {
209            Some(bs) => {
210                assert_eq!(bs.name(), "node");
211            }
212            None => {
213                panic!("Should detect node buildsystem with minimal package.json");
214            }
215        }
216    }
217
218    #[test]
219    fn test_node_detection_complex_package() {
220        let temp_dir = TempDir::new().unwrap();
221        let project_dir = temp_dir.path();
222
223        // Create package.json with dependencies
224        std::fs::write(
225            project_dir.join("package.json"),
226            r#"{
227  "name": "test-nodejs-package",
228  "version": "1.2.3",
229  "dependencies": {
230    "express": "^4.18.0",
231    "lodash": "^4.17.21"
232  },
233  "devDependencies": {
234    "jest": "^28.0.0"
235  },
236  "scripts": {
237    "test": "jest",
238    "build": "webpack"
239  }
240}"#,
241        )
242        .unwrap();
243
244        let result = Node::probe(project_dir);
245
246        assert!(
247            result.is_some(),
248            "Should detect node buildsystem with complex package.json"
249        );
250    }
251
252    #[test]
253    fn test_detect_buildsystems_integration() {
254        use crate::buildsystem::detect_buildsystems;
255
256        let temp_dir = TempDir::new().unwrap();
257        let project_dir = temp_dir.path();
258
259        // Create minimal package.json
260        std::fs::write(
261            project_dir.join("package.json"),
262            r#"{"name": "test-package", "version": "1.0.0"}"#,
263        )
264        .unwrap();
265
266        let buildsystems = detect_buildsystems(project_dir);
267
268        assert!(
269            !buildsystems.is_empty(),
270            "Should detect at least one buildsystem"
271        );
272
273        let has_node = buildsystems.iter().any(|bs| bs.name() == "node");
274        assert!(
275            has_node,
276            "Should detect node buildsystem. Found: {:?}",
277            buildsystems.iter().map(|bs| bs.name()).collect::<Vec<_>>()
278        );
279    }
280
281    #[test]
282    fn test_scoped_package_detection() {
283        let temp_dir = TempDir::new().unwrap();
284        let project_dir = temp_dir.path();
285
286        // Create package.json with scoped name
287        std::fs::write(
288            project_dir.join("package.json"),
289            r#"{"name": "@myorg/test-package", "version": "1.0.0"}"#,
290        )
291        .unwrap();
292
293        let result = Node::probe(project_dir);
294        assert!(
295            result.is_some(),
296            "Should detect node buildsystem with scoped package name"
297        );
298    }
299}