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