Skip to main content

fallow_config/workspace/
mod.rs

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