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