Skip to main content

morph_cli/core/detection/
workspace.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::core::detection::package_json::PackageJson;
7
8#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
9pub enum WorkspaceManager {
10    Npm,
11    Pnpm,
12    Yarn,
13}
14
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct WorkspacePackage {
17    pub name: String,
18    pub path: PathBuf,
19}
20
21#[derive(Debug, Clone, Default, serde::Serialize)]
22pub struct WorkspaceSummary {
23    pub managers: Vec<WorkspaceManager>,
24    pub packages: Vec<WorkspacePackage>,
25}
26
27impl WorkspaceSummary {
28    pub fn is_workspace(&self) -> bool {
29        !self.managers.is_empty() || !self.packages.is_empty()
30    }
31
32    pub fn find_package(&self, name: &str) -> Option<&WorkspacePackage> {
33        self.packages.iter().find(|package| package.name == name)
34    }
35}
36
37pub fn detect_workspaces(root: &Path) -> WorkspaceSummary {
38    let root_package = PackageJson::load(&root.join("package.json"));
39    let mut managers = Vec::new();
40    let mut patterns = Vec::new();
41
42    if let Some(package) = &root_package
43        && let Some(workspaces) = &package.workspaces
44    {
45        managers.push(if root.join("yarn.lock").exists() {
46            WorkspaceManager::Yarn
47        } else {
48            WorkspaceManager::Npm
49        });
50        patterns.extend(workspace_patterns_from_package_json(workspaces));
51    }
52
53    if root.join("pnpm-workspace.yaml").exists() {
54        managers.push(WorkspaceManager::Pnpm);
55        patterns.extend(workspace_patterns_from_pnpm(&root.join("pnpm-workspace.yaml")));
56    }
57
58    managers.dedup();
59
60    let packages = find_packages(root, &patterns);
61
62    WorkspaceSummary { managers, packages }
63}
64
65fn workspace_patterns_from_package_json(workspaces: &Value) -> Vec<String> {
66    match workspaces {
67        Value::Array(values) => values
68            .iter()
69            .filter_map(|value| value.as_str().map(ToOwned::to_owned))
70            .collect(),
71        Value::Object(object) => object
72            .get("packages")
73            .and_then(|packages| packages.as_array())
74            .map(|packages| {
75                packages
76                    .iter()
77                    .filter_map(|value| value.as_str().map(ToOwned::to_owned))
78                    .collect()
79            })
80            .unwrap_or_default(),
81        _ => Vec::new(),
82    }
83}
84
85fn workspace_patterns_from_pnpm(path: &Path) -> Vec<String> {
86    let Ok(content) = fs::read_to_string(path) else {
87        return Vec::new();
88    };
89
90    let mut patterns = Vec::new();
91    let mut in_packages = false;
92
93    for line in content.lines() {
94        let trimmed = line.trim();
95        if trimmed == "packages:" {
96            in_packages = true;
97            continue;
98        }
99
100        if in_packages {
101            if let Some(pattern) = trimmed.strip_prefix('-') {
102                patterns.push(pattern.trim().trim_matches(['"', '\'']).to_string());
103            } else if !trimmed.is_empty() && !line.starts_with(' ') {
104                break;
105            }
106        }
107    }
108
109    patterns
110}
111
112fn find_packages(root: &Path, patterns: &[String]) -> Vec<WorkspacePackage> {
113    let mut packages = Vec::new();
114
115    for pattern in patterns {
116        if pattern.starts_with('!') || pattern.contains("node_modules") {
117            continue;
118        }
119
120        let base = pattern.trim_end_matches("/*").trim_end_matches("/**");
121        let base_path = root.join(base);
122        if !base_path.exists() {
123            continue;
124        }
125
126        if pattern.ends_with("/*") || pattern.ends_with("/**") {
127            if let Ok(entries) = fs::read_dir(&base_path) {
128                for entry in entries.filter_map(Result::ok) {
129                    add_package_if_present(&mut packages, &entry.path());
130                }
131            }
132        } else {
133            add_package_if_present(&mut packages, &base_path);
134        }
135    }
136
137    packages.sort_by(|left, right| left.name.cmp(&right.name));
138    packages.dedup_by(|left, right| left.name == right.name || left.path == right.path);
139    packages
140}
141
142fn add_package_if_present(packages: &mut Vec<WorkspacePackage>, path: &Path) {
143    let package_path = path.join("package.json");
144    let Some(package) = PackageJson::load(&package_path) else {
145        return;
146    };
147
148    if package.name.is_empty() {
149        return;
150    }
151
152    packages.push(WorkspacePackage {
153        name: package.name,
154        path: path.to_path_buf(),
155    });
156}