ognibuild 0.2.12

Detect and run any build system
Documentation
use crate::dependencies::BinaryDependency;
use crate::dependency::Dependency;
use crate::installer::{Error, Explanation, InstallationScope, Installer};
use crate::session::Session;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
/// A dependency on a Node.js package.
pub struct NodePackageDependency {
    package: String,
}

impl NodePackageDependency {
    /// Creates a new NodePackageDependency instance.
    pub fn new(package: &str) -> Self {
        Self {
            package: package.to_string(),
        }
    }
}

impl Dependency for NodePackageDependency {
    fn family(&self) -> &'static str {
        "npm-package"
    }

    fn present(&self, session: &dyn Session) -> bool {
        // npm list -g package-name --depth=0 >/dev/null 2>&1
        session
            .command(vec!["npm", "list", "-g", &self.package, "--depth=0"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .run()
            .unwrap()
            .success()
    }

    fn project_present(&self, _session: &dyn Session) -> bool {
        todo!()
    }
    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

#[cfg(feature = "debian")]
impl crate::dependencies::debian::IntoDebianDependency for NodePackageDependency {
    fn try_into_debian_dependency(
        &self,
        apt: &crate::debian::apt::AptManager,
    ) -> Option<Vec<super::debian::DebianDependency>> {
        let paths = vec![
            format!(
                "/usr/share/nodejs/.*/node_modules/{}/package\\.json",
                regex::escape(&self.package)
            ),
            format!(
                "/usr/lib/nodejs/{}/package\\.json",
                regex::escape(&self.package)
            ),
            format!(
                "/usr/share/nodejs/{}/package\\.json",
                regex::escape(&self.package)
            ),
        ];

        let names = match apt.get_packages_for_paths(
            paths.iter().map(|p| p.as_str()).collect(),
            true,
            false,
        ) {
            Ok(names) => names,
            Err(e) => {
                log::warn!(
                    "Failed to search for Node package {} in APT: {}",
                    self.package,
                    e
                );
                return None;
            }
        };

        if names.is_empty() {
            None
        } else {
            Some(
                names
                    .into_iter()
                    .map(|name| super::debian::DebianDependency::new(&name))
                    .collect(),
            )
        }
    }
}

impl crate::buildlog::ToDependency for buildlog_consultant::problems::common::MissingNodePackage {
    fn to_dependency(&self) -> Option<Box<dyn Dependency>> {
        Some(Box::new(NodePackageDependency::new(&self.0)))
    }
}

#[cfg(feature = "upstream")]
impl crate::upstream::FindUpstream for NodePackageDependency {
    fn find_upstream(&self) -> Option<crate::upstream::UpstreamMetadata> {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(upstream_ontologist::providers::node::remote_npm_metadata(
            &self.package,
        ))
        .ok()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
/// A dependency on a Node.js module.
pub struct NodeModuleDependency {
    module: String,
}

impl NodeModuleDependency {
    /// Creates a new NodeModuleDependency instance.
    pub fn new(module: &str) -> Self {
        Self {
            module: module.to_string(),
        }
    }
}

impl Dependency for NodeModuleDependency {
    fn family(&self) -> &'static str {
        "node-module"
    }

    fn present(&self, session: &dyn Session) -> bool {
        // node -e 'try { require.resolve("express"); process.exit(0); } catch(e) { process.exit(1); }'
        session
            .command(vec![
                "node",
                "-e",
                &format!(
                    r#"try {{ require.resolve("{}"); process.exit(0); }} catch(e) {{ process.exit(1); }}"#,
                    self.module
                ),
            ])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .run()
            .unwrap()
            .success()
    }

    fn project_present(&self, _session: &dyn Session) -> bool {
        todo!()
    }
    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

#[cfg(feature = "debian")]
impl crate::dependencies::debian::IntoDebianDependency for NodeModuleDependency {
    fn try_into_debian_dependency(
        &self,
        apt: &crate::debian::apt::AptManager,
    ) -> Option<Vec<super::debian::DebianDependency>> {
        let paths = vec![
            format!(
                "/usr/share/nodejs/.*/node_modules/{}/package\\.json",
                regex::escape(&self.module)
            ),
            format!(
                "/usr/lib/nodejs/{}/package\\.json",
                regex::escape(&self.module)
            ),
            format!(
                "/usr/share/nodejs/{}/package\\.json",
                regex::escape(&self.module)
            ),
        ];

        let names = match apt.get_packages_for_paths(
            paths.iter().map(|p| p.as_str()).collect(),
            true,
            false,
        ) {
            Ok(names) => names,
            Err(e) => {
                log::warn!(
                    "Failed to search for Node module {} in APT: {}",
                    self.module,
                    e
                );
                return None;
            }
        };

        if names.is_empty() {
            None
        } else {
            Some(
                names
                    .into_iter()
                    .map(|name| super::debian::DebianDependency::new(&name))
                    .collect(),
            )
        }
    }
}

impl crate::buildlog::ToDependency for buildlog_consultant::problems::common::MissingNodeModule {
    fn to_dependency(&self) -> Option<Box<dyn Dependency>> {
        Some(Box::new(NodeModuleDependency::new(&self.0)))
    }
}

fn command_package(command: &str) -> Option<&str> {
    match command {
        "del-cli" => Some("del-cli"),
        "husky" => Some("husky"),
        "cross-env" => Some("cross-env"),
        "xo" => Some("xo"),
        "standard" => Some("standard"),
        "jshint" => Some("jshint"),
        "if-node-version" => Some("if-node-version"),
        "babel-cli" => Some("babel"),
        "c8" => Some("c8"),
        "prettier-standard" => Some("prettier-standard"),
        _ => None,
    }
}

/// A resolver for Node.js packages using npm.
pub struct NpmResolver<'a> {
    session: &'a dyn Session,
}

impl<'a> NpmResolver<'a> {
    /// Creates a new NpmResolver instance.
    pub fn new(session: &'a dyn Session) -> Self {
        Self { session }
    }

    fn cmd(
        &self,
        reqs: &[&NodePackageDependency],
        scope: InstallationScope,
    ) -> Result<Vec<String>, Error> {
        let mut cmd = vec!["npm".to_string(), "install".to_string()];
        match scope {
            InstallationScope::Global => cmd.push("-g".to_string()),
            InstallationScope::User => {}
            InstallationScope::Vendor => {
                return Err(Error::UnsupportedScope(scope));
            }
        }
        cmd.extend(reqs.iter().map(|req| req.package.clone()));
        Ok(cmd)
    }
}

impl From<NodeModuleDependency> for NodePackageDependency {
    fn from(dep: NodeModuleDependency) -> Self {
        let parts: Vec<&str> = dep.module.split('/').collect();
        Self {
            // TODO: Is this legit?
            package: if parts[0].starts_with('@') {
                parts[..2].join("/")
            } else {
                parts[0].to_string()
            },
        }
    }
}

fn to_node_package_req(requirement: &dyn Dependency) -> Option<NodePackageDependency> {
    if let Some(requirement) = requirement.as_any().downcast_ref::<NodeModuleDependency>() {
        Some(requirement.clone().into())
    } else if let Some(requirement) = requirement.as_any().downcast_ref::<NodePackageDependency>() {
        Some(requirement.clone())
    } else if let Some(requirement) = requirement.as_any().downcast_ref::<BinaryDependency>() {
        command_package(&requirement.binary_name).map(NodePackageDependency::new)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {

    #[test]
    #[cfg(feature = "debian")]
    fn test_get_project_wide_deps_no_hang() {
        use crate::buildsystem::detect_buildsystems;
        use crate::session::test_utils;
        use tempfile::TempDir;

        log::debug!("Starting test_get_project_wide_deps_no_hang");

        let temp_dir = TempDir::new().unwrap();
        let project_dir = temp_dir.path().join("test-nodejs");
        std::fs::create_dir(&project_dir).unwrap();

        std::fs::write(
            project_dir.join("package.json"),
            r#"{"name": "test", "version": "1.0.0", "dependencies": {"lodash": "^4.17.21"}}"#,
        )
        .unwrap();

        log::debug!("Created test project at {:?}", project_dir);

        let buildsystems = detect_buildsystems(&project_dir);
        assert!(!buildsystems.is_empty(), "Should detect node buildsystem");

        let node_buildsystem = buildsystems
            .iter()
            .find(|bs| bs.name() == "node")
            .expect("Should find node buildsystem");

        log::debug!("Detected buildsystem: {}", node_buildsystem.name());

        // Use test session for better isolation when possible
        let session = test_utils::get_test_session().expect("Failed to create test session");

        // This should NOT hang, even with network dependencies
        log::debug!("Calling get_project_wide_deps...");
        let start = std::time::Instant::now();
        let result = crate::debian::upstream_deps::get_project_wide_deps(
            session.as_ref(),
            node_buildsystem.as_ref(),
        );
        let duration = start.elapsed();

        // Should complete quickly with isolated session (no large APT downloads)
        assert!(
            duration.as_secs() < 30,
            "get_project_wide_deps took too long: {:?}",
            duration
        );

        log::debug!("get_project_wide_deps completed in {:?}", duration);
        log::debug!("Build deps: {} items", result.0.len());
        log::debug!("Test deps: {} items", result.1.len());
    }
}

impl<'a> Installer for NpmResolver<'a> {
    fn explain(
        &self,
        requirement: &dyn Dependency,
        scope: InstallationScope,
    ) -> Result<Explanation, Error> {
        let requirement = to_node_package_req(requirement).ok_or(Error::UnknownDependencyFamily)?;

        Ok(Explanation {
            message: format!("install node package {}", requirement.package),
            command: Some(self.cmd(&[&requirement], scope)?),
        })
    }

    fn install(&self, requirement: &dyn Dependency, scope: InstallationScope) -> Result<(), Error> {
        let requirement = to_node_package_req(requirement).ok_or(Error::UnknownDependencyFamily)?;

        let args = &self.cmd(&[&requirement], scope)?;
        let mut cmd = self
            .session
            .command(args.iter().map(|s| s.as_str()).collect());

        match scope {
            InstallationScope::Global => {
                cmd = cmd.user("root");
            }
            InstallationScope::User => {}
            InstallationScope::Vendor => {}
        }

        cmd.run_detecting_problems()?;

        Ok(())
    }
}