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