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