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/// A diagnostic about workspace configuration issues.
33#[derive(Debug, Clone)]
34pub struct WorkspaceDiagnostic {
35    /// Path to the directory with the issue.
36    pub path: PathBuf,
37    /// Human-readable description of the issue.
38    pub message: String,
39}
40
41/// Discover all workspace packages in a monorepo.
42///
43/// Sources (additive, deduplicated by canonical path):
44/// 1. `package.json` `workspaces` field
45/// 2. `pnpm-workspace.yaml` `packages` field
46/// 3. `tsconfig.json` `references` field (TypeScript project references)
47#[must_use]
48pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
49    let patterns = collect_workspace_patterns(root);
50    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
51
52    let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
53    workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
54    if patterns.is_empty() {
55        workspaces.extend(collect_shallow_package_workspaces(root, &canonical_root));
56    }
57
58    if workspaces.is_empty() {
59        return Vec::new();
60    }
61
62    mark_internal_dependencies(&mut workspaces);
63    workspaces.into_iter().map(|(ws, _)| ws).collect()
64}
65
66/// Find directories containing `package.json` that are not declared as workspaces.
67///
68/// Only meaningful in monorepos that declare workspaces (via `package.json` `workspaces`
69/// field or `pnpm-workspace.yaml`). Scans up to two directory levels deep, skipping
70/// hidden directories, `node_modules`, and `build`.
71#[must_use]
72pub fn find_undeclared_workspaces(
73    root: &Path,
74    declared: &[WorkspaceInfo],
75) -> Vec<WorkspaceDiagnostic> {
76    // Only run when workspaces are declared
77    let patterns = collect_workspace_patterns(root);
78    if patterns.is_empty() {
79        return Vec::new();
80    }
81
82    let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
83        .iter()
84        .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
85        .collect();
86
87    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
88
89    let mut undeclared = Vec::new();
90
91    // Walk first two levels of directories
92    let Ok(top_entries) = std::fs::read_dir(root) else {
93        return Vec::new();
94    };
95
96    for entry in top_entries.filter_map(Result::ok) {
97        let path = entry.path();
98        if !path.is_dir() {
99            continue;
100        }
101
102        let name = entry.file_name();
103        let name_str = name.to_string_lossy();
104        if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
105            continue;
106        }
107
108        // Check this directory itself
109        check_undeclared(
110            &path,
111            root,
112            &canonical_root,
113            &declared_roots,
114            &mut undeclared,
115        );
116
117        // Check immediate children (second level)
118        let Ok(child_entries) = std::fs::read_dir(&path) else {
119            continue;
120        };
121        for child in child_entries.filter_map(Result::ok) {
122            let child_path = child.path();
123            if !child_path.is_dir() {
124                continue;
125            }
126            let child_name = child.file_name();
127            let child_name_str = child_name.to_string_lossy();
128            if child_name_str.starts_with('.')
129                || child_name_str == "node_modules"
130                || child_name_str == "build"
131            {
132                continue;
133            }
134            check_undeclared(
135                &child_path,
136                root,
137                &canonical_root,
138                &declared_roots,
139                &mut undeclared,
140            );
141        }
142    }
143
144    undeclared
145}
146
147/// Check a single directory for an undeclared workspace.
148fn check_undeclared(
149    dir: &Path,
150    root: &Path,
151    canonical_root: &Path,
152    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
153    undeclared: &mut Vec<WorkspaceDiagnostic>,
154) {
155    if !dir.join("package.json").exists() {
156        return;
157    }
158    let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
159    // Skip the project root itself
160    if canonical == *canonical_root {
161        return;
162    }
163    if declared_roots.contains(&canonical) {
164        return;
165    }
166    let relative = dir.strip_prefix(root).unwrap_or(dir);
167    undeclared.push(WorkspaceDiagnostic {
168        path: dir.to_path_buf(),
169        message: format!(
170            "Directory '{}' contains package.json but is not declared as a workspace",
171            relative.display()
172        ),
173    });
174}
175
176/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
177fn collect_workspace_patterns(root: &Path) -> Vec<String> {
178    let mut patterns = Vec::new();
179
180    // Check root package.json for workspace patterns
181    let pkg_path = root.join("package.json");
182    if let Ok(pkg) = PackageJson::load(&pkg_path) {
183        patterns.extend(pkg.workspace_patterns());
184    }
185
186    // Check pnpm-workspace.yaml
187    let pnpm_workspace = root.join("pnpm-workspace.yaml");
188    if pnpm_workspace.exists()
189        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
190    {
191        patterns.extend(parse_pnpm_workspace_yaml(&content));
192    }
193
194    patterns
195}
196
197/// Expand workspace glob patterns to discover workspace directories.
198///
199/// Handles positive/negated pattern splitting, glob matching, and package.json
200/// loading for each matched directory.
201fn expand_patterns_to_workspaces(
202    root: &Path,
203    patterns: &[String],
204    canonical_root: &Path,
205) -> Vec<(WorkspaceInfo, Vec<String>)> {
206    if patterns.is_empty() {
207        return Vec::new();
208    }
209
210    let mut workspaces = Vec::new();
211
212    // Separate positive and negated patterns.
213    // Negated patterns (e.g., `!**/test/**`) are used as exclusion filters —
214    // the `glob` crate does not support `!` prefixed patterns natively.
215    let (positive, negative): (Vec<&String>, Vec<&String>) =
216        patterns.iter().partition(|p| !p.starts_with('!'));
217    let negation_matchers: Vec<globset::GlobMatcher> = negative
218        .iter()
219        .filter_map(|p| {
220            let stripped = p.strip_prefix('!').unwrap_or(p);
221            globset::Glob::new(stripped)
222                .ok()
223                .map(|g| g.compile_matcher())
224        })
225        .collect();
226
227    for pattern in &positive {
228        // Normalize the pattern for directory matching:
229        // - `packages/*` → glob for `packages/*` (find all subdirs)
230        // - `packages/` → glob for `packages/*` (trailing slash means "contents of")
231        // - `apps`       → glob for `apps` (exact directory)
232        let glob_pattern = if pattern.ends_with('/') {
233            format!("{pattern}*")
234        } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
235            // Bare directory name — treat as exact match
236            (*pattern).clone()
237        } else {
238            (*pattern).clone()
239        };
240
241        // Walk directories matching the glob.
242        // expand_workspace_glob already filters to dirs with package.json
243        // and returns (original_path, canonical_path) — no redundant canonicalize().
244        let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
245        for (dir, canonical_dir) in matched_dirs {
246            // Skip workspace entries that point to the project root itself
247            // (e.g. pnpm-workspace.yaml listing `.` as a workspace)
248            if canonical_dir == *canonical_root {
249                continue;
250            }
251
252            // Check against negation patterns — skip directories that match any negated pattern
253            let relative = dir.strip_prefix(root).unwrap_or(&dir);
254            let relative_str = relative.to_string_lossy();
255            if negation_matchers
256                .iter()
257                .any(|m| m.is_match(relative_str.as_ref()))
258            {
259                continue;
260            }
261
262            // package.json existence already checked in expand_workspace_glob
263            let ws_pkg_path = dir.join("package.json");
264            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
265                // Collect dependency names during initial load to avoid
266                // re-reading package.json later.
267                let dep_names = pkg.all_dependency_names();
268                let name = pkg.name.unwrap_or_else(|| {
269                    dir.file_name()
270                        .map(|n| n.to_string_lossy().to_string())
271                        .unwrap_or_default()
272                });
273                workspaces.push((
274                    WorkspaceInfo {
275                        root: dir,
276                        name,
277                        is_internal_dependency: false,
278                    },
279                    dep_names,
280                ));
281            }
282        }
283    }
284
285    workspaces
286}
287
288/// Discover workspaces from TypeScript project references in `tsconfig.json`.
289///
290/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
291/// This enables cross-workspace resolution for TypeScript composite projects.
292fn collect_tsconfig_workspaces(
293    root: &Path,
294    canonical_root: &Path,
295) -> Vec<(WorkspaceInfo, Vec<String>)> {
296    let mut workspaces = Vec::new();
297
298    for dir in parse_tsconfig_references(root) {
299        let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
300        // Security: skip references pointing to project root or outside it
301        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
302            continue;
303        }
304
305        // Read package.json if available; otherwise use directory name
306        let ws_pkg_path = dir.join("package.json");
307        let (name, dep_names) = if ws_pkg_path.exists() {
308            if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
309                let deps = pkg.all_dependency_names();
310                let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
311                (n, deps)
312            } else {
313                (dir_name(&dir), Vec::new())
314            }
315        } else {
316            // No package.json — use directory name, no deps.
317            // Valid for TypeScript-only composite projects.
318            (dir_name(&dir), Vec::new())
319        };
320
321        workspaces.push((
322            WorkspaceInfo {
323                root: dir,
324                name,
325                is_internal_dependency: false,
326            },
327            dep_names,
328        ));
329    }
330
331    workspaces
332}
333
334/// Discover shallow package workspaces when no explicit workspace config exists.
335///
336/// Scans direct children of the project root and their immediate children for
337/// `package.json` files. This catches repos that contain multiple standalone
338/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
339/// npm/pnpm workspaces at the root.
340fn collect_shallow_package_workspaces(
341    root: &Path,
342    canonical_root: &Path,
343) -> Vec<(WorkspaceInfo, Vec<String>)> {
344    let mut workspaces = Vec::new();
345    let Ok(top_entries) = std::fs::read_dir(root) else {
346        return workspaces;
347    };
348
349    for entry in top_entries.filter_map(Result::ok) {
350        let path = entry.path();
351        if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
352            continue;
353        }
354
355        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
356
357        let Ok(child_entries) = std::fs::read_dir(&path) else {
358            continue;
359        };
360        for child in child_entries.filter_map(Result::ok) {
361            let child_path = child.path();
362            if !child_path.is_dir()
363                || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
364            {
365                continue;
366            }
367
368            collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
369        }
370    }
371
372    workspaces
373}
374
375fn collect_shallow_workspace_candidate(
376    dir: &Path,
377    canonical_root: &Path,
378    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
379) {
380    let pkg_path = dir.join("package.json");
381    if !pkg_path.exists() {
382        return;
383    }
384
385    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
386    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
387        return;
388    }
389
390    let Ok(pkg) = PackageJson::load(&pkg_path) else {
391        return;
392    };
393    let dep_names = pkg.all_dependency_names();
394    let name = pkg.name.unwrap_or_else(|| dir_name(dir));
395
396    workspaces.push((
397        WorkspaceInfo {
398            root: dir.to_path_buf(),
399            name,
400            is_internal_dependency: false,
401        },
402        dep_names,
403    ));
404}
405
406fn should_skip_workspace_scan_dir(name: &str) -> bool {
407    name.starts_with('.') || name == "node_modules" || name == "build"
408}
409
410/// Deduplicate workspaces by canonical path and mark internal dependencies.
411///
412/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
413/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
414/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
415fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
416    // Deduplicate by canonical path
417    {
418        let mut seen = rustc_hash::FxHashSet::default();
419        workspaces.retain(|(ws, _)| {
420            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
421            seen.insert(canonical)
422        });
423    }
424
425    // Mark workspaces that are depended on by other workspaces.
426    // Uses dep names collected during initial package.json load
427    // to avoid re-reading all workspace package.json files.
428    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
429        .iter()
430        .flat_map(|(_, deps)| deps.iter().cloned())
431        .collect();
432    for (ws, _) in &mut *workspaces {
433        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
434    }
435}
436
437/// Extract the directory name as a string, for workspace name fallback.
438fn dir_name(dir: &Path) -> String {
439    dir.file_name()
440        .map(|n| n.to_string_lossy().to_string())
441        .unwrap_or_default()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn discover_workspaces_from_tsconfig_references() {
450        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
451        let _ = std::fs::remove_dir_all(&temp_dir);
452        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
453        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
454
455        // No package.json workspaces — only tsconfig references
456        std::fs::write(
457            temp_dir.join("tsconfig.json"),
458            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
459        )
460        .unwrap();
461
462        // core has package.json with a name
463        std::fs::write(
464            temp_dir.join("packages/core/package.json"),
465            r#"{"name": "@project/core"}"#,
466        )
467        .unwrap();
468
469        // ui has NO package.json — name should fall back to directory name
470        let workspaces = discover_workspaces(&temp_dir);
471        assert_eq!(workspaces.len(), 2);
472        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
473        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
474
475        let _ = std::fs::remove_dir_all(&temp_dir);
476    }
477
478    #[test]
479    fn tsconfig_references_outside_root_rejected() {
480        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
481        let _ = std::fs::remove_dir_all(&temp_dir);
482        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
483        // "outside" is a sibling of "project", not inside it
484        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
485
486        std::fs::write(
487            temp_dir.join("project/tsconfig.json"),
488            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
489        )
490        .unwrap();
491
492        // Security: "../outside" points outside the project root and should be rejected
493        let workspaces = discover_workspaces(&temp_dir.join("project"));
494        assert_eq!(
495            workspaces.len(),
496            1,
497            "reference outside project root should be rejected: {workspaces:?}"
498        );
499        assert!(
500            workspaces[0]
501                .root
502                .to_string_lossy()
503                .contains("packages/core")
504        );
505
506        let _ = std::fs::remove_dir_all(&temp_dir);
507    }
508
509    // ── dir_name ────────────────────────────────────────────────────
510
511    #[test]
512    fn dir_name_extracts_last_component() {
513        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
514        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
515    }
516
517    #[test]
518    fn dir_name_empty_for_root_path() {
519        // Root path has no file_name component
520        assert_eq!(dir_name(Path::new("/")), "");
521    }
522
523    // ── WorkspaceConfig deserialization ──────────────────────────────
524
525    #[test]
526    fn workspace_config_deserialize_json() {
527        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
528        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
529        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
530    }
531
532    #[test]
533    fn workspace_config_deserialize_empty_patterns() {
534        let json = r#"{"patterns": []}"#;
535        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
536        assert!(config.patterns.is_empty());
537    }
538
539    #[test]
540    fn workspace_config_default_patterns() {
541        let json = "{}";
542        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
543        assert!(config.patterns.is_empty());
544    }
545
546    // ── WorkspaceInfo ───────────────────────────────────────────────
547
548    #[test]
549    fn workspace_info_default_not_internal() {
550        let ws = WorkspaceInfo {
551            root: PathBuf::from("/project/packages/a"),
552            name: "a".to_string(),
553            is_internal_dependency: false,
554        };
555        assert!(!ws.is_internal_dependency);
556    }
557
558    // ── mark_internal_dependencies ──────────────────────────────────
559
560    #[test]
561    fn mark_internal_deps_detects_cross_references() {
562        let temp_dir = tempfile::tempdir().expect("create temp dir");
563        let pkg_a = temp_dir.path().join("a");
564        let pkg_b = temp_dir.path().join("b");
565        std::fs::create_dir_all(&pkg_a).unwrap();
566        std::fs::create_dir_all(&pkg_b).unwrap();
567
568        let mut workspaces = vec![
569            (
570                WorkspaceInfo {
571                    root: pkg_a,
572                    name: "@scope/a".to_string(),
573                    is_internal_dependency: false,
574                },
575                vec!["@scope/b".to_string()], // "a" depends on "b"
576            ),
577            (
578                WorkspaceInfo {
579                    root: pkg_b,
580                    name: "@scope/b".to_string(),
581                    is_internal_dependency: false,
582                },
583                vec!["lodash".to_string()], // "b" depends on external only
584            ),
585        ];
586
587        mark_internal_dependencies(&mut workspaces);
588
589        // "b" is depended on by "a", so it should be marked as internal
590        let ws_a = workspaces
591            .iter()
592            .find(|(ws, _)| ws.name == "@scope/a")
593            .unwrap();
594        assert!(
595            !ws_a.0.is_internal_dependency,
596            "a is not depended on by others"
597        );
598
599        let ws_b = workspaces
600            .iter()
601            .find(|(ws, _)| ws.name == "@scope/b")
602            .unwrap();
603        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
604    }
605
606    #[test]
607    fn mark_internal_deps_no_cross_references() {
608        let temp_dir = tempfile::tempdir().expect("create temp dir");
609        let pkg_a = temp_dir.path().join("a");
610        let pkg_b = temp_dir.path().join("b");
611        std::fs::create_dir_all(&pkg_a).unwrap();
612        std::fs::create_dir_all(&pkg_b).unwrap();
613
614        let mut workspaces = vec![
615            (
616                WorkspaceInfo {
617                    root: pkg_a,
618                    name: "a".to_string(),
619                    is_internal_dependency: false,
620                },
621                vec!["react".to_string()],
622            ),
623            (
624                WorkspaceInfo {
625                    root: pkg_b,
626                    name: "b".to_string(),
627                    is_internal_dependency: false,
628                },
629                vec!["lodash".to_string()],
630            ),
631        ];
632
633        mark_internal_dependencies(&mut workspaces);
634
635        assert!(!workspaces[0].0.is_internal_dependency);
636        assert!(!workspaces[1].0.is_internal_dependency);
637    }
638
639    #[test]
640    fn mark_internal_deps_deduplicates_by_path() {
641        let temp_dir = tempfile::tempdir().expect("create temp dir");
642        let pkg_a = temp_dir.path().join("a");
643        std::fs::create_dir_all(&pkg_a).unwrap();
644
645        let mut workspaces = vec![
646            (
647                WorkspaceInfo {
648                    root: pkg_a.clone(),
649                    name: "a".to_string(),
650                    is_internal_dependency: false,
651                },
652                vec![],
653            ),
654            (
655                WorkspaceInfo {
656                    root: pkg_a,
657                    name: "a".to_string(),
658                    is_internal_dependency: false,
659                },
660                vec![],
661            ),
662        ];
663
664        mark_internal_dependencies(&mut workspaces);
665        assert_eq!(
666            workspaces.len(),
667            1,
668            "duplicate paths should be deduplicated"
669        );
670    }
671
672    // ── collect_workspace_patterns ──────────────────────────────────
673
674    #[test]
675    fn collect_patterns_from_package_json() {
676        let dir = tempfile::tempdir().expect("create temp dir");
677        std::fs::write(
678            dir.path().join("package.json"),
679            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
680        )
681        .unwrap();
682
683        let patterns = collect_workspace_patterns(dir.path());
684        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
685    }
686
687    #[test]
688    fn collect_patterns_from_pnpm_workspace() {
689        let dir = tempfile::tempdir().expect("create temp dir");
690        std::fs::write(
691            dir.path().join("pnpm-workspace.yaml"),
692            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
693        )
694        .unwrap();
695
696        let patterns = collect_workspace_patterns(dir.path());
697        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
698    }
699
700    #[test]
701    fn collect_patterns_combines_sources() {
702        let dir = tempfile::tempdir().expect("create temp dir");
703        std::fs::write(
704            dir.path().join("package.json"),
705            r#"{"workspaces": ["packages/*"]}"#,
706        )
707        .unwrap();
708        std::fs::write(
709            dir.path().join("pnpm-workspace.yaml"),
710            "packages:\n  - 'apps/*'\n",
711        )
712        .unwrap();
713
714        let patterns = collect_workspace_patterns(dir.path());
715        assert!(patterns.contains(&"packages/*".to_string()));
716        assert!(patterns.contains(&"apps/*".to_string()));
717    }
718
719    #[test]
720    fn collect_patterns_empty_when_no_configs() {
721        let dir = tempfile::tempdir().expect("create temp dir");
722        let patterns = collect_workspace_patterns(dir.path());
723        assert!(patterns.is_empty());
724    }
725
726    // ── discover_workspaces integration ─────────────────────────────
727
728    #[test]
729    fn discover_workspaces_from_package_json() {
730        let dir = tempfile::tempdir().expect("create temp dir");
731        let pkg_a = dir.path().join("packages").join("a");
732        let pkg_b = dir.path().join("packages").join("b");
733        std::fs::create_dir_all(&pkg_a).unwrap();
734        std::fs::create_dir_all(&pkg_b).unwrap();
735
736        std::fs::write(
737            dir.path().join("package.json"),
738            r#"{"workspaces": ["packages/*"]}"#,
739        )
740        .unwrap();
741        std::fs::write(
742            pkg_a.join("package.json"),
743            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
744        )
745        .unwrap();
746        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
747
748        let workspaces = discover_workspaces(dir.path());
749        assert_eq!(workspaces.len(), 2);
750
751        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
752        assert!(!ws_a.is_internal_dependency);
753
754        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
755        assert!(ws_b.is_internal_dependency, "b is depended on by a");
756    }
757
758    #[test]
759    fn discover_workspaces_empty_project() {
760        let dir = tempfile::tempdir().expect("create temp dir");
761        let workspaces = discover_workspaces(dir.path());
762        assert!(workspaces.is_empty());
763    }
764
765    #[test]
766    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
767        let dir = tempfile::tempdir().expect("create temp dir");
768        let benchmarks = dir.path().join("benchmarks");
769        let vscode = dir.path().join("editors").join("vscode");
770        let deep = dir.path().join("tests").join("fixtures").join("demo");
771        std::fs::create_dir_all(&benchmarks).unwrap();
772        std::fs::create_dir_all(&vscode).unwrap();
773        std::fs::create_dir_all(&deep).unwrap();
774
775        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
776        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
777        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
778
779        let workspaces = discover_workspaces(dir.path());
780        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
781
782        assert!(
783            names.contains(&"benchmarks"),
784            "top-level nested package should be discovered: {workspaces:?}"
785        );
786        assert!(
787            names.contains(&"fallow-vscode"),
788            "second-level nested package should be discovered: {workspaces:?}"
789        );
790        assert!(
791            !names.contains(&"deep-fixture"),
792            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
793        );
794    }
795
796    #[test]
797    fn discover_workspaces_with_negated_patterns() {
798        let dir = tempfile::tempdir().expect("create temp dir");
799        let pkg_a = dir.path().join("packages").join("a");
800        let pkg_test = dir.path().join("packages").join("test-utils");
801        std::fs::create_dir_all(&pkg_a).unwrap();
802        std::fs::create_dir_all(&pkg_test).unwrap();
803
804        std::fs::write(
805            dir.path().join("package.json"),
806            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
807        )
808        .unwrap();
809        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
810        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
811
812        let workspaces = discover_workspaces(dir.path());
813        assert_eq!(workspaces.len(), 1);
814        assert_eq!(workspaces[0].name, "a");
815    }
816
817    #[test]
818    fn discover_workspaces_skips_root_as_workspace() {
819        let dir = tempfile::tempdir().expect("create temp dir");
820        // pnpm-workspace.yaml listing "." should not add root as workspace
821        std::fs::write(
822            dir.path().join("pnpm-workspace.yaml"),
823            "packages:\n  - '.'\n",
824        )
825        .unwrap();
826        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
827
828        let workspaces = discover_workspaces(dir.path());
829        assert!(
830            workspaces.is_empty(),
831            "root directory should not be added as workspace"
832        );
833    }
834
835    #[test]
836    fn discover_workspaces_name_fallback_to_dir_name() {
837        let dir = tempfile::tempdir().expect("create temp dir");
838        let pkg_a = dir.path().join("packages").join("my-app");
839        std::fs::create_dir_all(&pkg_a).unwrap();
840
841        std::fs::write(
842            dir.path().join("package.json"),
843            r#"{"workspaces": ["packages/*"]}"#,
844        )
845        .unwrap();
846        // package.json without a name field
847        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
848
849        let workspaces = discover_workspaces(dir.path());
850        assert_eq!(workspaces.len(), 1);
851        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
852    }
853
854    #[test]
855    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
856        let dir = tempfile::tempdir().expect("create temp dir");
857        let pkg_a = dir.path().join("packages").join("a");
858        let benchmarks = dir.path().join("benchmarks");
859        std::fs::create_dir_all(&pkg_a).unwrap();
860        std::fs::create_dir_all(&benchmarks).unwrap();
861
862        std::fs::write(
863            dir.path().join("package.json"),
864            r#"{"workspaces": ["packages/*"]}"#,
865        )
866        .unwrap();
867        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
868        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
869
870        let workspaces = discover_workspaces(dir.path());
871        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
872
873        assert_eq!(workspaces.len(), 1);
874        assert!(names.contains(&"a"));
875        assert!(
876            !names.contains(&"benchmarks"),
877            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
878        );
879    }
880
881    // ── find_undeclared_workspaces ─────────────────────────────────
882
883    #[test]
884    fn undeclared_workspace_detected() {
885        let dir = tempfile::tempdir().expect("create temp dir");
886        let pkg_a = dir.path().join("packages").join("a");
887        let pkg_b = dir.path().join("packages").join("b");
888        std::fs::create_dir_all(&pkg_a).unwrap();
889        std::fs::create_dir_all(&pkg_b).unwrap();
890
891        // Only packages/a is declared as a workspace
892        std::fs::write(
893            dir.path().join("package.json"),
894            r#"{"workspaces": ["packages/a"]}"#,
895        )
896        .unwrap();
897        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
898        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
899
900        let declared = discover_workspaces(dir.path());
901        assert_eq!(declared.len(), 1);
902
903        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
904        assert_eq!(undeclared.len(), 1);
905        assert!(
906            undeclared[0]
907                .path
908                .to_string_lossy()
909                .replace('\\', "/")
910                .contains("packages/b"),
911            "should detect packages/b as undeclared: {:?}",
912            undeclared[0].path
913        );
914    }
915
916    #[test]
917    fn no_undeclared_when_all_covered() {
918        let dir = tempfile::tempdir().expect("create temp dir");
919        let pkg_a = dir.path().join("packages").join("a");
920        std::fs::create_dir_all(&pkg_a).unwrap();
921
922        std::fs::write(
923            dir.path().join("package.json"),
924            r#"{"workspaces": ["packages/*"]}"#,
925        )
926        .unwrap();
927        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
928
929        let declared = discover_workspaces(dir.path());
930        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
931        assert!(undeclared.is_empty());
932    }
933
934    #[test]
935    fn no_undeclared_when_no_workspace_patterns() {
936        let dir = tempfile::tempdir().expect("create temp dir");
937        let sub = dir.path().join("lib");
938        std::fs::create_dir_all(&sub).unwrap();
939
940        // No workspaces field at all — non-monorepo project
941        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
942        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
943
944        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
945        assert!(
946            undeclared.is_empty(),
947            "should skip check when no workspace patterns exist"
948        );
949    }
950
951    #[test]
952    fn undeclared_skips_node_modules_and_hidden_dirs() {
953        let dir = tempfile::tempdir().expect("create temp dir");
954        let nm = dir.path().join("node_modules").join("some-pkg");
955        let hidden = dir.path().join(".hidden");
956        std::fs::create_dir_all(&nm).unwrap();
957        std::fs::create_dir_all(&hidden).unwrap();
958
959        std::fs::write(
960            dir.path().join("package.json"),
961            r#"{"workspaces": ["packages/*"]}"#,
962        )
963        .unwrap();
964        // Put package.json in node_modules and hidden dirs
965        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
966        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
967
968        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
969        assert!(
970            undeclared.is_empty(),
971            "should not flag node_modules or hidden directories"
972        );
973    }
974}