vership 0.5.0

Multi-target release orchestrator
Documentation
use std::cell::RefCell;
use std::path::{Path, PathBuf};

use super::ProjectType;
use crate::error::{Error, Result};
use crate::version;

pub struct NodeProject {
    modified_files: RefCell<Vec<PathBuf>>,
}

impl NodeProject {
    pub fn new() -> Self {
        Self {
            modified_files: RefCell::new(Vec::new()),
        }
    }

    fn detect_lockfile(root: &Path) -> Option<(&'static str, &'static str)> {
        let lockfiles = [
            ("package-lock.json", "npm"),
            ("yarn.lock", "yarn"),
            ("pnpm-lock.yaml", "pnpm"),
        ];
        lockfiles
            .into_iter()
            .find(|(file, _)| root.join(file).exists())
    }

    fn has_script(root: &Path, script: &str) -> bool {
        let path = root.join("package.json");
        let Ok(content) = std::fs::read_to_string(&path) else {
            return false;
        };
        let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
            return false;
        };
        parsed.get("scripts").and_then(|s| s.get(script)).is_some()
    }
}

impl Default for NodeProject {
    fn default() -> Self {
        Self::new()
    }
}

impl ProjectType for NodeProject {
    fn name(&self) -> &str {
        "Node"
    }

    fn read_version(&self, root: &Path) -> Result<semver::Version> {
        let path = root.join("package.json");
        let content = std::fs::read_to_string(&path)
            .map_err(|e| Error::Other(format!("read package.json: {e}")))?;
        version::parse_package_json_version(&content)
    }

    fn write_version(&self, root: &Path, new_version: &semver::Version) -> Result<()> {
        let mut modified = self.modified_files.borrow_mut();
        modified.clear();

        let path = root.join("package.json");
        let content = std::fs::read_to_string(&path)
            .map_err(|e| Error::Other(format!("read package.json: {e}")))?;
        let updated = version::replace_package_json_version(&content, new_version);
        std::fs::write(&path, updated)
            .map_err(|e| Error::Other(format!("write package.json: {e}")))?;
        modified.push(PathBuf::from("package.json"));

        if let Some((lockfile, _)) = Self::detect_lockfile(root) {
            modified.push(PathBuf::from(lockfile));
        }

        Ok(())
    }

    fn verify_lockfile(&self, root: &Path) -> Result<()> {
        if Self::detect_lockfile(root).is_none() {
            return Err(Error::CheckFailed(
                "No lockfile found. Run `npm install`, `yarn install`, or `pnpm install`."
                    .to_string(),
            ));
        }
        Ok(())
    }

    fn sync_lockfile(&self, root: &Path) -> Result<()> {
        let Some((_, manager)) = Self::detect_lockfile(root) else {
            return Ok(());
        };

        let (program, args): (&str, &[&str]) = match manager {
            "npm" => (
                "npm",
                &["install", "--package-lock-only", "--ignore-scripts"],
            ),
            "yarn" => ("yarn", &["install"]),
            "pnpm" => ("pnpm", &["install", "--lockfile-only"]),
            _ => return Ok(()),
        };

        let status = std::process::Command::new(program)
            .args(args)
            .current_dir(root)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map_err(|e| Error::Other(format!("run {program}: {e}")))?;

        if status.success() {
            Ok(())
        } else {
            Err(Error::CheckFailed(format!(
                "lockfile sync failed. Run `{program} install` to fix."
            )))
        }
    }

    fn run_lint(&self, root: &Path) -> Result<()> {
        if !Self::has_script(root, "lint") {
            return Ok(());
        }

        let manager = Self::detect_lockfile(root).map(|(_, m)| m).unwrap_or("npm");

        let (program, args): (&str, &[&str]) = match manager {
            "pnpm" => ("pnpm", &["run", "lint"]),
            "yarn" => ("yarn", &["run", "lint"]),
            _ => ("npm", &["run", "lint"]),
        };

        let status = std::process::Command::new(program)
            .args(args)
            .current_dir(root)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map_err(|e| Error::Other(format!("run {program} lint: {e}")))?;

        if status.success() {
            Ok(())
        } else {
            Err(Error::CheckFailed("lint checks failed".to_string()))
        }
    }

    fn run_tests(&self, root: &Path) -> Result<()> {
        if !Self::has_script(root, "test") {
            return Ok(());
        }

        let manager = Self::detect_lockfile(root).map(|(_, m)| m).unwrap_or("npm");

        let (program, args): (&str, &[&str]) = match manager {
            "pnpm" => ("pnpm", &["test"]),
            "yarn" => ("yarn", &["test"]),
            _ => ("npm", &["test"]),
        };

        let status = std::process::Command::new(program)
            .args(args)
            .current_dir(root)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map_err(|e| Error::Other(format!("run {program} test: {e}")))?;

        if status.success() {
            Ok(())
        } else {
            Err(Error::CheckFailed("tests failed".to_string()))
        }
    }

    fn modified_files(&self) -> Vec<PathBuf> {
        self.modified_files.borrow().clone()
    }
}