Skip to main content

fallow_config/workspace/
mod.rs

1mod 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/// Workspace configuration for monorepo support.
13#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct WorkspaceConfig {
15    /// Additional workspace patterns (beyond what's in root package.json).
16    #[serde(default)]
17    pub patterns: Vec<String>,
18}
19
20/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
21#[derive(Debug, Clone)]
22pub struct WorkspaceInfo {
23    /// Workspace root path.
24    pub root: PathBuf,
25    /// Package name from package.json.
26    pub name: String,
27    /// Whether this workspace is depended on by other workspaces.
28    pub is_internal_dependency: bool,
29}
30
31/// Discover all workspace packages in a monorepo.
32///
33/// Sources (additive, deduplicated by canonical path):
34/// 1. `package.json` `workspaces` field
35/// 2. `pnpm-workspace.yaml` `packages` field
36/// 3. `tsconfig.json` `references` field (TypeScript project references)
37#[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
53/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
54fn collect_workspace_patterns(root: &Path) -> Vec<String> {
55    let mut patterns = Vec::new();
56
57    // Check root package.json for workspace patterns
58    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    // Check pnpm-workspace.yaml
64    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
74/// Expand workspace glob patterns to discover workspace directories.
75///
76/// Handles positive/negated pattern splitting, glob matching, and package.json
77/// loading for each matched directory.
78fn 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    // Separate positive and negated patterns.
90    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
91    // the `glob` crate does not support `!` prefixed patterns natively.
92    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        // Normalize the pattern for directory matching:
106        // - `packages/*` → glob for `packages/*` (find all subdirs)
107        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
108        // - `apps`       → glob for `apps` (exact directory)
109        let glob_pattern = if pattern.ends_with('/') {
110            format!("{pattern}*")
111        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
112            // Bare directory name — treat as exact match
113            (*pattern).clone()
114        } else {
115            (*pattern).clone()
116        };
117
118        // Walk directories matching the glob.
119        // expand_workspace_glob already filters to dirs with package.json
120        // and returns (original_path, canonical_path) — no redundant canonicalize().
121        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
122        for (dir, canonical_dir) in matched_dirs {
123            // Skip workspace entries that point to the project root itself
124            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
125            if canonical_dir == *canonical_root {
126                continue;
127            }
128
129            // Check against negation patterns — skip directories that match any negated pattern
130            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            // package.json existence already checked in expand_workspace_glob
140            let ws_pkg_path = dir.join("package.json");
141            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
142                // Collect dependency names during initial load to avoid
143                // re-reading package.json later.
144                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
165/// Discover workspaces from TypeScript project references in `tsconfig.json`.
166///
167/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
168/// This enables cross-workspace resolution for TypeScript composite projects.
169fn 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        // Security: skip references pointing to project root or outside it
178        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
179            continue;
180        }
181
182        // Read package.json if available; otherwise use directory name
183        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            // No package.json — use directory name, no deps.
194            // Valid for TypeScript-only composite projects.
195            (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
211/// Deduplicate workspaces by canonical path and mark internal dependencies.
212///
213/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
214/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
215/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
216fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
217    // Deduplicate by canonical path
218    {
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    // Mark workspaces that are depended on by other workspaces.
227    // Uses dep names collected during initial package.json load
228    // to avoid re-reading all workspace package.json files.
229    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
238/// Extract the directory name as a string, for workspace name fallback.
239fn 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        // No package.json workspaces — only tsconfig references
257        std::fs::write(
258            temp_dir.join("tsconfig.json"),
259            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
260        )
261        .unwrap();
262
263        // core has package.json with a name
264        std::fs::write(
265            temp_dir.join("packages/core/package.json"),
266            r#"{"name": "@project/core"}"#,
267        )
268        .unwrap();
269
270        // ui has NO package.json — name should fall back to directory name
271        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        // "outside" is a sibling of "project", not inside it
285        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        // Security: "../outside" points outside the project root and should be rejected
294        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}