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