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
103fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
118 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
119 let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
120 dedupe_key: format!(
121 "{}::{}::{}",
122 canonical.display(),
123 diag.kind.id(),
124 diag.path.display()
125 ),
126 message: diag.message.clone(),
127 };
128
129 let mut plans: Vec<PlannedWarning> = Vec::new();
130 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
131 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
132 for diag in diagnostics {
133 match &diag.kind {
134 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
135 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
136 Some((_, group)) => group.push(diag),
137 None => glob_groups.push((pattern.as_str(), vec![diag])),
138 }
139 }
140 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
141 _ => plans.push(per_instance(diag)),
142 }
143 }
144
145 for (pattern, group) in glob_groups {
146 if let [only] = group.as_slice() {
147 plans.push(per_instance(only));
148 continue;
149 }
150 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
151 plans.push(PlannedWarning {
152 dedupe_key: format!(
153 "{}::glob-matched-no-package-json-agg::{pattern}",
154 canonical.display()
155 ),
156 message: build_glob_group_message(root, pattern, &paths),
157 });
158 }
159
160 if let [only] = tsconfig_ref_misses.as_slice() {
161 plans.push(per_instance(only));
162 } else if !tsconfig_ref_misses.is_empty() {
163 let paths: Vec<&Path> = tsconfig_ref_misses
164 .iter()
165 .map(|d| d.path.as_path())
166 .collect();
167 plans.push(PlannedWarning {
168 dedupe_key: format!(
169 "{}::tsconfig-reference-dir-missing-agg",
170 canonical.display()
171 ),
172 message: build_tsconfig_refs_message(root, &paths),
173 });
174 }
175
176 plans
177}
178
179pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
188 #[cfg(test)]
189 for diag in diagnostics {
190 capture_diag(diag);
191 }
192
193 for plan in plan_warnings(root, diagnostics) {
194 if should_emit(plan.dedupe_key) {
195 tracing::warn!("fallow: {}", plan.message);
196 }
197 }
198}
199
200fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
205 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
206 examples.sort();
207 let count = examples.len();
208 let shown = examples
209 .iter()
210 .take(GLOB_EXAMPLE_CAP)
211 .cloned()
212 .collect::<Vec<_>>()
213 .join(", ");
214 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
215 let listed = if remaining > 0 {
216 format!("{shown}, and {remaining} more")
217 } else {
218 shown
219 };
220 (listed, count)
221}
222
223fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
226 let (listed, count) = summarize_examples(root, paths);
227 format!(
228 "Glob '{pattern}' matched {count} directories with no package.json \
229 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
230 them to ignorePatterns."
231 )
232}
233
234fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
238 let (listed, count) = summarize_examples(root, paths);
239 format!(
240 "tsconfig.json references {count} directories that do not exist \
241 (e.g. {listed}). Update or remove the references, or restore the \
242 missing directories."
243 )
244}
245
246thread_local! {
247 #[cfg(test)]
254 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
255 const { std::cell::RefCell::new(None) };
256}
257
258#[cfg(test)]
264fn capture_diag(diag: &WorkspaceDiagnostic) {
265 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
266 if let Some(buf) = cell.borrow_mut().as_mut() {
267 buf.push(diag.clone());
268 }
269 });
270}
271
272#[cfg(test)]
280#[must_use]
281pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
282 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
283 *cell.borrow_mut() = Some(Vec::new());
284 });
285 let result = body();
286 let findings =
287 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
288 (result, findings)
289}
290
291static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
302 OnceLock::new();
303
304pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
319 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
320 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
321 if let Ok(mut map) = registry.lock() {
322 let mut combined = diagnostics;
323 if let Some(existing) = map.get(&canonical) {
324 combined.extend(
325 existing
326 .iter()
327 .filter(|d| d.kind.is_source_discovery())
328 .cloned(),
329 );
330 }
331 map.insert(canonical, combined);
332 }
333}
334
335pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
344 if additions.is_empty() {
345 return;
346 }
347 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
348 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
349 if let Ok(mut map) = registry.lock() {
350 let existing = map.entry(canonical).or_default();
351 let mut seen: FxHashSet<(String, String)> = existing
352 .iter()
353 .map(|d| {
354 (
355 d.kind.id().to_owned(),
356 dunce::canonicalize(&d.path)
357 .unwrap_or_else(|_| d.path.clone())
358 .display()
359 .to_string(),
360 )
361 })
362 .collect();
363 for addition in additions {
364 let key = (
365 addition.kind.id().to_owned(),
366 dunce::canonicalize(&addition.path)
367 .unwrap_or_else(|_| addition.path.clone())
368 .display()
369 .to_string(),
370 );
371 if seen.insert(key) {
372 existing.push(addition);
373 }
374 }
375 }
376}
377
378pub fn clear_source_discovery_diagnostics(root: &Path) {
391 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
392 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
393 return;
394 };
395 if let Ok(mut map) = registry.lock()
396 && let Some(existing) = map.get_mut(&canonical)
397 {
398 existing.retain(|d| !d.kind.is_source_discovery());
399 }
400}
401
402#[must_use]
408pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
409 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
410 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
411 return Vec::new();
412 };
413 registry
414 .lock()
415 .ok()
416 .and_then(|map| map.get(&canonical).cloned())
417 .unwrap_or_default()
418}
419
420#[must_use]
426pub(super) fn is_skip_listed_dir(name: &str) -> bool {
427 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
428}
429
430#[must_use]
435pub(super) fn is_ignored_workspace_dir(
436 relative_dir: &Path,
437 ignore_patterns: &globset::GlobSet,
438) -> bool {
439 if ignore_patterns.is_empty() {
440 return false;
441 }
442 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
443 ignore_patterns.is_match(relative_str.as_str())
444 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
452 WorkspaceDiagnostic::new(
453 root,
454 root.join(rel_path),
455 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
456 pattern: pattern.to_owned(),
457 },
458 )
459 }
460
461 #[test]
462 fn skipped_large_file_diagnostic_id_and_message() {
463 let root = Path::new("/project");
464 let diag = WorkspaceDiagnostic::new(
465 root,
466 root.join("src/vendor/app.bundle.js"),
467 WorkspaceDiagnosticKind::SkippedLargeFile {
468 size_bytes: 6 * 1024 * 1024,
469 },
470 );
471 assert_eq!(diag.kind.id(), "skipped-large-file");
472 assert!(
473 diag.message.contains("src/vendor/app.bundle.js"),
474 "message names the project-relative path: {}",
475 diag.message
476 );
477 assert!(
478 diag.message.contains("6.0 MB"),
479 "message reports the size: {}",
480 diag.message
481 );
482 assert!(
483 diag.message.contains("--max-file-size"),
484 "message names the override flag: {}",
485 diag.message
486 );
487 }
488
489 #[test]
490 fn skipped_minified_file_diagnostic_id_and_message() {
491 let root = Path::new("/project");
492 let diag = WorkspaceDiagnostic::new(
493 root,
494 root.join("src/assets/index-abc123.js"),
495 WorkspaceDiagnosticKind::SkippedMinifiedFile {
496 size_bytes: 2 * 1024 * 1024,
497 },
498 );
499 assert_eq!(diag.kind.id(), "skipped-minified-file");
500 assert!(
501 diag.message.contains("src/assets/index-abc123.js"),
502 "message names the project-relative path: {}",
503 diag.message
504 );
505 assert!(
506 diag.message.contains("2.0 MB"),
507 "message reports the size: {}",
508 diag.message
509 );
510 assert!(
511 diag.message.contains("--max-file-size 0"),
512 "message names the opt-out: {}",
513 diag.message
514 );
515 }
516
517 #[test]
518 fn stash_preserves_appended_skipped_large_file_across_restash() {
519 let root = Path::new("/fallow-test-1086-stash-preserve");
522 let undeclared = || {
523 WorkspaceDiagnostic::new(
524 root,
525 root.join("pkg"),
526 WorkspaceDiagnosticKind::UndeclaredWorkspace,
527 )
528 };
529 stash_workspace_diagnostics(root, vec![undeclared()]);
531 append_workspace_diagnostics(
533 root,
534 vec![WorkspaceDiagnostic::new(
535 root,
536 root.join("vendor/big.js"),
537 WorkspaceDiagnosticKind::SkippedLargeFile {
538 size_bytes: 9_999_999,
539 },
540 )],
541 );
542 stash_workspace_diagnostics(root, vec![undeclared()]);
545
546 let after = workspace_diagnostics_for(root);
547 assert_eq!(
548 after
549 .iter()
550 .filter(|d| d.kind.is_source_discovery())
551 .count(),
552 1,
553 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
554 );
555 assert_eq!(
556 after
557 .iter()
558 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
559 .count(),
560 1,
561 "the workspace-discovery diagnostic is replaced, not duplicated"
562 );
563 }
564
565 #[test]
566 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
567 let root = Path::new("/fallow-test-1086-clear-stale");
568 stash_workspace_diagnostics(
569 root,
570 vec![WorkspaceDiagnostic::new(
571 root,
572 root.join("pkg"),
573 WorkspaceDiagnosticKind::UndeclaredWorkspace,
574 )],
575 );
576 append_workspace_diagnostics(
577 root,
578 vec![WorkspaceDiagnostic::new(
579 root,
580 root.join("vendor/big.js"),
581 WorkspaceDiagnosticKind::SkippedLargeFile {
582 size_bytes: 9_999_999,
583 },
584 )],
585 );
586 clear_source_discovery_diagnostics(root);
588
589 let after = workspace_diagnostics_for(root);
590 assert!(
591 !after.iter().any(|d| d.kind.is_source_discovery()),
592 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
593 );
594 assert!(
595 after
596 .iter()
597 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
598 "the workspace-discovery diagnostic survives the source-discovery clear"
599 );
600 }
601
602 #[test]
603 fn build_glob_group_message_caps_examples_and_summarises_tail() {
604 let root = Path::new("/project");
605 let paths = [
606 root.join("playground/cli"),
607 root.join("playground/lib-types"),
608 root.join("playground/minify"),
609 root.join("playground/ssr"),
610 root.join("playground/worker"),
611 ];
612 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
613 let message = build_glob_group_message(root, "playground/**", &refs);
614
615 assert!(
616 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
617 "count and pattern lead the message: {message}"
618 );
619 assert!(
620 message.contains(
621 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
622 ),
623 "three sorted examples + tail count: {message}"
624 );
625 assert!(
626 message.ends_with(
627 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
628 ),
629 "next-step hint preserved: {message}"
630 );
631 assert!(
632 !message.contains("playground/ssr"),
633 "tail example not named: {message}"
634 );
635 }
636
637 #[test]
638 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
639 let root = Path::new("/project");
640 let paths = [root.join("packages/a"), root.join("packages/b")];
641 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
642 let message = build_glob_group_message(root, "packages/*", &refs);
643
644 assert!(message.contains("matched 2 directories"), "{message}");
645 assert!(
646 message.contains("(e.g. packages/a, packages/b)"),
647 "both examples named, no `and N more`: {message}"
648 );
649 assert!(!message.contains("more)"), "no tail clause: {message}");
650 }
651
652 #[test]
653 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
654 let root = Path::new("/project");
655 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
656 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
657 .collect();
658
659 let plans = plan_warnings(root, &diagnostics);
660
661 assert_eq!(
662 plans.len(),
663 1,
664 "50 same-pattern diagnostics collapse to one plan"
665 );
666 assert!(
667 plans[0]
668 .dedupe_key
669 .ends_with("::glob-matched-no-package-json-agg::playground/**")
670 );
671 assert!(plans[0].message.contains("matched 50 directories"));
672 }
673
674 #[test]
675 fn plan_warnings_keeps_distinct_patterns_separate() {
676 let root = Path::new("/project");
677 let diagnostics = vec![
678 glob_diag(root, "apps/*", "apps/a"),
679 glob_diag(root, "apps/*", "apps/b"),
680 glob_diag(root, "packages/*", "packages/x"),
681 glob_diag(root, "packages/*", "packages/y"),
682 ];
683
684 let plans = plan_warnings(root, &diagnostics);
685
686 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
687 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
688 assert!(
689 messages
690 .iter()
691 .any(|m| m.contains("Glob 'apps/*' matched 2")),
692 "{messages:?}"
693 );
694 assert!(
695 messages
696 .iter()
697 .any(|m| m.contains("Glob 'packages/*' matched 2")),
698 "{messages:?}"
699 );
700 }
701
702 #[test]
703 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
704 let root = Path::new("/project");
705 let diag = glob_diag(root, "packages/*", "packages/scratch");
706
707 let plans = plan_warnings(root, std::slice::from_ref(&diag));
708
709 assert_eq!(plans.len(), 1);
710 assert_eq!(plans[0].message, diag.message);
711 assert!(
712 plans[0]
713 .dedupe_key
714 .contains("::glob-matched-no-package-json::")
715 && plans[0].dedupe_key.ends_with("packages/scratch"),
716 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
717 plans[0].dedupe_key
718 );
719 assert!(
720 !plans[0].message.contains("directories"),
721 "single match is not aggregated"
722 );
723 }
724
725 #[test]
726 fn plan_warnings_non_glob_kinds_stay_per_instance() {
727 let root = Path::new("/project");
728 let diagnostics = vec![
729 WorkspaceDiagnostic::new(
730 root,
731 root.join("packages/a"),
732 WorkspaceDiagnosticKind::UndeclaredWorkspace,
733 ),
734 WorkspaceDiagnostic::new(
735 root,
736 root.join("packages/b"),
737 WorkspaceDiagnosticKind::MalformedPackageJson {
738 error: "trailing comma".to_owned(),
739 },
740 ),
741 ];
742
743 let plans = plan_warnings(root, &diagnostics);
744
745 assert_eq!(
746 plans.len(),
747 2,
748 "each non-glob diagnostic plans its own warning"
749 );
750 assert!(
751 plans
752 .iter()
753 .all(|p| !p.message.contains("directories with no package.json"))
754 );
755 }
756
757 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
758 WorkspaceDiagnostic::new(
759 root,
760 root.join(rel_path),
761 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
762 )
763 }
764
765 #[test]
766 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
767 let root = Path::new("/project");
768 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
769 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
770 .collect();
771
772 let plans = plan_warnings(root, &diagnostics);
773
774 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
775 assert!(
776 plans[0]
777 .dedupe_key
778 .ends_with("::tsconfig-reference-dir-missing-agg")
779 );
780 assert!(
781 plans[0]
782 .message
783 .starts_with("tsconfig.json references 30 directories that do not exist"),
784 "{}",
785 plans[0].message
786 );
787 assert!(
788 plans[0].message.contains(
789 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
790 packages/p02/tsconfig.json, and 27 more)"
791 ),
792 "three sorted examples + tail: {}",
793 plans[0].message
794 );
795 assert!(
796 plans[0]
797 .message
798 .ends_with("Update or remove the references, or restore the missing directories."),
799 "{}",
800 plans[0].message
801 );
802 }
803
804 #[test]
805 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
806 let root = Path::new("/project");
807 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
808
809 let plans = plan_warnings(root, std::slice::from_ref(&diag));
810
811 assert_eq!(plans.len(), 1);
812 assert_eq!(
813 plans[0].message, diag.message,
814 "single miss is not aggregated"
815 );
816 assert!(!plans[0].message.contains("directories that do not exist"));
817 }
818
819 #[test]
820 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
821 let root = Path::new("/project");
822 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
823 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
824 .collect();
825 diagnostics.extend(
826 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
827 );
828
829 let plans = plan_warnings(root, &diagnostics);
830
831 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
832 assert!(
833 plans
834 .iter()
835 .any(|p| p.message.contains("matched 5 directories"))
836 );
837 assert!(
838 plans
839 .iter()
840 .any(|p| p.message.contains("references 4 directories"))
841 );
842 }
843}