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