1use std::borrow::Cow;
19use std::collections::BTreeMap;
20
21use foldhash::HashMap;
22use foldhash::HashSet;
23use schemars::JsonSchema;
24
25use mago_database::DatabaseReader;
26use mago_database::ReadDatabase;
27
28use crate::Annotation;
29use crate::Issue;
30use crate::IssueCollection;
31
32#[inline]
39fn baseline_annotation(issue: &Issue) -> Option<&Annotation> {
40 issue.annotations.iter().find(|a| a.is_primary()).or_else(|| issue.annotations.first())
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
47pub enum BaselineVariant {
48 Strict,
53 #[default]
58 Loose,
59}
60
61#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct StrictBaselineIssue {
67 pub code: String,
68 pub start_line: u32,
69 pub end_line: u32,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct StrictBaselineEntry {
76 pub issues: Vec<StrictBaselineIssue>,
77}
78
79#[derive(Debug, Default, JsonSchema)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct StrictBaseline {
86 #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
89 pub variant: Option<BaselineVariant>,
90 pub entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry>,
92}
93
94#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct LooseBaselineIssue {
100 pub file: String,
102 pub code: String,
104 pub message: String,
106 pub count: u32,
108}
109
110#[derive(Debug, Default, JsonSchema)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct LooseBaseline {
114 pub variant: BaselineVariant,
116 pub issues: Vec<LooseBaselineIssue>,
118}
119
120#[derive(Debug, JsonSchema)]
122#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
123#[cfg_attr(feature = "serde", serde(untagged))]
124pub enum Baseline {
125 Strict(StrictBaseline),
127 Loose(LooseBaseline),
129}
130
131#[derive(Debug, Clone)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize))]
134pub struct BaselineChangeEntry {
135 pub file: String,
136 pub code: String,
137 pub start_line: u32,
138 pub end_line: u32,
139}
140
141#[derive(Debug, Clone)]
143pub struct BaselineComparisonResult {
144 pub is_up_to_date: bool,
146 pub new_issues: Vec<BaselineChangeEntry>,
148 pub removed_issues: Vec<BaselineChangeEntry>,
150 pub files_with_changes_count: usize,
152}
153
154fn normalize_path(path: &[u8]) -> String {
158 String::from_utf8_lossy(path).replace('\\', "/")
159}
160
161impl StrictBaseline {
162 #[must_use]
164 pub fn new() -> Self {
165 Self { variant: Some(BaselineVariant::Strict), entries: BTreeMap::new() }
166 }
167
168 #[must_use]
173 pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
174 let mut entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry> = BTreeMap::new();
175
176 for issue in issues.iter() {
177 let Some(annotation) = baseline_annotation(issue) else {
178 continue;
179 };
180
181 let Ok(file) = read_database.get(&annotation.span.file_id) else {
182 continue;
183 };
184
185 let normalized_path = normalize_path(&file.name);
186 let entry = entries.entry(Cow::Owned(normalized_path)).or_default();
187
188 let start_line = file.line_number(annotation.span.start.offset);
189 let end_line = file.line_number(annotation.span.end.offset);
190
191 let baseline_issue = StrictBaselineIssue {
192 code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
193 start_line,
194 end_line,
195 };
196
197 if !entry.issues.contains(&baseline_issue) {
198 entry.issues.push(baseline_issue);
199 }
200 }
201
202 for entry in entries.values_mut() {
204 entry.issues.sort();
205 }
206
207 Self { variant: Some(BaselineVariant::Strict), entries }
208 }
209
210 #[must_use]
215 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
216 let mut filtered_issues = Vec::new();
217
218 for issue in issues {
219 let Some(annotation) = baseline_annotation(&issue) else {
220 filtered_issues.push(issue);
221 continue;
222 };
223
224 let Ok(file) = read_database.get(&annotation.span.file_id) else {
225 filtered_issues.push(issue);
226 continue;
227 };
228
229 let normalized_path = normalize_path(&file.name);
230 let Some(baseline_entry) = self.entries.get(normalized_path.as_str()) else {
231 filtered_issues.push(issue);
232 continue;
233 };
234
235 let start_line = file.line_number(annotation.span.start.offset);
236 let end_line = file.line_number(annotation.span.end.offset);
237
238 let baseline_issue = StrictBaselineIssue {
239 code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
240 start_line,
241 end_line,
242 };
243
244 if !baseline_entry.issues.contains(&baseline_issue) {
245 filtered_issues.push(issue);
246 }
247 }
248
249 IssueCollection::from(filtered_issues)
250 }
251
252 #[must_use]
257 pub fn compare_with_issues(
258 &self,
259 issues: &IssueCollection,
260 read_database: &ReadDatabase,
261 ) -> BaselineComparisonResult {
262 let current_baseline = Self::generate_from_issues(issues, read_database);
263
264 if self.entries == current_baseline.entries {
266 return BaselineComparisonResult {
267 is_up_to_date: true,
268 new_issues: vec![],
269 removed_issues: vec![],
270 files_with_changes_count: 0,
271 };
272 }
273
274 let mut new_issues: Vec<BaselineChangeEntry> = vec![];
276 let mut removed_issues: Vec<BaselineChangeEntry> = vec![];
277 let mut files_with_changes = HashSet::default();
278
279 for (file_path, current_entry) in ¤t_baseline.entries {
281 if let Some(baseline_entry) = self.entries.get(file_path) {
282 let baseline_issues: HashSet<_> = baseline_entry.issues.iter().collect();
283 let current_issues: HashSet<_> = current_entry.issues.iter().collect();
284
285 for issue in current_issues.difference(&baseline_issues) {
286 new_issues.push(BaselineChangeEntry {
287 file: file_path.to_string(),
288 code: issue.code.clone(),
289 start_line: issue.start_line,
290 end_line: issue.end_line,
291 });
292 files_with_changes.insert(file_path.as_ref());
293 }
294 for issue in baseline_issues.difference(¤t_issues) {
295 removed_issues.push(BaselineChangeEntry {
296 file: file_path.to_string(),
297 code: issue.code.clone(),
298 start_line: issue.start_line,
299 end_line: issue.end_line,
300 });
301 files_with_changes.insert(file_path.as_ref());
302 }
303 } else {
304 for issue in ¤t_entry.issues {
306 new_issues.push(BaselineChangeEntry {
307 file: file_path.to_string(),
308 code: issue.code.clone(),
309 start_line: issue.start_line,
310 end_line: issue.end_line,
311 });
312 }
313 files_with_changes.insert(file_path.as_ref());
314 }
315 }
316
317 for (file_path, baseline_entry) in &self.entries {
319 if !current_baseline.entries.contains_key(file_path) {
320 for issue in &baseline_entry.issues {
321 removed_issues.push(BaselineChangeEntry {
322 file: file_path.to_string(),
323 code: issue.code.clone(),
324 start_line: issue.start_line,
325 end_line: issue.end_line,
326 });
327 }
328 files_with_changes.insert(file_path.as_ref());
329 }
330 }
331
332 BaselineComparisonResult {
333 is_up_to_date: false,
334 new_issues,
335 removed_issues,
336 files_with_changes_count: files_with_changes.len(),
337 }
338 }
339
340 #[must_use]
346 pub fn prune_outdated_entries(&self, issues: &IssueCollection, read_database: &ReadDatabase) -> (Self, usize) {
347 let current = Self::generate_from_issues(issues, read_database);
348
349 let mut entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry> = BTreeMap::new();
350 let mut removed_count = 0;
351
352 for (file_path, entry) in &self.entries {
353 let current_issues: HashSet<&StrictBaselineIssue> = match current.entries.get(file_path) {
354 Some(current_entry) => current_entry.issues.iter().collect(),
355 None => HashSet::default(),
356 };
357
358 let mut kept = Vec::new();
359 for issue in &entry.issues {
360 if current_issues.contains(issue) {
361 kept.push(issue.clone());
362 } else {
363 removed_count += 1;
364 }
365 }
366
367 if !kept.is_empty() {
368 entries.insert(file_path.clone(), StrictBaselineEntry { issues: kept });
369 }
370 }
371
372 (Self { variant: Some(BaselineVariant::Strict), entries }, removed_count)
373 }
374}
375
376impl LooseBaseline {
377 #[must_use]
379 pub fn new() -> Self {
380 Self { variant: BaselineVariant::Loose, issues: Vec::new() }
381 }
382
383 #[must_use]
388 pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
389 let mut issue_counts: HashMap<(String, String, String), u32> = HashMap::default();
390
391 for issue in issues.iter() {
392 let Some(annotation) = baseline_annotation(issue) else {
393 continue;
394 };
395
396 let Ok(file) = read_database.get(&annotation.span.file_id) else {
397 continue;
398 };
399
400 let normalized_path = normalize_path(&file.name);
401 let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
402 let message = issue.message.clone();
403
404 let key = (normalized_path, code, message);
405 *issue_counts.entry(key).or_insert(0) += 1;
406 }
407
408 let mut baseline_issues: Vec<LooseBaselineIssue> = issue_counts
409 .into_iter()
410 .map(|((file, code, message), count)| LooseBaselineIssue { file, code, message, count })
411 .collect();
412
413 baseline_issues.sort();
414
415 Self { variant: BaselineVariant::Loose, issues: baseline_issues }
416 }
417
418 #[must_use]
423 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
424 let mut remaining_counts: HashMap<(String, String, String), u32> =
425 self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
426
427 let mut filtered_issues = Vec::new();
428
429 for issue in issues {
430 let Some(annotation) = baseline_annotation(&issue) else {
431 filtered_issues.push(issue);
432 continue;
433 };
434
435 let Ok(file) = read_database.get(&annotation.span.file_id) else {
436 filtered_issues.push(issue);
437 continue;
438 };
439
440 let normalized_path = normalize_path(&file.name);
441 let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
442 let key = (normalized_path, code, issue.message.clone());
443
444 if let Some(count) = remaining_counts.get_mut(&key)
445 && *count > 0
446 {
447 *count -= 1;
448 continue;
449 }
450
451 filtered_issues.push(issue);
452 }
453
454 IssueCollection::from(filtered_issues)
455 }
456
457 #[must_use]
462 pub fn compare_with_issues(
463 &self,
464 issues: &IssueCollection,
465 read_database: &ReadDatabase,
466 ) -> BaselineComparisonResult {
467 let current = Self::generate_from_issues(issues, read_database);
468
469 let current_map: HashMap<_, _> =
470 current.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
471
472 let baseline_map: HashMap<_, _> =
473 self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
474
475 let mut new_issues: Vec<BaselineChangeEntry> = vec![];
476 let mut removed_issues: Vec<BaselineChangeEntry> = vec![];
477 let mut files_with_changes: HashSet<&str> = HashSet::default();
478
479 for (key, ¤t_count) in ¤t_map {
480 let baseline_count = baseline_map.get(key).copied().unwrap_or(0);
481 if current_count > baseline_count {
482 let delta = (current_count - baseline_count) as usize;
483 for _ in 0..delta {
484 new_issues.push(BaselineChangeEntry {
485 file: key.0.clone(),
486 code: key.1.clone(),
487 start_line: 0,
488 end_line: 0,
489 });
490 }
491 files_with_changes.insert(key.0.as_str());
492 }
493 }
494
495 for (key, &baseline_count) in &baseline_map {
496 let current_count = current_map.get(key).copied().unwrap_or(0);
497 if baseline_count > current_count {
498 let delta = (baseline_count - current_count) as usize;
499 for _ in 0..delta {
500 removed_issues.push(BaselineChangeEntry {
501 file: key.0.clone(),
502 code: key.1.clone(),
503 start_line: 0,
504 end_line: 0,
505 });
506 }
507 files_with_changes.insert(key.0.as_str());
508 }
509 }
510
511 BaselineComparisonResult {
512 is_up_to_date: new_issues.is_empty() && removed_issues.is_empty(),
513 new_issues,
514 removed_issues,
515 files_with_changes_count: files_with_changes.len(),
516 }
517 }
518
519 #[must_use]
526 pub fn prune_outdated_entries(&self, issues: &IssueCollection, read_database: &ReadDatabase) -> (Self, usize) {
527 let current = Self::generate_from_issues(issues, read_database);
528 let current_counts: HashMap<(String, String, String), u32> = current
529 .issues
530 .iter()
531 .map(|issue| ((issue.file.clone(), issue.code.clone(), issue.message.clone()), issue.count))
532 .collect();
533
534 let mut kept: Vec<LooseBaselineIssue> = Vec::new();
535 let mut removed_count = 0;
536
537 for issue in &self.issues {
538 let key = (issue.file.clone(), issue.code.clone(), issue.message.clone());
539 let current_count = current_counts.get(&key).copied().unwrap_or(0);
540 let new_count = issue.count.min(current_count);
541
542 removed_count += (issue.count - new_count) as usize;
543
544 if new_count > 0 {
545 kept.push(LooseBaselineIssue {
546 file: issue.file.clone(),
547 code: issue.code.clone(),
548 message: issue.message.clone(),
549 count: new_count,
550 });
551 }
552 }
553
554 kept.sort();
555
556 (Self { variant: BaselineVariant::Loose, issues: kept }, removed_count)
557 }
558}
559
560impl Baseline {
561 #[must_use]
563 pub fn generate_from_issues(
564 issues: &IssueCollection,
565 read_database: &ReadDatabase,
566 variant: BaselineVariant,
567 ) -> Self {
568 match variant {
569 BaselineVariant::Strict => Baseline::Strict(StrictBaseline::generate_from_issues(issues, read_database)),
570 BaselineVariant::Loose => Baseline::Loose(LooseBaseline::generate_from_issues(issues, read_database)),
571 }
572 }
573
574 #[must_use]
578 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
579 match self {
580 Baseline::Strict(strict) => strict.filter_issues(issues, read_database),
581 Baseline::Loose(loose) => loose.filter_issues(issues, read_database),
582 }
583 }
584
585 #[must_use]
590 pub fn compare_with_issues(
591 &self,
592 issues: &IssueCollection,
593 read_database: &ReadDatabase,
594 ) -> BaselineComparisonResult {
595 match self {
596 Baseline::Strict(strict) => strict.compare_with_issues(issues, read_database),
597 Baseline::Loose(loose) => loose.compare_with_issues(issues, read_database),
598 }
599 }
600
601 #[must_use]
606 pub fn prune_outdated_entries(&self, issues: &IssueCollection, read_database: &ReadDatabase) -> (Self, usize) {
607 match self {
608 Baseline::Strict(strict) => {
609 let (pruned, removed) = strict.prune_outdated_entries(issues, read_database);
610 (Baseline::Strict(pruned), removed)
611 }
612 Baseline::Loose(loose) => {
613 let (pruned, removed) = loose.prune_outdated_entries(issues, read_database);
614 (Baseline::Loose(pruned), removed)
615 }
616 }
617 }
618
619 #[must_use]
621 pub fn variant(&self) -> BaselineVariant {
622 match self {
623 Baseline::Strict(_) => BaselineVariant::Strict,
624 Baseline::Loose(_) => BaselineVariant::Loose,
625 }
626 }
627}
628
629#[cfg(test)]
630#[allow(clippy::unwrap_used, clippy::get_unwrap)]
631mod tests {
632 use super::*;
633 use crate::Annotation;
634 use crate::Issue;
635 use mago_database::Database;
636 use mago_database::file::File;
637 use mago_database::file::FileId;
638 use mago_span::Position;
639 use mago_span::Span;
640
641 fn create_test_database() -> (Database<'static>, FileId) {
642 let file =
643 File::ephemeral(Cow::Borrowed(b"test.php"), Cow::Borrowed(b"<?php\n// Line 1\n// Line 2\n// Line 3\n"));
644 let file_id = file.id;
645 let config =
646 mago_database::DatabaseConfiguration::new(std::path::Path::new("/"), vec![], vec![], vec![], vec![])
647 .into_static();
648 let db = Database::single(file, config);
649 (db, file_id)
650 }
651
652 fn create_test_issue(file_id: FileId, code: &str, start_offset: u32, end_offset: u32) -> Issue {
653 Issue::error("test error").with_code(code).with_annotation(Annotation::primary(Span::new(
654 file_id,
655 Position::new(start_offset),
656 Position::new(end_offset),
657 )))
658 }
659
660 fn create_test_issue_with_message(
661 file_id: FileId,
662 code: &str,
663 message: &str,
664 start_offset: u32,
665 end_offset: u32,
666 ) -> Issue {
667 Issue::error(message).with_code(code).with_annotation(Annotation::primary(Span::new(
668 file_id,
669 Position::new(start_offset),
670 Position::new(end_offset),
671 )))
672 }
673
674 #[test]
675 fn test_normalize_path() {
676 assert_eq!(normalize_path(b"foo/bar/baz.php"), "foo/bar/baz.php");
677 assert_eq!(normalize_path(b"foo\\bar\\baz.php"), "foo/bar/baz.php");
678 assert_eq!(normalize_path(b"C:\\Users\\test\\file.php"), "C:/Users/test/file.php");
679 }
680
681 #[test]
682 fn test_strict_generate_baseline_from_issues() {
683 let (db, file_id) = create_test_database();
684 let read_db = db.read_only();
685
686 let mut issues = IssueCollection::new();
687 issues.push(create_test_issue(file_id, "E001", 0, 5));
688 issues.push(create_test_issue(file_id, "E002", 10, 15));
689
690 let baseline = StrictBaseline::generate_from_issues(&issues, &read_db);
691
692 assert_eq!(baseline.variant, Some(BaselineVariant::Strict));
693 assert_eq!(baseline.entries.len(), 1);
694 let entry = baseline.entries.get("test.php").unwrap();
695 assert_eq!(entry.issues.len(), 2);
696 }
697
698 #[test]
699 fn test_strict_filter_issues() {
700 let (db, file_id) = create_test_database();
701 let read_db = db.read_only();
702
703 let mut baseline = StrictBaseline::new();
704 let mut entry = StrictBaselineEntry::default();
705 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
706 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
707
708 let mut issues = IssueCollection::new();
709 issues.push(create_test_issue(file_id, "E001", 0, 5));
710 issues.push(create_test_issue(file_id, "E002", 10, 15));
711
712 let filtered = baseline.filter_issues(issues, &read_db);
713
714 assert_eq!(filtered.len(), 1);
715 assert_eq!(filtered.iter().next().unwrap().code.as_ref().unwrap(), "E002");
716 }
717
718 #[test]
719 fn test_strict_compare_baseline_with_issues() {
720 let (db, file_id) = create_test_database();
721 let read_db = db.read_only();
722
723 let mut baseline = StrictBaseline::new();
724 let mut entry = StrictBaselineEntry::default();
725 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
726 entry.issues.push(StrictBaselineIssue { code: "E003".to_string(), start_line: 2, end_line: 2 });
727 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
728
729 let mut issues = IssueCollection::new();
730 issues.push(create_test_issue(file_id, "E001", 0, 5));
731 issues.push(create_test_issue(file_id, "E002", 10, 15));
732
733 let result = baseline.compare_with_issues(&issues, &read_db);
734
735 assert!(!result.is_up_to_date);
736 assert_eq!(result.removed_issues.len(), 1);
737 assert_eq!(result.new_issues.len(), 1);
738 assert_eq!(result.files_with_changes_count, 1);
739 }
740
741 #[test]
742 fn test_loose_generate_baseline_from_issues() {
743 let (db, file_id) = create_test_database();
744 let read_db = db.read_only();
745
746 let mut issues = IssueCollection::new();
747 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 0, 5));
748 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 10, 15));
749 issues.push(create_test_issue_with_message(file_id, "E002", "error 2", 20, 25));
750
751 let baseline = LooseBaseline::generate_from_issues(&issues, &read_db);
752
753 assert_eq!(baseline.variant, BaselineVariant::Loose);
754 assert_eq!(baseline.issues.len(), 2);
755
756 let e001 = baseline.issues.iter().find(|i| i.code == "E001").unwrap();
757 assert_eq!(e001.count, 2);
758
759 let e002 = baseline.issues.iter().find(|i| i.code == "E002").unwrap();
760 assert_eq!(e002.count, 1);
761 }
762
763 #[test]
764 fn test_loose_filter_issues() {
765 let (db, file_id) = create_test_database();
766 let read_db = db.read_only();
767
768 let baseline = LooseBaseline {
769 variant: BaselineVariant::Loose,
770 issues: vec![LooseBaselineIssue {
771 file: "test.php".to_string(),
772 code: "E001".to_string(),
773 message: "test error".to_string(),
774 count: 2,
775 }],
776 };
777
778 let mut issues = IssueCollection::new();
779 issues.push(create_test_issue(file_id, "E001", 0, 5));
780 issues.push(create_test_issue(file_id, "E001", 10, 15));
781 issues.push(create_test_issue(file_id, "E001", 20, 25));
782
783 let filtered = baseline.filter_issues(issues, &read_db);
784
785 assert_eq!(filtered.len(), 1);
786 }
787
788 #[test]
789 fn test_loose_compare_baseline_with_issues() {
790 let (db, file_id) = create_test_database();
791 let read_db = db.read_only();
792
793 let baseline = LooseBaseline {
794 variant: BaselineVariant::Loose,
795 issues: vec![
796 LooseBaselineIssue {
797 file: "test.php".to_string(),
798 code: "E001".to_string(),
799 message: "test error".to_string(),
800 count: 2,
801 },
802 LooseBaselineIssue {
803 file: "test.php".to_string(),
804 code: "E003".to_string(),
805 message: "test error".to_string(),
806 count: 1,
807 },
808 ],
809 };
810
811 let mut issues = IssueCollection::new();
812 issues.push(create_test_issue(file_id, "E001", 0, 5));
813 issues.push(create_test_issue(file_id, "E002", 10, 15));
814
815 let result = baseline.compare_with_issues(&issues, &read_db);
816
817 assert!(!result.is_up_to_date);
818 assert_eq!(result.new_issues.len(), 1);
819 assert_eq!(result.removed_issues.len(), 2);
820 assert_eq!(result.files_with_changes_count, 1);
821 }
822
823 #[test]
824 fn test_unified_baseline_generate_strict() {
825 let (db, file_id) = create_test_database();
826 let read_db = db.read_only();
827
828 let mut issues = IssueCollection::new();
829 issues.push(create_test_issue(file_id, "E001", 0, 5));
830
831 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Strict);
832
833 assert!(matches!(baseline, Baseline::Strict(_)));
834 assert_eq!(baseline.variant(), BaselineVariant::Strict);
835 }
836
837 #[test]
838 fn test_unified_baseline_generate_loose() {
839 let (db, file_id) = create_test_database();
840 let read_db = db.read_only();
841
842 let mut issues = IssueCollection::new();
843 issues.push(create_test_issue(file_id, "E001", 0, 5));
844
845 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Loose);
846
847 assert!(matches!(baseline, Baseline::Loose(_)));
848 assert_eq!(baseline.variant(), BaselineVariant::Loose);
849 }
850
851 #[test]
852 fn test_strict_prune_outdated_entries() {
853 let (db, file_id) = create_test_database();
854 let read_db = db.read_only();
855
856 let mut baseline = StrictBaseline::new();
857 let mut entry = StrictBaselineEntry::default();
858 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
859 entry.issues.push(StrictBaselineIssue { code: "E999".to_string(), start_line: 2, end_line: 2 });
860 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
861
862 let mut issues = IssueCollection::new();
863 issues.push(create_test_issue(file_id, "E001", 0, 5));
864 issues.push(create_test_issue(file_id, "E500", 10, 15));
865
866 let (pruned, removed) = baseline.prune_outdated_entries(&issues, &read_db);
867
868 assert_eq!(removed, 1, "the stale `E999` entry should be removed");
869 let Some(kept) = pruned.entries.get("test.php") else {
870 panic!("the file entry should be kept");
871 };
872 assert_eq!(kept.issues.len(), 1, "the new `E500` issue must not be added");
873 assert_eq!(kept.issues.first().map(|issue| issue.code.as_str()), Some("E001"));
874 }
875
876 #[test]
877 fn test_strict_prune_drops_emptied_file_entry() {
878 let (db, _file_id) = create_test_database();
879 let read_db = db.read_only();
880
881 let mut baseline = StrictBaseline::new();
882 let mut entry = StrictBaselineEntry::default();
883 entry.issues.push(StrictBaselineIssue { code: "E999".to_string(), start_line: 2, end_line: 2 });
884 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
885
886 let issues = IssueCollection::new();
887
888 let (pruned, removed) = baseline.prune_outdated_entries(&issues, &read_db);
889
890 assert_eq!(removed, 1);
891 assert!(pruned.entries.is_empty(), "a file with no surviving entries should be dropped");
892 }
893
894 #[test]
895 fn test_loose_prune_outdated_entries() {
896 let (db, file_id) = create_test_database();
897 let read_db = db.read_only();
898
899 let baseline = LooseBaseline {
900 variant: BaselineVariant::Loose,
901 issues: vec![
902 LooseBaselineIssue {
903 file: "test.php".to_string(),
904 code: "E001".to_string(),
905 message: "test error".to_string(),
906 count: 3,
907 },
908 LooseBaselineIssue {
909 file: "test.php".to_string(),
910 code: "E999".to_string(),
911 message: "test error".to_string(),
912 count: 1,
913 },
914 ],
915 };
916
917 let mut issues = IssueCollection::new();
918 issues.push(create_test_issue(file_id, "E001", 0, 5));
919 issues.push(create_test_issue(file_id, "E500", 10, 15));
920
921 let (pruned, removed) = baseline.prune_outdated_entries(&issues, &read_db);
922
923 assert_eq!(removed, 3);
924 assert_eq!(pruned.issues.len(), 1, "the new `E500` issue must not be added");
925 assert_eq!(pruned.issues[0].code, "E001");
926 assert_eq!(pruned.issues[0].count, 1, "the count must be capped at the current occurrences");
927 }
928}