1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4pub(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 let content = content.trim_start_matches('\u{FEFF}');
16
17 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 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 let cleaned = p.strip_prefix("./").unwrap_or(p);
43 root.join(cleaned)
44 })
45 })
46 .filter(|p| p.is_dir())
47 .collect()
48}
49
50pub 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
79pub(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 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 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 i += 1;
122 continue;
123 }
124 }
125
126 result.push(b);
127 i += 1;
128 }
129
130 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
133}
134
135pub(super) fn expand_workspace_glob(
144 root: &Path,
145 pattern: &str,
146 canonical_root: &Path,
147) -> Vec<(PathBuf, PathBuf)> {
148 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
177fn 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 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
204fn 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 if name == "node_modules" || name == ".git" {
223 continue;
224 }
225 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 walk_workspace_dirs(&path, matcher, canonical_root, results);
235 }
236}
237
238pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
240 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; }
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 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 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 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 #[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 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 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 #[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 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 #[test]
718 fn strip_trailing_commas_string_with_closing_brackets() {
719 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 #[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 #[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 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 #[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 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 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 #[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 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 #[test]
834 fn expand_recursive_glob_prunes_node_modules() {
835 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
839 let _ = std::fs::remove_dir_all(&temp_dir);
840
841 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 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 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 let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
896 let _ = std::fs::remove_dir_all(&temp_dir);
897
898 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 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}