morph_cli/core/detection/
workspace.rs1use 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}