1use std::borrow::Cow;
19use std::collections::BTreeMap;
20
21use foldhash::HashMap;
22use foldhash::HashSet;
23use schemars::JsonSchema;
24use serde::Deserialize;
25use serde::Serialize;
26
27use mago_database::DatabaseReader;
28use mago_database::ReadDatabase;
29
30use crate::Annotation;
31use crate::Issue;
32use crate::IssueCollection;
33
34#[inline]
41fn baseline_annotation(issue: &Issue) -> Option<&Annotation> {
42 issue.annotations.iter().find(|a| a.is_primary()).or_else(|| issue.annotations.first())
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
47#[serde(rename_all = "lowercase")]
48pub enum BaselineVariant {
49 Strict,
54 #[default]
59 Loose,
60}
61
62#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
66pub struct StrictBaselineIssue {
67 pub code: String,
68 pub start_line: u32,
69 pub end_line: u32,
70}
71
72#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
74pub struct StrictBaselineEntry {
75 pub issues: Vec<StrictBaselineIssue>,
76}
77
78#[derive(Serialize, Deserialize, Debug, Default, JsonSchema)]
83pub struct StrictBaseline {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub variant: Option<BaselineVariant>,
88 pub entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry>,
90}
91
92#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
96pub struct LooseBaselineIssue {
97 pub file: String,
99 pub code: String,
101 pub message: String,
103 pub count: u32,
105}
106
107#[derive(Serialize, Deserialize, Debug, Default, JsonSchema)]
109pub struct LooseBaseline {
110 pub variant: BaselineVariant,
112 pub issues: Vec<LooseBaselineIssue>,
114}
115
116#[derive(Debug, Serialize, Deserialize, JsonSchema)]
118#[serde(untagged)]
119pub enum Baseline {
120 Strict(StrictBaseline),
122 Loose(LooseBaseline),
124}
125
126#[derive(Debug, Clone)]
128pub struct BaselineComparisonResult {
129 pub is_up_to_date: bool,
131 pub new_issues_count: usize,
133 pub removed_issues_count: usize,
135 pub files_with_changes_count: usize,
137}
138
139fn normalize_path(path: &str) -> String {
143 path.replace('\\', "/")
144}
145
146impl StrictBaseline {
147 #[must_use]
149 pub fn new() -> Self {
150 Self { variant: Some(BaselineVariant::Strict), entries: BTreeMap::new() }
151 }
152
153 #[must_use]
158 pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
159 let mut entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry> = BTreeMap::new();
160
161 for issue in issues.iter() {
162 let Some(annotation) = baseline_annotation(issue) else {
163 continue;
164 };
165
166 let Ok(file) = read_database.get(&annotation.span.file_id) else {
167 continue;
168 };
169
170 let normalized_path = normalize_path(&file.name);
171 let entry = entries.entry(Cow::Owned(normalized_path)).or_default();
172
173 let start_line = file.line_number(annotation.span.start.offset);
174 let end_line = file.line_number(annotation.span.end.offset);
175
176 let baseline_issue = StrictBaselineIssue {
177 code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
178 start_line,
179 end_line,
180 };
181
182 if !entry.issues.contains(&baseline_issue) {
183 entry.issues.push(baseline_issue);
184 }
185 }
186
187 for entry in entries.values_mut() {
189 entry.issues.sort();
190 }
191
192 Self { variant: Some(BaselineVariant::Strict), entries }
193 }
194
195 #[must_use]
200 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
201 let mut filtered_issues = Vec::new();
202
203 for issue in issues {
204 let Some(annotation) = baseline_annotation(&issue) else {
205 filtered_issues.push(issue);
206 continue;
207 };
208
209 let Ok(file) = read_database.get(&annotation.span.file_id) else {
210 filtered_issues.push(issue);
211 continue;
212 };
213
214 let normalized_path = normalize_path(&file.name);
215 let Some(baseline_entry) = self.entries.get(normalized_path.as_str()) else {
216 filtered_issues.push(issue);
217 continue;
218 };
219
220 let start_line = file.line_number(annotation.span.start.offset);
221 let end_line = file.line_number(annotation.span.end.offset);
222
223 let baseline_issue = StrictBaselineIssue {
224 code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
225 start_line,
226 end_line,
227 };
228
229 if !baseline_entry.issues.contains(&baseline_issue) {
230 filtered_issues.push(issue);
231 }
232 }
233
234 IssueCollection::from(filtered_issues)
235 }
236
237 #[must_use]
242 pub fn compare_with_issues(
243 &self,
244 issues: &IssueCollection,
245 read_database: &ReadDatabase,
246 ) -> BaselineComparisonResult {
247 let current_baseline = Self::generate_from_issues(issues, read_database);
248
249 if self.entries == current_baseline.entries {
251 return BaselineComparisonResult {
252 is_up_to_date: true,
253 new_issues_count: 0,
254 removed_issues_count: 0,
255 files_with_changes_count: 0,
256 };
257 }
258
259 let mut new_issues = 0;
261 let mut removed_issues = 0;
262 let mut files_with_changes = HashSet::default();
263
264 for (file_path, current_entry) in ¤t_baseline.entries {
266 if let Some(baseline_entry) = self.entries.get(file_path) {
267 let baseline_issues: HashSet<_> = baseline_entry.issues.iter().collect();
268 let current_issues: HashSet<_> = current_entry.issues.iter().collect();
269
270 let new_in_file = current_issues.difference(&baseline_issues).count();
271 let removed_in_file = baseline_issues.difference(¤t_issues).count();
272
273 if new_in_file > 0 || removed_in_file > 0 {
274 files_with_changes.insert(file_path.as_ref());
275 new_issues += new_in_file;
276 removed_issues += removed_in_file;
277 }
278 } else {
279 new_issues += current_entry.issues.len();
281 files_with_changes.insert(file_path.as_ref());
282 }
283 }
284
285 for (file_path, baseline_entry) in &self.entries {
287 if !current_baseline.entries.contains_key(file_path) {
288 removed_issues += baseline_entry.issues.len();
289 files_with_changes.insert(file_path.as_ref());
290 }
291 }
292
293 BaselineComparisonResult {
294 is_up_to_date: false,
295 new_issues_count: new_issues,
296 removed_issues_count: removed_issues,
297 files_with_changes_count: files_with_changes.len(),
298 }
299 }
300}
301
302impl LooseBaseline {
303 #[must_use]
305 pub fn new() -> Self {
306 Self { variant: BaselineVariant::Loose, issues: Vec::new() }
307 }
308
309 #[must_use]
314 pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
315 let mut issue_counts: HashMap<(String, String, String), u32> = HashMap::default();
316
317 for issue in issues.iter() {
318 let Some(annotation) = baseline_annotation(issue) else {
319 continue;
320 };
321
322 let Ok(file) = read_database.get(&annotation.span.file_id) else {
323 continue;
324 };
325
326 let normalized_path = normalize_path(&file.name);
327 let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
328 let message = issue.message.clone();
329
330 let key = (normalized_path, code, message);
331 *issue_counts.entry(key).or_insert(0) += 1;
332 }
333
334 let mut baseline_issues: Vec<LooseBaselineIssue> = issue_counts
335 .into_iter()
336 .map(|((file, code, message), count)| LooseBaselineIssue { file, code, message, count })
337 .collect();
338
339 baseline_issues.sort();
340
341 Self { variant: BaselineVariant::Loose, issues: baseline_issues }
342 }
343
344 #[must_use]
349 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
350 let mut remaining_counts: HashMap<(String, String, String), u32> =
351 self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
352
353 let mut filtered_issues = Vec::new();
354
355 for issue in issues {
356 let Some(annotation) = baseline_annotation(&issue) else {
357 filtered_issues.push(issue);
358 continue;
359 };
360
361 let Ok(file) = read_database.get(&annotation.span.file_id) else {
362 filtered_issues.push(issue);
363 continue;
364 };
365
366 let normalized_path = normalize_path(&file.name);
367 let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
368 let key = (normalized_path, code, issue.message.clone());
369
370 if let Some(count) = remaining_counts.get_mut(&key)
371 && *count > 0
372 {
373 *count -= 1;
374 continue;
375 }
376
377 filtered_issues.push(issue);
378 }
379
380 IssueCollection::from(filtered_issues)
381 }
382
383 #[must_use]
388 pub fn compare_with_issues(
389 &self,
390 issues: &IssueCollection,
391 read_database: &ReadDatabase,
392 ) -> BaselineComparisonResult {
393 let current = Self::generate_from_issues(issues, read_database);
394
395 let current_map: HashMap<_, _> =
396 current.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
397
398 let baseline_map: HashMap<_, _> =
399 self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
400
401 let mut new_issues = 0usize;
402 let mut removed_issues = 0usize;
403 let mut files_with_changes: HashSet<&str> = HashSet::default();
404
405 for (key, ¤t_count) in ¤t_map {
406 let baseline_count = baseline_map.get(key).copied().unwrap_or(0);
407 if current_count > baseline_count {
408 new_issues += (current_count - baseline_count) as usize;
409 files_with_changes.insert(key.0.as_str());
410 }
411 }
412
413 for (key, &baseline_count) in &baseline_map {
414 let current_count = current_map.get(key).copied().unwrap_or(0);
415 if baseline_count > current_count {
416 removed_issues += (baseline_count - current_count) as usize;
417 files_with_changes.insert(key.0.as_str());
418 }
419 }
420
421 BaselineComparisonResult {
422 is_up_to_date: new_issues == 0 && removed_issues == 0,
423 new_issues_count: new_issues,
424 removed_issues_count: removed_issues,
425 files_with_changes_count: files_with_changes.len(),
426 }
427 }
428}
429
430impl Baseline {
431 #[must_use]
433 pub fn generate_from_issues(
434 issues: &IssueCollection,
435 read_database: &ReadDatabase,
436 variant: BaselineVariant,
437 ) -> Self {
438 match variant {
439 BaselineVariant::Strict => Baseline::Strict(StrictBaseline::generate_from_issues(issues, read_database)),
440 BaselineVariant::Loose => Baseline::Loose(LooseBaseline::generate_from_issues(issues, read_database)),
441 }
442 }
443
444 #[must_use]
448 pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
449 match self {
450 Baseline::Strict(strict) => strict.filter_issues(issues, read_database),
451 Baseline::Loose(loose) => loose.filter_issues(issues, read_database),
452 }
453 }
454
455 #[must_use]
460 pub fn compare_with_issues(
461 &self,
462 issues: &IssueCollection,
463 read_database: &ReadDatabase,
464 ) -> BaselineComparisonResult {
465 match self {
466 Baseline::Strict(strict) => strict.compare_with_issues(issues, read_database),
467 Baseline::Loose(loose) => loose.compare_with_issues(issues, read_database),
468 }
469 }
470
471 #[must_use]
473 pub fn variant(&self) -> BaselineVariant {
474 match self {
475 Baseline::Strict(_) => BaselineVariant::Strict,
476 Baseline::Loose(_) => BaselineVariant::Loose,
477 }
478 }
479}
480
481#[cfg(test)]
482#[allow(clippy::unwrap_used, clippy::get_unwrap)]
483mod tests {
484 use super::*;
485 use crate::Annotation;
486 use crate::Issue;
487 use mago_database::Database;
488 use mago_database::file::File;
489 use mago_database::file::FileId;
490 use mago_span::Position;
491 use mago_span::Span;
492
493 fn create_test_database() -> (Database<'static>, FileId) {
494 let file =
495 File::ephemeral(Cow::Borrowed("test.php"), Cow::Borrowed("<?php\n// Line 1\n// Line 2\n// Line 3\n"));
496 let file_id = file.id;
497 let config =
498 mago_database::DatabaseConfiguration::new(std::path::Path::new("/"), vec![], vec![], vec![], vec![])
499 .into_static();
500 let db = Database::single(file, config);
501 (db, file_id)
502 }
503
504 fn create_test_issue(file_id: FileId, code: &str, start_offset: u32, end_offset: u32) -> Issue {
505 Issue::error("test error").with_code(code).with_annotation(Annotation::primary(Span::new(
506 file_id,
507 Position::new(start_offset),
508 Position::new(end_offset),
509 )))
510 }
511
512 fn create_test_issue_with_message(
513 file_id: FileId,
514 code: &str,
515 message: &str,
516 start_offset: u32,
517 end_offset: u32,
518 ) -> Issue {
519 Issue::error(message).with_code(code).with_annotation(Annotation::primary(Span::new(
520 file_id,
521 Position::new(start_offset),
522 Position::new(end_offset),
523 )))
524 }
525
526 #[test]
527 fn test_normalize_path() {
528 assert_eq!(normalize_path("foo/bar/baz.php"), "foo/bar/baz.php");
529 assert_eq!(normalize_path("foo\\bar\\baz.php"), "foo/bar/baz.php");
530 assert_eq!(normalize_path("C:\\Users\\test\\file.php"), "C:/Users/test/file.php");
531 }
532
533 #[test]
534 fn test_strict_generate_baseline_from_issues() {
535 let (db, file_id) = create_test_database();
536 let read_db = db.read_only();
537
538 let mut issues = IssueCollection::new();
539 issues.push(create_test_issue(file_id, "E001", 0, 5));
540 issues.push(create_test_issue(file_id, "E002", 10, 15));
541
542 let baseline = StrictBaseline::generate_from_issues(&issues, &read_db);
543
544 assert_eq!(baseline.variant, Some(BaselineVariant::Strict));
545 assert_eq!(baseline.entries.len(), 1);
546 let entry = baseline.entries.get("test.php").unwrap();
547 assert_eq!(entry.issues.len(), 2);
548 }
549
550 #[test]
551 fn test_strict_filter_issues() {
552 let (db, file_id) = create_test_database();
553 let read_db = db.read_only();
554
555 let mut baseline = StrictBaseline::new();
556 let mut entry = StrictBaselineEntry::default();
557 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
558 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
559
560 let mut issues = IssueCollection::new();
561 issues.push(create_test_issue(file_id, "E001", 0, 5));
562 issues.push(create_test_issue(file_id, "E002", 10, 15));
563
564 let filtered = baseline.filter_issues(issues, &read_db);
565
566 assert_eq!(filtered.len(), 1);
567 assert_eq!(filtered.iter().next().unwrap().code.as_ref().unwrap(), "E002");
568 }
569
570 #[test]
571 fn test_strict_compare_baseline_with_issues() {
572 let (db, file_id) = create_test_database();
573 let read_db = db.read_only();
574
575 let mut baseline = StrictBaseline::new();
576 let mut entry = StrictBaselineEntry::default();
577 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
578 entry.issues.push(StrictBaselineIssue { code: "E003".to_string(), start_line: 2, end_line: 2 });
579 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
580
581 let mut issues = IssueCollection::new();
582 issues.push(create_test_issue(file_id, "E001", 0, 5));
583 issues.push(create_test_issue(file_id, "E002", 10, 15));
584
585 let result = baseline.compare_with_issues(&issues, &read_db);
586
587 assert!(!result.is_up_to_date);
588 assert_eq!(result.removed_issues_count, 1);
589 assert_eq!(result.new_issues_count, 1);
590 assert_eq!(result.files_with_changes_count, 1);
591 }
592
593 #[test]
594 fn test_loose_generate_baseline_from_issues() {
595 let (db, file_id) = create_test_database();
596 let read_db = db.read_only();
597
598 let mut issues = IssueCollection::new();
599 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 0, 5));
600 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 10, 15));
601 issues.push(create_test_issue_with_message(file_id, "E002", "error 2", 20, 25));
602
603 let baseline = LooseBaseline::generate_from_issues(&issues, &read_db);
604
605 assert_eq!(baseline.variant, BaselineVariant::Loose);
606 assert_eq!(baseline.issues.len(), 2);
607
608 let e001 = baseline.issues.iter().find(|i| i.code == "E001").unwrap();
609 assert_eq!(e001.count, 2);
610
611 let e002 = baseline.issues.iter().find(|i| i.code == "E002").unwrap();
612 assert_eq!(e002.count, 1);
613 }
614
615 #[test]
616 fn test_loose_filter_issues() {
617 let (db, file_id) = create_test_database();
618 let read_db = db.read_only();
619
620 let baseline = LooseBaseline {
621 variant: BaselineVariant::Loose,
622 issues: vec![LooseBaselineIssue {
623 file: "test.php".to_string(),
624 code: "E001".to_string(),
625 message: "test error".to_string(),
626 count: 2,
627 }],
628 };
629
630 let mut issues = IssueCollection::new();
631 issues.push(create_test_issue(file_id, "E001", 0, 5));
632 issues.push(create_test_issue(file_id, "E001", 10, 15));
633 issues.push(create_test_issue(file_id, "E001", 20, 25));
634
635 let filtered = baseline.filter_issues(issues, &read_db);
636
637 assert_eq!(filtered.len(), 1);
638 }
639
640 #[test]
641 fn test_loose_compare_baseline_with_issues() {
642 let (db, file_id) = create_test_database();
643 let read_db = db.read_only();
644
645 let baseline = LooseBaseline {
646 variant: BaselineVariant::Loose,
647 issues: vec![
648 LooseBaselineIssue {
649 file: "test.php".to_string(),
650 code: "E001".to_string(),
651 message: "test error".to_string(),
652 count: 2,
653 },
654 LooseBaselineIssue {
655 file: "test.php".to_string(),
656 code: "E003".to_string(),
657 message: "test error".to_string(),
658 count: 1,
659 },
660 ],
661 };
662
663 let mut issues = IssueCollection::new();
664 issues.push(create_test_issue(file_id, "E001", 0, 5));
665 issues.push(create_test_issue(file_id, "E002", 10, 15));
666
667 let result = baseline.compare_with_issues(&issues, &read_db);
668
669 assert!(!result.is_up_to_date);
670 assert_eq!(result.new_issues_count, 1);
671 assert_eq!(result.removed_issues_count, 2);
672 assert_eq!(result.files_with_changes_count, 1);
673 }
674
675 #[test]
676 fn test_unified_baseline_generate_strict() {
677 let (db, file_id) = create_test_database();
678 let read_db = db.read_only();
679
680 let mut issues = IssueCollection::new();
681 issues.push(create_test_issue(file_id, "E001", 0, 5));
682
683 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Strict);
684
685 assert!(matches!(baseline, Baseline::Strict(_)));
686 assert_eq!(baseline.variant(), BaselineVariant::Strict);
687 }
688
689 #[test]
690 fn test_unified_baseline_generate_loose() {
691 let (db, file_id) = create_test_database();
692 let read_db = db.read_only();
693
694 let mut issues = IssueCollection::new();
695 issues.push(create_test_issue(file_id, "E001", 0, 5));
696
697 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Loose);
698
699 assert!(matches!(baseline, Baseline::Loose(_)));
700 assert_eq!(baseline.variant(), BaselineVariant::Loose);
701 }
702}