1use std::path::{Path, PathBuf};
2
3pub(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 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 let cleaned = p.strip_prefix("./").unwrap_or(p);
29 root.join(cleaned)
30 })
31 })
32 .filter(|p| p.is_dir())
33 .collect()
34}
35
36pub 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#[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 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 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 i += 1;
102 continue;
103 }
104 }
105
106 result.push(b);
107 i += 1;
108 }
109
110 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
113}
114
115pub(super) fn expand_workspace_glob(
124 root: &Path,
125 pattern: &str,
126 canonical_root: &Path,
127) -> Vec<(PathBuf, PathBuf)> {
128 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
157fn 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 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
184fn 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 if name == "node_modules" || name == ".git" {
203 continue;
204 }
205 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 walk_workspace_dirs(&path, matcher, canonical_root, results);
215 }
216}
217
218pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
220 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; }
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 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 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 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 #[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 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 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 #[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 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 #[test]
698 fn strip_trailing_commas_string_with_closing_brackets() {
699 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 #[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 #[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 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 #[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 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 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 #[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 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 #[test]
814 fn expand_recursive_glob_prunes_node_modules() {
815 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
819 let _ = std::fs::remove_dir_all(&temp_dir);
820
821 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 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 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 let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
908 let _ = std::fs::remove_dir_all(&temp_dir);
909
910 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 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}