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)
37pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
38    let patterns = collect_workspace_patterns(root);
39    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
40
41    let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
42    workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
43
44    if workspaces.is_empty() {
45        return Vec::new();
46    }
47
48    mark_internal_dependencies(&mut workspaces);
49    workspaces.into_iter().map(|(ws, _)| ws).collect()
50}
51
52/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
53fn collect_workspace_patterns(root: &Path) -> Vec<String> {
54    let mut patterns = Vec::new();
55
56    // Check root package.json for workspace patterns
57    let pkg_path = root.join("package.json");
58    if let Ok(pkg) = PackageJson::load(&pkg_path) {
59        patterns.extend(pkg.workspace_patterns());
60    }
61
62    // Check pnpm-workspace.yaml
63    let pnpm_workspace = root.join("pnpm-workspace.yaml");
64    if pnpm_workspace.exists()
65        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
66    {
67        patterns.extend(parse_pnpm_workspace_yaml(&content));
68    }
69
70    patterns
71}
72
73/// Expand workspace glob patterns to discover workspace directories.
74///
75/// Handles positive/negated pattern splitting, glob matching, and package.json
76/// loading for each matched directory.
77fn expand_patterns_to_workspaces(
78    root: &Path,
79    patterns: &[String],
80    canonical_root: &Path,
81) -> Vec<(WorkspaceInfo, Vec<String>)> {
82    if patterns.is_empty() {
83        return Vec::new();
84    }
85
86    let mut workspaces = Vec::new();
87
88    // Separate positive and negated patterns.
89    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
90    // the `glob` crate does not support `!` prefixed patterns natively.
91    let (positive, negative): (Vec<&String>, Vec<&String>) =
92        patterns.iter().partition(|p| !p.starts_with('!'));
93    let negation_matchers: Vec<globset::GlobMatcher> = negative
94        .iter()
95        .filter_map(|p| {
96            let stripped = p.strip_prefix('!').unwrap_or(p);
97            globset::Glob::new(stripped)
98                .ok()
99                .map(|g| g.compile_matcher())
100        })
101        .collect();
102
103    for pattern in &positive {
104        // Normalize the pattern for directory matching:
105        // - `packages/*` → glob for `packages/*` (find all subdirs)
106        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
107        // - `apps`       → glob for `apps` (exact directory)
108        let glob_pattern = if pattern.ends_with('/') {
109            format!("{pattern}*")
110        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
111            // Bare directory name — treat as exact match
112            (*pattern).clone()
113        } else {
114            (*pattern).clone()
115        };
116
117        // Walk directories matching the glob.
118        // expand_workspace_glob already filters to dirs with package.json
119        // and returns (original_path, canonical_path) — no redundant canonicalize().
120        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
121        for (dir, canonical_dir) in matched_dirs {
122            // Skip workspace entries that point to the project root itself
123            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
124            if canonical_dir == *canonical_root {
125                continue;
126            }
127
128            // Check against negation patterns — skip directories that match any negated pattern
129            let relative = dir.strip_prefix(root).unwrap_or(&dir);
130            let relative_str = relative.to_string_lossy();
131            if negation_matchers
132                .iter()
133                .any(|m| m.is_match(relative_str.as_ref()))
134            {
135                continue;
136            }
137
138            // package.json existence already checked in expand_workspace_glob
139            let ws_pkg_path = dir.join("package.json");
140            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
141                // Collect dependency names during initial load to avoid
142                // re-reading package.json later.
143                let dep_names = pkg.all_dependency_names();
144                let name = pkg.name.unwrap_or_else(|| {
145                    dir.file_name()
146                        .map(|n| n.to_string_lossy().to_string())
147                        .unwrap_or_default()
148                });
149                workspaces.push((
150                    WorkspaceInfo {
151                        root: dir,
152                        name,
153                        is_internal_dependency: false,
154                    },
155                    dep_names,
156                ));
157            }
158        }
159    }
160
161    workspaces
162}
163
164/// Discover workspaces from TypeScript project references in `tsconfig.json`.
165///
166/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
167/// This enables cross-workspace resolution for TypeScript composite projects.
168fn collect_tsconfig_workspaces(
169    root: &Path,
170    canonical_root: &Path,
171) -> Vec<(WorkspaceInfo, Vec<String>)> {
172    let mut workspaces = Vec::new();
173
174    for dir in parse_tsconfig_references(root) {
175        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
176        // Security: skip references pointing to project root or outside it
177        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
178            continue;
179        }
180
181        // Read package.json if available; otherwise use directory name
182        let ws_pkg_path = dir.join("package.json");
183        let (name, dep_names) = if ws_pkg_path.exists() {
184            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
185                let deps = pkg.all_dependency_names();
186                let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
187                (n, deps)
188            } else {
189                (dir_name(&dir), Vec::new())
190            }
191        } else {
192            // No package.json — use directory name, no deps.
193            // Valid for TypeScript-only composite projects.
194            (dir_name(&dir), Vec::new())
195        };
196
197        workspaces.push((
198            WorkspaceInfo {
199                root: dir,
200                name,
201                is_internal_dependency: false,
202            },
203            dep_names,
204        ));
205    }
206
207    workspaces
208}
209
210/// Deduplicate workspaces by canonical path and mark internal dependencies.
211///
212/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
213/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
214/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
215fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
216    // Deduplicate by canonical path
217    {
218        let mut seen = rustc_hash::FxHashSet::default();
219        workspaces.retain(|(ws, _)| {
220            let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
221            seen.insert(canonical)
222        });
223    }
224
225    // Mark workspaces that are depended on by other workspaces.
226    // Uses dep names collected during initial package.json load
227    // to avoid re-reading all workspace package.json files.
228    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
229        .iter()
230        .flat_map(|(_, deps)| deps.iter().cloned())
231        .collect();
232    for (ws, _) in &mut *workspaces {
233        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
234    }
235}
236
237/// Extract the directory name as a string, for workspace name fallback.
238fn dir_name(dir: &Path) -> String {
239    dir.file_name()
240        .map(|n| n.to_string_lossy().to_string())
241        .unwrap_or_default()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn discover_workspaces_from_tsconfig_references() {
250        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
251        let _ = std::fs::remove_dir_all(&temp_dir);
252        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
253        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
254
255        // No package.json workspaces — only tsconfig references
256        std::fs::write(
257            temp_dir.join("tsconfig.json"),
258            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
259        )
260        .unwrap();
261
262        // core has package.json with a name
263        std::fs::write(
264            temp_dir.join("packages/core/package.json"),
265            r#"{"name": "@project/core"}"#,
266        )
267        .unwrap();
268
269        // ui has NO package.json — name should fall back to directory name
270        let workspaces = discover_workspaces(&temp_dir);
271        assert_eq!(workspaces.len(), 2);
272        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
273        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
274
275        let _ = std::fs::remove_dir_all(&temp_dir);
276    }
277
278    #[test]
279    fn tsconfig_references_outside_root_rejected() {
280        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
281        let _ = std::fs::remove_dir_all(&temp_dir);
282        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
283        // "outside" is a sibling of "project", not inside it
284        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
285
286        std::fs::write(
287            temp_dir.join("project/tsconfig.json"),
288            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
289        )
290        .unwrap();
291
292        // Security: "../outside" points outside the project root and should be rejected
293        let workspaces = discover_workspaces(&temp_dir.join("project"));
294        assert_eq!(
295            workspaces.len(),
296            1,
297            "reference outside project root should be rejected: {workspaces:?}"
298        );
299        assert!(
300            workspaces[0]
301                .root
302                .to_string_lossy()
303                .contains("packages/core")
304        );
305
306        let _ = std::fs::remove_dir_all(&temp_dir);
307    }
308}