Skip to main content

fallow_config/
workspace.rs

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