Skip to main content

fallow_config/workspace/
mod.rs

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