Skip to main content

mago_reporting/
baseline.rs

1//! Baseline functionality for filtering known issues.
2//!
3//! This module provides functionality to track and filter known issues using baseline files.
4//! A baseline allows teams to adopt static analysis gradually by suppressing existing issues
5//! while ensuring no new issues are introduced.
6//!
7//! Two baseline variants are supported:
8//!
9//! - **Strict**: Stores exact line numbers for each issue. Changes to line numbers require
10//!   baseline regeneration. This is the most precise variant.
11//!
12//! - **Loose**: Stores issue counts per (file, code, message) tuple. More resilient to code
13//!   changes as line number shifts don't affect the baseline. This is the default.
14//!
15//! File paths in baselines are normalized to use forward slashes for cross-platform compatibility,
16//! ensuring baselines created on Windows work on Unix systems and vice versa.
17
18use 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/// The annotation a baseline uses to identify an issue's location.
33///
34/// Prefers a primary annotation, falls back to the first annotation. This
35/// keeps the baseline resilient to rules that forget to mark any
36/// annotation as primary — those issues still get baselined/filtered
37/// instead of leaking through as unbaselined on every re-run.
38#[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/// The variant of baseline format to use.
44#[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 baseline with exact line matching.
49    ///
50    /// Each issue is stored with its exact start and end line numbers.
51    /// Any change in line numbers requires baseline regeneration.
52    Strict,
53    /// Loose baseline with count-based matching.
54    ///
55    /// Issues are grouped by (file, code, message) and stored with a count.
56    /// More resilient to code changes as line shifts don't affect the baseline.
57    #[default]
58    Loose,
59}
60
61/// Represents a single issue in the strict baseline format.
62///
63/// This is a simplified representation of an issue for storage in the baseline file.
64#[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/// Represents a collection of issues for a specific file path in the strict baseline.
73#[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/// The strict baseline structure containing all entries organized by file path.
80///
81/// File paths are stored in a normalized format (using forward slashes)
82/// to ensure cross-platform compatibility.
83#[derive(Debug, Default, JsonSchema)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct StrictBaseline {
86    /// The baseline variant marker. When present, indicates this is a strict baseline.
87    /// When absent (for backward compatibility), the baseline is assumed to be strict.
88    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
89    pub variant: Option<BaselineVariant>,
90    /// The entries organized by file path.
91    pub entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry>,
92}
93
94/// Represents a single issue entry in the loose baseline format.
95///
96/// Issues are grouped by (file, code, message) tuple with a count.
97#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct LooseBaselineIssue {
100    /// The normalized file path where the issues occur.
101    pub file: String,
102    /// The issue code (e.g., "missing-type-hint").
103    pub code: String,
104    /// The issue message.
105    pub message: String,
106    /// The number of occurrences of this issue.
107    pub count: u32,
108}
109
110/// The loose baseline structure with count-based issue tracking.
111#[derive(Debug, Default, JsonSchema)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct LooseBaseline {
114    /// The baseline variant marker.
115    pub variant: BaselineVariant,
116    /// The list of issues with their counts.
117    pub issues: Vec<LooseBaselineIssue>,
118}
119
120/// A baseline that can be either strict or loose.
121#[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 baseline with exact line matching.
126    Strict(StrictBaseline),
127    /// Loose baseline with count-based matching.
128    Loose(LooseBaseline),
129}
130
131/// A specific issue that appeared or disappeared during a baseline comparison.
132#[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/// The result of comparing a baseline with current issues.
142#[derive(Debug, Clone)]
143pub struct BaselineComparisonResult {
144    /// Whether the baseline is up to date with current issues
145    pub is_up_to_date: bool,
146    /// Issues present in the current run but absent from the baseline
147    pub new_issues: Vec<BaselineChangeEntry>,
148    /// Issues recorded in the baseline that no longer appear
149    pub removed_issues: Vec<BaselineChangeEntry>,
150    /// Number of files with changes (new, removed, or modified issues)
151    pub files_with_changes_count: usize,
152}
153
154/// Normalizes a file path to use forward slashes for cross-platform compatibility.
155///
156/// This ensures that baselines created on Windows work on Linux and vice versa.
157fn normalize_path(path: &[u8]) -> String {
158    String::from_utf8_lossy(path).replace('\\', "/")
159}
160
161impl StrictBaseline {
162    /// Creates a new empty strict baseline.
163    #[must_use]
164    pub fn new() -> Self {
165        Self { variant: Some(BaselineVariant::Strict), entries: BTreeMap::new() }
166    }
167
168    /// Generates a strict baseline from a collection of issues.
169    ///
170    /// The baseline will contain all issues organized by their file paths with exact line numbers.
171    /// File paths are normalized to ensure cross-platform compatibility.
172    #[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        // Sort issues within each entry for consistent output
203        for entry in entries.values_mut() {
204            entry.issues.sort();
205        }
206
207        Self { variant: Some(BaselineVariant::Strict), entries }
208    }
209
210    /// Filters an issue collection against this strict baseline.
211    ///
212    /// Returns a new issue collection containing only issues that are not in the baseline.
213    /// Issues are matched by exact file path, code, and line numbers.
214    #[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    /// Compares this strict baseline with a collection of current issues.
253    ///
254    /// Returns a comparison result with statistics about differences between the baseline
255    /// and current issues.
256    #[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        // Quick check - if they're exactly the same, we're done
265        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        // Analyze what's different
275        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        // Check for new issues (in current but not in baseline)
280        for (file_path, current_entry) in &current_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(&current_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                // Entire file is new
305                for issue in &current_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        // Check for files that were removed entirely
318        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    /// Removes baseline entries that no longer correspond to a current issue.
341    ///
342    /// Unlike [`generate_from_issues`](Self::generate_from_issues), this never adds new
343    /// entries. The returned baseline keeps only entries that still match an issue in
344    /// `issues`; the second tuple element is the number of stale entries removed.
345    #[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    /// Creates a new empty loose baseline.
378    #[must_use]
379    pub fn new() -> Self {
380        Self { variant: BaselineVariant::Loose, issues: Vec::new() }
381    }
382
383    /// Generates a loose baseline from a collection of issues.
384    ///
385    /// Issues are grouped by (file, code, message) tuple and stored with a count.
386    /// File paths are normalized to ensure cross-platform compatibility.
387    #[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    /// Filters an issue collection against this loose baseline.
419    ///
420    /// Returns a new issue collection containing only issues that exceed the baseline counts.
421    /// For each (file, code, message) tuple, issues are filtered out up to the count in the baseline.
422    #[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    /// Compares this loose baseline with a collection of current issues.
458    ///
459    /// Returns a comparison result with statistics about differences between the baseline
460    /// and current issues.
461    #[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, &current_count) in &current_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    /// Removes baseline issues that no longer correspond to a current issue.
520    ///
521    /// Unlike [`generate_from_issues`](Self::generate_from_issues), this never adds new
522    /// issues and never raises a count. For each `(file, code, message)` tuple the count
523    /// is capped at the current number of occurrences; tuples with no current occurrence
524    /// are dropped. The second tuple element is the number of stale occurrences removed.
525    #[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    /// Generates a baseline from a collection of issues using the specified variant.
562    #[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    /// Filters an issue collection against this baseline.
575    ///
576    /// Returns a new issue collection containing only issues that are not in the baseline.
577    #[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    /// Compares this baseline with a collection of current issues.
586    ///
587    /// Returns a comparison result with statistics about differences between the baseline
588    /// and current issues.
589    #[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    /// Removes baseline entries that no longer correspond to a current issue.
602    ///
603    /// This never adds new entries, so issues introduced since the baseline was created
604    /// remain reportable. The second tuple element is the number of stale entries removed.
605    #[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    /// Returns the variant of this baseline.
620    #[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}