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