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 MalformedRootDenoConfig { path: PathBuf, error: String },
54}
55
56impl std::fmt::Display for WorkspaceLoadError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::MalformedRootPackageJson { path, error } => write!(
60 f,
61 "root package.json at '{}' is not valid JSON ({error}). \
62 Fix the syntax before re-running fallow.",
63 path.display()
64 ),
65 Self::MalformedRootDenoConfig { path, error } => write!(
66 f,
67 "root Deno config at '{}' is not valid JSONC ({error}). \
68 Fix the syntax before re-running fallow.",
69 path.display()
70 ),
71 }
72 }
73}
74
75impl std::error::Error for WorkspaceLoadError {}
76
77const GLOB_EXAMPLE_CAP: usize = 3;
81
82fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
89 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
90 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
91}
92
93fn should_emit(key: String) -> bool {
98 warned_keys().lock().map_or(true, |mut set| set.insert(key))
99}
100
101#[derive(Debug, PartialEq, Eq)]
106struct PlannedWarning {
107 dedupe_key: String,
108 message: String,
109}
110
111struct WarningGroups<'a> {
112 plans: Vec<PlannedWarning>,
113 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
114 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
115}
116
117fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
132 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
133 let WarningGroups {
134 mut plans,
135 glob_groups,
136 tsconfig_ref_misses,
137 } = group_warning_diagnostics(diagnostics, &canonical);
138
139 for (pattern, group) in glob_groups {
140 if let [only] = group.as_slice() {
141 plans.push(per_instance_warning(&canonical, only));
142 continue;
143 }
144 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
145 plans.push(PlannedWarning {
146 dedupe_key: format!(
147 "{}::glob-matched-no-package-json-agg::{pattern}",
148 canonical.display()
149 ),
150 message: build_glob_group_message(root, pattern, &paths),
151 });
152 }
153
154 if let [only] = tsconfig_ref_misses.as_slice() {
155 plans.push(per_instance_warning(&canonical, only));
156 } else if !tsconfig_ref_misses.is_empty() {
157 let paths: Vec<&Path> = tsconfig_ref_misses
158 .iter()
159 .map(|d| d.path.as_path())
160 .collect();
161 plans.push(PlannedWarning {
162 dedupe_key: format!(
163 "{}::tsconfig-reference-dir-missing-agg",
164 canonical.display()
165 ),
166 message: build_tsconfig_refs_message(root, &paths),
167 });
168 }
169
170 plans
171}
172
173fn group_warning_diagnostics<'a>(
174 diagnostics: &'a [WorkspaceDiagnostic],
175 canonical: &Path,
176) -> WarningGroups<'a> {
177 let mut plans: Vec<PlannedWarning> = Vec::new();
178 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
179 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
180 for diag in diagnostics {
181 match &diag.kind {
182 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
183 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
184 Some((_, group)) => group.push(diag),
185 None => glob_groups.push((pattern.as_str(), vec![diag])),
186 }
187 }
188 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
189 _ => plans.push(per_instance_warning(canonical, diag)),
190 }
191 }
192 WarningGroups {
193 plans,
194 glob_groups,
195 tsconfig_ref_misses,
196 }
197}
198
199fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
200 PlannedWarning {
201 dedupe_key: format!(
202 "{}::{}::{}",
203 canonical.display(),
204 diag.kind.id(),
205 diag.path.display()
206 ),
207 message: diag.message.clone(),
208 }
209}
210
211pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
220 #[cfg(test)]
221 for diag in diagnostics {
222 capture_diag(diag);
223 }
224
225 for plan in plan_warnings(root, diagnostics) {
226 if should_emit(plan.dedupe_key) {
227 tracing::warn!("fallow: {}", plan.message);
228 }
229 }
230}
231
232fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
237 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
238 examples.sort();
239 let count = examples.len();
240 let shown = examples
241 .iter()
242 .take(GLOB_EXAMPLE_CAP)
243 .cloned()
244 .collect::<Vec<_>>()
245 .join(", ");
246 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
247 let listed = if remaining > 0 {
248 format!("{shown}, and {remaining} more")
249 } else {
250 shown
251 };
252 (listed, count)
253}
254
255fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
258 let (listed, count) = summarize_examples(root, paths);
259 format!(
260 "Glob '{pattern}' matched {count} directories with no package.json \
261 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
262 them to ignorePatterns."
263 )
264}
265
266fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
270 let (listed, count) = summarize_examples(root, paths);
271 format!(
272 "tsconfig.json references {count} directories that do not exist \
273 (e.g. {listed}). Update or remove the references, or restore the \
274 missing directories."
275 )
276}
277
278thread_local! {
279 #[cfg(test)]
286 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
287 const { std::cell::RefCell::new(None) };
288}
289
290#[cfg(test)]
296fn capture_diag(diag: &WorkspaceDiagnostic) {
297 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
298 if let Some(buf) = cell.borrow_mut().as_mut() {
299 buf.push(diag.clone());
300 }
301 });
302}
303
304#[cfg(test)]
312#[must_use]
313pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
314 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
315 *cell.borrow_mut() = Some(Vec::new());
316 });
317 let result = body();
318 let findings =
319 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
320 (result, findings)
321}
322
323static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
334 OnceLock::new();
335
336pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
351 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
352 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
353 if let Ok(mut map) = registry.lock() {
354 let mut combined = diagnostics;
355 if let Some(existing) = map.get(&canonical) {
356 combined.extend(
357 existing
358 .iter()
359 .filter(|d| d.kind.is_source_discovery())
360 .cloned(),
361 );
362 }
363 map.insert(canonical, combined);
364 }
365}
366
367pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
376 if additions.is_empty() {
377 return;
378 }
379 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
380 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
381 if let Ok(mut map) = registry.lock() {
382 let existing = map.entry(canonical).or_default();
383 let mut seen: FxHashSet<(String, String)> = existing
384 .iter()
385 .map(|d| {
386 (
387 d.kind.id().to_owned(),
388 dunce::canonicalize(&d.path)
389 .unwrap_or_else(|_| d.path.clone())
390 .display()
391 .to_string(),
392 )
393 })
394 .collect();
395 for addition in additions {
396 let key = (
397 addition.kind.id().to_owned(),
398 dunce::canonicalize(&addition.path)
399 .unwrap_or_else(|_| addition.path.clone())
400 .display()
401 .to_string(),
402 );
403 if seen.insert(key) {
404 existing.push(addition);
405 }
406 }
407 }
408}
409
410#[must_use]
417pub fn record_source_read_failures(
418 root: &Path,
419 failures: &[fallow_types::extract::SourceReadFailure],
420) -> Vec<WorkspaceDiagnostic> {
421 let diagnostics: Vec<WorkspaceDiagnostic> = failures
422 .iter()
423 .map(|failure| {
424 WorkspaceDiagnostic::new(
425 root,
426 failure.path.clone(),
427 WorkspaceDiagnosticKind::SourceReadFailure {
428 error: failure.error.clone(),
429 },
430 )
431 })
432 .collect();
433 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
434 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
435 if let Ok(mut map) = registry.lock() {
436 let existing = map.entry(canonical).or_default();
437 existing.retain(|diagnostic| {
438 !matches!(
439 diagnostic.kind,
440 WorkspaceDiagnosticKind::SourceReadFailure { .. }
441 )
442 });
443 existing.extend(diagnostics.iter().cloned());
444 }
445 emit_diagnostics(root, &diagnostics);
446 diagnostics
447}
448
449pub fn clear_source_discovery_diagnostics(root: &Path) {
462 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
463 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
464 return;
465 };
466 if let Ok(mut map) = registry.lock()
467 && let Some(existing) = map.get_mut(&canonical)
468 {
469 existing.retain(|d| !d.kind.is_source_discovery());
470 }
471}
472
473#[must_use]
479pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
480 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
481 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
482 return Vec::new();
483 };
484 registry
485 .lock()
486 .ok()
487 .and_then(|map| map.get(&canonical).cloned())
488 .unwrap_or_default()
489}
490
491#[must_use]
497pub(super) fn is_skip_listed_dir(name: &str) -> bool {
498 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
499}
500
501#[must_use]
506pub(super) fn is_ignored_workspace_dir(
507 relative_dir: &Path,
508 ignore_patterns: &globset::GlobSet,
509) -> bool {
510 if ignore_patterns.is_empty() {
511 return false;
512 }
513 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
514 ignore_patterns.is_match(relative_str.as_str())
515 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use fallow_types::discover::FileId;
522 use fallow_types::extract::SourceReadFailure;
523
524 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
525 WorkspaceDiagnostic::new(
526 root,
527 root.join(rel_path),
528 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
529 pattern: pattern.to_owned(),
530 },
531 )
532 }
533
534 #[test]
535 fn skipped_large_file_diagnostic_id_and_message() {
536 let root = Path::new("/project");
537 let diag = WorkspaceDiagnostic::new(
538 root,
539 root.join("src/vendor/app.bundle.js"),
540 WorkspaceDiagnosticKind::SkippedLargeFile {
541 size_bytes: 6 * 1024 * 1024,
542 },
543 );
544 assert_eq!(diag.kind.id(), "skipped-large-file");
545 assert!(
546 diag.message.contains("src/vendor/app.bundle.js"),
547 "message names the project-relative path: {}",
548 diag.message
549 );
550 assert!(
551 diag.message.contains("6.0 MB"),
552 "message reports the size: {}",
553 diag.message
554 );
555 assert!(
556 diag.message.contains("--max-file-size"),
557 "message names the override flag: {}",
558 diag.message
559 );
560 }
561
562 #[test]
563 fn skipped_minified_file_diagnostic_id_and_message() {
564 let root = Path::new("/project");
565 let diag = WorkspaceDiagnostic::new(
566 root,
567 root.join("src/assets/index-abc123.js"),
568 WorkspaceDiagnosticKind::SkippedMinifiedFile {
569 size_bytes: 2 * 1024 * 1024,
570 },
571 );
572 assert_eq!(diag.kind.id(), "skipped-minified-file");
573 assert!(
574 diag.message.contains("src/assets/index-abc123.js"),
575 "message names the project-relative path: {}",
576 diag.message
577 );
578 assert!(
579 diag.message.contains("2.0 MB"),
580 "message reports the size: {}",
581 diag.message
582 );
583 assert!(
584 diag.message.contains("--max-file-size 0"),
585 "message names the opt-out: {}",
586 diag.message
587 );
588 }
589
590 #[test]
591 fn stash_preserves_appended_skipped_large_file_across_restash() {
592 let root = Path::new("/fallow-test-1086-stash-preserve");
595 let undeclared = || {
596 WorkspaceDiagnostic::new(
597 root,
598 root.join("pkg"),
599 WorkspaceDiagnosticKind::UndeclaredWorkspace,
600 )
601 };
602 stash_workspace_diagnostics(root, vec![undeclared()]);
604 append_workspace_diagnostics(
606 root,
607 vec![WorkspaceDiagnostic::new(
608 root,
609 root.join("vendor/big.js"),
610 WorkspaceDiagnosticKind::SkippedLargeFile {
611 size_bytes: 9_999_999,
612 },
613 )],
614 );
615 stash_workspace_diagnostics(root, vec![undeclared()]);
618
619 let after = workspace_diagnostics_for(root);
620 assert_eq!(
621 after
622 .iter()
623 .filter(|d| d.kind.is_source_discovery())
624 .count(),
625 1,
626 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
627 );
628 assert_eq!(
629 after
630 .iter()
631 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
632 .count(),
633 1,
634 "the workspace-discovery diagnostic is replaced, not duplicated"
635 );
636 }
637
638 #[test]
639 fn source_read_failures_replace_only_their_previous_parse_set() {
640 let root = Path::new("/fallow-test-source-read-replace");
641 stash_workspace_diagnostics(
642 root,
643 vec![WorkspaceDiagnostic::new(
644 root,
645 root.join("pkg"),
646 WorkspaceDiagnosticKind::UndeclaredWorkspace,
647 )],
648 );
649 append_workspace_diagnostics(
650 root,
651 vec![WorkspaceDiagnostic::new(
652 root,
653 root.join("vendor/big.js"),
654 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
655 )],
656 );
657 let first = SourceReadFailure {
658 file_id: FileId(1),
659 path: root.join("src/first.ts"),
660 error: "removed".to_string(),
661 };
662 let _ = record_source_read_failures(root, &[first]);
663 let second = SourceReadFailure {
664 file_id: FileId(2),
665 path: root.join("src/second.ts"),
666 error: "permission denied".to_string(),
667 };
668
669 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
670
671 let diagnostics = workspace_diagnostics_for(root);
672 let source_failures: Vec<_> = diagnostics
673 .iter()
674 .filter(|diagnostic| {
675 matches!(
676 diagnostic.kind,
677 WorkspaceDiagnosticKind::SourceReadFailure { .. }
678 )
679 })
680 .collect();
681 assert_eq!(source_failures.len(), 1);
682 assert_eq!(source_failures[0].path, second.path);
683 assert!(diagnostics.iter().any(|diagnostic| matches!(
684 diagnostic.kind,
685 WorkspaceDiagnosticKind::UndeclaredWorkspace
686 )));
687 assert!(diagnostics.iter().any(|diagnostic| matches!(
688 diagnostic.kind,
689 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
690 )));
691
692 let _ = record_source_read_failures(root, &[]);
693 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
694 !matches!(
695 diagnostic.kind,
696 WorkspaceDiagnosticKind::SourceReadFailure { .. }
697 )
698 }));
699 }
700
701 #[test]
702 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
703 let root = Path::new("/fallow-test-1086-clear-stale");
704 stash_workspace_diagnostics(
705 root,
706 vec![WorkspaceDiagnostic::new(
707 root,
708 root.join("pkg"),
709 WorkspaceDiagnosticKind::UndeclaredWorkspace,
710 )],
711 );
712 append_workspace_diagnostics(
713 root,
714 vec![WorkspaceDiagnostic::new(
715 root,
716 root.join("vendor/big.js"),
717 WorkspaceDiagnosticKind::SkippedLargeFile {
718 size_bytes: 9_999_999,
719 },
720 )],
721 );
722 clear_source_discovery_diagnostics(root);
724
725 let after = workspace_diagnostics_for(root);
726 assert!(
727 !after.iter().any(|d| d.kind.is_source_discovery()),
728 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
729 );
730 assert!(
731 after
732 .iter()
733 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
734 "the workspace-discovery diagnostic survives the source-discovery clear"
735 );
736 }
737
738 #[test]
739 fn build_glob_group_message_caps_examples_and_summarises_tail() {
740 let root = Path::new("/project");
741 let paths = [
742 root.join("playground/cli"),
743 root.join("playground/lib-types"),
744 root.join("playground/minify"),
745 root.join("playground/ssr"),
746 root.join("playground/worker"),
747 ];
748 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
749 let message = build_glob_group_message(root, "playground/**", &refs);
750
751 assert!(
752 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
753 "count and pattern lead the message: {message}"
754 );
755 assert!(
756 message.contains(
757 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
758 ),
759 "three sorted examples + tail count: {message}"
760 );
761 assert!(
762 message.ends_with(
763 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
764 ),
765 "next-step hint preserved: {message}"
766 );
767 assert!(
768 !message.contains("playground/ssr"),
769 "tail example not named: {message}"
770 );
771 }
772
773 #[test]
774 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
775 let root = Path::new("/project");
776 let paths = [root.join("packages/a"), root.join("packages/b")];
777 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
778 let message = build_glob_group_message(root, "packages/*", &refs);
779
780 assert!(message.contains("matched 2 directories"), "{message}");
781 assert!(
782 message.contains("(e.g. packages/a, packages/b)"),
783 "both examples named, no `and N more`: {message}"
784 );
785 assert!(!message.contains("more)"), "no tail clause: {message}");
786 }
787
788 #[test]
789 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
790 let root = Path::new("/project");
791 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
792 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
793 .collect();
794
795 let plans = plan_warnings(root, &diagnostics);
796
797 assert_eq!(
798 plans.len(),
799 1,
800 "50 same-pattern diagnostics collapse to one plan"
801 );
802 assert!(
803 plans[0]
804 .dedupe_key
805 .ends_with("::glob-matched-no-package-json-agg::playground/**")
806 );
807 assert!(plans[0].message.contains("matched 50 directories"));
808 }
809
810 #[test]
811 fn plan_warnings_keeps_distinct_patterns_separate() {
812 let root = Path::new("/project");
813 let diagnostics = vec![
814 glob_diag(root, "apps/*", "apps/a"),
815 glob_diag(root, "apps/*", "apps/b"),
816 glob_diag(root, "packages/*", "packages/x"),
817 glob_diag(root, "packages/*", "packages/y"),
818 ];
819
820 let plans = plan_warnings(root, &diagnostics);
821
822 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
823 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
824 assert!(
825 messages
826 .iter()
827 .any(|m| m.contains("Glob 'apps/*' matched 2")),
828 "{messages:?}"
829 );
830 assert!(
831 messages
832 .iter()
833 .any(|m| m.contains("Glob 'packages/*' matched 2")),
834 "{messages:?}"
835 );
836 }
837
838 #[test]
839 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
840 let root = Path::new("/project");
841 let diag = glob_diag(root, "packages/*", "packages/scratch");
842
843 let plans = plan_warnings(root, std::slice::from_ref(&diag));
844
845 assert_eq!(plans.len(), 1);
846 assert_eq!(plans[0].message, diag.message);
847 assert!(
848 plans[0]
849 .dedupe_key
850 .contains("::glob-matched-no-package-json::")
851 && plans[0].dedupe_key.ends_with("packages/scratch"),
852 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
853 plans[0].dedupe_key
854 );
855 assert!(
856 !plans[0].message.contains("directories"),
857 "single match is not aggregated"
858 );
859 }
860
861 #[test]
862 fn plan_warnings_non_glob_kinds_stay_per_instance() {
863 let root = Path::new("/project");
864 let diagnostics = vec![
865 WorkspaceDiagnostic::new(
866 root,
867 root.join("packages/a"),
868 WorkspaceDiagnosticKind::UndeclaredWorkspace,
869 ),
870 WorkspaceDiagnostic::new(
871 root,
872 root.join("packages/b"),
873 WorkspaceDiagnosticKind::MalformedPackageJson {
874 error: "trailing comma".to_owned(),
875 },
876 ),
877 ];
878
879 let plans = plan_warnings(root, &diagnostics);
880
881 assert_eq!(
882 plans.len(),
883 2,
884 "each non-glob diagnostic plans its own warning"
885 );
886 assert!(
887 plans
888 .iter()
889 .all(|p| !p.message.contains("directories with no package.json"))
890 );
891 }
892
893 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
894 WorkspaceDiagnostic::new(
895 root,
896 root.join(rel_path),
897 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
898 )
899 }
900
901 #[test]
902 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
903 let root = Path::new("/project");
904 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
905 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
906 .collect();
907
908 let plans = plan_warnings(root, &diagnostics);
909
910 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
911 assert!(
912 plans[0]
913 .dedupe_key
914 .ends_with("::tsconfig-reference-dir-missing-agg")
915 );
916 assert!(
917 plans[0]
918 .message
919 .starts_with("tsconfig.json references 30 directories that do not exist"),
920 "{}",
921 plans[0].message
922 );
923 assert!(
924 plans[0].message.contains(
925 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
926 packages/p02/tsconfig.json, and 27 more)"
927 ),
928 "three sorted examples + tail: {}",
929 plans[0].message
930 );
931 assert!(
932 plans[0]
933 .message
934 .ends_with("Update or remove the references, or restore the missing directories."),
935 "{}",
936 plans[0].message
937 );
938 }
939
940 #[test]
941 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
942 let root = Path::new("/project");
943 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
944
945 let plans = plan_warnings(root, std::slice::from_ref(&diag));
946
947 assert_eq!(plans.len(), 1);
948 assert_eq!(
949 plans[0].message, diag.message,
950 "single miss is not aggregated"
951 );
952 assert!(!plans[0].message.contains("directories that do not exist"));
953 }
954
955 #[test]
956 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
957 let root = Path::new("/project");
958 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
959 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
960 .collect();
961 diagnostics.extend(
962 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
963 );
964
965 let plans = plan_warnings(root, &diagnostics);
966
967 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
968 assert!(
969 plans
970 .iter()
971 .any(|p| p.message.contains("matched 5 directories"))
972 );
973 assert!(
974 plans
975 .iter()
976 .any(|p| p.message.contains("references 4 directories"))
977 );
978 }
979}