1use std::path::{Path, PathBuf};
2
3use super::diagnostics::{
4 WorkspaceDiagnostic, WorkspaceDiagnosticKind, is_ignored_workspace_dir, is_skip_listed_dir,
5};
6
7#[cfg(test)]
14pub(super) fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
15 let mut diagnostics = Vec::new();
16 parse_tsconfig_references_with_diagnostics(root, &globset::GlobSet::empty(), &mut diagnostics)
17}
18
19pub(super) fn parse_tsconfig_references_with_diagnostics(
30 root: &Path,
31 ignore_patterns: &globset::GlobSet,
32 diagnostics: &mut Vec<WorkspaceDiagnostic>,
33) -> Vec<PathBuf> {
34 let tsconfig_path = root.join("tsconfig.json");
35 let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
36 return Vec::new();
38 };
39
40 let content = content.trim_start_matches('\u{FEFF}');
42
43 let value: serde_json::Value = match crate::jsonc::parse_to_value(content) {
44 Ok(v) => v,
45 Err(error) => {
46 let diag = WorkspaceDiagnostic::new(
47 root,
48 tsconfig_path,
49 WorkspaceDiagnosticKind::MalformedTsconfig {
50 error: error.to_string(),
51 },
52 );
53 diagnostics.push(diag);
54 return Vec::new();
55 }
56 };
57
58 let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
59 return Vec::new();
60 };
61
62 let mut results = Vec::new();
63 for r in refs {
64 let Some(raw_path) = r.get("path").and_then(|p| p.as_str()) else {
65 continue;
66 };
67 let cleaned = raw_path.strip_prefix("./").unwrap_or(raw_path);
70 let candidate = root.join(cleaned);
71 if candidate.is_dir() {
72 results.push(candidate);
73 continue;
74 }
75
76 let relative = candidate
80 .strip_prefix(root)
81 .unwrap_or(candidate.as_path())
82 .to_path_buf();
83 if is_ignored_workspace_dir(&relative, ignore_patterns) {
84 continue;
85 }
86
87 let diag = WorkspaceDiagnostic::new(
88 root,
89 candidate,
90 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
91 );
92 diagnostics.push(diag);
93 }
94 results
95}
96
97pub fn parse_tsconfig_root_dir(root: &Path) -> Option<String> {
101 let tsconfig_path = root.join("tsconfig.json");
102 let content = std::fs::read_to_string(&tsconfig_path).ok()?;
103 let content = content.trim_start_matches('\u{FEFF}');
104
105 let value: serde_json::Value = crate::jsonc::parse_to_value(content).ok()?;
106
107 value
108 .get("compilerOptions")
109 .and_then(|opts| opts.get("rootDir"))
110 .and_then(|v| v.as_str())
111 .map(|s| {
112 s.strip_prefix("./")
113 .unwrap_or(s)
114 .trim_end_matches('/')
115 .to_owned()
116 })
117}
118
119#[cfg(test)]
124pub(super) fn strip_trailing_commas(input: &str) -> String {
125 let bytes = input.as_bytes();
126 let len = bytes.len();
127 let mut result = Vec::with_capacity(len);
128 let mut in_string = false;
129 let mut i = 0;
130
131 while i < len {
132 let b = bytes[i];
133
134 if in_string {
135 result.push(b);
136 if b == b'\\' && i + 1 < len {
137 i += 1;
139 result.push(bytes[i]);
140 } else if b == b'"' {
141 in_string = false;
142 }
143 i += 1;
144 continue;
145 }
146
147 if b == b'"' {
148 in_string = true;
149 result.push(b);
150 i += 1;
151 continue;
152 }
153
154 if b == b',' {
155 let mut j = i + 1;
157 while j < len && bytes[j].is_ascii_whitespace() {
158 j += 1;
159 }
160 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
161 i += 1;
163 continue;
164 }
165 }
166
167 result.push(b);
168 i += 1;
169 }
170
171 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
174}
175
176#[cfg(test)]
189pub(super) fn expand_workspace_glob(
190 root: &Path,
191 pattern: &str,
192 canonical_root: &Path,
193) -> Vec<(PathBuf, PathBuf)> {
194 let mut diagnostics = Vec::new();
195 expand_workspace_glob_with_diagnostics(
196 root,
197 pattern,
198 pattern,
199 canonical_root,
200 &globset::GlobSet::empty(),
201 &mut diagnostics,
202 )
203}
204
205pub(super) fn expand_workspace_glob_with_diagnostics(
221 root: &Path,
222 raw_pattern: &str,
223 expanded_pattern: &str,
224 canonical_root: &Path,
225 ignore_patterns: &globset::GlobSet,
226 diagnostics: &mut Vec<WorkspaceDiagnostic>,
227) -> Vec<(PathBuf, PathBuf)> {
228 if expanded_pattern.contains("**") {
233 return expand_recursive_workspace_pattern(
234 root,
235 raw_pattern,
236 expanded_pattern,
237 canonical_root,
238 ignore_patterns,
239 diagnostics,
240 );
241 }
242
243 let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
244 match glob::glob(&full_pattern) {
245 Ok(paths) => {
246 let mut results = Vec::new();
247 for path in paths.filter_map(Result::ok) {
248 if !path.is_dir() {
249 continue;
250 }
251 if path.components().any(|c| c.as_os_str() == "node_modules") {
252 continue;
253 }
254 if path.join("package.json").exists() {
255 if let Some(cp) = dunce::canonicalize(&path)
256 .ok()
257 .filter(|cp| cp.starts_with(canonical_root))
258 {
259 results.push((path, cp));
260 }
261 continue;
262 }
263 maybe_emit_glob_no_pkg_diag(root, raw_pattern, &path, ignore_patterns, diagnostics);
264 }
265 results
266 }
267 Err(e) => {
268 tracing::warn!("invalid workspace glob pattern '{raw_pattern}': {e}");
269 Vec::new()
270 }
271 }
272}
273
274fn maybe_emit_glob_no_pkg_diag(
285 root: &Path,
286 raw_pattern: &str,
287 path: &Path,
288 ignore_patterns: &globset::GlobSet,
289 diagnostics: &mut Vec<WorkspaceDiagnostic>,
290) {
291 let leaf = path
292 .file_name()
293 .map(|n| n.to_string_lossy().into_owned())
294 .unwrap_or_default();
295 if is_skip_listed_dir(&leaf) {
296 return;
297 }
298 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
299 let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
300 let relative = canonical_path
301 .strip_prefix(&canonical_root)
302 .unwrap_or(canonical_path.as_path())
303 .to_path_buf();
304 if is_ignored_workspace_dir(&relative, ignore_patterns) {
305 return;
306 }
307 let diag = WorkspaceDiagnostic::new(
308 root,
309 path.to_path_buf(),
310 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
311 pattern: raw_pattern.to_string(),
312 },
313 );
314 diagnostics.push(diag);
315}
316
317fn expand_recursive_workspace_pattern(
323 root: &Path,
324 raw_pattern: &str,
325 expanded_pattern: &str,
326 canonical_root: &Path,
327 ignore_patterns: &globset::GlobSet,
328 diagnostics: &mut Vec<WorkspaceDiagnostic>,
329) -> Vec<(PathBuf, PathBuf)> {
330 let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
331 let Ok(matcher) = glob::Pattern::new(&full_pattern) else {
332 tracing::warn!("invalid workspace glob pattern '{raw_pattern}'");
333 return Vec::new();
334 };
335
336 let base_dir = match expanded_pattern.find('*') {
338 Some(idx) => root.join(&expanded_pattern[..idx]),
339 None => root.join(expanded_pattern),
340 };
341
342 let mut results = Vec::new();
343 walk_workspace_dirs(
344 root,
345 &base_dir,
346 raw_pattern,
347 &matcher,
348 canonical_root,
349 ignore_patterns,
350 &mut results,
351 diagnostics,
352 );
353 results
354}
355
356#[expect(
363 clippy::too_many_arguments,
364 reason = "internal recursion that threads diagnostic accumulator + ignore globset; refactoring into a context struct would obscure the recursive call site"
365)]
366fn walk_workspace_dirs(
367 root: &Path,
368 dir: &Path,
369 raw_pattern: &str,
370 matcher: &glob::Pattern,
371 canonical_root: &Path,
372 ignore_patterns: &globset::GlobSet,
373 results: &mut Vec<(PathBuf, PathBuf)>,
374 diagnostics: &mut Vec<WorkspaceDiagnostic>,
375) {
376 let Ok(entries) = std::fs::read_dir(dir) else {
377 return;
378 };
379 for entry in entries.flatten() {
380 let path = entry.path();
381 if !path.is_dir() {
382 continue;
383 }
384 let name = entry.file_name();
385 if name == "node_modules" || name == ".git" {
387 continue;
388 }
389 if matcher.matches_path(&path) {
391 if path.join("package.json").exists() {
392 if let Ok(cp) = dunce::canonicalize(&path)
393 && cp.starts_with(canonical_root)
394 {
395 results.push((path.clone(), cp));
396 }
397 } else {
398 maybe_emit_glob_no_pkg_diag(root, raw_pattern, &path, ignore_patterns, diagnostics);
399 }
400 }
401 walk_workspace_dirs(
403 root,
404 &path,
405 raw_pattern,
406 matcher,
407 canonical_root,
408 ignore_patterns,
409 results,
410 diagnostics,
411 );
412 }
413}
414
415pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
417 let mut patterns = Vec::new();
422 let mut in_packages = false;
423
424 for line in content.lines() {
425 let trimmed = line.trim();
426 if trimmed == "packages:" {
427 in_packages = true;
428 continue;
429 }
430 if in_packages {
431 if trimmed.starts_with("- ") {
432 let value = trimmed
433 .strip_prefix("- ")
434 .unwrap_or(trimmed)
435 .trim_matches('\'')
436 .trim_matches('"');
437 patterns.push(value.to_string());
438 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
439 break; }
441 }
442 }
443
444 patterns
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn parse_pnpm_workspace_basic() {
453 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
454 let patterns = parse_pnpm_workspace_yaml(yaml);
455 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
456 }
457
458 #[test]
459 fn parse_pnpm_workspace_double_quotes() {
460 let yaml = "packages:\n - \"packages/*\"\n - \"apps/*\"\n";
461 let patterns = parse_pnpm_workspace_yaml(yaml);
462 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
463 }
464
465 #[test]
466 fn parse_pnpm_workspace_no_quotes() {
467 let yaml = "packages:\n - packages/*\n - apps/*\n";
468 let patterns = parse_pnpm_workspace_yaml(yaml);
469 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
470 }
471
472 #[test]
473 fn parse_pnpm_workspace_empty() {
474 let yaml = "";
475 let patterns = parse_pnpm_workspace_yaml(yaml);
476 assert!(patterns.is_empty());
477 }
478
479 #[test]
480 fn parse_pnpm_workspace_no_packages_key() {
481 let yaml = "other:\n - something\n";
482 let patterns = parse_pnpm_workspace_yaml(yaml);
483 assert!(patterns.is_empty());
484 }
485
486 #[test]
487 fn parse_pnpm_workspace_with_comments() {
488 let yaml = "packages:\n # Comment\n - 'packages/*'\n";
489 let patterns = parse_pnpm_workspace_yaml(yaml);
490 assert_eq!(patterns, vec!["packages/*"]);
491 }
492
493 #[test]
494 fn parse_pnpm_workspace_stops_at_next_key() {
495 let yaml = "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n";
496 let patterns = parse_pnpm_workspace_yaml(yaml);
497 assert_eq!(patterns, vec!["packages/*"]);
498 }
499
500 #[test]
501 fn strip_trailing_commas_basic() {
502 assert_eq!(
503 strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
504 r#"{"a": 1, "b": 2}"#
505 );
506 }
507
508 #[test]
509 fn strip_trailing_commas_array() {
510 assert_eq!(strip_trailing_commas(r"[1, 2, 3,]"), r"[1, 2, 3]");
511 }
512
513 #[test]
514 fn strip_trailing_commas_with_whitespace() {
515 assert_eq!(
516 strip_trailing_commas("{\n \"a\": 1,\n}"),
517 "{\n \"a\": 1\n}"
518 );
519 }
520
521 #[test]
522 fn strip_trailing_commas_preserves_strings() {
523 assert_eq!(
525 strip_trailing_commas(r#"{"a": "hello,}"}"#),
526 r#"{"a": "hello,}"}"#
527 );
528 }
529
530 #[test]
531 fn strip_trailing_commas_nested() {
532 let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
533 let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
534 assert_eq!(strip_trailing_commas(input), expected);
535 }
536
537 #[test]
538 fn strip_trailing_commas_escaped_quotes() {
539 assert_eq!(
540 strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
541 r#"{"a": "he\"llo,}"}"#
542 );
543 }
544
545 #[test]
546 fn tsconfig_references_from_dir() {
547 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
548 let _ = std::fs::remove_dir_all(&temp_dir);
549 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
550 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
551
552 std::fs::write(
553 temp_dir.join("tsconfig.json"),
554 r#"{
555 // Root tsconfig with project references
556 "references": [
557 {"path": "./packages/core"},
558 {"path": "./packages/ui"},
559 ],
560 }"#,
561 )
562 .unwrap();
563
564 let refs = parse_tsconfig_references(&temp_dir);
565 assert_eq!(refs.len(), 2);
566 assert!(refs.iter().any(|p| p.ends_with("packages/core")));
567 assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
568
569 let _ = std::fs::remove_dir_all(&temp_dir);
570 }
571
572 #[test]
573 fn tsconfig_references_no_file() {
574 let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
575 assert!(refs.is_empty());
576 }
577
578 #[test]
579 fn tsconfig_references_no_references_field() {
580 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
581 let _ = std::fs::remove_dir_all(&temp_dir);
582 std::fs::create_dir_all(&temp_dir).unwrap();
583
584 std::fs::write(
585 temp_dir.join("tsconfig.json"),
586 r#"{"compilerOptions": {"strict": true}}"#,
587 )
588 .unwrap();
589
590 let refs = parse_tsconfig_references(&temp_dir);
591 assert!(refs.is_empty());
592
593 let _ = std::fs::remove_dir_all(&temp_dir);
594 }
595
596 #[test]
597 fn tsconfig_references_skips_nonexistent_dirs() {
598 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
599 let _ = std::fs::remove_dir_all(&temp_dir);
600 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
601
602 std::fs::write(
603 temp_dir.join("tsconfig.json"),
604 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
605 )
606 .unwrap();
607
608 let refs = parse_tsconfig_references(&temp_dir);
609 assert_eq!(refs.len(), 1);
610 assert!(refs[0].ends_with("packages/core"));
611
612 let _ = std::fs::remove_dir_all(&temp_dir);
613 }
614
615 #[test]
616 fn strip_trailing_commas_no_commas() {
617 let input = r#"{"a": 1, "b": [2, 3]}"#;
618 assert_eq!(strip_trailing_commas(input), input);
619 }
620
621 #[test]
622 fn strip_trailing_commas_empty_input() {
623 assert_eq!(strip_trailing_commas(""), "");
624 }
625
626 #[test]
627 fn strip_trailing_commas_nested_objects() {
628 let input = "{\n \"a\": {\n \"b\": 1,\n \"c\": 2,\n },\n \"d\": 3,\n}";
629 let expected = "{\n \"a\": {\n \"b\": 1,\n \"c\": 2\n },\n \"d\": 3\n}";
630 assert_eq!(strip_trailing_commas(input), expected);
631 }
632
633 #[test]
634 fn strip_trailing_commas_array_of_objects() {
635 let input = r#"[{"a": 1,}, {"b": 2,},]"#;
636 let expected = r#"[{"a": 1}, {"b": 2}]"#;
637 assert_eq!(strip_trailing_commas(input), expected);
638 }
639
640 #[test]
641 fn tsconfig_references_malformed_json() {
642 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-malformed");
643 let _ = std::fs::remove_dir_all(&temp_dir);
644 std::fs::create_dir_all(&temp_dir).unwrap();
645
646 std::fs::write(
647 temp_dir.join("tsconfig.json"),
648 r"{ this is not valid json at all",
649 )
650 .unwrap();
651
652 let refs = parse_tsconfig_references(&temp_dir);
653 assert!(refs.is_empty());
654
655 let _ = std::fs::remove_dir_all(&temp_dir);
656 }
657
658 #[test]
659 fn tsconfig_references_empty_array() {
660 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-empty-refs");
661 let _ = std::fs::remove_dir_all(&temp_dir);
662 std::fs::create_dir_all(&temp_dir).unwrap();
663
664 std::fs::write(temp_dir.join("tsconfig.json"), r#"{"references": []}"#).unwrap();
665
666 let refs = parse_tsconfig_references(&temp_dir);
667 assert!(refs.is_empty());
668
669 let _ = std::fs::remove_dir_all(&temp_dir);
670 }
671
672 #[test]
673 fn parse_pnpm_workspace_malformed() {
674 let patterns = parse_pnpm_workspace_yaml(":::not yaml at all:::");
676 assert!(patterns.is_empty());
677 }
678
679 #[test]
680 fn parse_pnpm_workspace_packages_key_empty_list() {
681 let yaml = "packages:\nother:\n - something\n";
682 let patterns = parse_pnpm_workspace_yaml(yaml);
683 assert!(patterns.is_empty());
684 }
685
686 #[test]
687 fn expand_workspace_glob_exact_path() {
688 let temp_dir = std::env::temp_dir().join("fallow-test-expand-exact");
689 let _ = std::fs::remove_dir_all(&temp_dir);
690 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
691 std::fs::write(
692 temp_dir.join("packages/core/package.json"),
693 r#"{"name": "core"}"#,
694 )
695 .unwrap();
696
697 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
698 let results = expand_workspace_glob(&temp_dir, "packages/core", &canonical_root);
699 assert_eq!(results.len(), 1);
700 assert!(results[0].0.ends_with("packages/core"));
701
702 let _ = std::fs::remove_dir_all(&temp_dir);
703 }
704
705 #[test]
706 fn expand_workspace_glob_star() {
707 let temp_dir = std::env::temp_dir().join("fallow-test-expand-star");
708 let _ = std::fs::remove_dir_all(&temp_dir);
709 std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
710 std::fs::create_dir_all(temp_dir.join("packages/b")).unwrap();
711 std::fs::create_dir_all(temp_dir.join("packages/c")).unwrap();
712 std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
713 std::fs::write(temp_dir.join("packages/b/package.json"), r#"{"name": "b"}"#).unwrap();
714 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
717 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
718 assert_eq!(results.len(), 2);
719
720 let _ = std::fs::remove_dir_all(&temp_dir);
721 }
722
723 #[test]
724 fn expand_workspace_glob_nested() {
725 let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested");
726 let _ = std::fs::remove_dir_all(&temp_dir);
727 std::fs::create_dir_all(temp_dir.join("packages/scope/a")).unwrap();
728 std::fs::create_dir_all(temp_dir.join("packages/scope/b")).unwrap();
729 std::fs::write(
730 temp_dir.join("packages/scope/a/package.json"),
731 r#"{"name": "@scope/a"}"#,
732 )
733 .unwrap();
734 std::fs::write(
735 temp_dir.join("packages/scope/b/package.json"),
736 r#"{"name": "@scope/b"}"#,
737 )
738 .unwrap();
739
740 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
741 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
742 assert_eq!(results.len(), 2);
743
744 let _ = std::fs::remove_dir_all(&temp_dir);
745 }
746
747 #[test]
750 fn tsconfig_root_dir_extracted() {
751 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir");
752 let _ = std::fs::remove_dir_all(&temp_dir);
753 std::fs::create_dir_all(&temp_dir).unwrap();
754
755 std::fs::write(
756 temp_dir.join("tsconfig.json"),
757 r#"{ "compilerOptions": { "rootDir": "./src" } }"#,
758 )
759 .unwrap();
760
761 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("src".to_string()));
762 let _ = std::fs::remove_dir_all(&temp_dir);
763 }
764
765 #[test]
766 fn tsconfig_root_dir_lib() {
767 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-lib");
768 let _ = std::fs::remove_dir_all(&temp_dir);
769 std::fs::create_dir_all(&temp_dir).unwrap();
770
771 std::fs::write(
772 temp_dir.join("tsconfig.json"),
773 r#"{ "compilerOptions": { "rootDir": "lib/" } }"#,
774 )
775 .unwrap();
776
777 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("lib".to_string()));
778 let _ = std::fs::remove_dir_all(&temp_dir);
779 }
780
781 #[test]
782 fn tsconfig_root_dir_missing_field() {
783 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-nofield");
784 let _ = std::fs::remove_dir_all(&temp_dir);
785 std::fs::create_dir_all(&temp_dir).unwrap();
786
787 std::fs::write(
788 temp_dir.join("tsconfig.json"),
789 r#"{ "compilerOptions": { "strict": true } }"#,
790 )
791 .unwrap();
792
793 assert_eq!(parse_tsconfig_root_dir(&temp_dir), None);
794 let _ = std::fs::remove_dir_all(&temp_dir);
795 }
796
797 #[test]
798 fn tsconfig_root_dir_no_file() {
799 assert_eq!(parse_tsconfig_root_dir(Path::new("/nonexistent")), None);
800 }
801
802 #[test]
803 fn tsconfig_root_dir_with_comments() {
804 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-comments");
805 let _ = std::fs::remove_dir_all(&temp_dir);
806 std::fs::create_dir_all(&temp_dir).unwrap();
807
808 std::fs::write(
809 temp_dir.join("tsconfig.json"),
810 "{\n // Root directory\n \"compilerOptions\": { \"rootDir\": \"app\" }\n}",
811 )
812 .unwrap();
813
814 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
815 let _ = std::fs::remove_dir_all(&temp_dir);
816 }
817
818 #[test]
819 fn tsconfig_root_dir_dot_value() {
820 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-dot");
821 let _ = std::fs::remove_dir_all(&temp_dir);
822 std::fs::create_dir_all(&temp_dir).unwrap();
823
824 std::fs::write(
825 temp_dir.join("tsconfig.json"),
826 r#"{ "compilerOptions": { "rootDir": "." } }"#,
827 )
828 .unwrap();
829
830 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some(".".to_string()));
832 let _ = std::fs::remove_dir_all(&temp_dir);
833 }
834
835 #[test]
836 fn tsconfig_root_dir_parent_traversal() {
837 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-parent");
838 let _ = std::fs::remove_dir_all(&temp_dir);
839 std::fs::create_dir_all(&temp_dir).unwrap();
840
841 std::fs::write(
842 temp_dir.join("tsconfig.json"),
843 r#"{ "compilerOptions": { "rootDir": "../other" } }"#,
844 )
845 .unwrap();
846
847 assert_eq!(
849 parse_tsconfig_root_dir(&temp_dir),
850 Some("../other".to_string())
851 );
852 let _ = std::fs::remove_dir_all(&temp_dir);
853 }
854
855 #[test]
856 fn expand_workspace_glob_no_matches() {
857 let temp_dir = std::env::temp_dir().join("fallow-test-expand-nomatch");
858 let _ = std::fs::remove_dir_all(&temp_dir);
859 std::fs::create_dir_all(&temp_dir).unwrap();
860
861 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
862 let results = expand_workspace_glob(&temp_dir, "nonexistent/*", &canonical_root);
863 assert!(results.is_empty());
864
865 let _ = std::fs::remove_dir_all(&temp_dir);
866 }
867
868 #[test]
871 fn parse_pnpm_workspace_with_empty_lines_between_entries() {
872 let yaml = "packages:\n - 'packages/*'\n\n - 'apps/*'\n";
873 let patterns = parse_pnpm_workspace_yaml(yaml);
874 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
876 }
877
878 #[test]
879 fn parse_pnpm_workspace_mixed_quotes() {
880 let yaml = "packages:\n - 'single/*'\n - \"double/*\"\n - bare/*\n";
881 let patterns = parse_pnpm_workspace_yaml(yaml);
882 assert_eq!(patterns, vec!["single/*", "double/*", "bare/*"]);
883 }
884
885 #[test]
886 fn parse_pnpm_workspace_with_negation() {
887 let yaml = "packages:\n - 'packages/*'\n - '!packages/test-*'\n";
888 let patterns = parse_pnpm_workspace_yaml(yaml);
889 assert_eq!(patterns, vec!["packages/*", "!packages/test-*"]);
890 }
891
892 #[test]
895 fn strip_trailing_commas_string_with_closing_brackets() {
896 let input = r#"{"key": "value with ] and }",}"#;
898 let expected = r#"{"key": "value with ] and }"}"#;
899 assert_eq!(strip_trailing_commas(input), expected);
900 }
901
902 #[test]
903 fn strip_trailing_commas_multiple_levels() {
904 let input = r#"{"a": {"b": [1, 2,], "c": 3,},}"#;
905 let expected = r#"{"a": {"b": [1, 2], "c": 3}}"#;
906 assert_eq!(strip_trailing_commas(input), expected);
907 }
908
909 #[test]
912 fn tsconfig_root_dir_with_trailing_commas() {
913 let temp_dir = std::env::temp_dir().join("fallow-test-rootdir-trailing-comma");
914 let _ = std::fs::remove_dir_all(&temp_dir);
915 std::fs::create_dir_all(&temp_dir).unwrap();
916
917 std::fs::write(
918 temp_dir.join("tsconfig.json"),
919 "{\n \"compilerOptions\": {\n \"rootDir\": \"app\",\n },\n}",
920 )
921 .unwrap();
922
923 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
924 let _ = std::fs::remove_dir_all(&temp_dir);
925 }
926
927 #[test]
930 fn expand_workspace_glob_trailing_slash() {
931 let temp_dir = std::env::temp_dir().join("fallow-test-expand-trailing");
932 let _ = std::fs::remove_dir_all(&temp_dir);
933 std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
934 std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
935
936 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
937 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
939 assert_eq!(results.len(), 1);
940
941 let _ = std::fs::remove_dir_all(&temp_dir);
942 }
943
944 #[test]
947 fn expand_workspace_glob_excludes_node_modules() {
948 let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-nodemod");
949 let _ = std::fs::remove_dir_all(&temp_dir);
950
951 let nm_pkg = temp_dir.join("packages/foo/node_modules/bar");
953 std::fs::create_dir_all(&nm_pkg).unwrap();
954 std::fs::write(nm_pkg.join("package.json"), r#"{"name":"bar"}"#).unwrap();
955
956 let ws_pkg = temp_dir.join("packages/foo");
958 std::fs::write(ws_pkg.join("package.json"), r#"{"name":"foo"}"#).unwrap();
959
960 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
961 let results = expand_workspace_glob(&temp_dir, "packages/**", &canonical_root);
962
963 assert!(results.iter().any(|(_orig, canon)| {
964 canon
965 .to_string_lossy()
966 .replace('\\', "/")
967 .contains("packages/foo")
968 && !canon.to_string_lossy().contains("node_modules")
969 }));
970 assert!(
971 !results
972 .iter()
973 .any(|(_, cp)| cp.to_string_lossy().contains("node_modules"))
974 );
975
976 let _ = std::fs::remove_dir_all(&temp_dir);
977 }
978
979 #[test]
982 fn expand_workspace_glob_skips_dirs_without_pkg() {
983 let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-pkg");
984 let _ = std::fs::remove_dir_all(&temp_dir);
985 std::fs::create_dir_all(temp_dir.join("packages/with-pkg")).unwrap();
986 std::fs::create_dir_all(temp_dir.join("packages/without-pkg")).unwrap();
987 std::fs::write(
988 temp_dir.join("packages/with-pkg/package.json"),
989 r#"{"name": "with"}"#,
990 )
991 .unwrap();
992 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
995 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
996 assert_eq!(results.len(), 1);
997 assert!(
998 results[0]
999 .0
1000 .to_string_lossy()
1001 .replace('\\', "/")
1002 .ends_with("packages/with-pkg")
1003 );
1004
1005 let _ = std::fs::remove_dir_all(&temp_dir);
1006 }
1007
1008 #[test]
1011 fn expand_recursive_glob_prunes_node_modules() {
1012 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
1016 let _ = std::fs::remove_dir_all(&temp_dir);
1017
1018 std::fs::create_dir_all(temp_dir.join("packages/app")).unwrap();
1020 std::fs::write(
1021 temp_dir.join("packages/app/package.json"),
1022 r#"{"name": "app"}"#,
1023 )
1024 .unwrap();
1025 std::fs::create_dir_all(temp_dir.join("packages/lib")).unwrap();
1026 std::fs::write(
1027 temp_dir.join("packages/lib/package.json"),
1028 r#"{"name": "lib"}"#,
1029 )
1030 .unwrap();
1031
1032 let nm_dep = temp_dir.join("packages/app/node_modules/dep");
1034 std::fs::create_dir_all(&nm_dep).unwrap();
1035 std::fs::write(nm_dep.join("package.json"), r#"{"name": "dep"}"#).unwrap();
1036
1037 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1038 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1039
1040 let found_names: Vec<String> = results
1042 .iter()
1043 .map(|(orig, _)| orig.file_name().unwrap().to_string_lossy().to_string())
1044 .collect();
1045 assert!(
1046 found_names.contains(&"app".to_string()),
1047 "should find packages/app"
1048 );
1049 assert!(
1050 found_names.contains(&"lib".to_string()),
1051 "should find packages/lib"
1052 );
1053 assert!(
1054 !results
1055 .iter()
1056 .any(|(_, cp)| cp.to_string_lossy().contains("node_modules")),
1057 "should NOT include packages inside node_modules"
1058 );
1059 assert_eq!(
1060 results.len(),
1061 2,
1062 "should find exactly 2 workspace packages (node_modules pruned)"
1063 );
1064
1065 let _ = std::fs::remove_dir_all(&temp_dir);
1066 }
1067
1068 #[test]
1069 fn expand_recursive_glob_preserves_nested_workspace_roots() {
1070 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-workspace-prune");
1071 let _ = std::fs::remove_dir_all(&temp_dir);
1072
1073 std::fs::create_dir_all(temp_dir.join("apps/app/packages/nested")).unwrap();
1074 std::fs::write(temp_dir.join("apps/app/package.json"), r#"{"name":"app"}"#).unwrap();
1075 std::fs::write(
1076 temp_dir.join("apps/app/packages/nested/package.json"),
1077 r#"{"name":"nested"}"#,
1078 )
1079 .unwrap();
1080
1081 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1082 let results = expand_workspace_glob(&temp_dir, "apps/**", &canonical_root);
1083 let mut paths: Vec<_> = results
1084 .iter()
1085 .map(|(path, _)| path.strip_prefix(&temp_dir).unwrap().to_path_buf())
1086 .collect();
1087 paths.sort();
1088
1089 assert_eq!(
1090 paths,
1091 vec![
1092 PathBuf::from("apps/app"),
1093 PathBuf::from("apps/app/packages/nested")
1094 ]
1095 );
1096
1097 let _ = std::fs::remove_dir_all(&temp_dir);
1098 }
1099
1100 #[test]
1101 fn expand_recursive_glob_prunes_deeply_nested_node_modules() {
1102 let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
1105 let _ = std::fs::remove_dir_all(&temp_dir);
1106
1107 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
1109 std::fs::write(
1110 temp_dir.join("packages/core/package.json"),
1111 r#"{"name": "core"}"#,
1112 )
1113 .unwrap();
1114
1115 let deep_nm = temp_dir.join("packages/core/node_modules/.pnpm/react@18/node_modules/react");
1117 std::fs::create_dir_all(&deep_nm).unwrap();
1118 std::fs::write(deep_nm.join("package.json"), r#"{"name": "react"}"#).unwrap();
1119
1120 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1121 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1122
1123 assert_eq!(
1124 results.len(),
1125 1,
1126 "should find exactly 1 workspace package, pruning deep node_modules"
1127 );
1128 assert!(
1129 results[0]
1130 .0
1131 .to_string_lossy()
1132 .replace('\\', "/")
1133 .ends_with("packages/core"),
1134 "the single result should be packages/core"
1135 );
1136
1137 let _ = std::fs::remove_dir_all(&temp_dir);
1138 }
1139}