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() {
210            continue;
211        }
212
213        let name = entry.file_name();
214        let name_str = name.to_string_lossy();
215        if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
216            continue;
217        }
218
219        check_undeclared(
220            &path,
221            root,
222            &canonical_root,
223            &declared_roots,
224            ignore_patterns,
225            &mut undeclared,
226        );
227
228        let Ok(child_entries) = std::fs::read_dir(&path) else {
229            continue;
230        };
231        for child in child_entries.filter_map(Result::ok) {
232            let child_path = child.path();
233            if !child_path.is_dir() {
234                continue;
235            }
236            let child_name = child.file_name();
237            let child_name_str = child_name.to_string_lossy();
238            if child_name_str.starts_with('.')
239                || child_name_str == "node_modules"
240                || child_name_str == "build"
241            {
242                continue;
243            }
244            check_undeclared(
245                &child_path,
246                root,
247                &canonical_root,
248                &declared_roots,
249                ignore_patterns,
250                &mut undeclared,
251            );
252        }
253    }
254
255    undeclared
256}
257
258/// Check a single directory for an undeclared workspace.
259fn check_undeclared(
260    dir: &Path,
261    root: &Path,
262    canonical_root: &Path,
263    declared_roots: &rustc_hash::FxHashSet<PathBuf>,
264    ignore_patterns: &globset::GlobSet,
265    undeclared: &mut Vec<WorkspaceDiagnostic>,
266) {
267    if !dir.join("package.json").exists() {
268        return;
269    }
270    let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
271    if canonical == *canonical_root {
272        return;
273    }
274    if declared_roots.contains(&canonical) {
275        return;
276    }
277    let relative = dir.strip_prefix(root).unwrap_or(dir);
278    let relative_str = relative.to_string_lossy().replace('\\', "/");
279    if ignore_patterns.is_match(relative_str.as_str())
280        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
281    {
282        return;
283    }
284    undeclared.push(WorkspaceDiagnostic::new(
285        root,
286        dir.to_path_buf(),
287        WorkspaceDiagnosticKind::UndeclaredWorkspace,
288    ));
289}
290
291/// Collect glob patterns from `package.json` `workspaces` field and `pnpm-workspace.yaml`.
292fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
293    let mut patterns = Vec::new();
294
295    let pkg_path = root.join("package.json");
296    if pkg_path.exists() {
297        match PackageJson::load(&pkg_path) {
298            Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
299            Err(error) => {
300                return Err(WorkspaceLoadError::MalformedRootPackageJson {
301                    path: pkg_path,
302                    error,
303                });
304            }
305        }
306    }
307
308    let pnpm_workspace = root.join("pnpm-workspace.yaml");
309    if pnpm_workspace.exists()
310        && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
311    {
312        patterns.extend(parse_pnpm_workspace_yaml(&content));
313    }
314
315    Ok(patterns)
316}
317
318/// Expand workspace glob patterns to discover workspace directories.
319///
320/// Handles positive/negated pattern splitting, glob matching, and package.json
321/// loading for each matched directory.
322fn expand_patterns_to_workspaces(
323    root: &Path,
324    patterns: &[String],
325    canonical_root: &Path,
326    ignore_patterns: &globset::GlobSet,
327    diagnostics: &mut Vec<WorkspaceDiagnostic>,
328) -> Vec<(WorkspaceInfo, Vec<String>)> {
329    if patterns.is_empty() {
330        return Vec::new();
331    }
332
333    let mut workspaces = Vec::new();
334
335    let (positive, negative): (Vec<&String>, Vec<&String>) =
336        patterns.iter().partition(|p| !p.starts_with('!'));
337    let negation_matchers: Vec<globset::GlobMatcher> = negative
338        .iter()
339        .filter_map(|p| {
340            let stripped = p.strip_prefix('!').unwrap_or(p);
341            globset::Glob::new(stripped)
342                .ok()
343                .map(|g| g.compile_matcher())
344        })
345        .collect();
346
347    for pattern in &positive {
348        let glob_pattern = if pattern.ends_with('/') {
349            format!("{pattern}*")
350        } else {
351            (*pattern).clone()
352        };
353
354        let matched_dirs = expand_workspace_glob_with_diagnostics(
355            root,
356            pattern,
357            &glob_pattern,
358            canonical_root,
359            ignore_patterns,
360            diagnostics,
361        );
362        for (dir, canonical_dir) in matched_dirs {
363            if canonical_dir == *canonical_root {
364                continue;
365            }
366
367            let relative = dir.strip_prefix(root).unwrap_or(&dir);
368            let relative_str = relative.to_string_lossy();
369            if negation_matchers
370                .iter()
371                .any(|m| m.is_match(relative_str.as_ref()))
372            {
373                continue;
374            }
375
376            let ws_pkg_path = dir.join("package.json");
377            match PackageJson::load(&ws_pkg_path) {
378                Ok(pkg) => {
379                    let dep_names = pkg.all_dependency_names();
380                    let name = pkg.name.unwrap_or_else(|| {
381                        dir.file_name()
382                            .map(|n| n.to_string_lossy().to_string())
383                            .unwrap_or_default()
384                    });
385                    workspaces.push((
386                        WorkspaceInfo {
387                            root: dir,
388                            name,
389                            is_internal_dependency: false,
390                        },
391                        dep_names,
392                    ));
393                }
394                Err(error) => {
395                    let diag = WorkspaceDiagnostic::new(
396                        root,
397                        dir.clone(),
398                        WorkspaceDiagnosticKind::MalformedPackageJson { error },
399                    );
400                    diagnostics.push(diag);
401                }
402            }
403        }
404    }
405
406    workspaces
407}
408
409/// Discover workspaces from TypeScript project references in `tsconfig.json`.
410///
411/// Referenced directories are added as workspaces, supplementing npm/pnpm workspaces.
412/// This enables cross-workspace resolution for TypeScript composite projects.
413fn collect_tsconfig_workspaces(
414    root: &Path,
415    canonical_root: &Path,
416    ignore_patterns: &globset::GlobSet,
417    diagnostics: &mut Vec<WorkspaceDiagnostic>,
418) -> Vec<(WorkspaceInfo, Vec<String>)> {
419    let mut workspaces = Vec::new();
420
421    for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
422        let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
423        if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
424            continue;
425        }
426
427        let ws_pkg_path = dir.join("package.json");
428        let (name, dep_names) = if ws_pkg_path.exists() {
429            match PackageJson::load(&ws_pkg_path) {
430                Ok(pkg) => {
431                    let deps = pkg.all_dependency_names();
432                    let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
433                    (n, deps)
434                }
435                Err(error) => {
436                    let diag = WorkspaceDiagnostic::new(
437                        root,
438                        dir.clone(),
439                        WorkspaceDiagnosticKind::MalformedPackageJson { error },
440                    );
441                    diagnostics.push(diag);
442                    (dir_name(&dir), Vec::new())
443                }
444            }
445        } else {
446            (dir_name(&dir), Vec::new())
447        };
448
449        workspaces.push((
450            WorkspaceInfo {
451                root: dir,
452                name,
453                is_internal_dependency: false,
454            },
455            dep_names,
456        ));
457    }
458
459    workspaces
460}
461
462/// Discover shallow package workspaces when no explicit workspace config exists.
463///
464/// Scans direct children of the project root and their immediate children for
465/// `package.json` files. This catches repos that contain multiple standalone
466/// packages (for example `benchmarks/` or `editors/vscode/`) without declaring
467/// npm/pnpm workspaces at the root.
468fn collect_shallow_package_workspaces(
469    root: &Path,
470    canonical_root: &Path,
471) -> Vec<(WorkspaceInfo, Vec<String>)> {
472    let mut workspaces = Vec::new();
473    let Ok(top_entries) = std::fs::read_dir(root) else {
474        return workspaces;
475    };
476
477    for entry in top_entries.filter_map(Result::ok) {
478        let path = entry.path();
479        if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
480            continue;
481        }
482
483        collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
484
485        let Ok(child_entries) = std::fs::read_dir(&path) else {
486            continue;
487        };
488        for child in child_entries.filter_map(Result::ok) {
489            let child_path = child.path();
490            if !child_path.is_dir()
491                || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
492            {
493                continue;
494            }
495
496            collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
497        }
498    }
499
500    workspaces
501}
502
503fn collect_shallow_workspace_candidate(
504    dir: &Path,
505    canonical_root: &Path,
506    workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
507) {
508    let pkg_path = dir.join("package.json");
509    if !pkg_path.exists() {
510        return;
511    }
512
513    let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
514    if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
515        return;
516    }
517
518    let Ok(pkg) = PackageJson::load(&pkg_path) else {
519        return;
520    };
521    let dep_names = pkg.all_dependency_names();
522    let name = pkg.name.unwrap_or_else(|| dir_name(dir));
523
524    workspaces.push((
525        WorkspaceInfo {
526            root: dir.to_path_buf(),
527            name,
528            is_internal_dependency: false,
529        },
530        dep_names,
531    ));
532}
533
534fn should_skip_workspace_scan_dir(name: &str) -> bool {
535    is_skip_listed_dir(name)
536}
537
538/// Deduplicate workspaces by canonical path and mark internal dependencies.
539///
540/// Overlapping sources (npm workspaces + tsconfig references pointing to the same
541/// directory) are collapsed. npm-discovered entries take precedence (they appear first).
542/// Workspaces depended on by other workspaces are marked as `is_internal_dependency`.
543fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
544    {
545        let mut seen = rustc_hash::FxHashSet::default();
546        workspaces.retain(|(ws, _)| {
547            let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
548            seen.insert(canonical)
549        });
550    }
551
552    let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
553        .iter()
554        .flat_map(|(_, deps)| deps.iter().cloned())
555        .collect();
556    for (ws, _) in &mut *workspaces {
557        ws.is_internal_dependency = all_dep_names.contains(&ws.name);
558    }
559}
560
561/// Extract the directory name as a string, for workspace name fallback.
562fn dir_name(dir: &Path) -> String {
563    dir.file_name()
564        .map(|n| n.to_string_lossy().to_string())
565        .unwrap_or_default()
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn discover_workspaces_from_tsconfig_references() {
574        let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
575        let _ = std::fs::remove_dir_all(&temp_dir);
576        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
577        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
578
579        std::fs::write(
580            temp_dir.join("tsconfig.json"),
581            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
582        )
583        .unwrap();
584
585        std::fs::write(
586            temp_dir.join("packages/core/package.json"),
587            r#"{"name": "@project/core"}"#,
588        )
589        .unwrap();
590
591        let workspaces = discover_workspaces(&temp_dir);
592        assert_eq!(workspaces.len(), 2);
593        assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
594        assert!(workspaces.iter().any(|ws| ws.name == "ui"));
595
596        let _ = std::fs::remove_dir_all(&temp_dir);
597    }
598
599    #[test]
600    fn tsconfig_references_outside_root_rejected() {
601        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
602        let _ = std::fs::remove_dir_all(&temp_dir);
603        std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
604        std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
605
606        std::fs::write(
607            temp_dir.join("project/tsconfig.json"),
608            r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
609        )
610        .unwrap();
611
612        let workspaces = discover_workspaces(&temp_dir.join("project"));
613        assert_eq!(
614            workspaces.len(),
615            1,
616            "reference outside project root should be rejected: {workspaces:?}"
617        );
618        assert!(
619            workspaces[0]
620                .root
621                .to_string_lossy()
622                .contains("packages/core")
623        );
624
625        let _ = std::fs::remove_dir_all(&temp_dir);
626    }
627
628    #[test]
629    fn dir_name_extracts_last_component() {
630        assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
631        assert_eq!(dir_name(Path::new("/my-app")), "my-app");
632    }
633
634    #[test]
635    fn dir_name_empty_for_root_path() {
636        assert_eq!(dir_name(Path::new("/")), "");
637    }
638
639    #[test]
640    fn workspace_config_deserialize_json() {
641        let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
642        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
643        assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
644    }
645
646    #[test]
647    fn workspace_config_deserialize_empty_patterns() {
648        let json = r#"{"patterns": []}"#;
649        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
650        assert!(config.patterns.is_empty());
651    }
652
653    #[test]
654    fn workspace_config_default_patterns() {
655        let json = "{}";
656        let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
657        assert!(config.patterns.is_empty());
658    }
659
660    #[test]
661    fn workspace_info_default_not_internal() {
662        let ws = WorkspaceInfo {
663            root: PathBuf::from("/project/packages/a"),
664            name: "a".to_string(),
665            is_internal_dependency: false,
666        };
667        assert!(!ws.is_internal_dependency);
668    }
669
670    #[test]
671    fn mark_internal_deps_detects_cross_references() {
672        let temp_dir = tempfile::tempdir().expect("create temp dir");
673        let pkg_a = temp_dir.path().join("a");
674        let pkg_b = temp_dir.path().join("b");
675        std::fs::create_dir_all(&pkg_a).unwrap();
676        std::fs::create_dir_all(&pkg_b).unwrap();
677
678        let mut workspaces = vec![
679            (
680                WorkspaceInfo {
681                    root: pkg_a,
682                    name: "@scope/a".to_string(),
683                    is_internal_dependency: false,
684                },
685                vec!["@scope/b".to_string()], // "a" depends on "b"
686            ),
687            (
688                WorkspaceInfo {
689                    root: pkg_b,
690                    name: "@scope/b".to_string(),
691                    is_internal_dependency: false,
692                },
693                vec!["lodash".to_string()], // "b" depends on external only
694            ),
695        ];
696
697        mark_internal_dependencies(&mut workspaces);
698
699        let ws_a = workspaces
700            .iter()
701            .find(|(ws, _)| ws.name == "@scope/a")
702            .unwrap();
703        assert!(
704            !ws_a.0.is_internal_dependency,
705            "a is not depended on by others"
706        );
707
708        let ws_b = workspaces
709            .iter()
710            .find(|(ws, _)| ws.name == "@scope/b")
711            .unwrap();
712        assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
713    }
714
715    #[test]
716    fn mark_internal_deps_no_cross_references() {
717        let temp_dir = tempfile::tempdir().expect("create temp dir");
718        let pkg_a = temp_dir.path().join("a");
719        let pkg_b = temp_dir.path().join("b");
720        std::fs::create_dir_all(&pkg_a).unwrap();
721        std::fs::create_dir_all(&pkg_b).unwrap();
722
723        let mut workspaces = vec![
724            (
725                WorkspaceInfo {
726                    root: pkg_a,
727                    name: "a".to_string(),
728                    is_internal_dependency: false,
729                },
730                vec!["react".to_string()],
731            ),
732            (
733                WorkspaceInfo {
734                    root: pkg_b,
735                    name: "b".to_string(),
736                    is_internal_dependency: false,
737                },
738                vec!["lodash".to_string()],
739            ),
740        ];
741
742        mark_internal_dependencies(&mut workspaces);
743
744        assert!(!workspaces[0].0.is_internal_dependency);
745        assert!(!workspaces[1].0.is_internal_dependency);
746    }
747
748    #[test]
749    fn mark_internal_deps_deduplicates_by_path() {
750        let temp_dir = tempfile::tempdir().expect("create temp dir");
751        let pkg_a = temp_dir.path().join("a");
752        std::fs::create_dir_all(&pkg_a).unwrap();
753
754        let mut workspaces = vec![
755            (
756                WorkspaceInfo {
757                    root: pkg_a.clone(),
758                    name: "a".to_string(),
759                    is_internal_dependency: false,
760                },
761                vec![],
762            ),
763            (
764                WorkspaceInfo {
765                    root: pkg_a,
766                    name: "a".to_string(),
767                    is_internal_dependency: false,
768                },
769                vec![],
770            ),
771        ];
772
773        mark_internal_dependencies(&mut workspaces);
774        assert_eq!(
775            workspaces.len(),
776            1,
777            "duplicate paths should be deduplicated"
778        );
779    }
780
781    #[test]
782    fn collect_patterns_from_package_json() {
783        let dir = tempfile::tempdir().expect("create temp dir");
784        std::fs::write(
785            dir.path().join("package.json"),
786            r#"{"workspaces": ["packages/*", "apps/*"]}"#,
787        )
788        .unwrap();
789
790        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
791        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
792    }
793
794    #[test]
795    fn collect_patterns_from_pnpm_workspace() {
796        let dir = tempfile::tempdir().expect("create temp dir");
797        std::fs::write(
798            dir.path().join("pnpm-workspace.yaml"),
799            "packages:\n  - 'packages/*'\n  - 'libs/*'\n",
800        )
801        .unwrap();
802
803        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
804        assert_eq!(patterns, vec!["packages/*", "libs/*"]);
805    }
806
807    #[test]
808    fn collect_patterns_combines_sources() {
809        let dir = tempfile::tempdir().expect("create temp dir");
810        std::fs::write(
811            dir.path().join("package.json"),
812            r#"{"workspaces": ["packages/*"]}"#,
813        )
814        .unwrap();
815        std::fs::write(
816            dir.path().join("pnpm-workspace.yaml"),
817            "packages:\n  - 'apps/*'\n",
818        )
819        .unwrap();
820
821        let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
822        assert!(patterns.contains(&"packages/*".to_string()));
823        assert!(patterns.contains(&"apps/*".to_string()));
824    }
825
826    #[test]
827    fn collect_patterns_empty_when_no_configs() {
828        let dir = tempfile::tempdir().expect("create temp dir");
829        let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
830        assert!(patterns.is_empty());
831    }
832
833    #[test]
834    fn discover_workspaces_from_package_json() {
835        let dir = tempfile::tempdir().expect("create temp dir");
836        let pkg_a = dir.path().join("packages").join("a");
837        let pkg_b = dir.path().join("packages").join("b");
838        std::fs::create_dir_all(&pkg_a).unwrap();
839        std::fs::create_dir_all(&pkg_b).unwrap();
840
841        std::fs::write(
842            dir.path().join("package.json"),
843            r#"{"workspaces": ["packages/*"]}"#,
844        )
845        .unwrap();
846        std::fs::write(
847            pkg_a.join("package.json"),
848            r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
849        )
850        .unwrap();
851        std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
852
853        let workspaces = discover_workspaces(dir.path());
854        assert_eq!(workspaces.len(), 2);
855
856        let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
857        assert!(!ws_a.is_internal_dependency);
858
859        let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
860        assert!(ws_b.is_internal_dependency, "b is depended on by a");
861    }
862
863    #[test]
864    fn discover_workspaces_empty_project() {
865        let dir = tempfile::tempdir().expect("create temp dir");
866        let workspaces = discover_workspaces(dir.path());
867        assert!(workspaces.is_empty());
868    }
869
870    #[test]
871    fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
872        let dir = tempfile::tempdir().expect("create temp dir");
873        let benchmarks = dir.path().join("benchmarks");
874        let vscode = dir.path().join("editors").join("vscode");
875        let deep = dir.path().join("tests").join("fixtures").join("demo");
876        std::fs::create_dir_all(&benchmarks).unwrap();
877        std::fs::create_dir_all(&vscode).unwrap();
878        std::fs::create_dir_all(&deep).unwrap();
879
880        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
881        std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
882        std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
883
884        let workspaces = discover_workspaces(dir.path());
885        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
886
887        assert!(
888            names.contains(&"benchmarks"),
889            "top-level nested package should be discovered: {workspaces:?}"
890        );
891        assert!(
892            names.contains(&"fallow-vscode"),
893            "second-level nested package should be discovered: {workspaces:?}"
894        );
895        assert!(
896            !names.contains(&"deep-fixture"),
897            "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
898        );
899    }
900
901    #[test]
902    fn discover_workspaces_with_negated_patterns() {
903        let dir = tempfile::tempdir().expect("create temp dir");
904        let pkg_a = dir.path().join("packages").join("a");
905        let pkg_test = dir.path().join("packages").join("test-utils");
906        std::fs::create_dir_all(&pkg_a).unwrap();
907        std::fs::create_dir_all(&pkg_test).unwrap();
908
909        std::fs::write(
910            dir.path().join("package.json"),
911            r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
912        )
913        .unwrap();
914        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
915        std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
916
917        let workspaces = discover_workspaces(dir.path());
918        assert_eq!(workspaces.len(), 1);
919        assert_eq!(workspaces[0].name, "a");
920    }
921
922    #[test]
923    fn discover_workspaces_skips_root_as_workspace() {
924        let dir = tempfile::tempdir().expect("create temp dir");
925        std::fs::write(
926            dir.path().join("pnpm-workspace.yaml"),
927            "packages:\n  - '.'\n",
928        )
929        .unwrap();
930        std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
931
932        let workspaces = discover_workspaces(dir.path());
933        assert!(
934            workspaces.is_empty(),
935            "root directory should not be added as workspace"
936        );
937    }
938
939    #[test]
940    fn discover_workspaces_name_fallback_to_dir_name() {
941        let dir = tempfile::tempdir().expect("create temp dir");
942        let pkg_a = dir.path().join("packages").join("my-app");
943        std::fs::create_dir_all(&pkg_a).unwrap();
944
945        std::fs::write(
946            dir.path().join("package.json"),
947            r#"{"workspaces": ["packages/*"]}"#,
948        )
949        .unwrap();
950        std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
951
952        let workspaces = discover_workspaces(dir.path());
953        assert_eq!(workspaces.len(), 1);
954        assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
955    }
956
957    #[test]
958    fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
959        let dir = tempfile::tempdir().expect("create temp dir");
960        let pkg_a = dir.path().join("packages").join("a");
961        let benchmarks = dir.path().join("benchmarks");
962        std::fs::create_dir_all(&pkg_a).unwrap();
963        std::fs::create_dir_all(&benchmarks).unwrap();
964
965        std::fs::write(
966            dir.path().join("package.json"),
967            r#"{"workspaces": ["packages/*"]}"#,
968        )
969        .unwrap();
970        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
971        std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
972
973        let workspaces = discover_workspaces(dir.path());
974        let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
975
976        assert_eq!(workspaces.len(), 1);
977        assert!(names.contains(&"a"));
978        assert!(
979            !names.contains(&"benchmarks"),
980            "explicit workspace config should keep undeclared packages out: {workspaces:?}"
981        );
982    }
983
984    #[test]
985    fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
986        // Issue #842 (reporter metrists/metrists): root declares
987        // `["./packages/*", "./themes/*"]`, but the real package lives two levels
988        // deep at `packages/themes/metrists-theme-next` while `packages/themes`
989        // itself has no package.json. The single-level glob only matches the bare
990        // `packages/themes`; without recovery the deep package is never discovered,
991        // its files fall back to the root manifest, and its declared deps (react)
992        // are reported as unlisted. Discovery must recover the named deep package.
993        let dir = tempfile::tempdir().expect("create temp dir");
994        let theme = dir
995            .path()
996            .join("packages")
997            .join("themes")
998            .join("metrists-theme-next");
999        std::fs::create_dir_all(&theme).unwrap();
1000        std::fs::write(
1001            dir.path().join("package.json"),
1002            r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1003        )
1004        .unwrap();
1005        // packages/themes intentionally has NO package.json (bare grouping dir).
1006        std::fs::write(
1007            theme.join("package.json"),
1008            r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1009        )
1010        .unwrap();
1011
1012        let workspaces = discover_workspaces(dir.path());
1013        assert!(
1014            workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1015            "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1016        );
1017    }
1018
1019    #[test]
1020    fn undeclared_workspace_detected() {
1021        let dir = tempfile::tempdir().expect("create temp dir");
1022        let pkg_a = dir.path().join("packages").join("a");
1023        let pkg_b = dir.path().join("packages").join("b");
1024        std::fs::create_dir_all(&pkg_a).unwrap();
1025        std::fs::create_dir_all(&pkg_b).unwrap();
1026
1027        std::fs::write(
1028            dir.path().join("package.json"),
1029            r#"{"workspaces": ["packages/a"]}"#,
1030        )
1031        .unwrap();
1032        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1033        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1034
1035        let declared = discover_workspaces(dir.path());
1036        assert_eq!(declared.len(), 1);
1037
1038        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1039        assert_eq!(undeclared.len(), 1);
1040        assert!(
1041            undeclared[0]
1042                .path
1043                .to_string_lossy()
1044                .replace('\\', "/")
1045                .contains("packages/b"),
1046            "should detect packages/b as undeclared: {:?}",
1047            undeclared[0].path
1048        );
1049    }
1050
1051    #[test]
1052    fn no_undeclared_when_all_covered() {
1053        let dir = tempfile::tempdir().expect("create temp dir");
1054        let pkg_a = dir.path().join("packages").join("a");
1055        std::fs::create_dir_all(&pkg_a).unwrap();
1056
1057        std::fs::write(
1058            dir.path().join("package.json"),
1059            r#"{"workspaces": ["packages/*"]}"#,
1060        )
1061        .unwrap();
1062        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1063
1064        let declared = discover_workspaces(dir.path());
1065        let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1066        assert!(undeclared.is_empty());
1067    }
1068
1069    #[test]
1070    fn no_undeclared_when_no_workspace_patterns() {
1071        let dir = tempfile::tempdir().expect("create temp dir");
1072        let sub = dir.path().join("lib");
1073        std::fs::create_dir_all(&sub).unwrap();
1074
1075        std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1076        std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1077
1078        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1079        assert!(
1080            undeclared.is_empty(),
1081            "should skip check when no workspace patterns exist"
1082        );
1083    }
1084
1085    #[test]
1086    fn undeclared_skips_node_modules_and_hidden_dirs() {
1087        let dir = tempfile::tempdir().expect("create temp dir");
1088        let nm = dir.path().join("node_modules").join("some-pkg");
1089        let hidden = dir.path().join(".hidden");
1090        std::fs::create_dir_all(&nm).unwrap();
1091        std::fs::create_dir_all(&hidden).unwrap();
1092
1093        std::fs::write(
1094            dir.path().join("package.json"),
1095            r#"{"workspaces": ["packages/*"]}"#,
1096        )
1097        .unwrap();
1098        std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1099        std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1100
1101        let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1102        assert!(
1103            undeclared.is_empty(),
1104            "should not flag node_modules or hidden directories"
1105        );
1106    }
1107
1108    fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1109        let mut builder = globset::GlobSetBuilder::new();
1110        for pattern in patterns {
1111            builder.add(globset::Glob::new(pattern).expect("valid glob"));
1112        }
1113        builder.build().expect("build globset")
1114    }
1115
1116    #[test]
1117    fn undeclared_skips_dirs_matching_ignore_patterns() {
1118        let dir = tempfile::tempdir().expect("create temp dir");
1119        let pkg_a = dir.path().join("packages").join("a");
1120        let vitest_ref = dir.path().join("references").join("vitest");
1121        let tanstack_ref = dir.path().join("references").join("tanstack-router");
1122        std::fs::create_dir_all(&pkg_a).unwrap();
1123        std::fs::create_dir_all(&vitest_ref).unwrap();
1124        std::fs::create_dir_all(&tanstack_ref).unwrap();
1125
1126        std::fs::write(
1127            dir.path().join("package.json"),
1128            r#"{"workspaces": ["packages/*"]}"#,
1129        )
1130        .unwrap();
1131        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1132        std::fs::write(
1133            vitest_ref.join("package.json"),
1134            r#"{"name": "vitest-reference"}"#,
1135        )
1136        .unwrap();
1137        std::fs::write(
1138            tanstack_ref.join("package.json"),
1139            r#"{"name": "tanstack-reference"}"#,
1140        )
1141        .unwrap();
1142
1143        let declared = discover_workspaces(dir.path());
1144        let ignore = build_globset(&["references/*"]);
1145        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1146        assert!(
1147            undeclared.is_empty(),
1148            "references/* should be ignored: {undeclared:?}"
1149        );
1150    }
1151
1152    #[test]
1153    fn undeclared_still_reported_when_ignore_does_not_match() {
1154        let dir = tempfile::tempdir().expect("create temp dir");
1155        let pkg_b = dir.path().join("packages").join("b");
1156        std::fs::create_dir_all(&pkg_b).unwrap();
1157
1158        std::fs::write(
1159            dir.path().join("package.json"),
1160            r#"{"workspaces": ["packages/a"]}"#,
1161        )
1162        .unwrap();
1163        std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1164
1165        let declared = discover_workspaces(dir.path());
1166        let ignore = build_globset(&["references/*"]);
1167        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1168        assert_eq!(
1169            undeclared.len(),
1170            1,
1171            "non-matching ignore patterns should not silence other undeclared dirs"
1172        );
1173    }
1174
1175    #[test]
1176    fn undeclared_skips_dirs_matching_package_json_glob() {
1177        let dir = tempfile::tempdir().expect("create temp dir");
1178        let pkg_a = dir.path().join("packages").join("a");
1179        let vitest_ref = dir.path().join("references").join("vitest");
1180        std::fs::create_dir_all(&pkg_a).unwrap();
1181        std::fs::create_dir_all(&vitest_ref).unwrap();
1182
1183        std::fs::write(
1184            dir.path().join("package.json"),
1185            r#"{"workspaces": ["packages/*"]}"#,
1186        )
1187        .unwrap();
1188        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1189        std::fs::write(
1190            vitest_ref.join("package.json"),
1191            r#"{"name": "vitest-reference"}"#,
1192        )
1193        .unwrap();
1194
1195        let declared = discover_workspaces(dir.path());
1196        let ignore = build_globset(&["references/*/package.json"]);
1197        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1198        assert!(
1199            undeclared.is_empty(),
1200            "package.json-suffixed glob should silence the warning: {undeclared:?}"
1201        );
1202    }
1203
1204    #[test]
1205    fn undeclared_skips_dirs_matching_doublestar_ignore() {
1206        let dir = tempfile::tempdir().expect("create temp dir");
1207        let pkg_a = dir.path().join("packages").join("a");
1208        let nested_ref = dir.path().join("references").join("vitest");
1209        std::fs::create_dir_all(&pkg_a).unwrap();
1210        std::fs::create_dir_all(&nested_ref).unwrap();
1211
1212        std::fs::write(
1213            dir.path().join("package.json"),
1214            r#"{"workspaces": ["packages/*"]}"#,
1215        )
1216        .unwrap();
1217        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1218        std::fs::write(
1219            nested_ref.join("package.json"),
1220            r#"{"name": "vitest-reference"}"#,
1221        )
1222        .unwrap();
1223
1224        let declared = discover_workspaces(dir.path());
1225        let ignore = build_globset(&["**/references/**"]);
1226        let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1227        assert!(
1228            undeclared.is_empty(),
1229            "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1230        );
1231    }
1232
1233    #[test]
1234    fn malformed_workspace_package_json_emits_diagnostic() {
1235        let dir = tempfile::tempdir().expect("create temp dir");
1236        let pkg_a = dir.path().join("packages").join("a");
1237        let pkg_bad = dir.path().join("packages").join("bad");
1238        std::fs::create_dir_all(&pkg_a).unwrap();
1239        std::fs::create_dir_all(&pkg_bad).unwrap();
1240        std::fs::write(
1241            dir.path().join("package.json"),
1242            r#"{"workspaces": ["packages/*"]}"#,
1243        )
1244        .unwrap();
1245        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1246        std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1247
1248        let (result, captured) = capture_workspace_warnings(|| {
1249            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1250        });
1251        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1252
1253        assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1254        assert_eq!(workspaces[0].name, "a");
1255        assert_eq!(diagnostics.len(), 1);
1256        assert!(matches!(
1257            diagnostics[0].kind,
1258            WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1259        ));
1260        assert!(
1261            captured
1262                .iter()
1263                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1264        );
1265    }
1266
1267    #[test]
1268    fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1269        let dir = tempfile::tempdir().expect("create temp dir");
1270        for name in ["a", "b", "c"] {
1271            let pkg = dir.path().join("packages").join(name);
1272            std::fs::create_dir_all(&pkg).unwrap();
1273            std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1274        }
1275        std::fs::write(
1276            dir.path().join("package.json"),
1277            r#"{"workspaces": ["packages/*"]}"#,
1278        )
1279        .unwrap();
1280
1281        let (result, _) = capture_workspace_warnings(|| {
1282            discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1283        });
1284        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1285
1286        assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1287        assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1288        assert!(
1289            diagnostics
1290                .iter()
1291                .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1292            "every diagnostic should be malformed-package-json"
1293        );
1294    }
1295
1296    #[test]
1297    fn malformed_root_package_json_returns_load_error() {
1298        let dir = tempfile::tempdir().expect("create temp dir");
1299        std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1300
1301        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1302
1303        match result {
1304            Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1305                assert!(path.ends_with("package.json"));
1306                assert!(!error.is_empty(), "underlying parse error is preserved");
1307            }
1308            Ok(_) => panic!("expected MalformedRootPackageJson"),
1309        }
1310    }
1311
1312    #[test]
1313    fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1314        let dir = tempfile::tempdir().expect("create temp dir");
1315        let pkg_a = dir.path().join("packages").join("a");
1316        let cache_dir = dir.path().join("packages").join(".cache");
1317        let scratch_dir = dir.path().join("packages").join("scratch");
1318        std::fs::create_dir_all(&pkg_a).unwrap();
1319        std::fs::create_dir_all(&cache_dir).unwrap();
1320        std::fs::create_dir_all(&scratch_dir).unwrap();
1321        std::fs::write(
1322            dir.path().join("package.json"),
1323            r#"{"workspaces": ["packages/*"]}"#,
1324        )
1325        .unwrap();
1326        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1327
1328        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1329        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1330
1331        assert_eq!(workspaces.len(), 1);
1332        let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1333        assert!(
1334            kinds.contains(&"glob-matched-no-package-json"),
1335            "scratch should diagnose: {kinds:?}"
1336        );
1337        assert!(
1338            !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1339            ".cache must be skip-listed: {diagnostics:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn glob_match_without_package_json_honors_ignore_patterns() {
1345        let dir = tempfile::tempdir().expect("create temp dir");
1346        let pkg_a = dir.path().join("packages").join("a");
1347        let legacy_dir = dir.path().join("packages").join("legacy");
1348        std::fs::create_dir_all(&pkg_a).unwrap();
1349        std::fs::create_dir_all(&legacy_dir).unwrap();
1350        std::fs::write(
1351            dir.path().join("package.json"),
1352            r#"{"workspaces": ["packages/*"]}"#,
1353        )
1354        .unwrap();
1355        std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1356
1357        let mut builder = globset::GlobSetBuilder::new();
1358        builder.add(globset::Glob::new("packages/legacy").unwrap());
1359        let ignore = builder.build().unwrap();
1360
1361        let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1362        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1363
1364        assert_eq!(workspaces.len(), 1);
1365        assert!(
1366            diagnostics.is_empty(),
1367            "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1368        );
1369    }
1370
1371    #[test]
1372    fn malformed_tsconfig_emits_diagnostic() {
1373        let dir = tempfile::tempdir().expect("create temp dir");
1374        std::fs::write(
1375            dir.path().join("package.json"),
1376            r#"{"workspaces": ["packages/*"]}"#,
1377        )
1378        .unwrap();
1379        std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1380
1381        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1382        let (_, diagnostics) = result.expect("root package.json is valid");
1383
1384        assert!(
1385            diagnostics
1386                .iter()
1387                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1388            "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1389        );
1390    }
1391
1392    #[test]
1393    fn tsconfig_missing_reference_dir_emits_diagnostic() {
1394        let dir = tempfile::tempdir().expect("create temp dir");
1395        std::fs::write(
1396            dir.path().join("tsconfig.json"),
1397            r#"{"references": [{"path": "./packages/missing"}]}"#,
1398        )
1399        .unwrap();
1400
1401        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1402        let (_, diagnostics) = result.expect("no package.json at root is OK");
1403
1404        assert!(
1405            diagnostics
1406                .iter()
1407                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1408            "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1409        );
1410    }
1411
1412    #[test]
1413    fn missing_tsconfig_is_silent() {
1414        let dir = tempfile::tempdir().expect("create temp dir");
1415
1416        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1417        let (_, diagnostics) = result.expect("no root package.json is OK");
1418
1419        assert!(
1420            !diagnostics
1421                .iter()
1422                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1423            "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1424        );
1425    }
1426
1427    #[test]
1428    fn shallow_scan_malformed_package_json_stays_silent() {
1429        let dir = tempfile::tempdir().expect("create temp dir");
1430        let scratch = dir.path().join("scratch");
1431        std::fs::create_dir_all(&scratch).unwrap();
1432        std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1433
1434        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1435        let (_, diagnostics) = result.expect("no root package.json is OK");
1436
1437        assert!(
1438            !diagnostics
1439                .iter()
1440                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1441            "shallow-scan malformed must stay silent: {diagnostics:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1447        let dir = tempfile::tempdir().expect("create temp dir");
1448        let pkg_good = dir.path().join("packages").join("good");
1449        let pkg_bad = dir.path().join("packages").join("bad");
1450        std::fs::create_dir_all(&pkg_good).unwrap();
1451        std::fs::create_dir_all(&pkg_bad).unwrap();
1452        std::fs::write(
1453            dir.path().join("package.json"),
1454            r#"{"workspaces": ["packages/*"]}"#,
1455        )
1456        .unwrap();
1457        std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1458        std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1459
1460        let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1461        let (workspaces, diagnostics) = result.expect("root package.json is valid");
1462
1463        assert_eq!(workspaces.len(), 1);
1464        assert_eq!(workspaces[0].name, "good");
1465        assert_eq!(diagnostics.len(), 1);
1466        assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1467    }
1468
1469    #[test]
1470    fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1471        let dir = tempfile::tempdir().expect("create temp dir");
1472        std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1473
1474        let workspaces = discover_workspaces(dir.path());
1475        assert!(
1476            workspaces.is_empty(),
1477            "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1478        );
1479    }
1480}