Skip to main content

fallow_config/workspace/
parsers.rs

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