Skip to main content

fallow_config/workspace/
parsers.rs

1use std::path::{Path, PathBuf};
2
3use super::diagnostics::{
4    WorkspaceDiagnostic, WorkspaceDiagnosticKind, is_ignored_workspace_dir, is_skip_listed_dir,
5};
6
7/// Parse `tsconfig.json` at the project root and extract `references[].path` directories.
8///
9/// Returns directories that exist on disk. tsconfig.json is JSONC (comments + trailing commas).
10///
11/// Test-only wrapper around [`parse_tsconfig_references_with_diagnostics`] that drops
12/// any emitted diagnostics. Production callers use the diagnostics-aware variant.
13#[cfg(test)]
14pub(super) fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
15    let mut diagnostics = Vec::new();
16    parse_tsconfig_references_with_diagnostics(root, &globset::GlobSet::empty(), &mut diagnostics)
17}
18
19/// Parse `tsconfig.json` at the project root and extract `references[].path` directories,
20/// surfacing parse errors and missing reference directories as workspace diagnostics.
21///
22/// Severity policy (mirrors what tsc itself does):
23/// - `tsconfig.json` missing: silent (many JS-only projects have none).
24/// - `tsconfig.json` exists but fails to parse as JSONC: emit
25///   [`WorkspaceDiagnosticKind::MalformedTsconfig`].
26/// - `references[].path` points to a directory that does not exist: emit
27///   [`WorkspaceDiagnosticKind::TsconfigReferenceDirMissing`], filtered through
28///   `ignore_patterns` so user-excluded paths stay quiet.
29pub(super) fn parse_tsconfig_references_with_diagnostics(
30    root: &Path,
31    ignore_patterns: &globset::GlobSet,
32    diagnostics: &mut Vec<WorkspaceDiagnostic>,
33) -> Vec<PathBuf> {
34    let tsconfig_path = root.join("tsconfig.json");
35    let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
36        // Missing tsconfig is not an error. Many JS-only projects have none.
37        return Vec::new();
38    };
39
40    // Strip UTF-8 BOM if present (common in Windows-authored tsconfig files)
41    let content = content.trim_start_matches('\u{FEFF}');
42
43    let value: serde_json::Value = match crate::jsonc::parse_to_value(content) {
44        Ok(v) => v,
45        Err(error) => {
46            let diag = WorkspaceDiagnostic::new(
47                root,
48                tsconfig_path,
49                WorkspaceDiagnosticKind::MalformedTsconfig {
50                    error: error.to_string(),
51                },
52            );
53            diagnostics.push(diag);
54            return Vec::new();
55        }
56    };
57
58    let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
59        return Vec::new();
60    };
61
62    let mut results = Vec::new();
63    for r in refs {
64        let Some(raw_path) = r.get("path").and_then(|p| p.as_str()) else {
65            continue;
66        };
67        // strip_prefix removes exactly one leading "./" (unlike trim_start_matches
68        // which would strip repeatedly).
69        let cleaned = raw_path.strip_prefix("./").unwrap_or(raw_path);
70        let candidate = root.join(cleaned);
71        if candidate.is_dir() {
72            results.push(candidate);
73            continue;
74        }
75
76        // Reference points to a missing directory. Filter through
77        // ignore_patterns so paths the user already excluded do not trigger
78        // a redundant diagnostic.
79        let relative = candidate
80            .strip_prefix(root)
81            .unwrap_or(candidate.as_path())
82            .to_path_buf();
83        if is_ignored_workspace_dir(&relative, ignore_patterns) {
84            continue;
85        }
86
87        let diag = WorkspaceDiagnostic::new(
88            root,
89            candidate,
90            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
91        );
92        diagnostics.push(diag);
93    }
94    results
95}
96
97/// Parse `tsconfig.json` at the project root and extract `compilerOptions.rootDir`.
98///
99/// Returns `None` if the file is missing, malformed, or has no `rootDir` set.
100pub fn parse_tsconfig_root_dir(root: &Path) -> Option<String> {
101    let tsconfig_path = root.join("tsconfig.json");
102    let content = std::fs::read_to_string(&tsconfig_path).ok()?;
103    let content = content.trim_start_matches('\u{FEFF}');
104
105    let value: serde_json::Value = crate::jsonc::parse_to_value(content).ok()?;
106
107    value
108        .get("compilerOptions")
109        .and_then(|opts| opts.get("rootDir"))
110        .and_then(|v| v.as_str())
111        .map(|s| {
112            s.strip_prefix("./")
113                .unwrap_or(s)
114                .trim_end_matches('/')
115                .to_owned()
116        })
117}
118
119/// Strip trailing commas before `]` and `}` in JSON-like content.
120///
121/// tsconfig.json commonly uses trailing commas which are valid JSONC but not valid JSON.
122/// This strips them so `serde_json` can parse the content.
123#[cfg(test)]
124pub(super) fn strip_trailing_commas(input: &str) -> String {
125    let bytes = input.as_bytes();
126    let len = bytes.len();
127    let mut result = Vec::with_capacity(len);
128    let mut in_string = false;
129    let mut i = 0;
130
131    while i < len {
132        let b = bytes[i];
133
134        if in_string {
135            result.push(b);
136            if b == b'\\' && i + 1 < len {
137                // Push escaped character and skip it
138                i += 1;
139                result.push(bytes[i]);
140            } else if b == b'"' {
141                in_string = false;
142            }
143            i += 1;
144            continue;
145        }
146
147        if b == b'"' {
148            in_string = true;
149            result.push(b);
150            i += 1;
151            continue;
152        }
153
154        if b == b',' {
155            // Look ahead past whitespace for ] or }
156            let mut j = i + 1;
157            while j < len && bytes[j].is_ascii_whitespace() {
158                j += 1;
159            }
160            if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
161                // Skip the trailing comma
162                i += 1;
163                continue;
164            }
165        }
166
167        result.push(b);
168        i += 1;
169    }
170
171    // We only removed ASCII commas and preserved all other bytes unchanged,
172    // so the result is valid UTF-8 if the input was. Use from_utf8 to be safe.
173    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
174}
175
176/// Expand a workspace glob pattern to matching directories.
177///
178/// Returns `(original_path, canonical_path)` tuples so callers can skip redundant
179/// `canonicalize()` calls. Only directories containing a `package.json` are
180/// canonicalized; this avoids expensive syscalls on the many non-workspace
181/// directories that globs like `packages/*` or `**` can match.
182///
183/// `canonical_root` is pre-computed to avoid repeated `canonicalize()` syscalls.
184///
185/// Test-only wrapper around [`expand_workspace_glob_with_diagnostics`] that
186/// drops any glob-matched-no-package.json diagnostics. Production callers use
187/// the diagnostics-aware variant.
188#[cfg(test)]
189pub(super) fn expand_workspace_glob(
190    root: &Path,
191    pattern: &str,
192    canonical_root: &Path,
193) -> Vec<(PathBuf, PathBuf)> {
194    let mut diagnostics = Vec::new();
195    expand_workspace_glob_with_diagnostics(
196        root,
197        pattern,
198        pattern,
199        canonical_root,
200        &globset::GlobSet::empty(),
201        &mut diagnostics,
202    )
203}
204
205/// Diagnostics-aware variant of `expand_workspace_glob` (the test-only
206/// back-compat wrapper above).
207///
208/// Emits [`WorkspaceDiagnosticKind::GlobMatchedNoPackageJson`] when a glob match
209/// resolves to a directory that contains no `package.json`, with two filters
210/// applied first:
211/// 1. The directory's leaf name is checked against [`is_skip_listed_dir`]
212///    (build artifacts, tooling caches, hidden directories). pnpm/npm/yarn
213///    silently filter the same set; fallow follows suit.
214/// 2. The project-root-relative path is checked against `ignore_patterns`.
215///    User-excluded paths produce no diagnostic.
216///
217/// `raw_pattern` is the user-supplied glob (e.g. `packages/*`) and goes into the
218/// diagnostic's message; `expanded_pattern` is the normalized glob string used
219/// for matching (e.g. `packages/*` after trailing-slash expansion).
220pub(super) fn expand_workspace_glob_with_diagnostics(
221    root: &Path,
222    raw_pattern: &str,
223    expanded_pattern: &str,
224    canonical_root: &Path,
225    ignore_patterns: &globset::GlobSet,
226    diagnostics: &mut Vec<WorkspaceDiagnostic>,
227) -> Vec<(PathBuf, PathBuf)> {
228    // For patterns with `**`, use a manual walk that prunes node_modules
229    // during traversal. The glob crate walks into node_modules before
230    // filtering, which is catastrophic with pnpm's deep symlink trees
231    // (50,000+ entries for `packages/**/*` in starlight).
232    if expanded_pattern.contains("**") {
233        return expand_recursive_workspace_pattern(
234            root,
235            raw_pattern,
236            expanded_pattern,
237            canonical_root,
238            ignore_patterns,
239            diagnostics,
240        );
241    }
242
243    let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
244    match glob::glob(&full_pattern) {
245        Ok(paths) => {
246            let mut results = Vec::new();
247            for path in paths.filter_map(Result::ok) {
248                if !path.is_dir() {
249                    continue;
250                }
251                if path.components().any(|c| c.as_os_str() == "node_modules") {
252                    continue;
253                }
254                if path.join("package.json").exists() {
255                    if let Some(cp) = dunce::canonicalize(&path)
256                        .ok()
257                        .filter(|cp| cp.starts_with(canonical_root))
258                    {
259                        results.push((path, cp));
260                    }
261                    continue;
262                }
263                maybe_emit_glob_no_pkg_diag(root, raw_pattern, &path, ignore_patterns, diagnostics);
264            }
265            results
266        }
267        Err(e) => {
268            tracing::warn!("invalid workspace glob pattern '{raw_pattern}': {e}");
269            Vec::new()
270        }
271    }
272}
273
274/// Emit a `glob-matched-no-package-json` diagnostic if the path is neither
275/// in the conventional skip list nor in the user `ignorePatterns`.
276///
277/// Path normalisation: macOS canonicalises `/tmp/<repo>` to
278/// `/private/tmp/<repo>`. If `root` was supplied as the canonical form (the
279/// CLI prints `/private/...` in `loaded config:` confirming this) but the
280/// `glob::glob` paths use the symlinked `/tmp/...` form, a naive
281/// `path.strip_prefix(root)` falls through to the full absolute path and the
282/// `ignorePatterns` check misses. Canonicalise both before stripping so the
283/// suppression contract holds end-to-end.
284fn maybe_emit_glob_no_pkg_diag(
285    root: &Path,
286    raw_pattern: &str,
287    path: &Path,
288    ignore_patterns: &globset::GlobSet,
289    diagnostics: &mut Vec<WorkspaceDiagnostic>,
290) {
291    let leaf = path
292        .file_name()
293        .map(|n| n.to_string_lossy().into_owned())
294        .unwrap_or_default();
295    if is_skip_listed_dir(&leaf) {
296        return;
297    }
298    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
299    let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
300    let relative = canonical_path
301        .strip_prefix(&canonical_root)
302        .unwrap_or(canonical_path.as_path())
303        .to_path_buf();
304    if is_ignored_workspace_dir(&relative, ignore_patterns) {
305        return;
306    }
307    let diag = WorkspaceDiagnostic::new(
308        root,
309        path.to_path_buf(),
310        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
311            pattern: raw_pattern.to_string(),
312        },
313    );
314    diagnostics.push(diag);
315}
316
317/// Expand a recursive workspace glob pattern (containing `**`) by walking the
318/// directory tree manually, pruning `node_modules` during traversal.
319///
320/// This avoids the `glob` crate's O(n) expansion where n includes all files
321/// inside `node_modules/` (catastrophic with pnpm's deep symlink trees).
322fn expand_recursive_workspace_pattern(
323    root: &Path,
324    raw_pattern: &str,
325    expanded_pattern: &str,
326    canonical_root: &Path,
327    ignore_patterns: &globset::GlobSet,
328    diagnostics: &mut Vec<WorkspaceDiagnostic>,
329) -> Vec<(PathBuf, PathBuf)> {
330    let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
331    let Ok(matcher) = glob::Pattern::new(&full_pattern) else {
332        tracing::warn!("invalid workspace glob pattern '{raw_pattern}'");
333        return Vec::new();
334    };
335
336    // Extract the base directory before the first `*` to avoid scanning from root
337    let base_dir = match expanded_pattern.find('*') {
338        Some(idx) => root.join(&expanded_pattern[..idx]),
339        None => root.join(expanded_pattern),
340    };
341
342    let mut results = Vec::new();
343    walk_workspace_dirs(
344        root,
345        &base_dir,
346        raw_pattern,
347        &matcher,
348        canonical_root,
349        ignore_patterns,
350        &mut results,
351        diagnostics,
352    );
353    results
354}
355
356/// Recursively walk directories, skipping `node_modules` and `.git`, collecting
357/// directories that match the glob pattern and contain a `package.json`.
358///
359/// Glob-matched directories without `package.json` are surfaced as
360/// `glob-matched-no-package-json` diagnostics unless they are in the
361/// conventional skip list or covered by `ignore_patterns`.
362#[expect(
363    clippy::too_many_arguments,
364    reason = "internal recursion that threads diagnostic accumulator + ignore globset; refactoring into a context struct would obscure the recursive call site"
365)]
366fn walk_workspace_dirs(
367    root: &Path,
368    dir: &Path,
369    raw_pattern: &str,
370    matcher: &glob::Pattern,
371    canonical_root: &Path,
372    ignore_patterns: &globset::GlobSet,
373    results: &mut Vec<(PathBuf, PathBuf)>,
374    diagnostics: &mut Vec<WorkspaceDiagnostic>,
375) {
376    let Ok(entries) = std::fs::read_dir(dir) else {
377        return;
378    };
379    for entry in entries.flatten() {
380        let path = entry.path();
381        if !path.is_dir() {
382            continue;
383        }
384        let name = entry.file_name();
385        // Prune node_modules and hidden directories during traversal
386        if name == "node_modules" || name == ".git" {
387            continue;
388        }
389        // Check if this directory matches the pattern.
390        if matcher.matches_path(&path) {
391            if path.join("package.json").exists() {
392                if let Ok(cp) = dunce::canonicalize(&path)
393                    && cp.starts_with(canonical_root)
394                {
395                    results.push((path.clone(), cp));
396                }
397            } else {
398                maybe_emit_glob_no_pkg_diag(root, raw_pattern, &path, ignore_patterns, diagnostics);
399            }
400        }
401        // Continue recursing into subdirectories
402        walk_workspace_dirs(
403            root,
404            &path,
405            raw_pattern,
406            matcher,
407            canonical_root,
408            ignore_patterns,
409            results,
410            diagnostics,
411        );
412    }
413}
414
415/// Parse pnpm-workspace.yaml to extract package patterns.
416pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
417    // Simple YAML parsing for the common format:
418    // packages:
419    //   - 'packages/*'
420    //   - 'apps/*'
421    let mut patterns = Vec::new();
422    let mut in_packages = false;
423
424    for line in content.lines() {
425        let trimmed = line.trim();
426        if trimmed == "packages:" {
427            in_packages = true;
428            continue;
429        }
430        if in_packages {
431            if trimmed.starts_with("- ") {
432                let value = trimmed
433                    .strip_prefix("- ")
434                    .unwrap_or(trimmed)
435                    .trim_matches('\'')
436                    .trim_matches('"');
437                patterns.push(value.to_string());
438            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
439                break; // New top-level key
440            }
441        }
442    }
443
444    patterns
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn parse_pnpm_workspace_basic() {
453        let yaml = "packages:\n  - 'packages/*'\n  - 'apps/*'\n";
454        let patterns = parse_pnpm_workspace_yaml(yaml);
455        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
456    }
457
458    #[test]
459    fn parse_pnpm_workspace_double_quotes() {
460        let yaml = "packages:\n  - \"packages/*\"\n  - \"apps/*\"\n";
461        let patterns = parse_pnpm_workspace_yaml(yaml);
462        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
463    }
464
465    #[test]
466    fn parse_pnpm_workspace_no_quotes() {
467        let yaml = "packages:\n  - packages/*\n  - apps/*\n";
468        let patterns = parse_pnpm_workspace_yaml(yaml);
469        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
470    }
471
472    #[test]
473    fn parse_pnpm_workspace_empty() {
474        let yaml = "";
475        let patterns = parse_pnpm_workspace_yaml(yaml);
476        assert!(patterns.is_empty());
477    }
478
479    #[test]
480    fn parse_pnpm_workspace_no_packages_key() {
481        let yaml = "other:\n  - something\n";
482        let patterns = parse_pnpm_workspace_yaml(yaml);
483        assert!(patterns.is_empty());
484    }
485
486    #[test]
487    fn parse_pnpm_workspace_with_comments() {
488        let yaml = "packages:\n  # Comment\n  - 'packages/*'\n";
489        let patterns = parse_pnpm_workspace_yaml(yaml);
490        assert_eq!(patterns, vec!["packages/*"]);
491    }
492
493    #[test]
494    fn parse_pnpm_workspace_stops_at_next_key() {
495        let yaml = "packages:\n  - 'packages/*'\ncatalog:\n  react: ^18\n";
496        let patterns = parse_pnpm_workspace_yaml(yaml);
497        assert_eq!(patterns, vec!["packages/*"]);
498    }
499
500    #[test]
501    fn strip_trailing_commas_basic() {
502        assert_eq!(
503            strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
504            r#"{"a": 1, "b": 2}"#
505        );
506    }
507
508    #[test]
509    fn strip_trailing_commas_array() {
510        assert_eq!(strip_trailing_commas(r"[1, 2, 3,]"), r"[1, 2, 3]");
511    }
512
513    #[test]
514    fn strip_trailing_commas_with_whitespace() {
515        assert_eq!(
516            strip_trailing_commas("{\n  \"a\": 1,\n}"),
517            "{\n  \"a\": 1\n}"
518        );
519    }
520
521    #[test]
522    fn strip_trailing_commas_preserves_strings() {
523        // Commas inside strings should NOT be stripped
524        assert_eq!(
525            strip_trailing_commas(r#"{"a": "hello,}"}"#),
526            r#"{"a": "hello,}"}"#
527        );
528    }
529
530    #[test]
531    fn strip_trailing_commas_nested() {
532        let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
533        let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
534        assert_eq!(strip_trailing_commas(input), expected);
535    }
536
537    #[test]
538    fn strip_trailing_commas_escaped_quotes() {
539        assert_eq!(
540            strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
541            r#"{"a": "he\"llo,}"}"#
542        );
543    }
544
545    #[test]
546    fn tsconfig_references_from_dir() {
547        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
548        let _ = std::fs::remove_dir_all(&temp_dir);
549        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
550        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
551
552        std::fs::write(
553            temp_dir.join("tsconfig.json"),
554            r#"{
555                // Root tsconfig with project references
556                "references": [
557                    {"path": "./packages/core"},
558                    {"path": "./packages/ui"},
559                ],
560            }"#,
561        )
562        .unwrap();
563
564        let refs = parse_tsconfig_references(&temp_dir);
565        assert_eq!(refs.len(), 2);
566        assert!(refs.iter().any(|p| p.ends_with("packages/core")));
567        assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
568
569        let _ = std::fs::remove_dir_all(&temp_dir);
570    }
571
572    #[test]
573    fn tsconfig_references_no_file() {
574        let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
575        assert!(refs.is_empty());
576    }
577
578    #[test]
579    fn tsconfig_references_no_references_field() {
580        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
581        let _ = std::fs::remove_dir_all(&temp_dir);
582        std::fs::create_dir_all(&temp_dir).unwrap();
583
584        std::fs::write(
585            temp_dir.join("tsconfig.json"),
586            r#"{"compilerOptions": {"strict": true}}"#,
587        )
588        .unwrap();
589
590        let refs = parse_tsconfig_references(&temp_dir);
591        assert!(refs.is_empty());
592
593        let _ = std::fs::remove_dir_all(&temp_dir);
594    }
595
596    #[test]
597    fn tsconfig_references_skips_nonexistent_dirs() {
598        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
599        let _ = std::fs::remove_dir_all(&temp_dir);
600        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
601
602        std::fs::write(
603            temp_dir.join("tsconfig.json"),
604            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
605        )
606        .unwrap();
607
608        let refs = parse_tsconfig_references(&temp_dir);
609        assert_eq!(refs.len(), 1);
610        assert!(refs[0].ends_with("packages/core"));
611
612        let _ = std::fs::remove_dir_all(&temp_dir);
613    }
614
615    #[test]
616    fn strip_trailing_commas_no_commas() {
617        let input = r#"{"a": 1, "b": [2, 3]}"#;
618        assert_eq!(strip_trailing_commas(input), input);
619    }
620
621    #[test]
622    fn strip_trailing_commas_empty_input() {
623        assert_eq!(strip_trailing_commas(""), "");
624    }
625
626    #[test]
627    fn strip_trailing_commas_nested_objects() {
628        let input = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2,\n  },\n  \"d\": 3,\n}";
629        let expected = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2\n  },\n  \"d\": 3\n}";
630        assert_eq!(strip_trailing_commas(input), expected);
631    }
632
633    #[test]
634    fn strip_trailing_commas_array_of_objects() {
635        let input = r#"[{"a": 1,}, {"b": 2,},]"#;
636        let expected = r#"[{"a": 1}, {"b": 2}]"#;
637        assert_eq!(strip_trailing_commas(input), expected);
638    }
639
640    #[test]
641    fn tsconfig_references_malformed_json() {
642        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-malformed");
643        let _ = std::fs::remove_dir_all(&temp_dir);
644        std::fs::create_dir_all(&temp_dir).unwrap();
645
646        std::fs::write(
647            temp_dir.join("tsconfig.json"),
648            r"{ this is not valid json at all",
649        )
650        .unwrap();
651
652        let refs = parse_tsconfig_references(&temp_dir);
653        assert!(refs.is_empty());
654
655        let _ = std::fs::remove_dir_all(&temp_dir);
656    }
657
658    #[test]
659    fn tsconfig_references_empty_array() {
660        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-empty-refs");
661        let _ = std::fs::remove_dir_all(&temp_dir);
662        std::fs::create_dir_all(&temp_dir).unwrap();
663
664        std::fs::write(temp_dir.join("tsconfig.json"), r#"{"references": []}"#).unwrap();
665
666        let refs = parse_tsconfig_references(&temp_dir);
667        assert!(refs.is_empty());
668
669        let _ = std::fs::remove_dir_all(&temp_dir);
670    }
671
672    #[test]
673    fn parse_pnpm_workspace_malformed() {
674        // Garbage input should return empty, not panic
675        let patterns = parse_pnpm_workspace_yaml(":::not yaml at all:::");
676        assert!(patterns.is_empty());
677    }
678
679    #[test]
680    fn parse_pnpm_workspace_packages_key_empty_list() {
681        let yaml = "packages:\nother:\n  - something\n";
682        let patterns = parse_pnpm_workspace_yaml(yaml);
683        assert!(patterns.is_empty());
684    }
685
686    #[test]
687    fn expand_workspace_glob_exact_path() {
688        let temp_dir = std::env::temp_dir().join("fallow-test-expand-exact");
689        let _ = std::fs::remove_dir_all(&temp_dir);
690        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
691        std::fs::write(
692            temp_dir.join("packages/core/package.json"),
693            r#"{"name": "core"}"#,
694        )
695        .unwrap();
696
697        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
698        let results = expand_workspace_glob(&temp_dir, "packages/core", &canonical_root);
699        assert_eq!(results.len(), 1);
700        assert!(results[0].0.ends_with("packages/core"));
701
702        let _ = std::fs::remove_dir_all(&temp_dir);
703    }
704
705    #[test]
706    fn expand_workspace_glob_star() {
707        let temp_dir = std::env::temp_dir().join("fallow-test-expand-star");
708        let _ = std::fs::remove_dir_all(&temp_dir);
709        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
710        std::fs::create_dir_all(temp_dir.join("packages/b")).unwrap();
711        std::fs::create_dir_all(temp_dir.join("packages/c")).unwrap();
712        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
713        std::fs::write(temp_dir.join("packages/b/package.json"), r#"{"name": "b"}"#).unwrap();
714        // c has no package.json — should be excluded
715
716        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
717        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
718        assert_eq!(results.len(), 2);
719
720        let _ = std::fs::remove_dir_all(&temp_dir);
721    }
722
723    #[test]
724    fn expand_workspace_glob_nested() {
725        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested");
726        let _ = std::fs::remove_dir_all(&temp_dir);
727        std::fs::create_dir_all(temp_dir.join("packages/scope/a")).unwrap();
728        std::fs::create_dir_all(temp_dir.join("packages/scope/b")).unwrap();
729        std::fs::write(
730            temp_dir.join("packages/scope/a/package.json"),
731            r#"{"name": "@scope/a"}"#,
732        )
733        .unwrap();
734        std::fs::write(
735            temp_dir.join("packages/scope/b/package.json"),
736            r#"{"name": "@scope/b"}"#,
737        )
738        .unwrap();
739
740        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
741        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
742        assert_eq!(results.len(), 2);
743
744        let _ = std::fs::remove_dir_all(&temp_dir);
745    }
746
747    // ── parse_tsconfig_root_dir ──────────────────────────────────
748
749    #[test]
750    fn tsconfig_root_dir_extracted() {
751        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir");
752        let _ = std::fs::remove_dir_all(&temp_dir);
753        std::fs::create_dir_all(&temp_dir).unwrap();
754
755        std::fs::write(
756            temp_dir.join("tsconfig.json"),
757            r#"{ "compilerOptions": { "rootDir": "./src" } }"#,
758        )
759        .unwrap();
760
761        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("src".to_string()));
762        let _ = std::fs::remove_dir_all(&temp_dir);
763    }
764
765    #[test]
766    fn tsconfig_root_dir_lib() {
767        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-lib");
768        let _ = std::fs::remove_dir_all(&temp_dir);
769        std::fs::create_dir_all(&temp_dir).unwrap();
770
771        std::fs::write(
772            temp_dir.join("tsconfig.json"),
773            r#"{ "compilerOptions": { "rootDir": "lib/" } }"#,
774        )
775        .unwrap();
776
777        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("lib".to_string()));
778        let _ = std::fs::remove_dir_all(&temp_dir);
779    }
780
781    #[test]
782    fn tsconfig_root_dir_missing_field() {
783        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-nofield");
784        let _ = std::fs::remove_dir_all(&temp_dir);
785        std::fs::create_dir_all(&temp_dir).unwrap();
786
787        std::fs::write(
788            temp_dir.join("tsconfig.json"),
789            r#"{ "compilerOptions": { "strict": true } }"#,
790        )
791        .unwrap();
792
793        assert_eq!(parse_tsconfig_root_dir(&temp_dir), None);
794        let _ = std::fs::remove_dir_all(&temp_dir);
795    }
796
797    #[test]
798    fn tsconfig_root_dir_no_file() {
799        assert_eq!(parse_tsconfig_root_dir(Path::new("/nonexistent")), None);
800    }
801
802    #[test]
803    fn tsconfig_root_dir_with_comments() {
804        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-comments");
805        let _ = std::fs::remove_dir_all(&temp_dir);
806        std::fs::create_dir_all(&temp_dir).unwrap();
807
808        std::fs::write(
809            temp_dir.join("tsconfig.json"),
810            "{\n  // Root directory\n  \"compilerOptions\": { \"rootDir\": \"app\" }\n}",
811        )
812        .unwrap();
813
814        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
815        let _ = std::fs::remove_dir_all(&temp_dir);
816    }
817
818    #[test]
819    fn tsconfig_root_dir_dot_value() {
820        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-dot");
821        let _ = std::fs::remove_dir_all(&temp_dir);
822        std::fs::create_dir_all(&temp_dir).unwrap();
823
824        std::fs::write(
825            temp_dir.join("tsconfig.json"),
826            r#"{ "compilerOptions": { "rootDir": "." } }"#,
827        )
828        .unwrap();
829
830        // "." is returned as-is — caller filters it out
831        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some(".".to_string()));
832        let _ = std::fs::remove_dir_all(&temp_dir);
833    }
834
835    #[test]
836    fn tsconfig_root_dir_parent_traversal() {
837        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-parent");
838        let _ = std::fs::remove_dir_all(&temp_dir);
839        std::fs::create_dir_all(&temp_dir).unwrap();
840
841        std::fs::write(
842            temp_dir.join("tsconfig.json"),
843            r#"{ "compilerOptions": { "rootDir": "../other" } }"#,
844        )
845        .unwrap();
846
847        // Returned as-is — caller filters it out
848        assert_eq!(
849            parse_tsconfig_root_dir(&temp_dir),
850            Some("../other".to_string())
851        );
852        let _ = std::fs::remove_dir_all(&temp_dir);
853    }
854
855    #[test]
856    fn expand_workspace_glob_no_matches() {
857        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nomatch");
858        let _ = std::fs::remove_dir_all(&temp_dir);
859        std::fs::create_dir_all(&temp_dir).unwrap();
860
861        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
862        let results = expand_workspace_glob(&temp_dir, "nonexistent/*", &canonical_root);
863        assert!(results.is_empty());
864
865        let _ = std::fs::remove_dir_all(&temp_dir);
866    }
867
868    // ── parse_pnpm_workspace_yaml edge cases ────────────────────────
869
870    #[test]
871    fn parse_pnpm_workspace_with_empty_lines_between_entries() {
872        let yaml = "packages:\n  - 'packages/*'\n\n  - 'apps/*'\n";
873        let patterns = parse_pnpm_workspace_yaml(yaml);
874        // Empty lines between entries should be tolerated (they're skipped)
875        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
876    }
877
878    #[test]
879    fn parse_pnpm_workspace_mixed_quotes() {
880        let yaml = "packages:\n  - 'single/*'\n  - \"double/*\"\n  - bare/*\n";
881        let patterns = parse_pnpm_workspace_yaml(yaml);
882        assert_eq!(patterns, vec!["single/*", "double/*", "bare/*"]);
883    }
884
885    #[test]
886    fn parse_pnpm_workspace_with_negation() {
887        let yaml = "packages:\n  - 'packages/*'\n  - '!packages/test-*'\n";
888        let patterns = parse_pnpm_workspace_yaml(yaml);
889        assert_eq!(patterns, vec!["packages/*", "!packages/test-*"]);
890    }
891
892    // ── strip_trailing_commas advanced ───────────────────────────────
893
894    #[test]
895    fn strip_trailing_commas_string_with_closing_brackets() {
896        // String containing "]" and "}" should not affect comma stripping
897        let input = r#"{"key": "value with ] and }",}"#;
898        let expected = r#"{"key": "value with ] and }"}"#;
899        assert_eq!(strip_trailing_commas(input), expected);
900    }
901
902    #[test]
903    fn strip_trailing_commas_multiple_levels() {
904        let input = r#"{"a": {"b": [1, 2,], "c": 3,},}"#;
905        let expected = r#"{"a": {"b": [1, 2], "c": 3}}"#;
906        assert_eq!(strip_trailing_commas(input), expected);
907    }
908
909    // ── tsconfig_root_dir edge cases ────────────────────────────────
910
911    #[test]
912    fn tsconfig_root_dir_with_trailing_commas() {
913        let temp_dir = std::env::temp_dir().join("fallow-test-rootdir-trailing-comma");
914        let _ = std::fs::remove_dir_all(&temp_dir);
915        std::fs::create_dir_all(&temp_dir).unwrap();
916
917        std::fs::write(
918            temp_dir.join("tsconfig.json"),
919            "{\n  \"compilerOptions\": {\n    \"rootDir\": \"app\",\n  },\n}",
920        )
921        .unwrap();
922
923        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
924        let _ = std::fs::remove_dir_all(&temp_dir);
925    }
926
927    // ── expand_workspace_glob with trailing slash ────────────────────
928
929    #[test]
930    fn expand_workspace_glob_trailing_slash() {
931        let temp_dir = std::env::temp_dir().join("fallow-test-expand-trailing");
932        let _ = std::fs::remove_dir_all(&temp_dir);
933        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
934        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
935
936        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
937        // Trailing slash pattern gets `*` appended -> `packages/*`
938        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
939        assert_eq!(results.len(), 1);
940
941        let _ = std::fs::remove_dir_all(&temp_dir);
942    }
943
944    // ── expand_workspace_glob excludes node_modules ──────────────────
945
946    #[test]
947    fn expand_workspace_glob_excludes_node_modules() {
948        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-nodemod");
949        let _ = std::fs::remove_dir_all(&temp_dir);
950
951        // Nested node_modules package — should be excluded
952        let nm_pkg = temp_dir.join("packages/foo/node_modules/bar");
953        std::fs::create_dir_all(&nm_pkg).unwrap();
954        std::fs::write(nm_pkg.join("package.json"), r#"{"name":"bar"}"#).unwrap();
955
956        // Legitimate workspace package — should be included
957        let ws_pkg = temp_dir.join("packages/foo");
958        std::fs::write(ws_pkg.join("package.json"), r#"{"name":"foo"}"#).unwrap();
959
960        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
961        let results = expand_workspace_glob(&temp_dir, "packages/**", &canonical_root);
962
963        assert!(results.iter().any(|(_orig, canon)| {
964            canon
965                .to_string_lossy()
966                .replace('\\', "/")
967                .contains("packages/foo")
968                && !canon.to_string_lossy().contains("node_modules")
969        }));
970        assert!(
971            !results
972                .iter()
973                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules"))
974        );
975
976        let _ = std::fs::remove_dir_all(&temp_dir);
977    }
978
979    // ── expand_workspace_glob skips dirs without package.json ────────
980
981    #[test]
982    fn expand_workspace_glob_skips_dirs_without_pkg() {
983        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-pkg");
984        let _ = std::fs::remove_dir_all(&temp_dir);
985        std::fs::create_dir_all(temp_dir.join("packages/with-pkg")).unwrap();
986        std::fs::create_dir_all(temp_dir.join("packages/without-pkg")).unwrap();
987        std::fs::write(
988            temp_dir.join("packages/with-pkg/package.json"),
989            r#"{"name": "with"}"#,
990        )
991        .unwrap();
992        // packages/without-pkg has no package.json
993
994        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
995        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
996        assert_eq!(results.len(), 1);
997        assert!(
998            results[0]
999                .0
1000                .to_string_lossy()
1001                .replace('\\', "/")
1002                .ends_with("packages/with-pkg")
1003        );
1004
1005        let _ = std::fs::remove_dir_all(&temp_dir);
1006    }
1007
1008    // ── expand_workspace_glob prunes node_modules with ** patterns ───
1009
1010    #[test]
1011    fn expand_recursive_glob_prunes_node_modules() {
1012        // When using `packages/**/*` the manual walk should prune
1013        // `node_modules` during traversal, so a package.json inside
1014        // `packages/app/node_modules/dep/` is never returned.
1015        let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
1016        let _ = std::fs::remove_dir_all(&temp_dir);
1017
1018        // Legitimate workspace packages
1019        std::fs::create_dir_all(temp_dir.join("packages/app")).unwrap();
1020        std::fs::write(
1021            temp_dir.join("packages/app/package.json"),
1022            r#"{"name": "app"}"#,
1023        )
1024        .unwrap();
1025        std::fs::create_dir_all(temp_dir.join("packages/lib")).unwrap();
1026        std::fs::write(
1027            temp_dir.join("packages/lib/package.json"),
1028            r#"{"name": "lib"}"#,
1029        )
1030        .unwrap();
1031
1032        // Nested node_modules dependency (should be pruned)
1033        let nm_dep = temp_dir.join("packages/app/node_modules/dep");
1034        std::fs::create_dir_all(&nm_dep).unwrap();
1035        std::fs::write(nm_dep.join("package.json"), r#"{"name": "dep"}"#).unwrap();
1036
1037        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1038        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1039
1040        // Should find exactly the two legitimate workspace packages
1041        let found_names: Vec<String> = results
1042            .iter()
1043            .map(|(orig, _)| orig.file_name().unwrap().to_string_lossy().to_string())
1044            .collect();
1045        assert!(
1046            found_names.contains(&"app".to_string()),
1047            "should find packages/app"
1048        );
1049        assert!(
1050            found_names.contains(&"lib".to_string()),
1051            "should find packages/lib"
1052        );
1053        assert!(
1054            !results
1055                .iter()
1056                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules")),
1057            "should NOT include packages inside node_modules"
1058        );
1059        assert_eq!(
1060            results.len(),
1061            2,
1062            "should find exactly 2 workspace packages (node_modules pruned)"
1063        );
1064
1065        let _ = std::fs::remove_dir_all(&temp_dir);
1066    }
1067
1068    #[test]
1069    fn expand_recursive_glob_preserves_nested_workspace_roots() {
1070        let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-workspace-prune");
1071        let _ = std::fs::remove_dir_all(&temp_dir);
1072
1073        std::fs::create_dir_all(temp_dir.join("apps/app/packages/nested")).unwrap();
1074        std::fs::write(temp_dir.join("apps/app/package.json"), r#"{"name":"app"}"#).unwrap();
1075        std::fs::write(
1076            temp_dir.join("apps/app/packages/nested/package.json"),
1077            r#"{"name":"nested"}"#,
1078        )
1079        .unwrap();
1080
1081        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1082        let results = expand_workspace_glob(&temp_dir, "apps/**", &canonical_root);
1083        let mut paths: Vec<_> = results
1084            .iter()
1085            .map(|(path, _)| path.strip_prefix(&temp_dir).unwrap().to_path_buf())
1086            .collect();
1087        paths.sort();
1088
1089        assert_eq!(
1090            paths,
1091            vec![
1092                PathBuf::from("apps/app"),
1093                PathBuf::from("apps/app/packages/nested")
1094            ]
1095        );
1096
1097        let _ = std::fs::remove_dir_all(&temp_dir);
1098    }
1099
1100    #[test]
1101    fn expand_recursive_glob_prunes_deeply_nested_node_modules() {
1102        // Even deeply nested node_modules (e.g., pnpm's deep symlink trees)
1103        // should be pruned during the walk.
1104        let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
1105        let _ = std::fs::remove_dir_all(&temp_dir);
1106
1107        // Legitimate workspace package
1108        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
1109        std::fs::write(
1110            temp_dir.join("packages/core/package.json"),
1111            r#"{"name": "core"}"#,
1112        )
1113        .unwrap();
1114
1115        // Deeply nested node_modules (simulates pnpm virtual store)
1116        let deep_nm = temp_dir.join("packages/core/node_modules/.pnpm/react@18/node_modules/react");
1117        std::fs::create_dir_all(&deep_nm).unwrap();
1118        std::fs::write(deep_nm.join("package.json"), r#"{"name": "react"}"#).unwrap();
1119
1120        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1121        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1122
1123        assert_eq!(
1124            results.len(),
1125            1,
1126            "should find exactly 1 workspace package, pruning deep node_modules"
1127        );
1128        assert!(
1129            results[0]
1130                .0
1131                .to_string_lossy()
1132                .replace('\\', "/")
1133                .ends_with("packages/core"),
1134            "the single result should be packages/core"
1135        );
1136
1137        let _ = std::fs::remove_dir_all(&temp_dir);
1138    }
1139}