Skip to main content

fallow_config/
workspace.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5/// Workspace configuration for monorepo support.
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct WorkspaceConfig {
8    /// Additional workspace patterns (beyond what's in root package.json).
9    #[serde(default)]
10    pub patterns: Vec<String>,
11}
12
13/// Discovered workspace info from package.json or pnpm-workspace.yaml.
14#[derive(Debug, Clone)]
15pub struct WorkspaceInfo {
16    /// Workspace root path.
17    pub root: PathBuf,
18    /// Package name from package.json.
19    pub name: String,
20    /// Whether this workspace is depended on by other workspaces.
21    pub is_internal_dependency: bool,
22}
23
24/// Discover all workspace packages in a monorepo.
25pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
26    let mut patterns = Vec::new();
27
28    // 1. Check root package.json for workspace patterns
29    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    // 2. Check pnpm-workspace.yaml
35    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    // 3. Separate positive and negated patterns.
47    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
48    // the `glob` crate does not support `!` prefixed patterns natively.
49    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    // Expand patterns to find workspace directories
62    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
63    let mut workspaces = Vec::new();
64    for pattern in &positive {
65        // Normalize the pattern for directory matching:
66        // - `packages/*` → glob for `packages/*` (find all subdirs)
67        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
68        // - `apps`       → glob for `apps` (exact directory)
69        let glob_pattern = if pattern.ends_with('/') {
70            format!("{}*", pattern)
71        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
72            // Bare directory name — treat as exact match
73            (*pattern).clone()
74        } else {
75            (*pattern).clone()
76        };
77
78        // Walk directories matching the glob
79        let matched_dirs = expand_workspace_glob(root, &glob_pattern);
80        for dir in matched_dirs {
81            // Skip workspace entries that point to the project root itself
82            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
83            let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
84            if canonical_dir == canonical_root {
85                continue;
86            }
87
88            // Check against negation patterns — skip directories that match any negated pattern
89            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    // 4. Mark workspaces that are depended on by other workspaces
117    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
133/// Expand a workspace glob pattern to matching directories.
134///
135/// Uses the `glob` crate for full glob support including `**` (deep matching).
136fn 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                // Security: ensure workspace directory is within project root
144                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
157/// Parse pnpm-workspace.yaml to extract package patterns.
158fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
159    // Simple YAML parsing for the common format:
160    // packages:
161    //   - 'packages/*'
162    //   - 'apps/*'
163    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; // New top-level key
182            }
183        }
184    }
185
186    patterns
187}
188
189/// Parsed package.json with fields relevant to fallow.
190#[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    /// Load from a package.json file.
220    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    /// Get all dependency names (production + dev + peer).
228    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    /// Get production dependency names only.
243    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    /// Get dev dependency names only.
251    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    /// Extract entry points from package.json fields.
259    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        // Handle bin field (string or object)
276        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        // Handle exports field (recursive)
291        if let Some(exports) = &self.exports {
292            extract_exports_entries(exports, &mut entries);
293        }
294
295        entries
296    }
297
298    /// Extract workspace patterns from package.json.
299    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
319/// Recursively extract file paths from package.json exports field.
320fn 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        // "not-a-relative-path" doesn't start with "./" so should be excluded
512        assert!(entries.is_empty());
513    }
514}