1use std::path::{Path, PathBuf};
22use std::sync::{Mutex, OnceLock};
23
24use rustc_hash::{FxHashMap, FxHashSet};
25
26pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
27
28fn display_relative(root: &Path, path: &Path) -> String {
35 path.strip_prefix(root)
36 .unwrap_or(path)
37 .display()
38 .to_string()
39 .replace('\\', "/")
40}
41
42#[derive(Debug, Clone)]
49pub enum WorkspaceLoadError {
50 MalformedRootPackageJson { path: PathBuf, error: String },
52}
53
54impl std::fmt::Display for WorkspaceLoadError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::MalformedRootPackageJson { path, error } => write!(
58 f,
59 "root package.json at '{}' is not valid JSON ({error}). \
60 Fix the syntax before re-running fallow.",
61 path.display()
62 ),
63 }
64 }
65}
66
67impl std::error::Error for WorkspaceLoadError {}
68
69const GLOB_EXAMPLE_CAP: usize = 3;
73
74fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
81 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
82 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
83}
84
85fn should_emit(key: String) -> bool {
90 warned_keys().lock().map_or(true, |mut set| set.insert(key))
91}
92
93#[derive(Debug, PartialEq, Eq)]
98struct PlannedWarning {
99 dedupe_key: String,
100 message: String,
101}
102
103struct WarningGroups<'a> {
104 plans: Vec<PlannedWarning>,
105 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
106 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
107}
108
109fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
124 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
125 let WarningGroups {
126 mut plans,
127 glob_groups,
128 tsconfig_ref_misses,
129 } = group_warning_diagnostics(diagnostics, &canonical);
130
131 for (pattern, group) in glob_groups {
132 if let [only] = group.as_slice() {
133 plans.push(per_instance_warning(&canonical, only));
134 continue;
135 }
136 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
137 plans.push(PlannedWarning {
138 dedupe_key: format!(
139 "{}::glob-matched-no-package-json-agg::{pattern}",
140 canonical.display()
141 ),
142 message: build_glob_group_message(root, pattern, &paths),
143 });
144 }
145
146 if let [only] = tsconfig_ref_misses.as_slice() {
147 plans.push(per_instance_warning(&canonical, only));
148 } else if !tsconfig_ref_misses.is_empty() {
149 let paths: Vec<&Path> = tsconfig_ref_misses
150 .iter()
151 .map(|d| d.path.as_path())
152 .collect();
153 plans.push(PlannedWarning {
154 dedupe_key: format!(
155 "{}::tsconfig-reference-dir-missing-agg",
156 canonical.display()
157 ),
158 message: build_tsconfig_refs_message(root, &paths),
159 });
160 }
161
162 plans
163}
164
165fn group_warning_diagnostics<'a>(
166 diagnostics: &'a [WorkspaceDiagnostic],
167 canonical: &Path,
168) -> WarningGroups<'a> {
169 let mut plans: Vec<PlannedWarning> = Vec::new();
170 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
171 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
172 for diag in diagnostics {
173 match &diag.kind {
174 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
175 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
176 Some((_, group)) => group.push(diag),
177 None => glob_groups.push((pattern.as_str(), vec![diag])),
178 }
179 }
180 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
181 _ => plans.push(per_instance_warning(canonical, diag)),
182 }
183 }
184 WarningGroups {
185 plans,
186 glob_groups,
187 tsconfig_ref_misses,
188 }
189}
190
191fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
192 PlannedWarning {
193 dedupe_key: format!(
194 "{}::{}::{}",
195 canonical.display(),
196 diag.kind.id(),
197 diag.path.display()
198 ),
199 message: diag.message.clone(),
200 }
201}
202
203pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
212 #[cfg(test)]
213 for diag in diagnostics {
214 capture_diag(diag);
215 }
216
217 for plan in plan_warnings(root, diagnostics) {
218 if should_emit(plan.dedupe_key) {
219 tracing::warn!("fallow: {}", plan.message);
220 }
221 }
222}
223
224fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
229 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
230 examples.sort();
231 let count = examples.len();
232 let shown = examples
233 .iter()
234 .take(GLOB_EXAMPLE_CAP)
235 .cloned()
236 .collect::<Vec<_>>()
237 .join(", ");
238 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
239 let listed = if remaining > 0 {
240 format!("{shown}, and {remaining} more")
241 } else {
242 shown
243 };
244 (listed, count)
245}
246
247fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
250 let (listed, count) = summarize_examples(root, paths);
251 format!(
252 "Glob '{pattern}' matched {count} directories with no package.json \
253 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
254 them to ignorePatterns."
255 )
256}
257
258fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
262 let (listed, count) = summarize_examples(root, paths);
263 format!(
264 "tsconfig.json references {count} directories that do not exist \
265 (e.g. {listed}). Update or remove the references, or restore the \
266 missing directories."
267 )
268}
269
270thread_local! {
271 #[cfg(test)]
278 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
279 const { std::cell::RefCell::new(None) };
280}
281
282#[cfg(test)]
288fn capture_diag(diag: &WorkspaceDiagnostic) {
289 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
290 if let Some(buf) = cell.borrow_mut().as_mut() {
291 buf.push(diag.clone());
292 }
293 });
294}
295
296#[cfg(test)]
304#[must_use]
305pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
306 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
307 *cell.borrow_mut() = Some(Vec::new());
308 });
309 let result = body();
310 let findings =
311 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
312 (result, findings)
313}
314
315static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
326 OnceLock::new();
327
328pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
343 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
344 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
345 if let Ok(mut map) = registry.lock() {
346 let mut combined = diagnostics;
347 if let Some(existing) = map.get(&canonical) {
348 combined.extend(
349 existing
350 .iter()
351 .filter(|d| d.kind.is_source_discovery())
352 .cloned(),
353 );
354 }
355 map.insert(canonical, combined);
356 }
357}
358
359pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
368 if additions.is_empty() {
369 return;
370 }
371 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
372 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
373 if let Ok(mut map) = registry.lock() {
374 let existing = map.entry(canonical).or_default();
375 let mut seen: FxHashSet<(String, String)> = existing
376 .iter()
377 .map(|d| {
378 (
379 d.kind.id().to_owned(),
380 dunce::canonicalize(&d.path)
381 .unwrap_or_else(|_| d.path.clone())
382 .display()
383 .to_string(),
384 )
385 })
386 .collect();
387 for addition in additions {
388 let key = (
389 addition.kind.id().to_owned(),
390 dunce::canonicalize(&addition.path)
391 .unwrap_or_else(|_| addition.path.clone())
392 .display()
393 .to_string(),
394 );
395 if seen.insert(key) {
396 existing.push(addition);
397 }
398 }
399 }
400}
401
402pub fn clear_source_discovery_diagnostics(root: &Path) {
415 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
416 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
417 return;
418 };
419 if let Ok(mut map) = registry.lock()
420 && let Some(existing) = map.get_mut(&canonical)
421 {
422 existing.retain(|d| !d.kind.is_source_discovery());
423 }
424}
425
426#[must_use]
432pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
433 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
434 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
435 return Vec::new();
436 };
437 registry
438 .lock()
439 .ok()
440 .and_then(|map| map.get(&canonical).cloned())
441 .unwrap_or_default()
442}
443
444#[must_use]
450pub(super) fn is_skip_listed_dir(name: &str) -> bool {
451 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
452}
453
454#[must_use]
459pub(super) fn is_ignored_workspace_dir(
460 relative_dir: &Path,
461 ignore_patterns: &globset::GlobSet,
462) -> bool {
463 if ignore_patterns.is_empty() {
464 return false;
465 }
466 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
467 ignore_patterns.is_match(relative_str.as_str())
468 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
476 WorkspaceDiagnostic::new(
477 root,
478 root.join(rel_path),
479 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
480 pattern: pattern.to_owned(),
481 },
482 )
483 }
484
485 #[test]
486 fn skipped_large_file_diagnostic_id_and_message() {
487 let root = Path::new("/project");
488 let diag = WorkspaceDiagnostic::new(
489 root,
490 root.join("src/vendor/app.bundle.js"),
491 WorkspaceDiagnosticKind::SkippedLargeFile {
492 size_bytes: 6 * 1024 * 1024,
493 },
494 );
495 assert_eq!(diag.kind.id(), "skipped-large-file");
496 assert!(
497 diag.message.contains("src/vendor/app.bundle.js"),
498 "message names the project-relative path: {}",
499 diag.message
500 );
501 assert!(
502 diag.message.contains("6.0 MB"),
503 "message reports the size: {}",
504 diag.message
505 );
506 assert!(
507 diag.message.contains("--max-file-size"),
508 "message names the override flag: {}",
509 diag.message
510 );
511 }
512
513 #[test]
514 fn skipped_minified_file_diagnostic_id_and_message() {
515 let root = Path::new("/project");
516 let diag = WorkspaceDiagnostic::new(
517 root,
518 root.join("src/assets/index-abc123.js"),
519 WorkspaceDiagnosticKind::SkippedMinifiedFile {
520 size_bytes: 2 * 1024 * 1024,
521 },
522 );
523 assert_eq!(diag.kind.id(), "skipped-minified-file");
524 assert!(
525 diag.message.contains("src/assets/index-abc123.js"),
526 "message names the project-relative path: {}",
527 diag.message
528 );
529 assert!(
530 diag.message.contains("2.0 MB"),
531 "message reports the size: {}",
532 diag.message
533 );
534 assert!(
535 diag.message.contains("--max-file-size 0"),
536 "message names the opt-out: {}",
537 diag.message
538 );
539 }
540
541 #[test]
542 fn stash_preserves_appended_skipped_large_file_across_restash() {
543 let root = Path::new("/fallow-test-1086-stash-preserve");
546 let undeclared = || {
547 WorkspaceDiagnostic::new(
548 root,
549 root.join("pkg"),
550 WorkspaceDiagnosticKind::UndeclaredWorkspace,
551 )
552 };
553 stash_workspace_diagnostics(root, vec![undeclared()]);
555 append_workspace_diagnostics(
557 root,
558 vec![WorkspaceDiagnostic::new(
559 root,
560 root.join("vendor/big.js"),
561 WorkspaceDiagnosticKind::SkippedLargeFile {
562 size_bytes: 9_999_999,
563 },
564 )],
565 );
566 stash_workspace_diagnostics(root, vec![undeclared()]);
569
570 let after = workspace_diagnostics_for(root);
571 assert_eq!(
572 after
573 .iter()
574 .filter(|d| d.kind.is_source_discovery())
575 .count(),
576 1,
577 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
578 );
579 assert_eq!(
580 after
581 .iter()
582 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
583 .count(),
584 1,
585 "the workspace-discovery diagnostic is replaced, not duplicated"
586 );
587 }
588
589 #[test]
590 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
591 let root = Path::new("/fallow-test-1086-clear-stale");
592 stash_workspace_diagnostics(
593 root,
594 vec![WorkspaceDiagnostic::new(
595 root,
596 root.join("pkg"),
597 WorkspaceDiagnosticKind::UndeclaredWorkspace,
598 )],
599 );
600 append_workspace_diagnostics(
601 root,
602 vec![WorkspaceDiagnostic::new(
603 root,
604 root.join("vendor/big.js"),
605 WorkspaceDiagnosticKind::SkippedLargeFile {
606 size_bytes: 9_999_999,
607 },
608 )],
609 );
610 clear_source_discovery_diagnostics(root);
612
613 let after = workspace_diagnostics_for(root);
614 assert!(
615 !after.iter().any(|d| d.kind.is_source_discovery()),
616 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
617 );
618 assert!(
619 after
620 .iter()
621 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
622 "the workspace-discovery diagnostic survives the source-discovery clear"
623 );
624 }
625
626 #[test]
627 fn build_glob_group_message_caps_examples_and_summarises_tail() {
628 let root = Path::new("/project");
629 let paths = [
630 root.join("playground/cli"),
631 root.join("playground/lib-types"),
632 root.join("playground/minify"),
633 root.join("playground/ssr"),
634 root.join("playground/worker"),
635 ];
636 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
637 let message = build_glob_group_message(root, "playground/**", &refs);
638
639 assert!(
640 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
641 "count and pattern lead the message: {message}"
642 );
643 assert!(
644 message.contains(
645 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
646 ),
647 "three sorted examples + tail count: {message}"
648 );
649 assert!(
650 message.ends_with(
651 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
652 ),
653 "next-step hint preserved: {message}"
654 );
655 assert!(
656 !message.contains("playground/ssr"),
657 "tail example not named: {message}"
658 );
659 }
660
661 #[test]
662 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
663 let root = Path::new("/project");
664 let paths = [root.join("packages/a"), root.join("packages/b")];
665 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
666 let message = build_glob_group_message(root, "packages/*", &refs);
667
668 assert!(message.contains("matched 2 directories"), "{message}");
669 assert!(
670 message.contains("(e.g. packages/a, packages/b)"),
671 "both examples named, no `and N more`: {message}"
672 );
673 assert!(!message.contains("more)"), "no tail clause: {message}");
674 }
675
676 #[test]
677 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
678 let root = Path::new("/project");
679 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
680 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
681 .collect();
682
683 let plans = plan_warnings(root, &diagnostics);
684
685 assert_eq!(
686 plans.len(),
687 1,
688 "50 same-pattern diagnostics collapse to one plan"
689 );
690 assert!(
691 plans[0]
692 .dedupe_key
693 .ends_with("::glob-matched-no-package-json-agg::playground/**")
694 );
695 assert!(plans[0].message.contains("matched 50 directories"));
696 }
697
698 #[test]
699 fn plan_warnings_keeps_distinct_patterns_separate() {
700 let root = Path::new("/project");
701 let diagnostics = vec![
702 glob_diag(root, "apps/*", "apps/a"),
703 glob_diag(root, "apps/*", "apps/b"),
704 glob_diag(root, "packages/*", "packages/x"),
705 glob_diag(root, "packages/*", "packages/y"),
706 ];
707
708 let plans = plan_warnings(root, &diagnostics);
709
710 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
711 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
712 assert!(
713 messages
714 .iter()
715 .any(|m| m.contains("Glob 'apps/*' matched 2")),
716 "{messages:?}"
717 );
718 assert!(
719 messages
720 .iter()
721 .any(|m| m.contains("Glob 'packages/*' matched 2")),
722 "{messages:?}"
723 );
724 }
725
726 #[test]
727 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
728 let root = Path::new("/project");
729 let diag = glob_diag(root, "packages/*", "packages/scratch");
730
731 let plans = plan_warnings(root, std::slice::from_ref(&diag));
732
733 assert_eq!(plans.len(), 1);
734 assert_eq!(plans[0].message, diag.message);
735 assert!(
736 plans[0]
737 .dedupe_key
738 .contains("::glob-matched-no-package-json::")
739 && plans[0].dedupe_key.ends_with("packages/scratch"),
740 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
741 plans[0].dedupe_key
742 );
743 assert!(
744 !plans[0].message.contains("directories"),
745 "single match is not aggregated"
746 );
747 }
748
749 #[test]
750 fn plan_warnings_non_glob_kinds_stay_per_instance() {
751 let root = Path::new("/project");
752 let diagnostics = vec![
753 WorkspaceDiagnostic::new(
754 root,
755 root.join("packages/a"),
756 WorkspaceDiagnosticKind::UndeclaredWorkspace,
757 ),
758 WorkspaceDiagnostic::new(
759 root,
760 root.join("packages/b"),
761 WorkspaceDiagnosticKind::MalformedPackageJson {
762 error: "trailing comma".to_owned(),
763 },
764 ),
765 ];
766
767 let plans = plan_warnings(root, &diagnostics);
768
769 assert_eq!(
770 plans.len(),
771 2,
772 "each non-glob diagnostic plans its own warning"
773 );
774 assert!(
775 plans
776 .iter()
777 .all(|p| !p.message.contains("directories with no package.json"))
778 );
779 }
780
781 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
782 WorkspaceDiagnostic::new(
783 root,
784 root.join(rel_path),
785 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
786 )
787 }
788
789 #[test]
790 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
791 let root = Path::new("/project");
792 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
793 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
794 .collect();
795
796 let plans = plan_warnings(root, &diagnostics);
797
798 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
799 assert!(
800 plans[0]
801 .dedupe_key
802 .ends_with("::tsconfig-reference-dir-missing-agg")
803 );
804 assert!(
805 plans[0]
806 .message
807 .starts_with("tsconfig.json references 30 directories that do not exist"),
808 "{}",
809 plans[0].message
810 );
811 assert!(
812 plans[0].message.contains(
813 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
814 packages/p02/tsconfig.json, and 27 more)"
815 ),
816 "three sorted examples + tail: {}",
817 plans[0].message
818 );
819 assert!(
820 plans[0]
821 .message
822 .ends_with("Update or remove the references, or restore the missing directories."),
823 "{}",
824 plans[0].message
825 );
826 }
827
828 #[test]
829 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
830 let root = Path::new("/project");
831 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
832
833 let plans = plan_warnings(root, std::slice::from_ref(&diag));
834
835 assert_eq!(plans.len(), 1);
836 assert_eq!(
837 plans[0].message, diag.message,
838 "single miss is not aggregated"
839 );
840 assert!(!plans[0].message.contains("directories that do not exist"));
841 }
842
843 #[test]
844 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
845 let root = Path::new("/project");
846 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
847 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
848 .collect();
849 diagnostics.extend(
850 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
851 );
852
853 let plans = plan_warnings(root, &diagnostics);
854
855 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
856 assert!(
857 plans
858 .iter()
859 .any(|p| p.message.contains("matched 5 directories"))
860 );
861 assert!(
862 plans
863 .iter()
864 .any(|p| p.message.contains("references 4 directories"))
865 );
866 }
867}