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;
10pub use parsers::parse_tsconfig_root_dir;
11use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
12
13/// Workspace configuration for monorepo support.
14#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
15pub struct WorkspaceConfig {
16    /// Additional workspace patterns (beyond what's in root package.json).
17    #[serde(default)]
18    pub patterns: Vec<String>,
19}
20
21/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
22#[derive(Debug, Clone)]
23pub struct WorkspaceInfo {
24    /// Workspace root path.
25    pub root: PathBuf,
26    /// Package name from package.json.
27    pub name: String,
28    /// Whether this workspace is depended on by other workspaces.
29    pub is_internal_dependency: bool,
30}
31
32/// Discover all workspace packages in a monorepo.
33///
34/// Sources (additive, deduplicated by canonical path):
35/// 1. `package.json` `workspaces` field
36/// 2. `pnpm-workspace.yaml` `packages` field
37/// 3. `tsconfig.json` `references` field (TypeScript project references)
38#[must_use]
39pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
40    let patterns = collect_workspace_patterns(root);
41    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
42
43    let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
44    workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
45
46    if workspaces.is_empty() {
47        return Vec::new();
48    }
49
50    mark_internal_dependencies(&mut workspaces);
51    workspaces.into_iter().map(|(ws, _)| ws).collect()
52}
53
54/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
55fn collect_workspace_patterns(root: &Path) -> Vec<String> {
56    let mut patterns = Vec::new();
57
58    // Check root package.json for workspace patterns
59    let pkg_path = root.join("package.json");
60    if let Ok(pkg) = PackageJson::load(&pkg_path) {
61        patterns.extend(pkg.workspace_patterns());
62    }
63
64    // Check pnpm-workspace.yaml
65    let pnpm_workspace = root.join("pnpm-workspace.yaml");
66    if pnpm_workspace.exists()
67        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
68    {
69        patterns.extend(parse_pnpm_workspace_yaml(&content));
70    }
71
72    patterns
73}
74
75/// Expand workspace glob patterns to discover workspace directories.
76///
77/// Handles positive/negated pattern splitting, glob matching, and package.json
78/// loading for each matched directory.
79fn expand_patterns_to_workspaces(
80    root: &Path,
81    patterns: &[String],
82    canonical_root: &Path,
83) -> Vec<(WorkspaceInfo, Vec<String>)> {
84    if patterns.is_empty() {
85        return Vec::new();
86    }
87
88    let mut workspaces = Vec::new();
89
90    // Separate positive and negated patterns.
91    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
92    // the `glob` crate does not support `!` prefixed patterns natively.
93    let (positive, negative): (Vec<&String>, Vec<&String>) =
94        patterns.iter().partition(|p| !p.starts_with('!'));
95    let negation_matchers: Vec<globset::GlobMatcher> = negative
96        .iter()
97        .filter_map(|p| {
98            let stripped = p.strip_prefix('!').unwrap_or(p);
99            globset::Glob::new(stripped)
100                .ok()
101                .map(|g| g.compile_matcher())
102        })
103        .collect();
104
105    for pattern in &positive {
106        // Normalize the pattern for directory matching:
107        // - `packages/*` → glob for `packages/*` (find all subdirs)
108        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
109        // - `apps`       → glob for `apps` (exact directory)
110        let glob_pattern = if pattern.ends_with('/') {
111            format!("{pattern}*")
112        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
113            // Bare directory name — treat as exact match
114            (*pattern).clone()
115        } else {
116            (*pattern).clone()
117        };
118
119        // Walk directories matching the glob.
120        // expand_workspace_glob already filters to dirs with package.json
121        // and returns (original_path, canonical_path) — no redundant canonicalize().
122        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
123        for (dir, canonical_dir) in matched_dirs {
124            // Skip workspace entries that point to the project root itself
125            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
126            if canonical_dir == *canonical_root {
127                continue;
128            }
129
130            // Check against negation patterns — skip directories that match any negated pattern
131            let relative = dir.strip_prefix(root).unwrap_or(&dir);
132            let relative_str = relative.to_string_lossy();
133            if negation_matchers
134                .iter()
135                .any(|m| m.is_match(relative_str.as_ref()))
136            {
137                continue;
138            }
139
140            // package.json existence already checked in expand_workspace_glob
141            let ws_pkg_path = dir.join("package.json");
142            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
143                // Collect dependency names during initial load to avoid
144                // re-reading package.json later.
145                let dep_names = pkg.all_dependency_names();
146                let name = pkg.name.unwrap_or_else(|| {
147                    dir.file_name()
148                        .map(|n| n.to_string_lossy().to_string())
149                        .unwrap_or_default()
150                });
151                workspaces.push((
152                    WorkspaceInfo {
153                        root: dir,
154                        name,
155                        is_internal_dependency: false,
156                    },
157                    dep_names,
158                ));
159            }
160        }
161    }
162
163    workspaces
164}
165
166/// Discover workspaces from TypeScript project references in `tsconfig.json`.
167///
168/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
169/// This enables cross-workspace resolution for TypeScript composite projects.
170fn collect_tsconfig_workspaces(
171    root: &Path,
172    canonical_root: &Path,
173) -> Vec<(WorkspaceInfo, Vec<String>)> {
174    let mut workspaces = Vec::new();
175
176    for dir in parse_tsconfig_references(root) {
177        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
178        // Security: skip references pointing to project root or outside it
179        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
180            continue;
181        }
182
183        // Read package.json if available; otherwise use directory name
184        let ws_pkg_path = dir.join("package.json");
185        let (name, dep_names) = if ws_pkg_path.exists() {
186            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
187                let deps = pkg.all_dependency_names();
188                let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
189                (n, deps)
190            } else {
191                (dir_name(&dir), Vec::new())
192            }
193        } else {
194            // No package.json — use directory name, no deps.
195            // Valid for TypeScript-only composite projects.
196            (dir_name(&dir), Vec::new())
197        };
198
199        workspaces.push((
200            WorkspaceInfo {
201                root: dir,
202                name,
203                is_internal_dependency: false,
204            },
205            dep_names,
206        ));
207    }
208
209    workspaces
210}
211
212/// Deduplicate workspaces by canonical path and mark internal dependencies.
213///
214/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
215/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
216/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
217fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
218    // Deduplicate by canonical path
219    {
220        let mut seen = rustc_hash::FxHashSet::default();
221        workspaces.retain(|(ws, _)| {
222            let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
223            seen.insert(canonical)
224        });
225    }
226
227    // Mark workspaces that are depended on by other workspaces.
228    // Uses dep names collected during initial package.json load
229    // to avoid re-reading all workspace package.json files.
230    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
231        .iter()
232        .flat_map(|(_, deps)| deps.iter().cloned())
233        .collect();
234    for (ws, _) in &mut *workspaces {
235        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
236    }
237}
238
239/// Extract the directory name as a string, for workspace name fallback.
240fn dir_name(dir: &Path) -> String {
241    dir.file_name()
242        .map(|n| n.to_string_lossy().to_string())
243        .unwrap_or_default()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn discover_workspaces_from_tsconfig_references() {
252        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
253        let _ = std::fs::remove_dir_all(&temp_dir);
254        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
255        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
256
257        // No package.json workspaces — only tsconfig references
258        std::fs::write(
259            temp_dir.join("tsconfig.json"),
260            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
261        )
262        .unwrap();
263
264        // core has package.json with a name
265        std::fs::write(
266            temp_dir.join("packages/core/package.json"),
267            r#"{"name": "@project/core"}"#,
268        )
269        .unwrap();
270
271        // ui has NO package.json — name should fall back to directory name
272        let workspaces = discover_workspaces(&temp_dir);
273        assert_eq!(workspaces.len(), 2);
274        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
275        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
276
277        let _ = std::fs::remove_dir_all(&temp_dir);
278    }
279
280    #[test]
281    fn tsconfig_references_outside_root_rejected() {
282        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
283        let _ = std::fs::remove_dir_all(&temp_dir);
284        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
285        // "outside" is a sibling of "project", not inside it
286        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
287
288        std::fs::write(
289            temp_dir.join("project/tsconfig.json"),
290            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
291        )
292        .unwrap();
293
294        // Security: "../outside" points outside the project root and should be rejected
295        let workspaces = discover_workspaces(&temp_dir.join("project"));
296        assert_eq!(
297            workspaces.len(),
298            1,
299            "reference outside project root should be rejected: {workspaces:?}"
300        );
301        assert!(
302            workspaces[0]
303                .root
304                .to_string_lossy()
305                .contains("packages/core")
306        );
307
308        let _ = std::fs::remove_dir_all(&temp_dir);
309    }
310
311    // ── dir_name ────────────────────────────────────────────────────
312
313    #[test]
314    fn dir_name_extracts_last_component() {
315        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
316        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
317    }
318
319    #[test]
320    fn dir_name_empty_for_root_path() {
321        // Root path has no file_name component
322        assert_eq!(dir_name(Path::new("/")), "");
323    }
324
325    // ── WorkspaceConfig deserialization ──────────────────────────────
326
327    #[test]
328    fn workspace_config_deserialize_json() {
329        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
330        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
331        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
332    }
333
334    #[test]
335    fn workspace_config_deserialize_empty_patterns() {
336        let json = r#"{"patterns": []}"#;
337        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
338        assert!(config.patterns.is_empty());
339    }
340
341    #[test]
342    fn workspace_config_default_patterns() {
343        let json = "{}";
344        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
345        assert!(config.patterns.is_empty());
346    }
347
348    // ── WorkspaceInfo ───────────────────────────────────────────────
349
350    #[test]
351    fn workspace_info_default_not_internal() {
352        let ws = WorkspaceInfo {
353            root: PathBuf::from("/project/packages/a"),
354            name: "a".to_string(),
355            is_internal_dependency: false,
356        };
357        assert!(!ws.is_internal_dependency);
358    }
359
360    // ── mark_internal_dependencies ──────────────────────────────────
361
362    #[test]
363    fn mark_internal_deps_detects_cross_references() {
364        let temp_dir = tempfile::tempdir().expect("create temp dir");
365        let pkg_a = temp_dir.path().join("a");
366        let pkg_b = temp_dir.path().join("b");
367        std::fs::create_dir_all(&pkg_a).unwrap();
368        std::fs::create_dir_all(&pkg_b).unwrap();
369
370        let mut workspaces = vec![
371            (
372                WorkspaceInfo {
373                    root: pkg_a,
374                    name: "@scope/a".to_string(),
375                    is_internal_dependency: false,
376                },
377                vec!["@scope/b".to_string()], // "a" depends on "b"
378            ),
379            (
380                WorkspaceInfo {
381                    root: pkg_b,
382                    name: "@scope/b".to_string(),
383                    is_internal_dependency: false,
384                },
385                vec!["lodash".to_string()], // "b" depends on external only
386            ),
387        ];
388
389        mark_internal_dependencies(&mut workspaces);
390
391        // "b" is depended on by "a", so it should be marked as internal
392        let ws_a = workspaces
393            .iter()
394            .find(|(ws, _)| ws.name == "@scope/a")
395            .unwrap();
396        assert!(
397            !ws_a.0.is_internal_dependency,
398            "a is not depended on by others"
399        );
400
401        let ws_b = workspaces
402            .iter()
403            .find(|(ws, _)| ws.name == "@scope/b")
404            .unwrap();
405        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
406    }
407
408    #[test]
409    fn mark_internal_deps_no_cross_references() {
410        let temp_dir = tempfile::tempdir().expect("create temp dir");
411        let pkg_a = temp_dir.path().join("a");
412        let pkg_b = temp_dir.path().join("b");
413        std::fs::create_dir_all(&pkg_a).unwrap();
414        std::fs::create_dir_all(&pkg_b).unwrap();
415
416        let mut workspaces = vec![
417            (
418                WorkspaceInfo {
419                    root: pkg_a,
420                    name: "a".to_string(),
421                    is_internal_dependency: false,
422                },
423                vec!["react".to_string()],
424            ),
425            (
426                WorkspaceInfo {
427                    root: pkg_b,
428                    name: "b".to_string(),
429                    is_internal_dependency: false,
430                },
431                vec!["lodash".to_string()],
432            ),
433        ];
434
435        mark_internal_dependencies(&mut workspaces);
436
437        assert!(!workspaces[0].0.is_internal_dependency);
438        assert!(!workspaces[1].0.is_internal_dependency);
439    }
440
441    #[test]
442    fn mark_internal_deps_deduplicates_by_path() {
443        let temp_dir = tempfile::tempdir().expect("create temp dir");
444        let pkg_a = temp_dir.path().join("a");
445        std::fs::create_dir_all(&pkg_a).unwrap();
446
447        let mut workspaces = vec![
448            (
449                WorkspaceInfo {
450                    root: pkg_a.clone(),
451                    name: "a".to_string(),
452                    is_internal_dependency: false,
453                },
454                vec![],
455            ),
456            (
457                WorkspaceInfo {
458                    root: pkg_a,
459                    name: "a".to_string(),
460                    is_internal_dependency: false,
461                },
462                vec![],
463            ),
464        ];
465
466        mark_internal_dependencies(&mut workspaces);
467        assert_eq!(
468            workspaces.len(),
469            1,
470            "duplicate paths should be deduplicated"
471        );
472    }
473
474    // ── collect_workspace_patterns ──────────────────────────────────
475
476    #[test]
477    fn collect_patterns_from_package_json() {
478        let dir = tempfile::tempdir().expect("create temp dir");
479        std::fs::write(
480            dir.path().join("package.json"),
481            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
482        )
483        .unwrap();
484
485        let patterns = collect_workspace_patterns(dir.path());
486        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
487    }
488
489    #[test]
490    fn collect_patterns_from_pnpm_workspace() {
491        let dir = tempfile::tempdir().expect("create temp dir");
492        std::fs::write(
493            dir.path().join("pnpm-workspace.yaml"),
494            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
495        )
496        .unwrap();
497
498        let patterns = collect_workspace_patterns(dir.path());
499        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
500    }
501
502    #[test]
503    fn collect_patterns_combines_sources() {
504        let dir = tempfile::tempdir().expect("create temp dir");
505        std::fs::write(
506            dir.path().join("package.json"),
507            r#"{"workspaces": ["packages/*"]}"#,
508        )
509        .unwrap();
510        std::fs::write(
511            dir.path().join("pnpm-workspace.yaml"),
512            "packages:\n  - 'apps/*'\n",
513        )
514        .unwrap();
515
516        let patterns = collect_workspace_patterns(dir.path());
517        assert!(patterns.contains(&"packages/*".to_string()));
518        assert!(patterns.contains(&"apps/*".to_string()));
519    }
520
521    #[test]
522    fn collect_patterns_empty_when_no_configs() {
523        let dir = tempfile::tempdir().expect("create temp dir");
524        let patterns = collect_workspace_patterns(dir.path());
525        assert!(patterns.is_empty());
526    }
527
528    // ── discover_workspaces integration ─────────────────────────────
529
530    #[test]
531    fn discover_workspaces_from_package_json() {
532        let dir = tempfile::tempdir().expect("create temp dir");
533        let pkg_a = dir.path().join("packages").join("a");
534        let pkg_b = dir.path().join("packages").join("b");
535        std::fs::create_dir_all(&pkg_a).unwrap();
536        std::fs::create_dir_all(&pkg_b).unwrap();
537
538        std::fs::write(
539            dir.path().join("package.json"),
540            r#"{"workspaces": ["packages/*"]}"#,
541        )
542        .unwrap();
543        std::fs::write(
544            pkg_a.join("package.json"),
545            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
546        )
547        .unwrap();
548        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
549
550        let workspaces = discover_workspaces(dir.path());
551        assert_eq!(workspaces.len(), 2);
552
553        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
554        assert!(!ws_a.is_internal_dependency);
555
556        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
557        assert!(ws_b.is_internal_dependency, "b is depended on by a");
558    }
559
560    #[test]
561    fn discover_workspaces_empty_project() {
562        let dir = tempfile::tempdir().expect("create temp dir");
563        let workspaces = discover_workspaces(dir.path());
564        assert!(workspaces.is_empty());
565    }
566
567    #[test]
568    fn discover_workspaces_with_negated_patterns() {
569        let dir = tempfile::tempdir().expect("create temp dir");
570        let pkg_a = dir.path().join("packages").join("a");
571        let pkg_test = dir.path().join("packages").join("test-utils");
572        std::fs::create_dir_all(&pkg_a).unwrap();
573        std::fs::create_dir_all(&pkg_test).unwrap();
574
575        std::fs::write(
576            dir.path().join("package.json"),
577            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
578        )
579        .unwrap();
580        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
581        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
582
583        let workspaces = discover_workspaces(dir.path());
584        assert_eq!(workspaces.len(), 1);
585        assert_eq!(workspaces[0].name, "a");
586    }
587
588    #[test]
589    fn discover_workspaces_skips_root_as_workspace() {
590        let dir = tempfile::tempdir().expect("create temp dir");
591        // pnpm-workspace.yaml listing "." should not add root as workspace
592        std::fs::write(
593            dir.path().join("pnpm-workspace.yaml"),
594            "packages:\n  - '.'\n",
595        )
596        .unwrap();
597        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
598
599        let workspaces = discover_workspaces(dir.path());
600        assert!(
601            workspaces.is_empty(),
602            "root directory should not be added as workspace"
603        );
604    }
605
606    #[test]
607    fn discover_workspaces_name_fallback_to_dir_name() {
608        let dir = tempfile::tempdir().expect("create temp dir");
609        let pkg_a = dir.path().join("packages").join("my-app");
610        std::fs::create_dir_all(&pkg_a).unwrap();
611
612        std::fs::write(
613            dir.path().join("package.json"),
614            r#"{"workspaces": ["packages/*"]}"#,
615        )
616        .unwrap();
617        // package.json without a name field
618        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
619
620        let workspaces = discover_workspaces(dir.path());
621        assert_eq!(workspaces.len(), 1);
622        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
623    }
624}