Skip to main content

fallow_config/workspace/
mod.rs

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