fallow_config/
workspace.rs1use std::path::{Path, PathBuf};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
8pub struct WorkspaceConfig {
9 #[serde(default)]
11 pub patterns: Vec<String>,
12}
13
14#[derive(Debug, Clone)]
16pub struct WorkspaceInfo {
17 pub root: PathBuf,
19 pub name: String,
21 pub is_internal_dependency: bool,
23}
24
25pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
27 let mut patterns = Vec::new();
28
29 let pkg_path = root.join("package.json");
31 if let Ok(pkg) = PackageJson::load(&pkg_path) {
32 patterns.extend(pkg.workspace_patterns());
33 }
34
35 let pnpm_workspace = root.join("pnpm-workspace.yaml");
37 if pnpm_workspace.exists()
38 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
39 {
40 patterns.extend(parse_pnpm_workspace_yaml(&content));
41 }
42
43 if patterns.is_empty() {
44 return Vec::new();
45 }
46
47 let (positive, negative): (Vec<&String>, Vec<&String>) =
51 patterns.iter().partition(|p| !p.starts_with('!'));
52 let negation_matchers: Vec<globset::GlobMatcher> = negative
53 .iter()
54 .filter_map(|p| {
55 let stripped = p.strip_prefix('!').unwrap_or(p);
56 globset::Glob::new(stripped)
57 .ok()
58 .map(|g| g.compile_matcher())
59 })
60 .collect();
61
62 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
64 let mut workspaces = Vec::new();
65 for pattern in &positive {
66 let glob_pattern = if pattern.ends_with('/') {
71 format!("{}*", pattern)
72 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
73 (*pattern).clone()
75 } else {
76 (*pattern).clone()
77 };
78
79 let matched_dirs = expand_workspace_glob(root, &glob_pattern);
81 for dir in matched_dirs {
82 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
85 if canonical_dir == canonical_root {
86 continue;
87 }
88
89 let relative = dir.strip_prefix(root).unwrap_or(&dir);
91 let relative_str = relative.to_string_lossy();
92 if negation_matchers
93 .iter()
94 .any(|m| m.is_match(relative_str.as_ref()))
95 {
96 continue;
97 }
98
99 let ws_pkg_path = dir.join("package.json");
100 if ws_pkg_path.exists()
101 && let Ok(pkg) = PackageJson::load(&ws_pkg_path)
102 {
103 let name = pkg.name.unwrap_or_else(|| {
104 dir.file_name()
105 .map(|n| n.to_string_lossy().to_string())
106 .unwrap_or_default()
107 });
108 workspaces.push(WorkspaceInfo {
109 root: dir,
110 name,
111 is_internal_dependency: false,
112 });
113 }
114 }
115 }
116
117 let all_dep_names: Vec<String> = workspaces
119 .iter()
120 .flat_map(|ws| {
121 let ws_pkg_path = ws.root.join("package.json");
122 PackageJson::load(&ws_pkg_path)
123 .map(|pkg| pkg.all_dependency_names())
124 .unwrap_or_default()
125 })
126 .collect();
127 for ws in &mut workspaces {
128 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
129 }
130
131 workspaces
132}
133
134fn expand_workspace_glob(root: &Path, pattern: &str) -> Vec<PathBuf> {
138 let full_pattern = root.join(pattern).to_string_lossy().to_string();
139 match glob::glob(&full_pattern) {
140 Ok(paths) => paths
141 .filter_map(Result::ok)
142 .filter(|p| p.is_dir())
143 .filter(|p| {
144 p.canonicalize()
146 .ok()
147 .and_then(|cp| root.canonicalize().ok().map(|cr| cp.starts_with(cr)))
148 .unwrap_or(false)
149 })
150 .collect(),
151 Err(e) => {
152 eprintln!("Warning: Invalid workspace glob pattern '{pattern}': {e}");
153 Vec::new()
154 }
155 }
156}
157
158fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
160 let mut patterns = Vec::new();
165 let mut in_packages = false;
166
167 for line in content.lines() {
168 let trimmed = line.trim();
169 if trimmed == "packages:" {
170 in_packages = true;
171 continue;
172 }
173 if in_packages {
174 if trimmed.starts_with("- ") {
175 let value = trimmed
176 .strip_prefix("- ")
177 .unwrap_or(trimmed)
178 .trim_matches('\'')
179 .trim_matches('"');
180 patterns.push(value.to_string());
181 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
182 break; }
184 }
185 }
186
187 patterns
188}
189
190#[derive(Debug, Clone, Default, Deserialize, Serialize)]
192pub struct PackageJson {
193 #[serde(default)]
194 pub name: Option<String>,
195 #[serde(default)]
196 pub main: Option<String>,
197 #[serde(default)]
198 pub module: Option<String>,
199 #[serde(default)]
200 pub types: Option<String>,
201 #[serde(default)]
202 pub typings: Option<String>,
203 #[serde(default)]
204 pub bin: Option<serde_json::Value>,
205 #[serde(default)]
206 pub exports: Option<serde_json::Value>,
207 #[serde(default)]
208 pub dependencies: Option<std::collections::HashMap<String, String>>,
209 #[serde(default, rename = "devDependencies")]
210 pub dev_dependencies: Option<std::collections::HashMap<String, String>>,
211 #[serde(default, rename = "peerDependencies")]
212 pub peer_dependencies: Option<std::collections::HashMap<String, String>>,
213 #[serde(default)]
214 pub scripts: Option<std::collections::HashMap<String, String>>,
215 #[serde(default)]
216 pub workspaces: Option<serde_json::Value>,
217}
218
219impl PackageJson {
220 pub fn load(path: &std::path::Path) -> Result<Self, String> {
222 let content = std::fs::read_to_string(path)
223 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
224 serde_json::from_str(&content)
225 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
226 }
227
228 pub fn all_dependency_names(&self) -> Vec<String> {
230 let mut deps = Vec::new();
231 if let Some(d) = &self.dependencies {
232 deps.extend(d.keys().cloned());
233 }
234 if let Some(d) = &self.dev_dependencies {
235 deps.extend(d.keys().cloned());
236 }
237 if let Some(d) = &self.peer_dependencies {
238 deps.extend(d.keys().cloned());
239 }
240 deps
241 }
242
243 pub fn production_dependency_names(&self) -> Vec<String> {
245 self.dependencies
246 .as_ref()
247 .map(|d| d.keys().cloned().collect())
248 .unwrap_or_default()
249 }
250
251 pub fn dev_dependency_names(&self) -> Vec<String> {
253 self.dev_dependencies
254 .as_ref()
255 .map(|d| d.keys().cloned().collect())
256 .unwrap_or_default()
257 }
258
259 pub fn entry_points(&self) -> Vec<String> {
261 let mut entries = Vec::new();
262
263 if let Some(main) = &self.main {
264 entries.push(main.clone());
265 }
266 if let Some(module) = &self.module {
267 entries.push(module.clone());
268 }
269 if let Some(types) = &self.types {
270 entries.push(types.clone());
271 }
272 if let Some(typings) = &self.typings {
273 entries.push(typings.clone());
274 }
275
276 if let Some(bin) = &self.bin {
278 match bin {
279 serde_json::Value::String(s) => entries.push(s.clone()),
280 serde_json::Value::Object(map) => {
281 for v in map.values() {
282 if let serde_json::Value::String(s) = v {
283 entries.push(s.clone());
284 }
285 }
286 }
287 _ => {}
288 }
289 }
290
291 if let Some(exports) = &self.exports {
293 extract_exports_entries(exports, &mut entries);
294 }
295
296 entries
297 }
298
299 pub fn workspace_patterns(&self) -> Vec<String> {
301 match &self.workspaces {
302 Some(serde_json::Value::Array(arr)) => arr
303 .iter()
304 .filter_map(|v| v.as_str().map(String::from))
305 .collect(),
306 Some(serde_json::Value::Object(obj)) => obj
307 .get("packages")
308 .and_then(|v| v.as_array())
309 .map(|arr| {
310 arr.iter()
311 .filter_map(|v| v.as_str().map(String::from))
312 .collect()
313 })
314 .unwrap_or_default(),
315 _ => Vec::new(),
316 }
317 }
318}
319
320fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
322 match value {
323 serde_json::Value::String(s) => {
324 if s.starts_with("./") || s.starts_with("../") {
325 entries.push(s.clone());
326 }
327 }
328 serde_json::Value::Object(map) => {
329 for v in map.values() {
330 extract_exports_entries(v, entries);
331 }
332 }
333 serde_json::Value::Array(arr) => {
334 for v in arr {
335 extract_exports_entries(v, entries);
336 }
337 }
338 _ => {}
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn parse_pnpm_workspace_basic() {
348 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
349 let patterns = parse_pnpm_workspace_yaml(yaml);
350 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
351 }
352
353 #[test]
354 fn parse_pnpm_workspace_double_quotes() {
355 let yaml = "packages:\n - \"packages/*\"\n - \"apps/*\"\n";
356 let patterns = parse_pnpm_workspace_yaml(yaml);
357 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
358 }
359
360 #[test]
361 fn parse_pnpm_workspace_no_quotes() {
362 let yaml = "packages:\n - packages/*\n - apps/*\n";
363 let patterns = parse_pnpm_workspace_yaml(yaml);
364 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
365 }
366
367 #[test]
368 fn parse_pnpm_workspace_empty() {
369 let yaml = "";
370 let patterns = parse_pnpm_workspace_yaml(yaml);
371 assert!(patterns.is_empty());
372 }
373
374 #[test]
375 fn parse_pnpm_workspace_no_packages_key() {
376 let yaml = "other:\n - something\n";
377 let patterns = parse_pnpm_workspace_yaml(yaml);
378 assert!(patterns.is_empty());
379 }
380
381 #[test]
382 fn parse_pnpm_workspace_with_comments() {
383 let yaml = "packages:\n # Comment\n - 'packages/*'\n";
384 let patterns = parse_pnpm_workspace_yaml(yaml);
385 assert_eq!(patterns, vec!["packages/*"]);
386 }
387
388 #[test]
389 fn parse_pnpm_workspace_stops_at_next_key() {
390 let yaml = "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n";
391 let patterns = parse_pnpm_workspace_yaml(yaml);
392 assert_eq!(patterns, vec!["packages/*"]);
393 }
394
395 #[test]
396 fn package_json_workspace_patterns_array() {
397 let pkg: PackageJson =
398 serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
399 let patterns = pkg.workspace_patterns();
400 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
401 }
402
403 #[test]
404 fn package_json_workspace_patterns_object() {
405 let pkg: PackageJson =
406 serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
407 let patterns = pkg.workspace_patterns();
408 assert_eq!(patterns, vec!["packages/*"]);
409 }
410
411 #[test]
412 fn package_json_workspace_patterns_none() {
413 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
414 let patterns = pkg.workspace_patterns();
415 assert!(patterns.is_empty());
416 }
417
418 #[test]
419 fn package_json_workspace_patterns_empty_array() {
420 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
421 let patterns = pkg.workspace_patterns();
422 assert!(patterns.is_empty());
423 }
424
425 #[test]
426 fn package_json_load_valid() {
427 let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
428 let _ = std::fs::create_dir_all(&temp_dir);
429 let pkg_path = temp_dir.join("package.json");
430 std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
431
432 let pkg = PackageJson::load(&pkg_path).unwrap();
433 assert_eq!(pkg.name, Some("test".to_string()));
434 assert_eq!(pkg.main, Some("index.js".to_string()));
435
436 let _ = std::fs::remove_dir_all(&temp_dir);
437 }
438
439 #[test]
440 fn package_json_load_missing_file() {
441 let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
442 assert!(result.is_err());
443 }
444
445 #[test]
446 fn package_json_entry_points_combined() {
447 let pkg: PackageJson = serde_json::from_str(
448 r#"{
449 "main": "dist/index.js",
450 "module": "dist/index.mjs",
451 "types": "dist/index.d.ts",
452 "typings": "dist/types.d.ts"
453 }"#,
454 )
455 .unwrap();
456 let entries = pkg.entry_points();
457 assert_eq!(entries.len(), 4);
458 assert!(entries.contains(&"dist/index.js".to_string()));
459 assert!(entries.contains(&"dist/index.mjs".to_string()));
460 assert!(entries.contains(&"dist/index.d.ts".to_string()));
461 assert!(entries.contains(&"dist/types.d.ts".to_string()));
462 }
463
464 #[test]
465 fn package_json_exports_nested() {
466 let pkg: PackageJson = serde_json::from_str(
467 r#"{
468 "exports": {
469 ".": {
470 "import": "./dist/index.mjs",
471 "require": "./dist/index.cjs"
472 },
473 "./utils": {
474 "import": "./dist/utils.mjs"
475 }
476 }
477 }"#,
478 )
479 .unwrap();
480 let entries = pkg.entry_points();
481 assert!(entries.contains(&"./dist/index.mjs".to_string()));
482 assert!(entries.contains(&"./dist/index.cjs".to_string()));
483 assert!(entries.contains(&"./dist/utils.mjs".to_string()));
484 }
485
486 #[test]
487 fn package_json_exports_array() {
488 let pkg: PackageJson = serde_json::from_str(
489 r#"{
490 "exports": {
491 ".": ["./dist/index.mjs", "./dist/index.cjs"]
492 }
493 }"#,
494 )
495 .unwrap();
496 let entries = pkg.entry_points();
497 assert!(entries.contains(&"./dist/index.mjs".to_string()));
498 assert!(entries.contains(&"./dist/index.cjs".to_string()));
499 }
500
501 #[test]
502 fn extract_exports_ignores_non_relative() {
503 let pkg: PackageJson = serde_json::from_str(
504 r#"{
505 "exports": {
506 ".": "not-a-relative-path"
507 }
508 }"#,
509 )
510 .unwrap();
511 let entries = pkg.entry_points();
512 assert!(entries.is_empty());
514 }
515}