fallow_config/workspace/
mod.rs1mod package_json;
2mod parsers;
3
4use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9pub use package_json::PackageJson;
10use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
11
12#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct WorkspaceConfig {
15 #[serde(default)]
17 pub patterns: Vec<String>,
18}
19
20#[derive(Debug, Clone)]
22pub struct WorkspaceInfo {
23 pub root: PathBuf,
25 pub name: String,
27 pub is_internal_dependency: bool,
29}
30
31pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
38 let patterns = collect_workspace_patterns(root);
39 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
40
41 let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
42 workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
43
44 if workspaces.is_empty() {
45 return Vec::new();
46 }
47
48 mark_internal_dependencies(&mut workspaces);
49 workspaces.into_iter().map(|(ws, _)| ws).collect()
50}
51
52fn collect_workspace_patterns(root: &Path) -> Vec<String> {
54 let mut patterns = Vec::new();
55
56 let pkg_path = root.join("package.json");
58 if let Ok(pkg) = PackageJson::load(&pkg_path) {
59 patterns.extend(pkg.workspace_patterns());
60 }
61
62 let pnpm_workspace = root.join("pnpm-workspace.yaml");
64 if pnpm_workspace.exists()
65 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
66 {
67 patterns.extend(parse_pnpm_workspace_yaml(&content));
68 }
69
70 patterns
71}
72
73fn expand_patterns_to_workspaces(
78 root: &Path,
79 patterns: &[String],
80 canonical_root: &Path,
81) -> Vec<(WorkspaceInfo, Vec<String>)> {
82 if patterns.is_empty() {
83 return Vec::new();
84 }
85
86 let mut workspaces = Vec::new();
87
88 let (positive, negative): (Vec<&String>, Vec<&String>) =
92 patterns.iter().partition(|p| !p.starts_with('!'));
93 let negation_matchers: Vec<globset::GlobMatcher> = negative
94 .iter()
95 .filter_map(|p| {
96 let stripped = p.strip_prefix('!').unwrap_or(p);
97 globset::Glob::new(stripped)
98 .ok()
99 .map(|g| g.compile_matcher())
100 })
101 .collect();
102
103 for pattern in &positive {
104 let glob_pattern = if pattern.ends_with('/') {
109 format!("{pattern}*")
110 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
111 (*pattern).clone()
113 } else {
114 (*pattern).clone()
115 };
116
117 let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
121 for (dir, canonical_dir) in matched_dirs {
122 if canonical_dir == *canonical_root {
125 continue;
126 }
127
128 let relative = dir.strip_prefix(root).unwrap_or(&dir);
130 let relative_str = relative.to_string_lossy();
131 if negation_matchers
132 .iter()
133 .any(|m| m.is_match(relative_str.as_ref()))
134 {
135 continue;
136 }
137
138 let ws_pkg_path = dir.join("package.json");
140 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
141 let dep_names = pkg.all_dependency_names();
144 let name = pkg.name.unwrap_or_else(|| {
145 dir.file_name()
146 .map(|n| n.to_string_lossy().to_string())
147 .unwrap_or_default()
148 });
149 workspaces.push((
150 WorkspaceInfo {
151 root: dir,
152 name,
153 is_internal_dependency: false,
154 },
155 dep_names,
156 ));
157 }
158 }
159 }
160
161 workspaces
162}
163
164fn collect_tsconfig_workspaces(
169 root: &Path,
170 canonical_root: &Path,
171) -> Vec<(WorkspaceInfo, Vec<String>)> {
172 let mut workspaces = Vec::new();
173
174 for dir in parse_tsconfig_references(root) {
175 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
176 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
178 continue;
179 }
180
181 let ws_pkg_path = dir.join("package.json");
183 let (name, dep_names) = if ws_pkg_path.exists() {
184 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
185 let deps = pkg.all_dependency_names();
186 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
187 (n, deps)
188 } else {
189 (dir_name(&dir), Vec::new())
190 }
191 } else {
192 (dir_name(&dir), Vec::new())
195 };
196
197 workspaces.push((
198 WorkspaceInfo {
199 root: dir,
200 name,
201 is_internal_dependency: false,
202 },
203 dep_names,
204 ));
205 }
206
207 workspaces
208}
209
210fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
216 {
218 let mut seen = rustc_hash::FxHashSet::default();
219 workspaces.retain(|(ws, _)| {
220 let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
221 seen.insert(canonical)
222 });
223 }
224
225 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
229 .iter()
230 .flat_map(|(_, deps)| deps.iter().cloned())
231 .collect();
232 for (ws, _) in &mut *workspaces {
233 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
234 }
235}
236
237fn dir_name(dir: &Path) -> String {
239 dir.file_name()
240 .map(|n| n.to_string_lossy().to_string())
241 .unwrap_or_default()
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn discover_workspaces_from_tsconfig_references() {
250 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
251 let _ = std::fs::remove_dir_all(&temp_dir);
252 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
253 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
254
255 std::fs::write(
257 temp_dir.join("tsconfig.json"),
258 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
259 )
260 .unwrap();
261
262 std::fs::write(
264 temp_dir.join("packages/core/package.json"),
265 r#"{"name": "@project/core"}"#,
266 )
267 .unwrap();
268
269 let workspaces = discover_workspaces(&temp_dir);
271 assert_eq!(workspaces.len(), 2);
272 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
273 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
274
275 let _ = std::fs::remove_dir_all(&temp_dir);
276 }
277
278 #[test]
279 fn tsconfig_references_outside_root_rejected() {
280 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
281 let _ = std::fs::remove_dir_all(&temp_dir);
282 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
283 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
285
286 std::fs::write(
287 temp_dir.join("project/tsconfig.json"),
288 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
289 )
290 .unwrap();
291
292 let workspaces = discover_workspaces(&temp_dir.join("project"));
294 assert_eq!(
295 workspaces.len(),
296 1,
297 "reference outside project root should be rejected: {workspaces:?}"
298 );
299 assert!(
300 workspaces[0]
301 .root
302 .to_string_lossy()
303 .contains("packages/core")
304 );
305
306 let _ = std::fs::remove_dir_all(&temp_dir);
307 }
308}