Skip to main content

fallow_config/workspace/
mod.rs

1mod diagnostics;
2mod package_json;
3mod parsers;
4mod pnpm_catalog;
5mod pnpm_overrides;
6
7use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[cfg(test)]
13pub use diagnostics::capture_workspace_warnings;
14pub use diagnostics::{
15    WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
16    clear_source_discovery_diagnostics, stash_workspace_diagnostics, workspace_diagnostics_for,
17};
18use diagnostics::{emit_diagnostics, is_skip_listed_dir};
19pub use package_json::{NapiConfig, PackageJson};
20pub use parsers::parse_tsconfig_root_dir;
21use parsers::{
22    expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
23    parse_tsconfig_references_with_diagnostics,
24};
25pub use pnpm_catalog::{
26    PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
27    parse_package_json_catalog_data, parse_pnpm_catalog_data,
28};
29pub use pnpm_overrides::{
30    MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
31    is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
32    parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
33};
34
35/// Workspace configuration for monorepo support.
36#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
37pub struct WorkspaceConfig {
38    /// Additional workspace patterns (beyond what's in root package.json).
39    #[serde(default)]
40    pub patterns: Vec<String>,
41}
42
43/// Discovered workspace info from package.json, pnpm-workspace.yaml, or tsconfig.json references.
44#[derive(Debug, Clone)]
45pub struct WorkspaceInfo {
46    /// Workspace root path.
47    pub root: PathBuf,
48    /// Package name from package.json.
49    pub name: String,
50    /// Whether this workspace is depended on by other workspaces.
51    pub is_internal_dependency: bool,
52}
53
54/// Discover all workspace packages in a monorepo.
55///
56/// Sources (additive, deduplicated by canonical path):
57/// 1. `package.json` `workspaces` field
58/// 2. `pnpm-workspace.yaml` `packages` field
59/// 3. `tsconfig.json` `references` field (TypeScript project references)
60///
61/// Back-compat wrapper: drops any diagnostics and silently treats a malformed
62/// root `package.json` as "no workspaces". New callers should use
63/// [`discover_workspaces_with_diagnostics`] to receive typed
64/// [`WorkspaceDiagnostic`] values and to surface root-malformed errors as
65/// hard exits.
66///
67/// This wrapper goes through the silent collector path that does NOT call
68/// `emit_diagnostics` (private helper in `crates/config/src/workspace/diagnostics.rs`
69/// that does the `tracing::warn!` emission). Without that split, sibling
70/// callers in `core/src/lib.rs` (analyze) and `core/src/discover/mod.rs`
71/// (file discovery) would re-emit `tracing::warn!` on paths the user already
72/// excluded via `ignorePatterns`, because the back-compat wrapper has no
73/// access to the user's globset.
74#[must_use]
75pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
76    collect_workspaces_and_diagnostics(root, &globset::GlobSet::empty())
77        .map(|(workspaces, _)| workspaces)
78        .unwrap_or_default()
79}
80
81/// Discover workspace packages and return any diagnostics produced along the
82/// way.
83///
84/// Replaces the four silent-drop sites in [`discover_workspaces`] with typed
85/// [`WorkspaceDiagnostic`] values:
86/// - malformed declared-workspace `package.json` (warn-and-continue),
87/// - glob match resolving to a directory without `package.json` (warn,
88///   filtered through `ignore_patterns` and an extended skip list),
89/// - malformed `tsconfig.json` (warn-and-continue),
90/// - tsconfig `references[].path` pointing to a missing directory (warn).
91///
92/// `ignore_patterns` mirrors the precedent in
93/// [`find_undeclared_workspaces_with_ignores`]: directories the user already
94/// excluded do not trigger a redundant diagnostic.
95///
96/// Returns [`WorkspaceLoadError::MalformedRootPackageJson`] when the root
97/// `package.json` exists but fails to parse: without a parseable root, no
98/// workspace patterns can be collected and the analysis output would be
99/// fiction. The CLI surfaces this as exit 2.
100///
101/// Shallow-scan fallback candidates (`collect_shallow_workspace_candidate`)
102/// stay silent: the user did not declare them, so a stray malformed
103/// `package.json` two levels deep in a `tools/scratch/` directory should not
104/// produce noise.
105///
106/// # Errors
107///
108/// Returns [`WorkspaceLoadError`] when the project root's `package.json`
109/// exists but is not valid JSON. Callers map this to a hard exit.
110pub fn discover_workspaces_with_diagnostics(
111    root: &Path,
112    ignore_patterns: &globset::GlobSet,
113) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
114    let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
115
116    emit_diagnostics(root, &diagnostics);
117
118    Ok((workspaces, diagnostics))
119}
120
121/// Collect workspaces and diagnostics without emitting `tracing::warn!`.
122///
123/// Both [`discover_workspaces_with_diagnostics`] (which adds the emit step)
124/// and [`discover_workspaces`] (which drops both diagnostics and emission)
125/// route through this function. Keeping emission in the public top-level
126/// only means downstream callers that have no access to the user's
127/// `ignorePatterns` cannot accidentally re-emit warnings on paths the user
128/// already excluded.
129fn collect_workspaces_and_diagnostics(
130    root: &Path,
131    ignore_patterns: &globset::GlobSet,
132) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
133    let mut diagnostics = Vec::new();
134    let patterns = collect_workspace_patterns(root)?;
135    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
136
137    let mut workspaces = expand_patterns_to_workspaces(
138        root,
139        &patterns,
140        &canonical_root,
141        ignore_patterns,
142        &mut diagnostics,
143    );
144    workspaces.extend(collect_tsconfig_workspaces(
145        root,
146        &canonical_root,
147        ignore_patterns,
148        &mut diagnostics,
149    ));
150    if patterns.is_empty() {
151        workspaces.extend(collect_shallow_package_workspaces(root, &canonical_root));
152    }
153
154    if !workspaces.is_empty() {
155        mark_internal_dependencies(&mut workspaces);
156    }
157    let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
158    Ok((workspaces, diagnostics))
159}
160
161/// Find directories containing `package.json` that are not declared as workspaces.
162///
163/// Only meaningful in monorepos that declare workspaces (via `package.json` `workspaces`
164/// field or `pnpm-workspace.yaml`). Scans up to two directory levels deep, skipping
165/// hidden directories, `node_modules`, and `build`.
166#[must_use]
167pub fn find_undeclared_workspaces(
168    root: &Path,
169    declared: &[WorkspaceInfo],
170) -> Vec<WorkspaceDiagnostic> {
171    find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
172}
173
174/// Find directories containing `package.json` that are not declared as workspaces,
175/// excluding candidates covered by the supplied ignore globset.
176///
177/// This is the ignore-aware variant used by the full analyzer after config
178/// resolution. See [`find_undeclared_workspaces`] for the compatibility wrapper.
179///
180/// Directories whose project-root-relative path matches `ignore_patterns` are skipped
181/// so users who already excluded a path via `ignorePatterns` don't see a redundant
182/// "not declared as workspace" warning. See issue #193.
183#[must_use]
184pub fn find_undeclared_workspaces_with_ignores(
185    root: &Path,
186    declared: &[WorkspaceInfo],
187    ignore_patterns: &globset::GlobSet,
188) -> Vec<WorkspaceDiagnostic> {
189    let patterns = collect_workspace_patterns(root).unwrap_or_default();
190    if patterns.is_empty() {
191        return Vec::new();
192    }
193
194    let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
195        .iter()
196        .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
197        .collect();
198
199    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
200
201    let mut undeclared = Vec::new();
202
203    let Ok(top_entries) = std::fs::read_dir(root) else {
204        return Vec::new();
205    };
206
207    for entry in top_entries.filter_map(Result::ok) {
208        let path = entry.path();
209        if !path.is_dir() || is_undeclared_scan_skip_dir(&entry.file_name().to_string_lossy()) {
210            continue;
211        }
212
213        let mut input = UndeclaredScanInput {
214            root,
215            canonical_root: &canonical_root,
216            declared_roots: &declared_roots,
217            ignore_patterns,
218            undeclared: &mut undeclared,
219        };
220        check_undeclared(
221            &path,
222            input.root,
223            input.canonical_root,
224            input.declared_roots,
225            input.ignore_patterns,
226            input.undeclared,
227        );
228        scan_child_dirs_for_undeclared(&path, &mut input);
229    }
230
231    undeclared
232}
233
234/// Whether an undeclared-workspace scan should skip a directory by leaf name.
235fn is_undeclared_scan_skip_dir(name: &str) -> bool {
236    name.starts_with('.') || name == "node_modules" || name == "build"
237}
238
239/// Borrowed inputs threaded through the second-level undeclared-workspace scan.
240struct UndeclaredScanInput<'a> {
241    root: &'a Path,
242    canonical_root: &'a Path,
243    declared_roots: &'a rustc_hash::FxHashSet<PathBuf>,
244    ignore_patterns: &'a globset::GlobSet,
245    undeclared: &'a mut Vec<WorkspaceDiagnostic>,
246}
247
248/// Check each immediate child of `parent` for an undeclared workspace.
249fn scan_child_dirs_for_undeclared(parent: &Path, input: &mut UndeclaredScanInput<'_>) {
250    let Ok(child_entries) = std::fs::read_dir(parent) else {
251        return;
252    };
253    for child in child_entries.filter_map(Result::ok) {
254        let child_path = child.path();
255        if !child_path.is_dir() || is_undeclared_scan_skip_dir(&child.file_name().to_string_lossy())
256        {
257            continue;
258        }
259        check_undeclared(
260            &child_path,
261            input.root,
262            input.canonical_root,
263            input.declared_roots,
264            input.ignore_patterns,
265            input.undeclared,
266        );
267    }
268}
269
270/// Check a single directory for an undeclared workspace.
271fn check_undeclared(
272    dir: &Path,
273    root: &Path,
274    canonical_root: &Path,
275    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
276    ignore_patterns: &globset::GlobSet,
277    undeclared: &mut Vec<WorkspaceDiagnostic>,
278) {
279    if !dir.join("package.json").exists() {
280        return;
281    }
282    let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
283    if canonical == *canonical_root {
284        return;
285    }
286    if declared_roots.contains(&canonical) {
287        return;
288    }
289    let relative = dir.strip_prefix(root).unwrap_or(dir);
290    let relative_str = relative.to_string_lossy().replace('\\', "/");
291    if ignore_patterns.is_match(relative_str.as_str())
292        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
293    {
294        return;
295    }
296    undeclared.push(WorkspaceDiagnostic::new(
297        root,
298        dir.to_path_buf(),
299        WorkspaceDiagnosticKind::UndeclaredWorkspace,
300    ));
301}
302
303/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
304fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
305    let mut patterns = Vec::new();
306
307    let pkg_path = root.join("package.json");
308    if pkg_path.exists() {
309        match PackageJson::load(&pkg_path) {
310            Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
311            Err(error) => {
312                return Err(WorkspaceLoadError::MalformedRootPackageJson {
313                    path: pkg_path,
314                    error,
315                });
316            }
317        }
318    }
319
320    let pnpm_workspace = root.join("pnpm-workspace.yaml");
321    if pnpm_workspace.exists()
322        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
323    {
324        patterns.extend(parse_pnpm_workspace_yaml(&content));
325    }
326
327    Ok(patterns)
328}
329
330/// Expand workspace glob patterns to discover workspace directories.
331///
332/// Handles positive/negated pattern splitting, glob matching, and package.json
333/// loading for each matched directory.
334fn expand_patterns_to_workspaces(
335    root: &Path,
336    patterns: &[String],
337    canonical_root: &Path,
338    ignore_patterns: &globset::GlobSet,
339    diagnostics: &mut Vec<WorkspaceDiagnostic>,
340) -> Vec<(WorkspaceInfo, Vec<String>)> {
341    if patterns.is_empty() {
342        return Vec::new();
343    }
344
345    let mut workspaces = Vec::new();
346
347    let (positive, negative): (Vec<&String>, Vec<&String>) =
348        patterns.iter().partition(|p| !p.starts_with('!'));
349    let negation_matchers: Vec<globset::GlobMatcher> = negative
350        .iter()
351        .filter_map(|p| {
352            let stripped = p.strip_prefix('!').unwrap_or(p);
353            globset::Glob::new(stripped)
354                .ok()
355                .map(|g| g.compile_matcher())
356        })
357        .collect();
358
359    for pattern in &positive {
360        let glob_pattern = if pattern.ends_with('/') {
361            format!("{pattern}*")
362        } else {
363            (*pattern).clone()
364        };
365
366        let matched_dirs = expand_workspace_glob_with_diagnostics(
367            root,
368            pattern,
369            &glob_pattern,
370            canonical_root,
371            ignore_patterns,
372            diagnostics,
373        );
374        for (dir, canonical_dir) in matched_dirs {
375            if canonical_dir == *canonical_root {
376                continue;
377            }
378            if matches_negation(root, &dir, &negation_matchers) {
379                continue;
380            }
381            register_matched_workspace(root, dir, &mut workspaces, diagnostics);
382        }
383    }
384
385    workspaces
386}
387
388/// Whether a matched directory is excluded by a negated workspace pattern.
389fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
390    let relative = dir.strip_prefix(root).unwrap_or(dir);
391    let relative_str = relative.to_string_lossy();
392    negation_matchers
393        .iter()
394        .any(|m| m.is_match(relative_str.as_ref()))
395}
396
397/// Load a matched directory's `package.json` and push a workspace, or a
398/// malformed-package diagnostic on parse failure.
399fn register_matched_workspace(
400    root: &Path,
401    dir: PathBuf,
402    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
403    diagnostics: &mut Vec<WorkspaceDiagnostic>,
404) {
405    let ws_pkg_path = dir.join("package.json");
406    match PackageJson::load(&ws_pkg_path) {
407        Ok(pkg) => {
408            let dep_names = pkg.all_dependency_names();
409            let name = pkg.name.unwrap_or_else(|| dir_name(&dir));
410            workspaces.push((
411                WorkspaceInfo {
412                    root: dir,
413                    name,
414                    is_internal_dependency: false,
415                },
416                dep_names,
417            ));
418        }
419        Err(error) => {
420            let diag = WorkspaceDiagnostic::new(
421                root,
422                dir,
423                WorkspaceDiagnosticKind::MalformedPackageJson { error },
424            );
425            diagnostics.push(diag);
426        }
427    }
428}
429
430/// Discover workspaces from TypeScript project references in `tsconfig.json`.
431///
432/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
433/// This enables cross-workspace resolution for TypeScript composite projects.
434fn collect_tsconfig_workspaces(
435    root: &Path,
436    canonical_root: &Path,
437    ignore_patterns: &globset::GlobSet,
438    diagnostics: &mut Vec<WorkspaceDiagnostic>,
439) -> Vec<(WorkspaceInfo, Vec<String>)> {
440    let mut workspaces = Vec::new();
441
442    for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
443        if let Some(workspace) = tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics)
444        {
445            workspaces.push(workspace);
446        }
447    }
448
449    workspaces
450}
451
452fn tsconfig_workspace_from_dir(
453    root: &Path,
454    dir: PathBuf,
455    canonical_root: &Path,
456    diagnostics: &mut Vec<WorkspaceDiagnostic>,
457) -> Option<(WorkspaceInfo, Vec<String>)> {
458    let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
459    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
460        return None;
461    }
462
463    let (name, dep_names) = load_tsconfig_workspace_package(root, &dir, diagnostics);
464    Some((
465        WorkspaceInfo {
466            root: dir,
467            name,
468            is_internal_dependency: false,
469        },
470        dep_names,
471    ))
472}
473
474fn load_tsconfig_workspace_package(
475    root: &Path,
476    dir: &Path,
477    diagnostics: &mut Vec<WorkspaceDiagnostic>,
478) -> (String, Vec<String>) {
479    let ws_pkg_path = dir.join("package.json");
480    if !ws_pkg_path.exists() {
481        return (dir_name(dir), Vec::new());
482    }
483
484    match PackageJson::load(&ws_pkg_path) {
485        Ok(pkg) => {
486            let deps = pkg.all_dependency_names();
487            let name = pkg.name.unwrap_or_else(|| dir_name(dir));
488            (name, deps)
489        }
490        Err(error) => {
491            let diag = WorkspaceDiagnostic::new(
492                root,
493                dir.to_path_buf(),
494                WorkspaceDiagnosticKind::MalformedPackageJson { error },
495            );
496            diagnostics.push(diag);
497            (dir_name(dir), Vec::new())
498        }
499    }
500}
501
502/// Discover shallow package workspaces when no explicit workspace config exists.
503///
504/// Scans direct children of the project root and their immediate children for
505/// `package.json` files. This catches repos that contain multiple standalone
506/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
507/// npm/pnpm workspaces at the root.
508fn collect_shallow_package_workspaces(
509    root: &Path,
510    canonical_root: &Path,
511) -> Vec<(WorkspaceInfo, Vec<String>)> {
512    let mut workspaces = Vec::new();
513    let Ok(top_entries) = std::fs::read_dir(root) else {
514        return workspaces;
515    };
516
517    for entry in top_entries.filter_map(Result::ok) {
518        let path = entry.path();
519        if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
520            continue;
521        }
522
523        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
524        collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces);
525    }
526
527    workspaces
528}
529
530fn collect_shallow_child_workspaces(
531    parent: &Path,
532    canonical_root: &Path,
533    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
534) {
535    let Ok(child_entries) = std::fs::read_dir(parent) else {
536        return;
537    };
538    for child in child_entries.filter_map(Result::ok) {
539        let child_path = child.path();
540        if !child_path.is_dir()
541            || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
542        {
543            continue;
544        }
545
546        collect_shallow_workspace_candidate(&child_path, canonical_root, workspaces);
547    }
548}
549
550fn collect_shallow_workspace_candidate(
551    dir: &Path,
552    canonical_root: &Path,
553    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
554) {
555    let pkg_path = dir.join("package.json");
556    if !pkg_path.exists() {
557        return;
558    }
559
560    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
561    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
562        return;
563    }
564
565    let Ok(pkg) = PackageJson::load(&pkg_path) else {
566        return;
567    };
568    let dep_names = pkg.all_dependency_names();
569    let name = pkg.name.unwrap_or_else(|| dir_name(dir));
570
571    workspaces.push((
572        WorkspaceInfo {
573            root: dir.to_path_buf(),
574            name,
575            is_internal_dependency: false,
576        },
577        dep_names,
578    ));
579}
580
581fn should_skip_workspace_scan_dir(name: &str) -> bool {
582    is_skip_listed_dir(name)
583}
584
585/// Deduplicate workspaces by canonical path and mark internal dependencies.
586///
587/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
588/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
589/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
590fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
591    {
592        let mut seen = rustc_hash::FxHashSet::default();
593        workspaces.retain(|(ws, _)| {
594            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
595            seen.insert(canonical)
596        });
597    }
598
599    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
600        .iter()
601        .flat_map(|(_, deps)| deps.iter().cloned())
602        .collect();
603    for (ws, _) in &mut *workspaces {
604        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
605    }
606}
607
608/// Extract the directory name as a string, for workspace name fallback.
609fn dir_name(dir: &Path) -> String {
610    dir.file_name()
611        .map(|n| n.to_string_lossy().to_string())
612        .unwrap_or_default()
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn discover_workspaces_from_tsconfig_references() {
621        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
622        let _ = std::fs::remove_dir_all(&temp_dir);
623        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
624        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
625
626        std::fs::write(
627            temp_dir.join("tsconfig.json"),
628            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
629        )
630        .unwrap();
631
632        std::fs::write(
633            temp_dir.join("packages/core/package.json"),
634            r#"{"name": "@project/core"}"#,
635        )
636        .unwrap();
637
638        let workspaces = discover_workspaces(&temp_dir);
639        assert_eq!(workspaces.len(), 2);
640        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
641        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
642
643        let _ = std::fs::remove_dir_all(&temp_dir);
644    }
645
646    #[test]
647    fn tsconfig_references_outside_root_rejected() {
648        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
649        let _ = std::fs::remove_dir_all(&temp_dir);
650        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
651        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
652
653        std::fs::write(
654            temp_dir.join("project/tsconfig.json"),
655            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
656        )
657        .unwrap();
658
659        let workspaces = discover_workspaces(&temp_dir.join("project"));
660        assert_eq!(
661            workspaces.len(),
662            1,
663            "reference outside project root should be rejected: {workspaces:?}"
664        );
665        assert!(
666            workspaces[0]
667                .root
668                .to_string_lossy()
669                .contains("packages/core")
670        );
671
672        let _ = std::fs::remove_dir_all(&temp_dir);
673    }
674
675    #[test]
676    fn dir_name_extracts_last_component() {
677        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
678        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
679    }
680
681    #[test]
682    fn dir_name_empty_for_root_path() {
683        assert_eq!(dir_name(Path::new("/")), "");
684    }
685
686    #[test]
687    fn workspace_config_deserialize_json() {
688        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
689        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
690        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
691    }
692
693    #[test]
694    fn workspace_config_deserialize_empty_patterns() {
695        let json = r#"{"patterns": []}"#;
696        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
697        assert!(config.patterns.is_empty());
698    }
699
700    #[test]
701    fn workspace_config_default_patterns() {
702        let json = "{}";
703        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
704        assert!(config.patterns.is_empty());
705    }
706
707    #[test]
708    fn workspace_info_default_not_internal() {
709        let ws = WorkspaceInfo {
710            root: PathBuf::from("/project/packages/a"),
711            name: "a".to_string(),
712            is_internal_dependency: false,
713        };
714        assert!(!ws.is_internal_dependency);
715    }
716
717    #[test]
718    fn mark_internal_deps_detects_cross_references() {
719        let temp_dir = tempfile::tempdir().expect("create temp dir");
720        let pkg_a = temp_dir.path().join("a");
721        let pkg_b = temp_dir.path().join("b");
722        std::fs::create_dir_all(&pkg_a).unwrap();
723        std::fs::create_dir_all(&pkg_b).unwrap();
724
725        let mut workspaces = vec![
726            (
727                WorkspaceInfo {
728                    root: pkg_a,
729                    name: "@scope/a".to_string(),
730                    is_internal_dependency: false,
731                },
732                vec!["@scope/b".to_string()], // "a" depends on "b"
733            ),
734            (
735                WorkspaceInfo {
736                    root: pkg_b,
737                    name: "@scope/b".to_string(),
738                    is_internal_dependency: false,
739                },
740                vec!["lodash".to_string()], // "b" depends on external only
741            ),
742        ];
743
744        mark_internal_dependencies(&mut workspaces);
745
746        let ws_a = workspaces
747            .iter()
748            .find(|(ws, _)| ws.name == "@scope/a")
749            .unwrap();
750        assert!(
751            !ws_a.0.is_internal_dependency,
752            "a is not depended on by others"
753        );
754
755        let ws_b = workspaces
756            .iter()
757            .find(|(ws, _)| ws.name == "@scope/b")
758            .unwrap();
759        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
760    }
761
762    #[test]
763    fn mark_internal_deps_no_cross_references() {
764        let temp_dir = tempfile::tempdir().expect("create temp dir");
765        let pkg_a = temp_dir.path().join("a");
766        let pkg_b = temp_dir.path().join("b");
767        std::fs::create_dir_all(&pkg_a).unwrap();
768        std::fs::create_dir_all(&pkg_b).unwrap();
769
770        let mut workspaces = vec![
771            (
772                WorkspaceInfo {
773                    root: pkg_a,
774                    name: "a".to_string(),
775                    is_internal_dependency: false,
776                },
777                vec!["react".to_string()],
778            ),
779            (
780                WorkspaceInfo {
781                    root: pkg_b,
782                    name: "b".to_string(),
783                    is_internal_dependency: false,
784                },
785                vec!["lodash".to_string()],
786            ),
787        ];
788
789        mark_internal_dependencies(&mut workspaces);
790
791        assert!(!workspaces[0].0.is_internal_dependency);
792        assert!(!workspaces[1].0.is_internal_dependency);
793    }
794
795    #[test]
796    fn mark_internal_deps_deduplicates_by_path() {
797        let temp_dir = tempfile::tempdir().expect("create temp dir");
798        let pkg_a = temp_dir.path().join("a");
799        std::fs::create_dir_all(&pkg_a).unwrap();
800
801        let mut workspaces = vec![
802            (
803                WorkspaceInfo {
804                    root: pkg_a.clone(),
805                    name: "a".to_string(),
806                    is_internal_dependency: false,
807                },
808                vec![],
809            ),
810            (
811                WorkspaceInfo {
812                    root: pkg_a,
813                    name: "a".to_string(),
814                    is_internal_dependency: false,
815                },
816                vec![],
817            ),
818        ];
819
820        mark_internal_dependencies(&mut workspaces);
821        assert_eq!(
822            workspaces.len(),
823            1,
824            "duplicate paths should be deduplicated"
825        );
826    }
827
828    #[test]
829    fn collect_patterns_from_package_json() {
830        let dir = tempfile::tempdir().expect("create temp dir");
831        std::fs::write(
832            dir.path().join("package.json"),
833            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
834        )
835        .unwrap();
836
837        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
838        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
839    }
840
841    #[test]
842    fn collect_patterns_from_pnpm_workspace() {
843        let dir = tempfile::tempdir().expect("create temp dir");
844        std::fs::write(
845            dir.path().join("pnpm-workspace.yaml"),
846            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
847        )
848        .unwrap();
849
850        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
851        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
852    }
853
854    #[test]
855    fn collect_patterns_combines_sources() {
856        let dir = tempfile::tempdir().expect("create temp dir");
857        std::fs::write(
858            dir.path().join("package.json"),
859            r#"{"workspaces": ["packages/*"]}"#,
860        )
861        .unwrap();
862        std::fs::write(
863            dir.path().join("pnpm-workspace.yaml"),
864            "packages:\n  - 'apps/*'\n",
865        )
866        .unwrap();
867
868        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
869        assert!(patterns.contains(&"packages/*".to_string()));
870        assert!(patterns.contains(&"apps/*".to_string()));
871    }
872
873    #[test]
874    fn collect_patterns_empty_when_no_configs() {
875        let dir = tempfile::tempdir().expect("create temp dir");
876        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
877        assert!(patterns.is_empty());
878    }
879
880    #[test]
881    fn discover_workspaces_from_package_json() {
882        let dir = tempfile::tempdir().expect("create temp dir");
883        let pkg_a = dir.path().join("packages").join("a");
884        let pkg_b = dir.path().join("packages").join("b");
885        std::fs::create_dir_all(&pkg_a).unwrap();
886        std::fs::create_dir_all(&pkg_b).unwrap();
887
888        std::fs::write(
889            dir.path().join("package.json"),
890            r#"{"workspaces": ["packages/*"]}"#,
891        )
892        .unwrap();
893        std::fs::write(
894            pkg_a.join("package.json"),
895            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
896        )
897        .unwrap();
898        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
899
900        let workspaces = discover_workspaces(dir.path());
901        assert_eq!(workspaces.len(), 2);
902
903        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
904        assert!(!ws_a.is_internal_dependency);
905
906        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
907        assert!(ws_b.is_internal_dependency, "b is depended on by a");
908    }
909
910    #[test]
911    fn discover_workspaces_empty_project() {
912        let dir = tempfile::tempdir().expect("create temp dir");
913        let workspaces = discover_workspaces(dir.path());
914        assert!(workspaces.is_empty());
915    }
916
917    #[test]
918    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
919        let dir = tempfile::tempdir().expect("create temp dir");
920        let benchmarks = dir.path().join("benchmarks");
921        let vscode = dir.path().join("editors").join("vscode");
922        let deep = dir.path().join("tests").join("fixtures").join("demo");
923        std::fs::create_dir_all(&benchmarks).unwrap();
924        std::fs::create_dir_all(&vscode).unwrap();
925        std::fs::create_dir_all(&deep).unwrap();
926
927        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
928        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
929        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
930
931        let workspaces = discover_workspaces(dir.path());
932        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
933
934        assert!(
935            names.contains(&"benchmarks"),
936            "top-level nested package should be discovered: {workspaces:?}"
937        );
938        assert!(
939            names.contains(&"fallow-vscode"),
940            "second-level nested package should be discovered: {workspaces:?}"
941        );
942        assert!(
943            !names.contains(&"deep-fixture"),
944            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
945        );
946    }
947
948    #[test]
949    fn discover_workspaces_with_negated_patterns() {
950        let dir = tempfile::tempdir().expect("create temp dir");
951        let pkg_a = dir.path().join("packages").join("a");
952        let pkg_test = dir.path().join("packages").join("test-utils");
953        std::fs::create_dir_all(&pkg_a).unwrap();
954        std::fs::create_dir_all(&pkg_test).unwrap();
955
956        std::fs::write(
957            dir.path().join("package.json"),
958            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
959        )
960        .unwrap();
961        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
962        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
963
964        let workspaces = discover_workspaces(dir.path());
965        assert_eq!(workspaces.len(), 1);
966        assert_eq!(workspaces[0].name, "a");
967    }
968
969    #[test]
970    fn discover_workspaces_skips_root_as_workspace() {
971        let dir = tempfile::tempdir().expect("create temp dir");
972        std::fs::write(
973            dir.path().join("pnpm-workspace.yaml"),
974            "packages:\n  - '.'\n",
975        )
976        .unwrap();
977        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
978
979        let workspaces = discover_workspaces(dir.path());
980        assert!(
981            workspaces.is_empty(),
982            "root directory should not be added as workspace"
983        );
984    }
985
986    #[test]
987    fn discover_workspaces_name_fallback_to_dir_name() {
988        let dir = tempfile::tempdir().expect("create temp dir");
989        let pkg_a = dir.path().join("packages").join("my-app");
990        std::fs::create_dir_all(&pkg_a).unwrap();
991
992        std::fs::write(
993            dir.path().join("package.json"),
994            r#"{"workspaces": ["packages/*"]}"#,
995        )
996        .unwrap();
997        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
998
999        let workspaces = discover_workspaces(dir.path());
1000        assert_eq!(workspaces.len(), 1);
1001        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1002    }
1003
1004    #[test]
1005    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1006        let dir = tempfile::tempdir().expect("create temp dir");
1007        let pkg_a = dir.path().join("packages").join("a");
1008        let benchmarks = dir.path().join("benchmarks");
1009        std::fs::create_dir_all(&pkg_a).unwrap();
1010        std::fs::create_dir_all(&benchmarks).unwrap();
1011
1012        std::fs::write(
1013            dir.path().join("package.json"),
1014            r#"{"workspaces": ["packages/*"]}"#,
1015        )
1016        .unwrap();
1017        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1018        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1019
1020        let workspaces = discover_workspaces(dir.path());
1021        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1022
1023        assert_eq!(workspaces.len(), 1);
1024        assert!(names.contains(&"a"));
1025        assert!(
1026            !names.contains(&"benchmarks"),
1027            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1028        );
1029    }
1030
1031    #[test]
1032    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1033        // Issue #842 (reporter metrists/metrists): root declares
1034        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
1035        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
1036        // itself has no package.json. The single-level glob only matches the bare
1037        // `packages/themes`; without recovery the deep package is never discovered,
1038        // its files fall back to the root manifest, and its declared deps (react)
1039        // are reported as unlisted. Discovery must recover the named deep package.
1040        let dir = tempfile::tempdir().expect("create temp dir");
1041        let theme = dir
1042            .path()
1043            .join("packages")
1044            .join("themes")
1045            .join("metrists-theme-next");
1046        std::fs::create_dir_all(&theme).unwrap();
1047        std::fs::write(
1048            dir.path().join("package.json"),
1049            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1050        )
1051        .unwrap();
1052        // packages/themes intentionally has NO package.json (bare grouping dir).
1053        std::fs::write(
1054            theme.join("package.json"),
1055            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1056        )
1057        .unwrap();
1058
1059        let workspaces = discover_workspaces(dir.path());
1060        assert!(
1061            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1062            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1063        );
1064    }
1065
1066    #[test]
1067    fn undeclared_workspace_detected() {
1068        let dir = tempfile::tempdir().expect("create temp dir");
1069        let pkg_a = dir.path().join("packages").join("a");
1070        let pkg_b = dir.path().join("packages").join("b");
1071        std::fs::create_dir_all(&pkg_a).unwrap();
1072        std::fs::create_dir_all(&pkg_b).unwrap();
1073
1074        std::fs::write(
1075            dir.path().join("package.json"),
1076            r#"{"workspaces": ["packages/a"]}"#,
1077        )
1078        .unwrap();
1079        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1080        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1081
1082        let declared = discover_workspaces(dir.path());
1083        assert_eq!(declared.len(), 1);
1084
1085        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1086        assert_eq!(undeclared.len(), 1);
1087        assert!(
1088            undeclared[0]
1089                .path
1090                .to_string_lossy()
1091                .replace('\\', "/")
1092                .contains("packages/b"),
1093            "should detect packages/b as undeclared: {:?}",
1094            undeclared[0].path
1095        );
1096    }
1097
1098    #[test]
1099    fn no_undeclared_when_all_covered() {
1100        let dir = tempfile::tempdir().expect("create temp dir");
1101        let pkg_a = dir.path().join("packages").join("a");
1102        std::fs::create_dir_all(&pkg_a).unwrap();
1103
1104        std::fs::write(
1105            dir.path().join("package.json"),
1106            r#"{"workspaces": ["packages/*"]}"#,
1107        )
1108        .unwrap();
1109        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1110
1111        let declared = discover_workspaces(dir.path());
1112        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1113        assert!(undeclared.is_empty());
1114    }
1115
1116    #[test]
1117    fn no_undeclared_when_no_workspace_patterns() {
1118        let dir = tempfile::tempdir().expect("create temp dir");
1119        let sub = dir.path().join("lib");
1120        std::fs::create_dir_all(&sub).unwrap();
1121
1122        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1123        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1124
1125        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1126        assert!(
1127            undeclared.is_empty(),
1128            "should skip check when no workspace patterns exist"
1129        );
1130    }
1131
1132    #[test]
1133    fn undeclared_skips_node_modules_and_hidden_dirs() {
1134        let dir = tempfile::tempdir().expect("create temp dir");
1135        let nm = dir.path().join("node_modules").join("some-pkg");
1136        let hidden = dir.path().join(".hidden");
1137        std::fs::create_dir_all(&nm).unwrap();
1138        std::fs::create_dir_all(&hidden).unwrap();
1139
1140        std::fs::write(
1141            dir.path().join("package.json"),
1142            r#"{"workspaces": ["packages/*"]}"#,
1143        )
1144        .unwrap();
1145        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1146        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1147
1148        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1149        assert!(
1150            undeclared.is_empty(),
1151            "should not flag node_modules or hidden directories"
1152        );
1153    }
1154
1155    fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1156        let mut builder = globset::GlobSetBuilder::new();
1157        for pattern in patterns {
1158            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1159        }
1160        builder.build().expect("build globset")
1161    }
1162
1163    #[test]
1164    fn undeclared_skips_dirs_matching_ignore_patterns() {
1165        let dir = tempfile::tempdir().expect("create temp dir");
1166        let pkg_a = dir.path().join("packages").join("a");
1167        let vitest_ref = dir.path().join("references").join("vitest");
1168        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1169        std::fs::create_dir_all(&pkg_a).unwrap();
1170        std::fs::create_dir_all(&vitest_ref).unwrap();
1171        std::fs::create_dir_all(&tanstack_ref).unwrap();
1172
1173        std::fs::write(
1174            dir.path().join("package.json"),
1175            r#"{"workspaces": ["packages/*"]}"#,
1176        )
1177        .unwrap();
1178        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1179        std::fs::write(
1180            vitest_ref.join("package.json"),
1181            r#"{"name": "vitest-reference"}"#,
1182        )
1183        .unwrap();
1184        std::fs::write(
1185            tanstack_ref.join("package.json"),
1186            r#"{"name": "tanstack-reference"}"#,
1187        )
1188        .unwrap();
1189
1190        let declared = discover_workspaces(dir.path());
1191        let ignore = build_globset(&["references/*"]);
1192        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1193        assert!(
1194            undeclared.is_empty(),
1195            "references/* should be ignored: {undeclared:?}"
1196        );
1197    }
1198
1199    #[test]
1200    fn undeclared_still_reported_when_ignore_does_not_match() {
1201        let dir = tempfile::tempdir().expect("create temp dir");
1202        let pkg_b = dir.path().join("packages").join("b");
1203        std::fs::create_dir_all(&pkg_b).unwrap();
1204
1205        std::fs::write(
1206            dir.path().join("package.json"),
1207            r#"{"workspaces": ["packages/a"]}"#,
1208        )
1209        .unwrap();
1210        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1211
1212        let declared = discover_workspaces(dir.path());
1213        let ignore = build_globset(&["references/*"]);
1214        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1215        assert_eq!(
1216            undeclared.len(),
1217            1,
1218            "non-matching ignore patterns should not silence other undeclared dirs"
1219        );
1220    }
1221
1222    #[test]
1223    fn undeclared_skips_dirs_matching_package_json_glob() {
1224        let dir = tempfile::tempdir().expect("create temp dir");
1225        let pkg_a = dir.path().join("packages").join("a");
1226        let vitest_ref = dir.path().join("references").join("vitest");
1227        std::fs::create_dir_all(&pkg_a).unwrap();
1228        std::fs::create_dir_all(&vitest_ref).unwrap();
1229
1230        std::fs::write(
1231            dir.path().join("package.json"),
1232            r#"{"workspaces": ["packages/*"]}"#,
1233        )
1234        .unwrap();
1235        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1236        std::fs::write(
1237            vitest_ref.join("package.json"),
1238            r#"{"name": "vitest-reference"}"#,
1239        )
1240        .unwrap();
1241
1242        let declared = discover_workspaces(dir.path());
1243        let ignore = build_globset(&["references/*/package.json"]);
1244        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1245        assert!(
1246            undeclared.is_empty(),
1247            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1248        );
1249    }
1250
1251    #[test]
1252    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1253        let dir = tempfile::tempdir().expect("create temp dir");
1254        let pkg_a = dir.path().join("packages").join("a");
1255        let nested_ref = dir.path().join("references").join("vitest");
1256        std::fs::create_dir_all(&pkg_a).unwrap();
1257        std::fs::create_dir_all(&nested_ref).unwrap();
1258
1259        std::fs::write(
1260            dir.path().join("package.json"),
1261            r#"{"workspaces": ["packages/*"]}"#,
1262        )
1263        .unwrap();
1264        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1265        std::fs::write(
1266            nested_ref.join("package.json"),
1267            r#"{"name": "vitest-reference"}"#,
1268        )
1269        .unwrap();
1270
1271        let declared = discover_workspaces(dir.path());
1272        let ignore = build_globset(&["**/references/**"]);
1273        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1274        assert!(
1275            undeclared.is_empty(),
1276            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1277        );
1278    }
1279
1280    #[test]
1281    fn malformed_workspace_package_json_emits_diagnostic() {
1282        let dir = tempfile::tempdir().expect("create temp dir");
1283        let pkg_a = dir.path().join("packages").join("a");
1284        let pkg_bad = dir.path().join("packages").join("bad");
1285        std::fs::create_dir_all(&pkg_a).unwrap();
1286        std::fs::create_dir_all(&pkg_bad).unwrap();
1287        std::fs::write(
1288            dir.path().join("package.json"),
1289            r#"{"workspaces": ["packages/*"]}"#,
1290        )
1291        .unwrap();
1292        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1293        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1294
1295        let (result, captured) = capture_workspace_warnings(|| {
1296            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1297        });
1298        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1299
1300        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1301        assert_eq!(workspaces[0].name, "a");
1302        assert_eq!(diagnostics.len(), 1);
1303        assert!(matches!(
1304            diagnostics[0].kind,
1305            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1306        ));
1307        assert!(
1308            captured
1309                .iter()
1310                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1311        );
1312    }
1313
1314    #[test]
1315    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1316        let dir = tempfile::tempdir().expect("create temp dir");
1317        for name in ["a", "b", "c"] {
1318            let pkg = dir.path().join("packages").join(name);
1319            std::fs::create_dir_all(&pkg).unwrap();
1320            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1321        }
1322        std::fs::write(
1323            dir.path().join("package.json"),
1324            r#"{"workspaces": ["packages/*"]}"#,
1325        )
1326        .unwrap();
1327
1328        let (result, _) = capture_workspace_warnings(|| {
1329            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1330        });
1331        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1332
1333        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1334        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1335        assert!(
1336            diagnostics
1337                .iter()
1338                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1339            "every diagnostic should be malformed-package-json"
1340        );
1341    }
1342
1343    #[test]
1344    fn malformed_root_package_json_returns_load_error() {
1345        let dir = tempfile::tempdir().expect("create temp dir");
1346        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1347
1348        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1349
1350        match result {
1351            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1352                assert!(path.ends_with("package.json"));
1353                assert!(!error.is_empty(), "underlying parse error is preserved");
1354            }
1355            Ok(_) => panic!("expected MalformedRootPackageJson"),
1356        }
1357    }
1358
1359    #[test]
1360    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1361        let dir = tempfile::tempdir().expect("create temp dir");
1362        let pkg_a = dir.path().join("packages").join("a");
1363        let cache_dir = dir.path().join("packages").join(".cache");
1364        let scratch_dir = dir.path().join("packages").join("scratch");
1365        std::fs::create_dir_all(&pkg_a).unwrap();
1366        std::fs::create_dir_all(&cache_dir).unwrap();
1367        std::fs::create_dir_all(&scratch_dir).unwrap();
1368        std::fs::write(
1369            dir.path().join("package.json"),
1370            r#"{"workspaces": ["packages/*"]}"#,
1371        )
1372        .unwrap();
1373        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1374
1375        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1376        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1377
1378        assert_eq!(workspaces.len(), 1);
1379        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1380        assert!(
1381            kinds.contains(&"glob-matched-no-package-json"),
1382            "scratch should diagnose: {kinds:?}"
1383        );
1384        assert!(
1385            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1386            ".cache must be skip-listed: {diagnostics:?}"
1387        );
1388    }
1389
1390    #[test]
1391    fn glob_match_without_package_json_honors_ignore_patterns() {
1392        let dir = tempfile::tempdir().expect("create temp dir");
1393        let pkg_a = dir.path().join("packages").join("a");
1394        let legacy_dir = dir.path().join("packages").join("legacy");
1395        std::fs::create_dir_all(&pkg_a).unwrap();
1396        std::fs::create_dir_all(&legacy_dir).unwrap();
1397        std::fs::write(
1398            dir.path().join("package.json"),
1399            r#"{"workspaces": ["packages/*"]}"#,
1400        )
1401        .unwrap();
1402        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1403
1404        let mut builder = globset::GlobSetBuilder::new();
1405        builder.add(globset::Glob::new("packages/legacy").unwrap());
1406        let ignore = builder.build().unwrap();
1407
1408        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1409        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1410
1411        assert_eq!(workspaces.len(), 1);
1412        assert!(
1413            diagnostics.is_empty(),
1414            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1415        );
1416    }
1417
1418    #[test]
1419    fn malformed_tsconfig_emits_diagnostic() {
1420        let dir = tempfile::tempdir().expect("create temp dir");
1421        std::fs::write(
1422            dir.path().join("package.json"),
1423            r#"{"workspaces": ["packages/*"]}"#,
1424        )
1425        .unwrap();
1426        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1427
1428        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1429        let (_, diagnostics) = result.expect("root package.json is valid");
1430
1431        assert!(
1432            diagnostics
1433                .iter()
1434                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1435            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1436        );
1437    }
1438
1439    #[test]
1440    fn tsconfig_missing_reference_dir_emits_diagnostic() {
1441        let dir = tempfile::tempdir().expect("create temp dir");
1442        std::fs::write(
1443            dir.path().join("tsconfig.json"),
1444            r#"{"references": [{"path": "./packages/missing"}]}"#,
1445        )
1446        .unwrap();
1447
1448        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1449        let (_, diagnostics) = result.expect("no package.json at root is OK");
1450
1451        assert!(
1452            diagnostics
1453                .iter()
1454                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1455            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1456        );
1457    }
1458
1459    #[test]
1460    fn missing_tsconfig_is_silent() {
1461        let dir = tempfile::tempdir().expect("create temp dir");
1462
1463        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1464        let (_, diagnostics) = result.expect("no root package.json is OK");
1465
1466        assert!(
1467            !diagnostics
1468                .iter()
1469                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1470            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn shallow_scan_malformed_package_json_stays_silent() {
1476        let dir = tempfile::tempdir().expect("create temp dir");
1477        let scratch = dir.path().join("scratch");
1478        std::fs::create_dir_all(&scratch).unwrap();
1479        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1480
1481        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1482        let (_, diagnostics) = result.expect("no root package.json is OK");
1483
1484        assert!(
1485            !diagnostics
1486                .iter()
1487                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1488            "shallow-scan malformed must stay silent: {diagnostics:?}"
1489        );
1490    }
1491
1492    #[test]
1493    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1494        let dir = tempfile::tempdir().expect("create temp dir");
1495        let pkg_good = dir.path().join("packages").join("good");
1496        let pkg_bad = dir.path().join("packages").join("bad");
1497        std::fs::create_dir_all(&pkg_good).unwrap();
1498        std::fs::create_dir_all(&pkg_bad).unwrap();
1499        std::fs::write(
1500            dir.path().join("package.json"),
1501            r#"{"workspaces": ["packages/*"]}"#,
1502        )
1503        .unwrap();
1504        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1505        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1506
1507        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1508        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1509
1510        assert_eq!(workspaces.len(), 1);
1511        assert_eq!(workspaces[0].name, "good");
1512        assert_eq!(diagnostics.len(), 1);
1513        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1514    }
1515
1516    #[test]
1517    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1518        let dir = tempfile::tempdir().expect("create temp dir");
1519        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1520
1521        let workspaces = discover_workspaces(dir.path());
1522        assert!(
1523            workspaces.is_empty(),
1524            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1525        );
1526    }
1527}