Skip to main content

fallow_config/workspace/
parsers.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4/// Parse `tsconfig.json` at the project root and extract `references[].path` directories.
5///
6/// Returns directories that exist on disk. tsconfig.json is JSONC (comments + trailing commas),
7/// so we strip both before parsing.
8pub(super) fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
9    let tsconfig_path = root.join("tsconfig.json");
10    let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
11        return Vec::new();
12    };
13
14    // Strip UTF-8 BOM if present (common in Windows-authored tsconfig files)
15    let content = content.trim_start_matches('\u{FEFF}');
16
17    // Strip JSONC comments
18    let mut stripped = String::new();
19    if json_comments::StripComments::new(content.as_bytes())
20        .read_to_string(&mut stripped)
21        .is_err()
22    {
23        return Vec::new();
24    }
25
26    // Strip trailing commas (common in tsconfig.json)
27    let cleaned = strip_trailing_commas(&stripped);
28
29    let Ok(value) = serde_json::from_str::<serde_json::Value>(&cleaned) else {
30        return Vec::new();
31    };
32
33    let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
34        return Vec::new();
35    };
36
37    refs.iter()
38        .filter_map(|r| {
39            r.get("path").and_then(|p| p.as_str()).map(|p| {
40                // strip_prefix removes exactly one leading "./" (unlike trim_start_matches
41                // which would strip repeatedly)
42                let cleaned = p.strip_prefix("./").unwrap_or(p);
43                root.join(cleaned)
44            })
45        })
46        .filter(|p| p.is_dir())
47        .collect()
48}
49
50/// Parse `tsconfig.json` at the project root and extract `compilerOptions.rootDir`.
51///
52/// Returns `None` if the file is missing, malformed, or has no `rootDir` set.
53/// Strips JSONC comments and trailing commas before parsing.
54pub fn parse_tsconfig_root_dir(root: &Path) -> Option<String> {
55    let tsconfig_path = root.join("tsconfig.json");
56    let content = std::fs::read_to_string(&tsconfig_path).ok()?;
57    let content = content.trim_start_matches('\u{FEFF}');
58
59    let mut stripped = String::new();
60    json_comments::StripComments::new(content.as_bytes())
61        .read_to_string(&mut stripped)
62        .ok()?;
63
64    let cleaned = strip_trailing_commas(&stripped);
65    let value: serde_json::Value = serde_json::from_str(&cleaned).ok()?;
66
67    value
68        .get("compilerOptions")
69        .and_then(|opts| opts.get("rootDir"))
70        .and_then(|v| v.as_str())
71        .map(|s| {
72            s.strip_prefix("./")
73                .unwrap_or(s)
74                .trim_end_matches('/')
75                .to_owned()
76        })
77}
78
79/// Strip trailing commas before `]` and `}` in JSON-like content.
80///
81/// tsconfig.json commonly uses trailing commas which are valid JSONC but not valid JSON.
82/// This strips them so `serde_json` can parse the content.
83pub(super) fn strip_trailing_commas(input: &str) -> String {
84    let bytes = input.as_bytes();
85    let len = bytes.len();
86    let mut result = Vec::with_capacity(len);
87    let mut in_string = false;
88    let mut i = 0;
89
90    while i < len {
91        let b = bytes[i];
92
93        if in_string {
94            result.push(b);
95            if b == b'\\' && i + 1 < len {
96                // Push escaped character and skip it
97                i += 1;
98                result.push(bytes[i]);
99            } else if b == b'"' {
100                in_string = false;
101            }
102            i += 1;
103            continue;
104        }
105
106        if b == b'"' {
107            in_string = true;
108            result.push(b);
109            i += 1;
110            continue;
111        }
112
113        if b == b',' {
114            // Look ahead past whitespace for ] or }
115            let mut j = i + 1;
116            while j < len && bytes[j].is_ascii_whitespace() {
117                j += 1;
118            }
119            if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
120                // Skip the trailing comma
121                i += 1;
122                continue;
123            }
124        }
125
126        result.push(b);
127        i += 1;
128    }
129
130    // We only removed ASCII commas and preserved all other bytes unchanged,
131    // so the result is valid UTF-8 if the input was. Use from_utf8 to be safe.
132    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
133}
134
135/// Expand a workspace glob pattern to matching directories.
136///
137/// Returns `(original_path, canonical_path)` tuples so callers can skip redundant
138/// `canonicalize()` calls. Only directories containing a `package.json` are
139/// canonicalized — this avoids expensive syscalls on the many non-workspace
140/// directories that globs like `packages/*` or `**` can match.
141///
142/// `canonical_root` is pre-computed to avoid repeated `canonicalize()` syscalls.
143pub(super) fn expand_workspace_glob(
144    root: &Path,
145    pattern: &str,
146    canonical_root: &Path,
147) -> Vec<(PathBuf, PathBuf)> {
148    // For patterns with `**`, use a manual walk that prunes node_modules
149    // during traversal. The glob crate walks into node_modules before
150    // filtering, which is catastrophic with pnpm's deep symlink trees
151    // (50,000+ entries for `packages/**/*` in starlight).
152    if pattern.contains("**") {
153        return expand_recursive_workspace_pattern(root, pattern, canonical_root);
154    }
155
156    let full_pattern = root.join(pattern).to_string_lossy().to_string();
157    match glob::glob(&full_pattern) {
158        Ok(paths) => paths
159            .filter_map(Result::ok)
160            .filter(|p| p.is_dir())
161            .filter(|p| !p.components().any(|c| c.as_os_str() == "node_modules"))
162            .filter(|p| p.join("package.json").exists())
163            .filter_map(|p| {
164                dunce::canonicalize(&p)
165                    .ok()
166                    .filter(|cp| cp.starts_with(canonical_root))
167                    .map(|cp| (p, cp))
168            })
169            .collect(),
170        Err(e) => {
171            tracing::warn!("invalid workspace glob pattern '{pattern}': {e}");
172            Vec::new()
173        }
174    }
175}
176
177/// Expand a recursive workspace glob pattern (containing `**`) by walking the
178/// directory tree manually, pruning `node_modules` during traversal.
179///
180/// This avoids the `glob` crate's O(n) expansion where n includes all files
181/// inside `node_modules/` (catastrophic with pnpm's deep symlink trees).
182fn expand_recursive_workspace_pattern(
183    root: &Path,
184    pattern: &str,
185    canonical_root: &Path,
186) -> Vec<(PathBuf, PathBuf)> {
187    let full_pattern = root.join(pattern).to_string_lossy().to_string();
188    let Ok(matcher) = glob::Pattern::new(&full_pattern) else {
189        tracing::warn!("invalid workspace glob pattern '{pattern}'");
190        return Vec::new();
191    };
192
193    // Extract the base directory before the first `*` to avoid scanning from root
194    let base_dir = match pattern.find('*') {
195        Some(idx) => root.join(&pattern[..idx]),
196        None => root.join(pattern),
197    };
198
199    let mut results = Vec::new();
200    walk_workspace_dirs(&base_dir, &matcher, canonical_root, &mut results);
201    results
202}
203
204/// Recursively walk directories, skipping `node_modules` and `.git`, collecting
205/// directories that match the glob pattern and contain a `package.json`.
206fn walk_workspace_dirs(
207    dir: &Path,
208    matcher: &glob::Pattern,
209    canonical_root: &Path,
210    results: &mut Vec<(PathBuf, PathBuf)>,
211) {
212    let Ok(entries) = std::fs::read_dir(dir) else {
213        return;
214    };
215    for entry in entries.flatten() {
216        let path = entry.path();
217        if !path.is_dir() {
218            continue;
219        }
220        let name = entry.file_name();
221        // Prune node_modules and hidden directories during traversal
222        if name == "node_modules" || name == ".git" {
223            continue;
224        }
225        // Check if this directory matches the pattern and has package.json
226        if matcher.matches_path(&path)
227            && path.join("package.json").exists()
228            && let Ok(cp) = dunce::canonicalize(&path)
229            && cp.starts_with(canonical_root)
230        {
231            results.push((path.clone(), cp));
232        }
233        // Continue recursing into subdirectories
234        walk_workspace_dirs(&path, matcher, canonical_root, results);
235    }
236}
237
238/// Parse pnpm-workspace.yaml to extract package patterns.
239pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
240    // Simple YAML parsing for the common format:
241    // packages:
242    //   - 'packages/*'
243    //   - 'apps/*'
244    let mut patterns = Vec::new();
245    let mut in_packages = false;
246
247    for line in content.lines() {
248        let trimmed = line.trim();
249        if trimmed == "packages:" {
250            in_packages = true;
251            continue;
252        }
253        if in_packages {
254            if trimmed.starts_with("- ") {
255                let value = trimmed
256                    .strip_prefix("- ")
257                    .unwrap_or(trimmed)
258                    .trim_matches('\'')
259                    .trim_matches('"');
260                patterns.push(value.to_string());
261            } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
262                break; // New top-level key
263            }
264        }
265    }
266
267    patterns
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn parse_pnpm_workspace_basic() {
276        let yaml = "packages:\n  - 'packages/*'\n  - 'apps/*'\n";
277        let patterns = parse_pnpm_workspace_yaml(yaml);
278        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
279    }
280
281    #[test]
282    fn parse_pnpm_workspace_double_quotes() {
283        let yaml = "packages:\n  - \"packages/*\"\n  - \"apps/*\"\n";
284        let patterns = parse_pnpm_workspace_yaml(yaml);
285        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
286    }
287
288    #[test]
289    fn parse_pnpm_workspace_no_quotes() {
290        let yaml = "packages:\n  - packages/*\n  - apps/*\n";
291        let patterns = parse_pnpm_workspace_yaml(yaml);
292        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
293    }
294
295    #[test]
296    fn parse_pnpm_workspace_empty() {
297        let yaml = "";
298        let patterns = parse_pnpm_workspace_yaml(yaml);
299        assert!(patterns.is_empty());
300    }
301
302    #[test]
303    fn parse_pnpm_workspace_no_packages_key() {
304        let yaml = "other:\n  - something\n";
305        let patterns = parse_pnpm_workspace_yaml(yaml);
306        assert!(patterns.is_empty());
307    }
308
309    #[test]
310    fn parse_pnpm_workspace_with_comments() {
311        let yaml = "packages:\n  # Comment\n  - 'packages/*'\n";
312        let patterns = parse_pnpm_workspace_yaml(yaml);
313        assert_eq!(patterns, vec!["packages/*"]);
314    }
315
316    #[test]
317    fn parse_pnpm_workspace_stops_at_next_key() {
318        let yaml = "packages:\n  - 'packages/*'\ncatalog:\n  react: ^18\n";
319        let patterns = parse_pnpm_workspace_yaml(yaml);
320        assert_eq!(patterns, vec!["packages/*"]);
321    }
322
323    #[test]
324    fn strip_trailing_commas_basic() {
325        assert_eq!(
326            strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
327            r#"{"a": 1, "b": 2}"#
328        );
329    }
330
331    #[test]
332    fn strip_trailing_commas_array() {
333        assert_eq!(strip_trailing_commas(r"[1, 2, 3,]"), r"[1, 2, 3]");
334    }
335
336    #[test]
337    fn strip_trailing_commas_with_whitespace() {
338        assert_eq!(
339            strip_trailing_commas("{\n  \"a\": 1,\n}"),
340            "{\n  \"a\": 1\n}"
341        );
342    }
343
344    #[test]
345    fn strip_trailing_commas_preserves_strings() {
346        // Commas inside strings should NOT be stripped
347        assert_eq!(
348            strip_trailing_commas(r#"{"a": "hello,}"}"#),
349            r#"{"a": "hello,}"}"#
350        );
351    }
352
353    #[test]
354    fn strip_trailing_commas_nested() {
355        let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
356        let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
357        assert_eq!(strip_trailing_commas(input), expected);
358    }
359
360    #[test]
361    fn strip_trailing_commas_escaped_quotes() {
362        assert_eq!(
363            strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
364            r#"{"a": "he\"llo,}"}"#
365        );
366    }
367
368    #[test]
369    fn tsconfig_references_from_dir() {
370        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
371        let _ = std::fs::remove_dir_all(&temp_dir);
372        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
373        std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
374
375        std::fs::write(
376            temp_dir.join("tsconfig.json"),
377            r#"{
378                // Root tsconfig with project references
379                "references": [
380                    {"path": "./packages/core"},
381                    {"path": "./packages/ui"},
382                ],
383            }"#,
384        )
385        .unwrap();
386
387        let refs = parse_tsconfig_references(&temp_dir);
388        assert_eq!(refs.len(), 2);
389        assert!(refs.iter().any(|p| p.ends_with("packages/core")));
390        assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
391
392        let _ = std::fs::remove_dir_all(&temp_dir);
393    }
394
395    #[test]
396    fn tsconfig_references_no_file() {
397        let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
398        assert!(refs.is_empty());
399    }
400
401    #[test]
402    fn tsconfig_references_no_references_field() {
403        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
404        let _ = std::fs::remove_dir_all(&temp_dir);
405        std::fs::create_dir_all(&temp_dir).unwrap();
406
407        std::fs::write(
408            temp_dir.join("tsconfig.json"),
409            r#"{"compilerOptions": {"strict": true}}"#,
410        )
411        .unwrap();
412
413        let refs = parse_tsconfig_references(&temp_dir);
414        assert!(refs.is_empty());
415
416        let _ = std::fs::remove_dir_all(&temp_dir);
417    }
418
419    #[test]
420    fn tsconfig_references_skips_nonexistent_dirs() {
421        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
422        let _ = std::fs::remove_dir_all(&temp_dir);
423        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
424
425        std::fs::write(
426            temp_dir.join("tsconfig.json"),
427            r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
428        )
429        .unwrap();
430
431        let refs = parse_tsconfig_references(&temp_dir);
432        assert_eq!(refs.len(), 1);
433        assert!(refs[0].ends_with("packages/core"));
434
435        let _ = std::fs::remove_dir_all(&temp_dir);
436    }
437
438    #[test]
439    fn strip_trailing_commas_no_commas() {
440        let input = r#"{"a": 1, "b": [2, 3]}"#;
441        assert_eq!(strip_trailing_commas(input), input);
442    }
443
444    #[test]
445    fn strip_trailing_commas_empty_input() {
446        assert_eq!(strip_trailing_commas(""), "");
447    }
448
449    #[test]
450    fn strip_trailing_commas_nested_objects() {
451        let input = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2,\n  },\n  \"d\": 3,\n}";
452        let expected = "{\n  \"a\": {\n    \"b\": 1,\n    \"c\": 2\n  },\n  \"d\": 3\n}";
453        assert_eq!(strip_trailing_commas(input), expected);
454    }
455
456    #[test]
457    fn strip_trailing_commas_array_of_objects() {
458        let input = r#"[{"a": 1,}, {"b": 2,},]"#;
459        let expected = r#"[{"a": 1}, {"b": 2}]"#;
460        assert_eq!(strip_trailing_commas(input), expected);
461    }
462
463    #[test]
464    fn tsconfig_references_malformed_json() {
465        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-malformed");
466        let _ = std::fs::remove_dir_all(&temp_dir);
467        std::fs::create_dir_all(&temp_dir).unwrap();
468
469        std::fs::write(
470            temp_dir.join("tsconfig.json"),
471            r"{ this is not valid json at all",
472        )
473        .unwrap();
474
475        let refs = parse_tsconfig_references(&temp_dir);
476        assert!(refs.is_empty());
477
478        let _ = std::fs::remove_dir_all(&temp_dir);
479    }
480
481    #[test]
482    fn tsconfig_references_empty_array() {
483        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-empty-refs");
484        let _ = std::fs::remove_dir_all(&temp_dir);
485        std::fs::create_dir_all(&temp_dir).unwrap();
486
487        std::fs::write(temp_dir.join("tsconfig.json"), r#"{"references": []}"#).unwrap();
488
489        let refs = parse_tsconfig_references(&temp_dir);
490        assert!(refs.is_empty());
491
492        let _ = std::fs::remove_dir_all(&temp_dir);
493    }
494
495    #[test]
496    fn parse_pnpm_workspace_malformed() {
497        // Garbage input should return empty, not panic
498        let patterns = parse_pnpm_workspace_yaml(":::not yaml at all:::");
499        assert!(patterns.is_empty());
500    }
501
502    #[test]
503    fn parse_pnpm_workspace_packages_key_empty_list() {
504        let yaml = "packages:\nother:\n  - something\n";
505        let patterns = parse_pnpm_workspace_yaml(yaml);
506        assert!(patterns.is_empty());
507    }
508
509    #[test]
510    fn expand_workspace_glob_exact_path() {
511        let temp_dir = std::env::temp_dir().join("fallow-test-expand-exact");
512        let _ = std::fs::remove_dir_all(&temp_dir);
513        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
514        std::fs::write(
515            temp_dir.join("packages/core/package.json"),
516            r#"{"name": "core"}"#,
517        )
518        .unwrap();
519
520        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
521        let results = expand_workspace_glob(&temp_dir, "packages/core", &canonical_root);
522        assert_eq!(results.len(), 1);
523        assert!(results[0].0.ends_with("packages/core"));
524
525        let _ = std::fs::remove_dir_all(&temp_dir);
526    }
527
528    #[test]
529    fn expand_workspace_glob_star() {
530        let temp_dir = std::env::temp_dir().join("fallow-test-expand-star");
531        let _ = std::fs::remove_dir_all(&temp_dir);
532        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
533        std::fs::create_dir_all(temp_dir.join("packages/b")).unwrap();
534        std::fs::create_dir_all(temp_dir.join("packages/c")).unwrap();
535        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
536        std::fs::write(temp_dir.join("packages/b/package.json"), r#"{"name": "b"}"#).unwrap();
537        // c has no package.json — should be excluded
538
539        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
540        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
541        assert_eq!(results.len(), 2);
542
543        let _ = std::fs::remove_dir_all(&temp_dir);
544    }
545
546    #[test]
547    fn expand_workspace_glob_nested() {
548        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested");
549        let _ = std::fs::remove_dir_all(&temp_dir);
550        std::fs::create_dir_all(temp_dir.join("packages/scope/a")).unwrap();
551        std::fs::create_dir_all(temp_dir.join("packages/scope/b")).unwrap();
552        std::fs::write(
553            temp_dir.join("packages/scope/a/package.json"),
554            r#"{"name": "@scope/a"}"#,
555        )
556        .unwrap();
557        std::fs::write(
558            temp_dir.join("packages/scope/b/package.json"),
559            r#"{"name": "@scope/b"}"#,
560        )
561        .unwrap();
562
563        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
564        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
565        assert_eq!(results.len(), 2);
566
567        let _ = std::fs::remove_dir_all(&temp_dir);
568    }
569
570    // ── parse_tsconfig_root_dir ──────────────────────────────────
571
572    #[test]
573    fn tsconfig_root_dir_extracted() {
574        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir");
575        let _ = std::fs::remove_dir_all(&temp_dir);
576        std::fs::create_dir_all(&temp_dir).unwrap();
577
578        std::fs::write(
579            temp_dir.join("tsconfig.json"),
580            r#"{ "compilerOptions": { "rootDir": "./src" } }"#,
581        )
582        .unwrap();
583
584        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("src".to_string()));
585        let _ = std::fs::remove_dir_all(&temp_dir);
586    }
587
588    #[test]
589    fn tsconfig_root_dir_lib() {
590        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-lib");
591        let _ = std::fs::remove_dir_all(&temp_dir);
592        std::fs::create_dir_all(&temp_dir).unwrap();
593
594        std::fs::write(
595            temp_dir.join("tsconfig.json"),
596            r#"{ "compilerOptions": { "rootDir": "lib/" } }"#,
597        )
598        .unwrap();
599
600        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("lib".to_string()));
601        let _ = std::fs::remove_dir_all(&temp_dir);
602    }
603
604    #[test]
605    fn tsconfig_root_dir_missing_field() {
606        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-nofield");
607        let _ = std::fs::remove_dir_all(&temp_dir);
608        std::fs::create_dir_all(&temp_dir).unwrap();
609
610        std::fs::write(
611            temp_dir.join("tsconfig.json"),
612            r#"{ "compilerOptions": { "strict": true } }"#,
613        )
614        .unwrap();
615
616        assert_eq!(parse_tsconfig_root_dir(&temp_dir), None);
617        let _ = std::fs::remove_dir_all(&temp_dir);
618    }
619
620    #[test]
621    fn tsconfig_root_dir_no_file() {
622        assert_eq!(parse_tsconfig_root_dir(Path::new("/nonexistent")), None);
623    }
624
625    #[test]
626    fn tsconfig_root_dir_with_comments() {
627        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-comments");
628        let _ = std::fs::remove_dir_all(&temp_dir);
629        std::fs::create_dir_all(&temp_dir).unwrap();
630
631        std::fs::write(
632            temp_dir.join("tsconfig.json"),
633            "{\n  // Root directory\n  \"compilerOptions\": { \"rootDir\": \"app\" }\n}",
634        )
635        .unwrap();
636
637        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
638        let _ = std::fs::remove_dir_all(&temp_dir);
639    }
640
641    #[test]
642    fn tsconfig_root_dir_dot_value() {
643        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-dot");
644        let _ = std::fs::remove_dir_all(&temp_dir);
645        std::fs::create_dir_all(&temp_dir).unwrap();
646
647        std::fs::write(
648            temp_dir.join("tsconfig.json"),
649            r#"{ "compilerOptions": { "rootDir": "." } }"#,
650        )
651        .unwrap();
652
653        // "." is returned as-is — caller filters it out
654        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some(".".to_string()));
655        let _ = std::fs::remove_dir_all(&temp_dir);
656    }
657
658    #[test]
659    fn tsconfig_root_dir_parent_traversal() {
660        let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-parent");
661        let _ = std::fs::remove_dir_all(&temp_dir);
662        std::fs::create_dir_all(&temp_dir).unwrap();
663
664        std::fs::write(
665            temp_dir.join("tsconfig.json"),
666            r#"{ "compilerOptions": { "rootDir": "../other" } }"#,
667        )
668        .unwrap();
669
670        // Returned as-is — caller filters it out
671        assert_eq!(
672            parse_tsconfig_root_dir(&temp_dir),
673            Some("../other".to_string())
674        );
675        let _ = std::fs::remove_dir_all(&temp_dir);
676    }
677
678    #[test]
679    fn expand_workspace_glob_no_matches() {
680        let temp_dir = std::env::temp_dir().join("fallow-test-expand-nomatch");
681        let _ = std::fs::remove_dir_all(&temp_dir);
682        std::fs::create_dir_all(&temp_dir).unwrap();
683
684        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
685        let results = expand_workspace_glob(&temp_dir, "nonexistent/*", &canonical_root);
686        assert!(results.is_empty());
687
688        let _ = std::fs::remove_dir_all(&temp_dir);
689    }
690
691    // ── parse_pnpm_workspace_yaml edge cases ────────────────────────
692
693    #[test]
694    fn parse_pnpm_workspace_with_empty_lines_between_entries() {
695        let yaml = "packages:\n  - 'packages/*'\n\n  - 'apps/*'\n";
696        let patterns = parse_pnpm_workspace_yaml(yaml);
697        // Empty lines between entries should be tolerated (they're skipped)
698        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
699    }
700
701    #[test]
702    fn parse_pnpm_workspace_mixed_quotes() {
703        let yaml = "packages:\n  - 'single/*'\n  - \"double/*\"\n  - bare/*\n";
704        let patterns = parse_pnpm_workspace_yaml(yaml);
705        assert_eq!(patterns, vec!["single/*", "double/*", "bare/*"]);
706    }
707
708    #[test]
709    fn parse_pnpm_workspace_with_negation() {
710        let yaml = "packages:\n  - 'packages/*'\n  - '!packages/test-*'\n";
711        let patterns = parse_pnpm_workspace_yaml(yaml);
712        assert_eq!(patterns, vec!["packages/*", "!packages/test-*"]);
713    }
714
715    // ── strip_trailing_commas advanced ───────────────────────────────
716
717    #[test]
718    fn strip_trailing_commas_string_with_closing_brackets() {
719        // String containing "]" and "}" should not affect comma stripping
720        let input = r#"{"key": "value with ] and }",}"#;
721        let expected = r#"{"key": "value with ] and }"}"#;
722        assert_eq!(strip_trailing_commas(input), expected);
723    }
724
725    #[test]
726    fn strip_trailing_commas_multiple_levels() {
727        let input = r#"{"a": {"b": [1, 2,], "c": 3,},}"#;
728        let expected = r#"{"a": {"b": [1, 2], "c": 3}}"#;
729        assert_eq!(strip_trailing_commas(input), expected);
730    }
731
732    // ── tsconfig_root_dir edge cases ────────────────────────────────
733
734    #[test]
735    fn tsconfig_root_dir_with_trailing_commas() {
736        let temp_dir = std::env::temp_dir().join("fallow-test-rootdir-trailing-comma");
737        let _ = std::fs::remove_dir_all(&temp_dir);
738        std::fs::create_dir_all(&temp_dir).unwrap();
739
740        std::fs::write(
741            temp_dir.join("tsconfig.json"),
742            "{\n  \"compilerOptions\": {\n    \"rootDir\": \"app\",\n  },\n}",
743        )
744        .unwrap();
745
746        assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
747        let _ = std::fs::remove_dir_all(&temp_dir);
748    }
749
750    // ── expand_workspace_glob with trailing slash ────────────────────
751
752    #[test]
753    fn expand_workspace_glob_trailing_slash() {
754        let temp_dir = std::env::temp_dir().join("fallow-test-expand-trailing");
755        let _ = std::fs::remove_dir_all(&temp_dir);
756        std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
757        std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
758
759        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
760        // Trailing slash pattern gets `*` appended -> `packages/*`
761        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
762        assert_eq!(results.len(), 1);
763
764        let _ = std::fs::remove_dir_all(&temp_dir);
765    }
766
767    // ── expand_workspace_glob excludes node_modules ──────────────────
768
769    #[test]
770    fn expand_workspace_glob_excludes_node_modules() {
771        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-nodemod");
772        let _ = std::fs::remove_dir_all(&temp_dir);
773
774        // Nested node_modules package — should be excluded
775        let nm_pkg = temp_dir.join("packages/foo/node_modules/bar");
776        std::fs::create_dir_all(&nm_pkg).unwrap();
777        std::fs::write(nm_pkg.join("package.json"), r#"{"name":"bar"}"#).unwrap();
778
779        // Legitimate workspace package — should be included
780        let ws_pkg = temp_dir.join("packages/foo");
781        std::fs::write(ws_pkg.join("package.json"), r#"{"name":"foo"}"#).unwrap();
782
783        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
784        let results = expand_workspace_glob(&temp_dir, "packages/**", &canonical_root);
785
786        assert!(results.iter().any(|(_orig, canon)| {
787            canon
788                .to_string_lossy()
789                .replace('\\', "/")
790                .contains("packages/foo")
791                && !canon.to_string_lossy().contains("node_modules")
792        }));
793        assert!(
794            !results
795                .iter()
796                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules"))
797        );
798
799        let _ = std::fs::remove_dir_all(&temp_dir);
800    }
801
802    // ── expand_workspace_glob skips dirs without package.json ────────
803
804    #[test]
805    fn expand_workspace_glob_skips_dirs_without_pkg() {
806        let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-pkg");
807        let _ = std::fs::remove_dir_all(&temp_dir);
808        std::fs::create_dir_all(temp_dir.join("packages/with-pkg")).unwrap();
809        std::fs::create_dir_all(temp_dir.join("packages/without-pkg")).unwrap();
810        std::fs::write(
811            temp_dir.join("packages/with-pkg/package.json"),
812            r#"{"name": "with"}"#,
813        )
814        .unwrap();
815        // packages/without-pkg has no package.json
816
817        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
818        let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
819        assert_eq!(results.len(), 1);
820        assert!(
821            results[0]
822                .0
823                .to_string_lossy()
824                .replace('\\', "/")
825                .ends_with("packages/with-pkg")
826        );
827
828        let _ = std::fs::remove_dir_all(&temp_dir);
829    }
830
831    // ── expand_workspace_glob prunes node_modules with ** patterns ───
832
833    #[test]
834    fn expand_recursive_glob_prunes_node_modules() {
835        // When using `packages/**/*` the manual walk should prune
836        // `node_modules` during traversal, so a package.json inside
837        // `packages/app/node_modules/dep/` is never returned.
838        let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
839        let _ = std::fs::remove_dir_all(&temp_dir);
840
841        // Legitimate workspace packages
842        std::fs::create_dir_all(temp_dir.join("packages/app")).unwrap();
843        std::fs::write(
844            temp_dir.join("packages/app/package.json"),
845            r#"{"name": "app"}"#,
846        )
847        .unwrap();
848        std::fs::create_dir_all(temp_dir.join("packages/lib")).unwrap();
849        std::fs::write(
850            temp_dir.join("packages/lib/package.json"),
851            r#"{"name": "lib"}"#,
852        )
853        .unwrap();
854
855        // Nested node_modules dependency (should be pruned)
856        let nm_dep = temp_dir.join("packages/app/node_modules/dep");
857        std::fs::create_dir_all(&nm_dep).unwrap();
858        std::fs::write(nm_dep.join("package.json"), r#"{"name": "dep"}"#).unwrap();
859
860        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
861        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
862
863        // Should find exactly the two legitimate workspace packages
864        let found_names: Vec<String> = results
865            .iter()
866            .map(|(orig, _)| orig.file_name().unwrap().to_string_lossy().to_string())
867            .collect();
868        assert!(
869            found_names.contains(&"app".to_string()),
870            "should find packages/app"
871        );
872        assert!(
873            found_names.contains(&"lib".to_string()),
874            "should find packages/lib"
875        );
876        assert!(
877            !results
878                .iter()
879                .any(|(_, cp)| cp.to_string_lossy().contains("node_modules")),
880            "should NOT include packages inside node_modules"
881        );
882        assert_eq!(
883            results.len(),
884            2,
885            "should find exactly 2 workspace packages (node_modules pruned)"
886        );
887
888        let _ = std::fs::remove_dir_all(&temp_dir);
889    }
890
891    #[test]
892    fn expand_recursive_glob_prunes_deeply_nested_node_modules() {
893        // Even deeply nested node_modules (e.g., pnpm's deep symlink trees)
894        // should be pruned during the walk.
895        let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
896        let _ = std::fs::remove_dir_all(&temp_dir);
897
898        // Legitimate workspace package
899        std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
900        std::fs::write(
901            temp_dir.join("packages/core/package.json"),
902            r#"{"name": "core"}"#,
903        )
904        .unwrap();
905
906        // Deeply nested node_modules (simulates pnpm virtual store)
907        let deep_nm = temp_dir.join("packages/core/node_modules/.pnpm/react@18/node_modules/react");
908        std::fs::create_dir_all(&deep_nm).unwrap();
909        std::fs::write(deep_nm.join("package.json"), r#"{"name": "react"}"#).unwrap();
910
911        let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
912        let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
913
914        assert_eq!(
915            results.len(),
916            1,
917            "should find exactly 1 workspace package, pruning deep node_modules"
918        );
919        assert!(
920            results[0]
921                .0
922                .to_string_lossy()
923                .replace('\\', "/")
924                .ends_with("packages/core"),
925            "the single result should be packages/core"
926        );
927
928        let _ = std::fs::remove_dir_all(&temp_dir);
929    }
930}