Skip to main content

fallow_config/workspace/
parsers.rs

1use std::path::{Path, PathBuf};
2
3use super::PackageJson;
4use super::diagnostics::{
5    WorkspaceDiagnostic, WorkspaceDiagnosticKind, is_ignored_workspace_dir, is_skip_listed_dir,
6};
7
8/// Parse `tsconfig.json` at the project root and extract workspace-candidate
9/// `references[].path` directories.
10///
11/// Per the TypeScript Project References spec, `path` may point at either a
12/// directory containing `tsconfig.json` OR a config file directly. Workspace
13/// discovery only cares about directory references because file references
14/// cannot host a `package.json`. File references are already followed for
15/// entry-point and alias extraction by the TypeScript plugin in
16/// `core::plugins::typescript::parse_tsconfig_references`; here they are
17/// skipped silently to keep the two reference-resolution sites consistent.
18///
19/// Returns directories that exist on disk. tsconfig.json is JSONC (comments + trailing commas).
20///
21/// Test-only wrapper around [`parse_tsconfig_references_with_diagnostics`] that drops
22/// any emitted diagnostics. Production callers use the diagnostics-aware variant.
23#[cfg(test)]
24pub(super) fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
25    let mut diagnostics = Vec::new();
26    parse_tsconfig_references_with_diagnostics(root, &globset::GlobSet::empty(), &mut diagnostics)
27}
28
29/// Parse `tsconfig.json` at the project root and extract workspace-candidate
30/// `references[].path` directories, surfacing parse errors and unresolved
31/// references as workspace diagnostics.
32///
33/// Severity policy (mirrors what tsc itself does):
34/// - `tsconfig.json` missing: silent (many JS-only projects have none).
35/// - `tsconfig.json` exists but fails to parse as JSONC: emit
36///   [`WorkspaceDiagnosticKind::MalformedTsconfig`].
37/// - `references[].path` points to an existing **file**: silent. The
38///   TypeScript Project References spec allows `path` to target a config
39///   file directly; the TypeScript plugin already follows these to extract
40///   entry points and path aliases, so workspace discovery skips them
41///   rather than misreporting them as missing directories.
42/// - `references[].path` points to a path that exists as neither a directory
43///   nor a file: emit
44///   [`WorkspaceDiagnosticKind::TsconfigReferenceDirMissing`], filtered through
45///   `ignore_patterns` so user-excluded paths stay quiet.
46pub(super) fn parse_tsconfig_references_with_diagnostics(
47    root: &Path,
48    ignore_patterns: &globset::GlobSet,
49    diagnostics: &mut Vec<WorkspaceDiagnostic>,
50) -> Vec<PathBuf> {
51    let tsconfig_path = root.join("tsconfig.json");
52    let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
53        return Vec::new();
54    };
55
56    let content = content.trim_start_matches('\u{FEFF}');
57
58    let value: serde_json::Value = match crate::jsonc::parse_to_value(content) {
59        Ok(v) => v,
60        Err(error) => {
61            let diag = WorkspaceDiagnostic::new(
62                root,
63                tsconfig_path,
64                WorkspaceDiagnosticKind::MalformedTsconfig {
65                    error: error.to_string(),
66                },
67            );
68            diagnostics.push(diag);
69            return Vec::new();
70        }
71    };
72
73    let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
74        return Vec::new();
75    };
76
77    let mut results = Vec::new();
78    for r in refs {
79        let Some(raw_path) = r.get("path").and_then(|p| p.as_str()) else {
80            continue;
81        };
82        let cleaned = raw_path.strip_prefix("./").unwrap_or(raw_path);
83        let candidate = root.join(cleaned);
84        if candidate.is_dir() {
85            results.push(candidate);
86            continue;
87        }
88
89        if candidate.is_file() {
90            continue;
91        }
92
93        let relative = candidate
94            .strip_prefix(root)
95            .unwrap_or(candidate.as_path())
96            .to_path_buf();
97        if is_ignored_workspace_dir(&relative, ignore_patterns) {
98            continue;
99        }
100
101        let diag = WorkspaceDiagnostic::new(
102            root,
103            candidate,
104            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
105        );
106        diagnostics.push(diag);
107    }
108    results
109}
110
111/// Parse `tsconfig.json` at the project root and extract `compilerOptions.rootDir`.
112///
113/// Returns `None` if the file is missing, malformed, or has no `rootDir` set.
114pub fn parse_tsconfig_root_dir(root: &Path) -> Option<String> {
115    let tsconfig_path = root.join("tsconfig.json");
116    let content = std::fs::read_to_string(&tsconfig_path).ok()?;
117    let content = content.trim_start_matches('\u{FEFF}');
118
119    let value: serde_json::Value = crate::jsonc::parse_to_value(content).ok()?;
120
121    value
122        .get("compilerOptions")
123        .and_then(|opts| opts.get("rootDir"))
124        .and_then(|v| v.as_str())
125        .map(|s| {
126            s.strip_prefix("./")
127                .unwrap_or(s)
128                .trim_end_matches('/')
129                .to_owned()
130        })
131}
132
133/// Strip trailing commas before `]` and `}` in JSON-like content.
134///
135/// tsconfig.json commonly uses trailing commas which are valid JSONC but not valid JSON.
136/// This strips them so `serde_json` can parse the content.
137#[cfg(test)]
138pub(super) fn strip_trailing_commas(input: &str) -> String {
139    let bytes = input.as_bytes();
140    let len = bytes.len();
141    let mut result = Vec::with_capacity(len);
142    let mut in_string = false;
143    let mut i = 0;
144
145    while i < len {
146        let b = bytes[i];
147
148        if in_string {
149            result.push(b);
150            if b == b'\\' && i + 1 < len {
151                i += 1;
152                result.push(bytes[i]);
153            } else if b == b'"' {
154                in_string = false;
155            }
156            i += 1;
157            continue;
158        }
159
160        if b == b'"' {
161            in_string = true;
162            result.push(b);
163            i += 1;
164            continue;
165        }
166
167        if b == b',' {
168            let mut j = i + 1;
169            while j < len && bytes[j].is_ascii_whitespace() {
170                j += 1;
171            }
172            if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
173                i += 1;
174                continue;
175            }
176        }
177
178        result.push(b);
179        i += 1;
180    }
181
182    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
183}
184
185/// Expand a workspace glob pattern to matching directories.
186///
187/// Returns `(original_path, canonical_path)` tuples so callers can skip redundant
188/// `canonicalize()` calls. Only directories containing a `package.json` are
189/// canonicalized; this avoids expensive syscalls on the many non-workspace
190/// directories that globs like `packages/*` or `**` can match.
191///
192/// `canonical_root` is pre-computed to avoid repeated `canonicalize()` syscalls.
193///
194/// Test-only wrapper around [`expand_workspace_glob_with_diagnostics`] that
195/// drops any glob-matched-no-package.json diagnostics. Production callers use
196/// the diagnostics-aware variant.
197#[cfg(test)]
198pub(super) fn expand_workspace_glob(
199    root: &Path,
200    pattern: &str,
201    canonical_root: &Path,
202) -> Vec<(PathBuf, PathBuf)> {
203    let mut diagnostics = Vec::new();
204    expand_workspace_glob_with_diagnostics(
205        root,
206        pattern,
207        pattern,
208        canonical_root,
209        &globset::GlobSet::empty(),
210        &mut diagnostics,
211    )
212}
213
214/// Diagnostics-aware variant of `expand_workspace_glob` (the test-only
215/// back-compat wrapper above).
216///
217/// Emits [`WorkspaceDiagnosticKind::GlobMatchedNoPackageJson`] when a glob match
218/// resolves to a directory that contains no `package.json`, with two filters
219/// applied first:
220/// 1. The directory's leaf name is checked against [`is_skip_listed_dir`]
221///    (build artifacts, tooling caches, hidden directories). pnpm/npm/yarn
222///    silently filter the same set; fallow follows suit.
223/// 2. The project-root-relative path is checked against `ignore_patterns`.
224///    User-excluded paths produce no diagnostic.
225///
226/// `raw_pattern` is the user-supplied glob (e.g. `packages/*`) and goes into the
227/// diagnostic's message; `expanded_pattern` is the normalized glob string used
228/// for matching (e.g. `packages/*` after trailing-slash expansion).
229pub(super) fn expand_workspace_glob_with_diagnostics(
230    root: &Path,
231    raw_pattern: &str,
232    expanded_pattern: &str,
233    canonical_root: &Path,
234    ignore_patterns: &globset::GlobSet,
235    diagnostics: &mut Vec<WorkspaceDiagnostic>,
236) -> Vec<(PathBuf, PathBuf)> {
237    if expanded_pattern.contains("**") {
238        return expand_recursive_workspace_pattern(
239            root,
240            raw_pattern,
241            expanded_pattern,
242            canonical_root,
243            ignore_patterns,
244            diagnostics,
245        );
246    }
247
248    let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
249    match glob::glob(&full_pattern) {
250        Ok(paths) => {
251            let mut results = Vec::new();
252            for path in paths.filter_map(Result::ok) {
253                collect_globbed_workspace_dir(
254                    path,
255                    &mut GlobbedWorkspaceContext {
256                        root,
257                        raw_pattern,
258                        canonical_root,
259                        ignore_patterns,
260                        results: &mut results,
261                        diagnostics,
262                    },
263                );
264            }
265            results
266        }
267        Err(e) => {
268            tracing::warn!("invalid workspace glob pattern '{raw_pattern}': {e}");
269            Vec::new()
270        }
271    }
272}
273
274struct GlobbedWorkspaceContext<'a, 'b> {
275    root: &'a Path,
276    raw_pattern: &'a str,
277    canonical_root: &'a Path,
278    ignore_patterns: &'a globset::GlobSet,
279    results: &'b mut Vec<(PathBuf, PathBuf)>,
280    diagnostics: &'b mut Vec<WorkspaceDiagnostic>,
281}
282
283/// Process one non-recursive glob match: keep package directories, recover named
284/// packages under a bare grouping directory, or emit a no-package.json
285/// diagnostic. See issue #842 for the recovery path.
286fn collect_globbed_workspace_dir(path: PathBuf, ctx: &mut GlobbedWorkspaceContext<'_, '_>) {
287    if !path.is_dir() {
288        return;
289    }
290    if path.components().any(|c| c.as_os_str() == "node_modules") {
291        return;
292    }
293    if path.join("package.json").exists() {
294        if let Some(cp) = dunce::canonicalize(&path)
295            .ok()
296            .filter(|cp| cp.starts_with(ctx.canonical_root))
297        {
298            ctx.results.push((path, cp));
299        }
300        return;
301    }
302    let recovered = recover_nested_packages(&path, ctx.canonical_root, ctx.ignore_patterns);
303    if recovered.is_empty() {
304        maybe_emit_glob_no_pkg_diag(
305            ctx.root,
306            ctx.raw_pattern,
307            &path,
308            ctx.ignore_patterns,
309            ctx.diagnostics,
310        );
311    } else {
312        let raw_pattern = ctx.raw_pattern;
313        // The user's glob is one level too shallow: it named the bare grouping
314        // directory, not the package below it. Recovery keeps the deep package
315        // discovered, but nudge the user toward the glob the package manager
316        // itself would need.
317        tracing::debug!(
318            "workspace glob '{raw_pattern}' matched '{}' which has no package.json; \
319             recovered {} nested package(s) one level down. Consider '{raw_pattern}/*' \
320             so npm/pnpm/yarn resolve them as workspace members too.",
321            path.display(),
322            recovered.len()
323        );
324        ctx.results.extend(recovered);
325    }
326}
327
328/// Descend one level into a glob-matched directory that has no `package.json`
329/// of its own and recover any immediate child that is a real, named package.
330///
331/// This handles the common `packages/<group>/<pkg>` layout where the root
332/// declares a one-level glob like `packages/*`: the glob matches the bare
333/// grouping directory (`packages/themes`), which has no manifest, so the deeper
334/// real package (`packages/themes/my-theme`) is never discovered and every file
335/// beneath it is misattributed to the project root, producing false
336/// `unlisted-dependencies`. See issue #842.
337///
338/// Recovery is conservative to avoid sweeping in non-packages: children are
339/// skipped when their leaf name is in the conventional skip list (build output,
340/// caches, hidden dirs) or `node_modules`, when their project-root-relative path
341/// matches the user's `ignore_patterns` (so a path the user excluded via
342/// `ignorePatterns` is a reliable opt-out and is never recovered), and a child
343/// is only registered when its `package.json` loads AND declares a `name` (so
344/// fixtures, build artifacts, and `__mocks__` manifests without a name are not
345/// treated as workspaces). Descends exactly one level: deeper
346/// `packages/<group>/<sub>/<pkg>` layouts are intentionally out of scope and
347/// should use a recursive (`**`) glob. Returns `(path, canonical_path)` pairs in
348/// the same shape as the glob expander.
349fn recover_nested_packages(
350    path: &Path,
351    canonical_root: &Path,
352    ignore_patterns: &globset::GlobSet,
353) -> Vec<(PathBuf, PathBuf)> {
354    let Ok(entries) = std::fs::read_dir(path) else {
355        return Vec::new();
356    };
357    let mut recovered = Vec::new();
358    for entry in entries.filter_map(Result::ok) {
359        let child = entry.path();
360        if !child.is_dir() {
361            continue;
362        }
363        let leaf = entry.file_name();
364        let leaf = leaf.to_string_lossy();
365        if leaf == "node_modules" || is_skip_listed_dir(&leaf) {
366            continue;
367        }
368        let Some(cp) = dunce::canonicalize(&child)
369            .ok()
370            .filter(|cp| cp.starts_with(canonical_root))
371        else {
372            continue;
373        };
374        // Honor the user's `ignorePatterns`: a recovered child the user already
375        // excluded must not be registered as a workspace (mirrors the
376        // suppression contract on the normal no-package-json glob path).
377        let relative = cp.strip_prefix(canonical_root).unwrap_or(cp.as_path());
378        if is_ignored_workspace_dir(relative, ignore_patterns) {
379            continue;
380        }
381        let pkg_path = child.join("package.json");
382        // Gate on a real, named package so fixtures / build output / mock
383        // manifests under the grouping directory are not registered.
384        let Ok(pkg) = PackageJson::load(&pkg_path) else {
385            continue;
386        };
387        if pkg.name.is_none() {
388            continue;
389        }
390        recovered.push((child, cp));
391    }
392    recovered
393}
394
395/// Emit a `glob-matched-no-package-json` diagnostic if the path is neither
396/// in the conventional skip list nor in the user `ignorePatterns`.
397///
398/// Path normalisation: macOS canonicalises `/tmp/<repo>` to
399/// `/private/tmp/<repo>`. If `root` was supplied as the canonical form (the
400/// CLI prints `/private/...` in `loaded config:` confirming this) but the
401/// `glob::glob` paths use the symlinked `/tmp/...` form, a naive
402/// `path.strip_prefix(root)` falls through to the full absolute path and the
403/// `ignorePatterns` check misses. Canonicalise both before stripping so the
404/// suppression contract holds end-to-end.
405fn maybe_emit_glob_no_pkg_diag(
406    root: &Path,
407    raw_pattern: &str,
408    path: &Path,
409    ignore_patterns: &globset::GlobSet,
410    diagnostics: &mut Vec<WorkspaceDiagnostic>,
411) {
412    let leaf = path
413        .file_name()
414        .map(|n| n.to_string_lossy().into_owned())
415        .unwrap_or_default();
416    if is_skip_listed_dir(&leaf) {
417        return;
418    }
419    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
420    let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
421    let relative = canonical_path
422        .strip_prefix(&canonical_root)
423        .unwrap_or(canonical_path.as_path())
424        .to_path_buf();
425    if is_ignored_workspace_dir(&relative, ignore_patterns) {
426        return;
427    }
428    let diag = WorkspaceDiagnostic::new(
429        root,
430        path.to_path_buf(),
431        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
432            pattern: raw_pattern.to_string(),
433        },
434    );
435    diagnostics.push(diag);
436}
437
438/// Expand a recursive workspace glob pattern (containing `**`) by walking the
439/// directory tree manually, pruning `node_modules` during traversal.
440///
441/// This avoids the `glob` crate's O(n) expansion where n includes all files
442/// inside `node_modules/` (catastrophic with pnpm's deep symlink trees).
443fn expand_recursive_workspace_pattern(
444    root: &Path,
445    raw_pattern: &str,
446    expanded_pattern: &str,
447    canonical_root: &Path,
448    ignore_patterns: &globset::GlobSet,
449    diagnostics: &mut Vec<WorkspaceDiagnostic>,
450) -> Vec<(PathBuf, PathBuf)> {
451    let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
452    let Ok(matcher) = glob::Pattern::new(&full_pattern) else {
453        tracing::warn!("invalid workspace glob pattern '{raw_pattern}'");
454        return Vec::new();
455    };
456
457    let base_dir = match expanded_pattern.find('*') {
458        Some(idx) => root.join(&expanded_pattern[..idx]),
459        None => root.join(expanded_pattern),
460    };
461
462    let mut results = Vec::new();
463    walk_workspace_dirs(
464        raw_pattern,
465        &base_dir,
466        &mut WorkspaceDirWalkInput {
467            root,
468            matcher: &matcher,
469            canonical_root,
470            ignore_patterns,
471            results: &mut results,
472            diagnostics,
473        },
474    );
475    results
476}
477
478/// Recursively walk directories, skipping `node_modules` and `.git`, collecting
479/// directories that match the glob pattern and contain a `package.json`.
480///
481/// Glob-matched directories without `package.json` are surfaced as
482/// `glob-matched-no-package-json` diagnostics unless they are in the
483/// conventional skip list or covered by `ignore_patterns`.
484struct WorkspaceDirWalkInput<'a> {
485    root: &'a Path,
486    matcher: &'a glob::Pattern,
487    canonical_root: &'a Path,
488    ignore_patterns: &'a globset::GlobSet,
489    results: &'a mut Vec<(PathBuf, PathBuf)>,
490    diagnostics: &'a mut Vec<WorkspaceDiagnostic>,
491}
492
493fn walk_workspace_dirs(raw_pattern: &str, dir: &Path, input: &mut WorkspaceDirWalkInput<'_>) {
494    let Ok(entries) = std::fs::read_dir(dir) else {
495        return;
496    };
497    for entry in entries.flatten() {
498        let path = entry.path();
499        if !path.is_dir() {
500            continue;
501        }
502        let name = entry.file_name();
503        if name == "node_modules" || name == ".git" {
504            continue;
505        }
506        if input.matcher.matches_path(&path) {
507            if path.join("package.json").exists() {
508                if let Ok(cp) = dunce::canonicalize(&path)
509                    && cp.starts_with(input.canonical_root)
510                {
511                    input.results.push((path.clone(), cp));
512                }
513            } else {
514                maybe_emit_glob_no_pkg_diag(
515                    input.root,
516                    raw_pattern,
517                    &path,
518                    input.ignore_patterns,
519                    input.diagnostics,
520                );
521            }
522        }
523        walk_workspace_dirs(raw_pattern, &path, input);
524    }
525}
526
527/// Parse pnpm-workspace.yaml to extract package patterns.
528pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
529    let mut patterns = Vec::new();
530    let mut in_packages = false;
531
532    for line in content.lines() {
533        let trimmed = line.trim();
534        if trimmed == "packages:" {
535            in_packages = true;
536            continue;
537        }
538        if in_packages {
539            if trimmed.starts_with("- ") {
540                let value = trimmed
541                    .strip_prefix("- ")
542                    .unwrap_or(trimmed)
543                    .trim_matches('\'')
544                    .trim_matches('"');
545                patterns.push(value.to_string());
546            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
547                break; // New top-level key
548            }
549        }
550    }
551
552    patterns
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn parse_pnpm_workspace_basic() {
561        let yaml = "packages:\n  - 'packages/*'\n  - 'apps/*'\n";
562        let patterns = parse_pnpm_workspace_yaml(yaml);
563        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
564    }
565
566    #[test]
567    fn parse_pnpm_workspace_double_quotes() {
568        let yaml = "packages:\n  - \"packages/*\"\n  - \"apps/*\"\n";
569        let patterns = parse_pnpm_workspace_yaml(yaml);
570        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
571    }
572
573    #[test]
574    fn parse_pnpm_workspace_no_quotes() {
575        let yaml = "packages:\n  - packages/*\n  - apps/*\n";
576        let patterns = parse_pnpm_workspace_yaml(yaml);
577        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
578    }
579
580    #[test]
581    fn parse_pnpm_workspace_empty() {
582        let yaml = "";
583        let patterns = parse_pnpm_workspace_yaml(yaml);
584        assert!(patterns.is_empty());
585    }
586
587    #[test]
588    fn parse_pnpm_workspace_no_packages_key() {
589        let yaml = "other:\n  - something\n";
590        let patterns = parse_pnpm_workspace_yaml(yaml);
591        assert!(patterns.is_empty());
592    }
593
594    #[test]
595    fn parse_pnpm_workspace_with_comments() {
596        let yaml = "packages:\n  # Comment\n  - 'packages/*'\n";
597        let patterns = parse_pnpm_workspace_yaml(yaml);
598        assert_eq!(patterns, vec!["packages/*"]);
599    }
600
601    #[test]
602    fn parse_pnpm_workspace_stops_at_next_key() {
603        let yaml = "packages:\n  - 'packages/*'\ncatalog:\n  react: ^18\n";
604        let patterns = parse_pnpm_workspace_yaml(yaml);
605        assert_eq!(patterns, vec!["packages/*"]);
606    }
607
608    #[test]
609    fn strip_trailing_commas_basic() {
610        assert_eq!(
611            strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
612            r#"{"a": 1, "b": 2}"#
613        );
614    }
615
616    #[test]
617    fn strip_trailing_commas_array() {
618        assert_eq!(strip_trailing_commas(r"[1, 2, 3,]"), r"[1, 2, 3]");
619    }
620
621    #[test]
622    fn strip_trailing_commas_with_whitespace() {
623        assert_eq!(
624            strip_trailing_commas("{\n  \"a\": 1,\n}"),
625            "{\n  \"a\": 1\n}"
626        );
627    }
628
629    #[test]
630    fn strip_trailing_commas_preserves_strings() {
631        assert_eq!(
632            strip_trailing_commas(r#"{"a": "hello,}"}"#),
633            r#"{"a": "hello,}"}"#
634        );
635    }
636
637    #[test]
638    fn strip_trailing_commas_nested() {
639        let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
640        let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
641        assert_eq!(strip_trailing_commas(input), expected);
642    }
643
644    #[test]
645    fn strip_trailing_commas_escaped_quotes() {
646        assert_eq!(
647            strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
648            r#"{"a": "he\"llo,}"}"#
649        );
650    }
651
652    #[test]
653    fn tsconfig_references_from_dir() {
654        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
655        let _ = std::fs::remove_dir_all(&temp_dir);
656        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
657        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
658
659        std::fs::write(
660            temp_dir.join("tsconfig.json"),
661            r#"{
662                "references": [
663                    {"path": "./packages/core"},
664                    {"path": "./packages/ui"},
665                ],
666            }"#,
667        )
668        .unwrap();
669
670        let refs = parse_tsconfig_references(&temp_dir);
671        assert_eq!(refs.len(), 2);
672        assert!(refs.iter().any(|p| p.ends_with("packages/core")));
673        assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
674
675        let _ = std::fs::remove_dir_all(&temp_dir);
676    }
677
678    #[test]
679    fn tsconfig_references_no_file() {
680        let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
681        assert!(refs.is_empty());
682    }
683
684    #[test]
685    fn tsconfig_references_no_references_field() {
686        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
687        let _ = std::fs::remove_dir_all(&temp_dir);
688        std::fs::create_dir_all(&temp_dir).unwrap();
689
690        std::fs::write(
691            temp_dir.join("tsconfig.json"),
692            r#"{"compilerOptions": {"strict": true}}"#,
693        )
694        .unwrap();
695
696        let refs = parse_tsconfig_references(&temp_dir);
697        assert!(refs.is_empty());
698
699        let _ = std::fs::remove_dir_all(&temp_dir);
700    }
701
702    #[test]
703    fn tsconfig_references_skips_nonexistent_dirs() {
704        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
705        let _ = std::fs::remove_dir_all(&temp_dir);
706        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
707
708        std::fs::write(
709            temp_dir.join("tsconfig.json"),
710            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
711        )
712        .unwrap();
713
714        let refs = parse_tsconfig_references(&temp_dir);
715        assert_eq!(refs.len(), 1);
716        assert!(refs[0].ends_with("packages/core"));
717
718        let _ = std::fs::remove_dir_all(&temp_dir);
719    }
720
721    #[test]
722    fn tsconfig_references_skip_file_paths_silently() {
723        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-file-refs");
724        let _ = std::fs::remove_dir_all(&temp_dir);
725        std::fs::create_dir_all(temp_dir.join("build")).unwrap();
726        std::fs::create_dir_all(temp_dir.join("dist/types")).unwrap();
727        std::fs::create_dir_all(temp_dir.join("packages/foo")).unwrap();
728
729        std::fs::write(
730            temp_dir.join("build/tsconfig.app.json"),
731            r#"{"compilerOptions": {}}"#,
732        )
733        .unwrap();
734        std::fs::write(
735            temp_dir.join("dist/types/index.d.json"),
736            r#"{"compilerOptions": {}}"#,
737        )
738        .unwrap();
739        std::fs::write(
740            temp_dir.join("packages/foo/tsconfig.lib.json"),
741            r#"{"compilerOptions": {}}"#,
742        )
743        .unwrap();
744        std::fs::write(
745            temp_dir.join("tsconfig.base.json"),
746            r#"{"compilerOptions": {}}"#,
747        )
748        .unwrap();
749
750        std::fs::write(
751            temp_dir.join("tsconfig.json"),
752            r#"{
753                "references": [
754                    {"path": "./build/tsconfig.app.json"},
755                    {"path": "./dist/types/index.d.json"},
756                    {"path": "./packages/foo/tsconfig.lib.json"},
757                    {"path": "./tsconfig.base.json"}
758                ]
759            }"#,
760        )
761        .unwrap();
762
763        let mut diagnostics = Vec::new();
764        let refs = parse_tsconfig_references_with_diagnostics(
765            &temp_dir,
766            &globset::GlobSet::empty(),
767            &mut diagnostics,
768        );
769
770        assert!(
771            refs.is_empty(),
772            "file references at any path should not be workspace candidates; got: {refs:?}"
773        );
774        assert!(
775            diagnostics.is_empty(),
776            "file references must not trigger TsconfigReferenceDirMissing; got: {diagnostics:?}"
777        );
778
779        let _ = std::fs::remove_dir_all(&temp_dir);
780    }
781
782    #[test]
783    fn tsconfig_references_mixed_file_and_dir() {
784        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-mixed-refs");
785        let _ = std::fs::remove_dir_all(&temp_dir);
786        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
787        std::fs::create_dir_all(temp_dir.join("apps/web")).unwrap();
788        std::fs::write(
789            temp_dir.join("tsconfig.shared.json"),
790            r#"{"compilerOptions": {}}"#,
791        )
792        .unwrap();
793        std::fs::write(
794            temp_dir.join("apps/web/tsconfig.json"),
795            r#"{"compilerOptions": {}}"#,
796        )
797        .unwrap();
798
799        std::fs::write(
800            temp_dir.join("tsconfig.json"),
801            r#"{
802                "references": [
803                    {"path": "./packages/core"},
804                    {"path": "./tsconfig.shared.json"},
805                    {"path": "./apps/web/tsconfig.json"}
806                ]
807            }"#,
808        )
809        .unwrap();
810
811        let mut diagnostics = Vec::new();
812        let refs = parse_tsconfig_references_with_diagnostics(
813            &temp_dir,
814            &globset::GlobSet::empty(),
815            &mut diagnostics,
816        );
817
818        assert_eq!(
819            refs.len(),
820            1,
821            "only the directory reference should be returned"
822        );
823        assert!(refs[0].ends_with("packages/core"));
824        assert!(
825            diagnostics.is_empty(),
826            "file references must not trigger diagnostics; got: {diagnostics:?}"
827        );
828
829        let _ = std::fs::remove_dir_all(&temp_dir);
830    }
831
832    #[test]
833    fn strip_trailing_commas_no_commas() {
834        let input = r#"{"a": 1, "b": [2, 3]}"#;
835        assert_eq!(strip_trailing_commas(input), input);
836    }
837
838    #[test]
839    fn strip_trailing_commas_empty_input() {
840        assert_eq!(strip_trailing_commas(""), "");
841    }
842
843    #[test]
844    fn strip_trailing_commas_nested_objects() {
845        let input = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2,\n  },\n  \"d\": 3,\n}";
846        let expected = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2\n  },\n  \"d\": 3\n}";
847        assert_eq!(strip_trailing_commas(input), expected);
848    }
849
850    #[test]
851    fn strip_trailing_commas_array_of_objects() {
852        let input = r#"[{"a": 1,}, {"b": 2,},]"#;
853        let expected = r#"[{"a": 1}, {"b": 2}]"#;
854        assert_eq!(strip_trailing_commas(input), expected);
855    }
856
857    #[test]
858    fn tsconfig_references_malformed_json() {
859        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-malformed");
860        let _ = std::fs::remove_dir_all(&temp_dir);
861        std::fs::create_dir_all(&temp_dir).unwrap();
862
863        std::fs::write(
864            temp_dir.join("tsconfig.json"),
865            r"{ this is not valid json at all",
866        )
867        .unwrap();
868
869        let refs = parse_tsconfig_references(&temp_dir);
870        assert!(refs.is_empty());
871
872        let _ = std::fs::remove_dir_all(&temp_dir);
873    }
874
875    #[test]
876    fn tsconfig_references_empty_array() {
877        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-empty-refs");
878        let _ = std::fs::remove_dir_all(&temp_dir);
879        std::fs::create_dir_all(&temp_dir).unwrap();
880
881        std::fs::write(temp_dir.join("tsconfig.json"), r#"{"references": []}"#).unwrap();
882
883        let refs = parse_tsconfig_references(&temp_dir);
884        assert!(refs.is_empty());
885
886        let _ = std::fs::remove_dir_all(&temp_dir);
887    }
888
889    #[test]
890    fn parse_pnpm_workspace_malformed() {
891        let patterns = parse_pnpm_workspace_yaml(":::not yaml at all:::");
892        assert!(patterns.is_empty());
893    }
894
895    #[test]
896    fn parse_pnpm_workspace_packages_key_empty_list() {
897        let yaml = "packages:\nother:\n  - something\n";
898        let patterns = parse_pnpm_workspace_yaml(yaml);
899        assert!(patterns.is_empty());
900    }
901
902    #[test]
903    fn expand_workspace_glob_exact_path() {
904        let temp_dir = std::env::temp_dir().join("fallow-test-expand-exact");
905        let _ = std::fs::remove_dir_all(&temp_dir);
906        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
907        std::fs::write(
908            temp_dir.join("packages/core/package.json"),
909            r#"{"name": "core"}"#,
910        )
911        .unwrap();
912
913        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
914        let results = expand_workspace_glob(&temp_dir, "packages/core", &canonical_root);
915        assert_eq!(results.len(), 1);
916        assert!(results[0].0.ends_with("packages/core"));
917
918        let _ = std::fs::remove_dir_all(&temp_dir);
919    }
920
921    #[test]
922    fn expand_workspace_glob_star() {
923        let temp_dir = std::env::temp_dir().join("fallow-test-expand-star");
924        let _ = std::fs::remove_dir_all(&temp_dir);
925        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
926        std::fs::create_dir_all(temp_dir.join("packages/b")).unwrap();
927        std::fs::create_dir_all(temp_dir.join("packages/c")).unwrap();
928        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
929        std::fs::write(temp_dir.join("packages/b/package.json"), r#"{"name": "b"}"#).unwrap();
930
931        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
932        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
933        assert_eq!(results.len(), 2);
934
935        let _ = std::fs::remove_dir_all(&temp_dir);
936    }
937
938    #[test]
939    fn expand_workspace_glob_nested() {
940        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested");
941        let _ = std::fs::remove_dir_all(&temp_dir);
942        std::fs::create_dir_all(temp_dir.join("packages/scope/a")).unwrap();
943        std::fs::create_dir_all(temp_dir.join("packages/scope/b")).unwrap();
944        std::fs::write(
945            temp_dir.join("packages/scope/a/package.json"),
946            r#"{"name": "@scope/a"}"#,
947        )
948        .unwrap();
949        std::fs::write(
950            temp_dir.join("packages/scope/b/package.json"),
951            r#"{"name": "@scope/b"}"#,
952        )
953        .unwrap();
954
955        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
956        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
957        assert_eq!(results.len(), 2);
958
959        let _ = std::fs::remove_dir_all(&temp_dir);
960    }
961
962    #[test]
963    fn tsconfig_root_dir_extracted() {
964        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir");
965        let _ = std::fs::remove_dir_all(&temp_dir);
966        std::fs::create_dir_all(&temp_dir).unwrap();
967
968        std::fs::write(
969            temp_dir.join("tsconfig.json"),
970            r#"{ "compilerOptions": { "rootDir": "./src" } }"#,
971        )
972        .unwrap();
973
974        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("src".to_string()));
975        let _ = std::fs::remove_dir_all(&temp_dir);
976    }
977
978    #[test]
979    fn tsconfig_root_dir_lib() {
980        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-lib");
981        let _ = std::fs::remove_dir_all(&temp_dir);
982        std::fs::create_dir_all(&temp_dir).unwrap();
983
984        std::fs::write(
985            temp_dir.join("tsconfig.json"),
986            r#"{ "compilerOptions": { "rootDir": "lib/" } }"#,
987        )
988        .unwrap();
989
990        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("lib".to_string()));
991        let _ = std::fs::remove_dir_all(&temp_dir);
992    }
993
994    #[test]
995    fn tsconfig_root_dir_missing_field() {
996        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-nofield");
997        let _ = std::fs::remove_dir_all(&temp_dir);
998        std::fs::create_dir_all(&temp_dir).unwrap();
999
1000        std::fs::write(
1001            temp_dir.join("tsconfig.json"),
1002            r#"{ "compilerOptions": { "strict": true } }"#,
1003        )
1004        .unwrap();
1005
1006        assert_eq!(parse_tsconfig_root_dir(&temp_dir), None);
1007        let _ = std::fs::remove_dir_all(&temp_dir);
1008    }
1009
1010    #[test]
1011    fn tsconfig_root_dir_no_file() {
1012        assert_eq!(parse_tsconfig_root_dir(Path::new("/nonexistent")), None);
1013    }
1014
1015    #[test]
1016    fn tsconfig_root_dir_with_comments() {
1017        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-comments");
1018        let _ = std::fs::remove_dir_all(&temp_dir);
1019        std::fs::create_dir_all(&temp_dir).unwrap();
1020
1021        std::fs::write(
1022            temp_dir.join("tsconfig.json"),
1023            "{\n  // Root directory\n  \"compilerOptions\": { \"rootDir\": \"app\" }\n}",
1024        )
1025        .unwrap();
1026
1027        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
1028        let _ = std::fs::remove_dir_all(&temp_dir);
1029    }
1030
1031    #[test]
1032    fn tsconfig_root_dir_dot_value() {
1033        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-dot");
1034        let _ = std::fs::remove_dir_all(&temp_dir);
1035        std::fs::create_dir_all(&temp_dir).unwrap();
1036
1037        std::fs::write(
1038            temp_dir.join("tsconfig.json"),
1039            r#"{ "compilerOptions": { "rootDir": "." } }"#,
1040        )
1041        .unwrap();
1042
1043        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some(".".to_string()));
1044        let _ = std::fs::remove_dir_all(&temp_dir);
1045    }
1046
1047    #[test]
1048    fn tsconfig_root_dir_parent_traversal() {
1049        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-parent");
1050        let _ = std::fs::remove_dir_all(&temp_dir);
1051        std::fs::create_dir_all(&temp_dir).unwrap();
1052
1053        std::fs::write(
1054            temp_dir.join("tsconfig.json"),
1055            r#"{ "compilerOptions": { "rootDir": "../other" } }"#,
1056        )
1057        .unwrap();
1058
1059        assert_eq!(
1060            parse_tsconfig_root_dir(&temp_dir),
1061            Some("../other".to_string())
1062        );
1063        let _ = std::fs::remove_dir_all(&temp_dir);
1064    }
1065
1066    #[test]
1067    fn expand_workspace_glob_no_matches() {
1068        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nomatch");
1069        let _ = std::fs::remove_dir_all(&temp_dir);
1070        std::fs::create_dir_all(&temp_dir).unwrap();
1071
1072        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1073        let results = expand_workspace_glob(&temp_dir, "nonexistent/*", &canonical_root);
1074        assert!(results.is_empty());
1075
1076        let _ = std::fs::remove_dir_all(&temp_dir);
1077    }
1078
1079    #[test]
1080    fn parse_pnpm_workspace_with_empty_lines_between_entries() {
1081        let yaml = "packages:\n  - 'packages/*'\n\n  - 'apps/*'\n";
1082        let patterns = parse_pnpm_workspace_yaml(yaml);
1083        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1084    }
1085
1086    #[test]
1087    fn parse_pnpm_workspace_mixed_quotes() {
1088        let yaml = "packages:\n  - 'single/*'\n  - \"double/*\"\n  - bare/*\n";
1089        let patterns = parse_pnpm_workspace_yaml(yaml);
1090        assert_eq!(patterns, vec!["single/*", "double/*", "bare/*"]);
1091    }
1092
1093    #[test]
1094    fn parse_pnpm_workspace_with_negation() {
1095        let yaml = "packages:\n  - 'packages/*'\n  - '!packages/test-*'\n";
1096        let patterns = parse_pnpm_workspace_yaml(yaml);
1097        assert_eq!(patterns, vec!["packages/*", "!packages/test-*"]);
1098    }
1099
1100    #[test]
1101    fn strip_trailing_commas_string_with_closing_brackets() {
1102        let input = r#"{"key": "value with ] and }",}"#;
1103        let expected = r#"{"key": "value with ] and }"}"#;
1104        assert_eq!(strip_trailing_commas(input), expected);
1105    }
1106
1107    #[test]
1108    fn strip_trailing_commas_multiple_levels() {
1109        let input = r#"{"a": {"b": [1, 2,], "c": 3,},}"#;
1110        let expected = r#"{"a": {"b": [1, 2], "c": 3}}"#;
1111        assert_eq!(strip_trailing_commas(input), expected);
1112    }
1113
1114    #[test]
1115    fn tsconfig_root_dir_with_trailing_commas() {
1116        let temp_dir = std::env::temp_dir().join("fallow-test-rootdir-trailing-comma");
1117        let _ = std::fs::remove_dir_all(&temp_dir);
1118        std::fs::create_dir_all(&temp_dir).unwrap();
1119
1120        std::fs::write(
1121            temp_dir.join("tsconfig.json"),
1122            "{\n  \"compilerOptions\": {\n    \"rootDir\": \"app\",\n  },\n}",
1123        )
1124        .unwrap();
1125
1126        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
1127        let _ = std::fs::remove_dir_all(&temp_dir);
1128    }
1129
1130    #[test]
1131    fn expand_workspace_glob_trailing_slash() {
1132        let temp_dir = std::env::temp_dir().join("fallow-test-expand-trailing");
1133        let _ = std::fs::remove_dir_all(&temp_dir);
1134        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
1135        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
1136
1137        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1138        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1139        assert_eq!(results.len(), 1);
1140
1141        let _ = std::fs::remove_dir_all(&temp_dir);
1142    }
1143
1144    #[test]
1145    fn expand_workspace_glob_excludes_node_modules() {
1146        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-nodemod");
1147        let _ = std::fs::remove_dir_all(&temp_dir);
1148
1149        let nm_pkg = temp_dir.join("packages/foo/node_modules/bar");
1150        std::fs::create_dir_all(&nm_pkg).unwrap();
1151        std::fs::write(nm_pkg.join("package.json"), r#"{"name":"bar"}"#).unwrap();
1152
1153        let ws_pkg = temp_dir.join("packages/foo");
1154        std::fs::write(ws_pkg.join("package.json"), r#"{"name":"foo"}"#).unwrap();
1155
1156        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1157        let results = expand_workspace_glob(&temp_dir, "packages/**", &canonical_root);
1158
1159        assert!(results.iter().any(|(_orig, canon)| {
1160            canon
1161                .to_string_lossy()
1162                .replace('\\', "/")
1163                .contains("packages/foo")
1164                && !canon.to_string_lossy().contains("node_modules")
1165        }));
1166        assert!(
1167            !results
1168                .iter()
1169                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules"))
1170        );
1171
1172        let _ = std::fs::remove_dir_all(&temp_dir);
1173    }
1174
1175    #[test]
1176    fn expand_workspace_glob_skips_dirs_without_pkg() {
1177        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-pkg");
1178        let _ = std::fs::remove_dir_all(&temp_dir);
1179        std::fs::create_dir_all(temp_dir.join("packages/with-pkg")).unwrap();
1180        std::fs::create_dir_all(temp_dir.join("packages/without-pkg")).unwrap();
1181        std::fs::write(
1182            temp_dir.join("packages/with-pkg/package.json"),
1183            r#"{"name": "with"}"#,
1184        )
1185        .unwrap();
1186
1187        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1188        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1189        assert_eq!(results.len(), 1);
1190        assert!(
1191            results[0]
1192                .0
1193                .to_string_lossy()
1194                .replace('\\', "/")
1195                .ends_with("packages/with-pkg")
1196        );
1197
1198        let _ = std::fs::remove_dir_all(&temp_dir);
1199    }
1200
1201    #[test]
1202    fn expand_workspace_glob_recovers_nested_package_under_bare_intermediate() {
1203        // Reporter layout (issue #842): root glob `packages/*` matches the bare
1204        // grouping dir `packages/themes` (no package.json); the real package is
1205        // one level deeper at `packages/themes/my-theme`. A nameless manifest and
1206        // a non-package dir under the same grouping dir must NOT be recovered.
1207        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested-recover");
1208        let _ = std::fs::remove_dir_all(&temp_dir);
1209        std::fs::create_dir_all(temp_dir.join("packages/themes/my-theme")).unwrap();
1210        std::fs::create_dir_all(temp_dir.join("packages/themes/no-name")).unwrap();
1211        std::fs::create_dir_all(temp_dir.join("packages/themes/just-src")).unwrap();
1212        std::fs::write(
1213            temp_dir.join("packages/themes/my-theme/package.json"),
1214            r#"{"name": "my-theme", "dependencies": {"react": "^18"}}"#,
1215        )
1216        .unwrap();
1217        // Nameless manifest: must be rejected (fixtures / build output shape).
1218        std::fs::write(
1219            temp_dir.join("packages/themes/no-name/package.json"),
1220            r#"{"private": true}"#,
1221        )
1222        .unwrap();
1223        // `just-src` has no package.json at all: nothing to recover.
1224
1225        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1226        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1227
1228        let names: Vec<String> = results
1229            .iter()
1230            .map(|(p, _)| p.to_string_lossy().replace('\\', "/"))
1231            .collect();
1232        assert_eq!(
1233            results.len(),
1234            1,
1235            "only the named nested package should be recovered, got {names:?}"
1236        );
1237        assert!(
1238            names[0].ends_with("packages/themes/my-theme"),
1239            "recovered path should be the deep named package, got {names:?}"
1240        );
1241
1242        let _ = std::fs::remove_dir_all(&temp_dir);
1243    }
1244
1245    #[test]
1246    fn expand_workspace_glob_recovery_honors_ignore_patterns() {
1247        // A nested package the user excluded via `ignorePatterns` must NOT be
1248        // recovered, so `ignorePatterns` stays a reliable opt-out. See issue #842.
1249        let temp_dir = std::env::temp_dir().join("fallow-test-recover-ignore");
1250        let _ = std::fs::remove_dir_all(&temp_dir);
1251        std::fs::create_dir_all(temp_dir.join("packages/themes/my-theme")).unwrap();
1252        std::fs::write(
1253            temp_dir.join("packages/themes/my-theme/package.json"),
1254            r#"{"name": "my-theme"}"#,
1255        )
1256        .unwrap();
1257
1258        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1259        let mut builder = globset::GlobSetBuilder::new();
1260        builder.add(globset::Glob::new("packages/themes/my-theme").unwrap());
1261        let ignore = builder.build().unwrap();
1262        let mut diagnostics = Vec::new();
1263        let results = expand_workspace_glob_with_diagnostics(
1264            &temp_dir,
1265            "packages/*",
1266            "packages/*",
1267            &canonical_root,
1268            &ignore,
1269            &mut diagnostics,
1270        );
1271        assert!(
1272            results.is_empty(),
1273            "an ignored nested package must not be recovered, got {results:?}"
1274        );
1275
1276        let _ = std::fs::remove_dir_all(&temp_dir);
1277    }
1278
1279    #[test]
1280    fn expand_recursive_glob_prunes_node_modules() {
1281        let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
1282        let _ = std::fs::remove_dir_all(&temp_dir);
1283
1284        std::fs::create_dir_all(temp_dir.join("packages/app")).unwrap();
1285        std::fs::write(
1286            temp_dir.join("packages/app/package.json"),
1287            r#"{"name": "app"}"#,
1288        )
1289        .unwrap();
1290        std::fs::create_dir_all(temp_dir.join("packages/lib")).unwrap();
1291        std::fs::write(
1292            temp_dir.join("packages/lib/package.json"),
1293            r#"{"name": "lib"}"#,
1294        )
1295        .unwrap();
1296
1297        let nm_dep = temp_dir.join("packages/app/node_modules/dep");
1298        std::fs::create_dir_all(&nm_dep).unwrap();
1299        std::fs::write(nm_dep.join("package.json"), r#"{"name": "dep"}"#).unwrap();
1300
1301        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1302        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1303
1304        let found_names: Vec<String> = results
1305            .iter()
1306            .map(|(orig, _)| orig.file_name().unwrap().to_string_lossy().to_string())
1307            .collect();
1308        assert!(
1309            found_names.contains(&"app".to_string()),
1310            "should find packages/app"
1311        );
1312        assert!(
1313            found_names.contains(&"lib".to_string()),
1314            "should find packages/lib"
1315        );
1316        assert!(
1317            !results
1318                .iter()
1319                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules")),
1320            "should NOT include packages inside node_modules"
1321        );
1322        assert_eq!(
1323            results.len(),
1324            2,
1325            "should find exactly 2 workspace packages (node_modules pruned)"
1326        );
1327
1328        let _ = std::fs::remove_dir_all(&temp_dir);
1329    }
1330
1331    #[test]
1332    fn expand_recursive_glob_preserves_nested_workspace_roots() {
1333        let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-workspace-prune");
1334        let _ = std::fs::remove_dir_all(&temp_dir);
1335
1336        std::fs::create_dir_all(temp_dir.join("apps/app/packages/nested")).unwrap();
1337        std::fs::write(temp_dir.join("apps/app/package.json"), r#"{"name":"app"}"#).unwrap();
1338        std::fs::write(
1339            temp_dir.join("apps/app/packages/nested/package.json"),
1340            r#"{"name":"nested"}"#,
1341        )
1342        .unwrap();
1343
1344        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1345        let results = expand_workspace_glob(&temp_dir, "apps/**", &canonical_root);
1346        let mut paths: Vec<_> = results
1347            .iter()
1348            .map(|(path, _)| path.strip_prefix(&temp_dir).unwrap().to_path_buf())
1349            .collect();
1350        paths.sort();
1351
1352        assert_eq!(
1353            paths,
1354            vec![
1355                PathBuf::from("apps/app"),
1356                PathBuf::from("apps/app/packages/nested")
1357            ]
1358        );
1359
1360        let _ = std::fs::remove_dir_all(&temp_dir);
1361    }
1362
1363    #[test]
1364    fn expand_recursive_glob_prunes_deeply_nested_node_modules() {
1365        let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
1366        let _ = std::fs::remove_dir_all(&temp_dir);
1367
1368        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
1369        std::fs::write(
1370            temp_dir.join("packages/core/package.json"),
1371            r#"{"name": "core"}"#,
1372        )
1373        .unwrap();
1374
1375        let deep_nm = temp_dir.join("packages/core/node_modules/.pnpm/react@18/node_modules/react");
1376        std::fs::create_dir_all(&deep_nm).unwrap();
1377        std::fs::write(deep_nm.join("package.json"), r#"{"name": "react"}"#).unwrap();
1378
1379        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1380        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1381
1382        assert_eq!(
1383            results.len(),
1384            1,
1385            "should find exactly 1 workspace package, pruning deep node_modules"
1386        );
1387        assert!(
1388            results[0]
1389                .0
1390                .to_string_lossy()
1391                .replace('\\', "/")
1392                .ends_with("packages/core"),
1393            "the single result should be packages/core"
1394        );
1395
1396        let _ = std::fs::remove_dir_all(&temp_dir);
1397    }
1398}