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, 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)]
482mod tests {
483 use super::*;
484 use crate::Annotation;
485 use crate::Issue;
486 use mago_database::Database;
487 use mago_database::file::File;
488 use mago_database::file::FileId;
489 use mago_span::Position;
490 use mago_span::Span;
491
492 fn create_test_database() -> (Database<'static>, FileId) {
493 let file =
494 File::ephemeral(Cow::Borrowed("test.php"), Cow::Borrowed("<?php\n// Line 1\n// Line 2\n// Line 3\n"));
495 let file_id = file.id;
496 let config =
497 mago_database::DatabaseConfiguration::new(std::path::Path::new("/"), vec![], vec![], vec![], vec![])
498 .into_static();
499 let db = Database::single(file, config);
500 (db, file_id)
501 }
502
503 fn create_test_issue(file_id: FileId, code: &str, start_offset: u32, end_offset: u32) -> Issue {
504 Issue::error("test error").with_code(code).with_annotation(Annotation::primary(Span::new(
505 file_id,
506 Position::new(start_offset),
507 Position::new(end_offset),
508 )))
509 }
510
511 fn create_test_issue_with_message(
512 file_id: FileId,
513 code: &str,
514 message: &str,
515 start_offset: u32,
516 end_offset: u32,
517 ) -> Issue {
518 Issue::error(message).with_code(code).with_annotation(Annotation::primary(Span::new(
519 file_id,
520 Position::new(start_offset),
521 Position::new(end_offset),
522 )))
523 }
524
525 #[test]
526 fn test_normalize_path() {
527 assert_eq!(normalize_path("foo/bar/baz.php"), "foo/bar/baz.php");
528 assert_eq!(normalize_path("foo\\bar\\baz.php"), "foo/bar/baz.php");
529 assert_eq!(normalize_path("C:\\Users\\test\\file.php"), "C:/Users/test/file.php");
530 }
531
532 #[test]
533 fn test_strict_generate_baseline_from_issues() {
534 let (db, file_id) = create_test_database();
535 let read_db = db.read_only();
536
537 let mut issues = IssueCollection::new();
538 issues.push(create_test_issue(file_id, "E001", 0, 5));
539 issues.push(create_test_issue(file_id, "E002", 10, 15));
540
541 let baseline = StrictBaseline::generate_from_issues(&issues, &read_db);
542
543 assert_eq!(baseline.variant, Some(BaselineVariant::Strict));
544 assert_eq!(baseline.entries.len(), 1);
545 let entry = baseline.entries.get("test.php").unwrap();
546 assert_eq!(entry.issues.len(), 2);
547 }
548
549 #[test]
550 fn test_strict_filter_issues() {
551 let (db, file_id) = create_test_database();
552 let read_db = db.read_only();
553
554 let mut baseline = StrictBaseline::new();
555 let mut entry = StrictBaselineEntry::default();
556 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
557 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
558
559 let mut issues = IssueCollection::new();
560 issues.push(create_test_issue(file_id, "E001", 0, 5));
561 issues.push(create_test_issue(file_id, "E002", 10, 15));
562
563 let filtered = baseline.filter_issues(issues, &read_db);
564
565 assert_eq!(filtered.len(), 1);
566 assert_eq!(filtered.iter().next().unwrap().code.as_ref().unwrap(), "E002");
567 }
568
569 #[test]
570 fn test_strict_compare_baseline_with_issues() {
571 let (db, file_id) = create_test_database();
572 let read_db = db.read_only();
573
574 let mut baseline = StrictBaseline::new();
575 let mut entry = StrictBaselineEntry::default();
576 entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
577 entry.issues.push(StrictBaselineIssue { code: "E003".to_string(), start_line: 2, end_line: 2 });
578 baseline.entries.insert(Cow::Borrowed("test.php"), entry);
579
580 let mut issues = IssueCollection::new();
581 issues.push(create_test_issue(file_id, "E001", 0, 5));
582 issues.push(create_test_issue(file_id, "E002", 10, 15));
583
584 let result = baseline.compare_with_issues(&issues, &read_db);
585
586 assert!(!result.is_up_to_date);
587 assert_eq!(result.removed_issues_count, 1);
588 assert_eq!(result.new_issues_count, 1);
589 assert_eq!(result.files_with_changes_count, 1);
590 }
591
592 #[test]
593 fn test_loose_generate_baseline_from_issues() {
594 let (db, file_id) = create_test_database();
595 let read_db = db.read_only();
596
597 let mut issues = IssueCollection::new();
598 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 0, 5));
599 issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 10, 15));
600 issues.push(create_test_issue_with_message(file_id, "E002", "error 2", 20, 25));
601
602 let baseline = LooseBaseline::generate_from_issues(&issues, &read_db);
603
604 assert_eq!(baseline.variant, BaselineVariant::Loose);
605 assert_eq!(baseline.issues.len(), 2);
606
607 let e001 = baseline.issues.iter().find(|i| i.code == "E001").unwrap();
608 assert_eq!(e001.count, 2);
609
610 let e002 = baseline.issues.iter().find(|i| i.code == "E002").unwrap();
611 assert_eq!(e002.count, 1);
612 }
613
614 #[test]
615 fn test_loose_filter_issues() {
616 let (db, file_id) = create_test_database();
617 let read_db = db.read_only();
618
619 let baseline = LooseBaseline {
620 variant: BaselineVariant::Loose,
621 issues: vec![LooseBaselineIssue {
622 file: "test.php".to_string(),
623 code: "E001".to_string(),
624 message: "test error".to_string(),
625 count: 2,
626 }],
627 };
628
629 let mut issues = IssueCollection::new();
630 issues.push(create_test_issue(file_id, "E001", 0, 5));
631 issues.push(create_test_issue(file_id, "E001", 10, 15));
632 issues.push(create_test_issue(file_id, "E001", 20, 25));
633
634 let filtered = baseline.filter_issues(issues, &read_db);
635
636 assert_eq!(filtered.len(), 1);
637 }
638
639 #[test]
640 fn test_loose_compare_baseline_with_issues() {
641 let (db, file_id) = create_test_database();
642 let read_db = db.read_only();
643
644 let baseline = LooseBaseline {
645 variant: BaselineVariant::Loose,
646 issues: vec![
647 LooseBaselineIssue {
648 file: "test.php".to_string(),
649 code: "E001".to_string(),
650 message: "test error".to_string(),
651 count: 2,
652 },
653 LooseBaselineIssue {
654 file: "test.php".to_string(),
655 code: "E003".to_string(),
656 message: "test error".to_string(),
657 count: 1,
658 },
659 ],
660 };
661
662 let mut issues = IssueCollection::new();
663 issues.push(create_test_issue(file_id, "E001", 0, 5));
664 issues.push(create_test_issue(file_id, "E002", 10, 15));
665
666 let result = baseline.compare_with_issues(&issues, &read_db);
667
668 assert!(!result.is_up_to_date);
669 assert_eq!(result.new_issues_count, 1);
670 assert_eq!(result.removed_issues_count, 2);
671 assert_eq!(result.files_with_changes_count, 1);
672 }
673
674 #[test]
675 fn test_unified_baseline_generate_strict() {
676 let (db, file_id) = create_test_database();
677 let read_db = db.read_only();
678
679 let mut issues = IssueCollection::new();
680 issues.push(create_test_issue(file_id, "E001", 0, 5));
681
682 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Strict);
683
684 assert!(matches!(baseline, Baseline::Strict(_)));
685 assert_eq!(baseline.variant(), BaselineVariant::Strict);
686 }
687
688 #[test]
689 fn test_unified_baseline_generate_loose() {
690 let (db, file_id) = create_test_database();
691 let read_db = db.read_only();
692
693 let mut issues = IssueCollection::new();
694 issues.push(create_test_issue(file_id, "E001", 0, 5));
695
696 let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Loose);
697
698 assert!(matches!(baseline, Baseline::Loose(_)));
699 assert_eq!(baseline.variant(), BaselineVariant::Loose);
700 }
701}