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
310    // ── dir_name ────────────────────────────────────────────────────
311
312    #[test]
313    fn dir_name_extracts_last_component() {
314        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
315        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
316    }
317
318    #[test]
319    fn dir_name_empty_for_root_path() {
320        // Root path has no file_name component
321        assert_eq!(dir_name(Path::new("/")), "");
322    }
323
324    // ── WorkspaceConfig deserialization ──────────────────────────────
325
326    #[test]
327    fn workspace_config_deserialize_json() {
328        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
329        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
330        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
331    }
332
333    #[test]
334    fn workspace_config_deserialize_empty_patterns() {
335        let json = r#"{"patterns": []}"#;
336        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
337        assert!(config.patterns.is_empty());
338    }
339
340    #[test]
341    fn workspace_config_default_patterns() {
342        let json = "{}";
343        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
344        assert!(config.patterns.is_empty());
345    }
346
347    // ── WorkspaceInfo ───────────────────────────────────────────────
348
349    #[test]
350    fn workspace_info_default_not_internal() {
351        let ws = WorkspaceInfo {
352            root: PathBuf::from("/project/packages/a"),
353            name: "a".to_string(),
354            is_internal_dependency: false,
355        };
356        assert!(!ws.is_internal_dependency);
357    }
358
359    // ── mark_internal_dependencies ──────────────────────────────────
360
361    #[test]
362    fn mark_internal_deps_detects_cross_references() {
363        let temp_dir = tempfile::tempdir().expect("create temp dir");
364        let pkg_a = temp_dir.path().join("a");
365        let pkg_b = temp_dir.path().join("b");
366        std::fs::create_dir_all(&pkg_a).unwrap();
367        std::fs::create_dir_all(&pkg_b).unwrap();
368
369        let mut workspaces = vec![
370            (
371                WorkspaceInfo {
372                    root: pkg_a,
373                    name: "@scope/a".to_string(),
374                    is_internal_dependency: false,
375                },
376                vec!["@scope/b".to_string()], // "a" depends on "b"
377            ),
378            (
379                WorkspaceInfo {
380                    root: pkg_b,
381                    name: "@scope/b".to_string(),
382                    is_internal_dependency: false,
383                },
384                vec!["lodash".to_string()], // "b" depends on external only
385            ),
386        ];
387
388        mark_internal_dependencies(&mut workspaces);
389
390        // "b" is depended on by "a", so it should be marked as internal
391        let ws_a = workspaces
392            .iter()
393            .find(|(ws, _)| ws.name == "@scope/a")
394            .unwrap();
395        assert!(
396            !ws_a.0.is_internal_dependency,
397            "a is not depended on by others"
398        );
399
400        let ws_b = workspaces
401            .iter()
402            .find(|(ws, _)| ws.name == "@scope/b")
403            .unwrap();
404        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
405    }
406
407    #[test]
408    fn mark_internal_deps_no_cross_references() {
409        let temp_dir = tempfile::tempdir().expect("create temp dir");
410        let pkg_a = temp_dir.path().join("a");
411        let pkg_b = temp_dir.path().join("b");
412        std::fs::create_dir_all(&pkg_a).unwrap();
413        std::fs::create_dir_all(&pkg_b).unwrap();
414
415        let mut workspaces = vec![
416            (
417                WorkspaceInfo {
418                    root: pkg_a,
419                    name: "a".to_string(),
420                    is_internal_dependency: false,
421                },
422                vec!["react".to_string()],
423            ),
424            (
425                WorkspaceInfo {
426                    root: pkg_b,
427                    name: "b".to_string(),
428                    is_internal_dependency: false,
429                },
430                vec!["lodash".to_string()],
431            ),
432        ];
433
434        mark_internal_dependencies(&mut workspaces);
435
436        assert!(!workspaces[0].0.is_internal_dependency);
437        assert!(!workspaces[1].0.is_internal_dependency);
438    }
439
440    #[test]
441    fn mark_internal_deps_deduplicates_by_path() {
442        let temp_dir = tempfile::tempdir().expect("create temp dir");
443        let pkg_a = temp_dir.path().join("a");
444        std::fs::create_dir_all(&pkg_a).unwrap();
445
446        let mut workspaces = vec![
447            (
448                WorkspaceInfo {
449                    root: pkg_a.clone(),
450                    name: "a".to_string(),
451                    is_internal_dependency: false,
452                },
453                vec![],
454            ),
455            (
456                WorkspaceInfo {
457                    root: pkg_a,
458                    name: "a".to_string(),
459                    is_internal_dependency: false,
460                },
461                vec![],
462            ),
463        ];
464
465        mark_internal_dependencies(&mut workspaces);
466        assert_eq!(
467            workspaces.len(),
468            1,
469            "duplicate paths should be deduplicated"
470        );
471    }
472
473    // ── collect_workspace_patterns ──────────────────────────────────
474
475    #[test]
476    fn collect_patterns_from_package_json() {
477        let dir = tempfile::tempdir().expect("create temp dir");
478        std::fs::write(
479            dir.path().join("package.json"),
480            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
481        )
482        .unwrap();
483
484        let patterns = collect_workspace_patterns(dir.path());
485        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
486    }
487
488    #[test]
489    fn collect_patterns_from_pnpm_workspace() {
490        let dir = tempfile::tempdir().expect("create temp dir");
491        std::fs::write(
492            dir.path().join("pnpm-workspace.yaml"),
493            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
494        )
495        .unwrap();
496
497        let patterns = collect_workspace_patterns(dir.path());
498        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
499    }
500
501    #[test]
502    fn collect_patterns_combines_sources() {
503        let dir = tempfile::tempdir().expect("create temp dir");
504        std::fs::write(
505            dir.path().join("package.json"),
506            r#"{"workspaces": ["packages/*"]}"#,
507        )
508        .unwrap();
509        std::fs::write(
510            dir.path().join("pnpm-workspace.yaml"),
511            "packages:\n  - 'apps/*'\n",
512        )
513        .unwrap();
514
515        let patterns = collect_workspace_patterns(dir.path());
516        assert!(patterns.contains(&"packages/*".to_string()));
517        assert!(patterns.contains(&"apps/*".to_string()));
518    }
519
520    #[test]
521    fn collect_patterns_empty_when_no_configs() {
522        let dir = tempfile::tempdir().expect("create temp dir");
523        let patterns = collect_workspace_patterns(dir.path());
524        assert!(patterns.is_empty());
525    }
526
527    // ── discover_workspaces integration ─────────────────────────────
528
529    #[test]
530    fn discover_workspaces_from_package_json() {
531        let dir = tempfile::tempdir().expect("create temp dir");
532        let pkg_a = dir.path().join("packages").join("a");
533        let pkg_b = dir.path().join("packages").join("b");
534        std::fs::create_dir_all(&pkg_a).unwrap();
535        std::fs::create_dir_all(&pkg_b).unwrap();
536
537        std::fs::write(
538            dir.path().join("package.json"),
539            r#"{"workspaces": ["packages/*"]}"#,
540        )
541        .unwrap();
542        std::fs::write(
543            pkg_a.join("package.json"),
544            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
545        )
546        .unwrap();
547        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
548
549        let workspaces = discover_workspaces(dir.path());
550        assert_eq!(workspaces.len(), 2);
551
552        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
553        assert!(!ws_a.is_internal_dependency);
554
555        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
556        assert!(ws_b.is_internal_dependency, "b is depended on by a");
557    }
558
559    #[test]
560    fn discover_workspaces_empty_project() {
561        let dir = tempfile::tempdir().expect("create temp dir");
562        let workspaces = discover_workspaces(dir.path());
563        assert!(workspaces.is_empty());
564    }
565
566    #[test]
567    fn discover_workspaces_with_negated_patterns() {
568        let dir = tempfile::tempdir().expect("create temp dir");
569        let pkg_a = dir.path().join("packages").join("a");
570        let pkg_test = dir.path().join("packages").join("test-utils");
571        std::fs::create_dir_all(&pkg_a).unwrap();
572        std::fs::create_dir_all(&pkg_test).unwrap();
573
574        std::fs::write(
575            dir.path().join("package.json"),
576            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
577        )
578        .unwrap();
579        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
580        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
581
582        let workspaces = discover_workspaces(dir.path());
583        assert_eq!(workspaces.len(), 1);
584        assert_eq!(workspaces[0].name, "a");
585    }
586
587    #[test]
588    fn discover_workspaces_skips_root_as_workspace() {
589        let dir = tempfile::tempdir().expect("create temp dir");
590        // pnpm-workspace.yaml listing "." should not add root as workspace
591        std::fs::write(
592            dir.path().join("pnpm-workspace.yaml"),
593            "packages:\n  - '.'\n",
594        )
595        .unwrap();
596        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
597
598        let workspaces = discover_workspaces(dir.path());
599        assert!(
600            workspaces.is_empty(),
601            "root directory should not be added as workspace"
602        );
603    }
604
605    #[test]
606    fn discover_workspaces_name_fallback_to_dir_name() {
607        let dir = tempfile::tempdir().expect("create temp dir");
608        let pkg_a = dir.path().join("packages").join("my-app");
609        std::fs::create_dir_all(&pkg_a).unwrap();
610
611        std::fs::write(
612            dir.path().join("package.json"),
613            r#"{"workspaces": ["packages/*"]}"#,
614        )
615        .unwrap();
616        // package.json without a name field
617        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
618
619        let workspaces = discover_workspaces(dir.path());
620        assert_eq!(workspaces.len(), 1);
621        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
622    }
623}