morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::core::detection::package_json::PackageJson;

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum WorkspaceManager {
    Npm,
    Pnpm,
    Yarn,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct WorkspacePackage {
    pub name: String,
    pub path: PathBuf,
}

#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct WorkspaceSummary {
    pub managers: Vec<WorkspaceManager>,
    pub packages: Vec<WorkspacePackage>,
}

impl WorkspaceSummary {
    pub fn is_workspace(&self) -> bool {
        !self.managers.is_empty() || !self.packages.is_empty()
    }

    pub fn find_package(&self, name: &str) -> Option<&WorkspacePackage> {
        self.packages.iter().find(|package| package.name == name)
    }
}

pub fn detect_workspaces(root: &Path) -> WorkspaceSummary {
    let root_package = PackageJson::load(&root.join("package.json"));
    let mut managers = Vec::new();
    let mut patterns = Vec::new();

    if let Some(package) = &root_package
        && let Some(workspaces) = &package.workspaces
    {
        managers.push(if root.join("yarn.lock").exists() {
            WorkspaceManager::Yarn
        } else {
            WorkspaceManager::Npm
        });
        patterns.extend(workspace_patterns_from_package_json(workspaces));
    }

    if root.join("pnpm-workspace.yaml").exists() {
        managers.push(WorkspaceManager::Pnpm);
        patterns.extend(workspace_patterns_from_pnpm(&root.join("pnpm-workspace.yaml")));
    }

    managers.dedup();

    let packages = find_packages(root, &patterns);

    WorkspaceSummary { managers, packages }
}

fn workspace_patterns_from_package_json(workspaces: &Value) -> Vec<String> {
    match workspaces {
        Value::Array(values) => values
            .iter()
            .filter_map(|value| value.as_str().map(ToOwned::to_owned))
            .collect(),
        Value::Object(object) => object
            .get("packages")
            .and_then(|packages| packages.as_array())
            .map(|packages| {
                packages
                    .iter()
                    .filter_map(|value| value.as_str().map(ToOwned::to_owned))
                    .collect()
            })
            .unwrap_or_default(),
        _ => Vec::new(),
    }
}

fn workspace_patterns_from_pnpm(path: &Path) -> Vec<String> {
    let Ok(content) = fs::read_to_string(path) else {
        return Vec::new();
    };

    let mut patterns = Vec::new();
    let mut in_packages = false;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed == "packages:" {
            in_packages = true;
            continue;
        }

        if in_packages {
            if let Some(pattern) = trimmed.strip_prefix('-') {
                patterns.push(pattern.trim().trim_matches(['"', '\'']).to_string());
            } else if !trimmed.is_empty() && !line.starts_with(' ') {
                break;
            }
        }
    }

    patterns
}

fn find_packages(root: &Path, patterns: &[String]) -> Vec<WorkspacePackage> {
    let mut packages = Vec::new();

    for pattern in patterns {
        if pattern.starts_with('!') || pattern.contains("node_modules") {
            continue;
        }

        let base = pattern.trim_end_matches("/*").trim_end_matches("/**");
        let base_path = root.join(base);
        if !base_path.exists() {
            continue;
        }

        if pattern.ends_with("/*") || pattern.ends_with("/**") {
            if let Ok(entries) = fs::read_dir(&base_path) {
                for entry in entries.filter_map(Result::ok) {
                    add_package_if_present(&mut packages, &entry.path());
                }
            }
        } else {
            add_package_if_present(&mut packages, &base_path);
        }
    }

    packages.sort_by(|left, right| left.name.cmp(&right.name));
    packages.dedup_by(|left, right| left.name == right.name || left.path == right.path);
    packages
}

fn add_package_if_present(packages: &mut Vec<WorkspacePackage>, path: &Path) {
    let package_path = path.join("package.json");
    let Some(package) = PackageJson::load(&package_path) else {
        return;
    };

    if package.name.is_empty() {
        return;
    }

    packages.push(WorkspacePackage {
        name: package.name,
        path: path.to_path_buf(),
    });
}