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