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;
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/// The annotation a baseline uses to identify an issue's location.
35///
36/// Prefers a primary annotation, falls back to the first annotation. This
37/// keeps the baseline resilient to rules that forget to mark any
38/// annotation as primary — those issues still get baselined/filtered
39/// instead of leaking through as unbaselined on every re-run.
40#[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/// The variant of baseline format to use.
46#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
47#[serde(rename_all = "lowercase")]
48pub enum BaselineVariant {
49    /// Strict baseline with exact line matching.
50    ///
51    /// Each issue is stored with its exact start and end line numbers.
52    /// Any change in line numbers requires baseline regeneration.
53    Strict,
54    /// Loose baseline with count-based matching.
55    ///
56    /// Issues are grouped by (file, code, message) and stored with a count.
57    /// More resilient to code changes as line shifts don't affect the baseline.
58    #[default]
59    Loose,
60}
61
62/// Represents a single issue in the strict baseline format.
63///
64/// This is a simplified representation of an issue for storage in the baseline file.
65#[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/// Represents a collection of issues for a specific file path in the strict baseline.
73#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
74pub struct StrictBaselineEntry {
75    pub issues: Vec<StrictBaselineIssue>,
76}
77
78/// The strict baseline structure containing all entries organized by file path.
79///
80/// File paths are stored in a normalized format (using forward slashes)
81/// to ensure cross-platform compatibility.
82#[derive(Serialize, Deserialize, Debug, Default, JsonSchema)]
83pub struct StrictBaseline {
84    /// The baseline variant marker. When present, indicates this is a strict baseline.
85    /// When absent (for backward compatibility), the baseline is assumed to be strict.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub variant: Option<BaselineVariant>,
88    /// The entries organized by file path.
89    pub entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry>,
90}
91
92/// Represents a single issue entry in the loose baseline format.
93///
94/// Issues are grouped by (file, code, message) tuple with a count.
95#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, JsonSchema)]
96pub struct LooseBaselineIssue {
97    /// The normalized file path where the issues occur.
98    pub file: String,
99    /// The issue code (e.g., "missing-type-hint").
100    pub code: String,
101    /// The issue message.
102    pub message: String,
103    /// The number of occurrences of this issue.
104    pub count: u32,
105}
106
107/// The loose baseline structure with count-based issue tracking.
108#[derive(Serialize, Deserialize, Debug, Default, JsonSchema)]
109pub struct LooseBaseline {
110    /// The baseline variant marker.
111    pub variant: BaselineVariant,
112    /// The list of issues with their counts.
113    pub issues: Vec<LooseBaselineIssue>,
114}
115
116/// A baseline that can be either strict or loose.
117#[derive(Debug, Serialize, Deserialize, JsonSchema)]
118#[serde(untagged)]
119pub enum Baseline {
120    /// Strict baseline with exact line matching.
121    Strict(StrictBaseline),
122    /// Loose baseline with count-based matching.
123    Loose(LooseBaseline),
124}
125
126/// The result of comparing a baseline with current issues.
127#[derive(Debug, Clone)]
128pub struct BaselineComparisonResult {
129    /// Whether the baseline is up to date with current issues
130    pub is_up_to_date: bool,
131    /// Number of new issues not in the baseline
132    pub new_issues_count: usize,
133    /// Number of issues in baseline that no longer exist
134    pub removed_issues_count: usize,
135    /// Number of files with changes (new, removed, or modified issues)
136    pub files_with_changes_count: usize,
137}
138
139/// Normalizes a file path to use forward slashes for cross-platform compatibility.
140///
141/// This ensures that baselines created on Windows work on Linux and vice versa.
142fn normalize_path(path: &str) -> String {
143    path.replace('\\', "/")
144}
145
146impl StrictBaseline {
147    /// Creates a new empty strict baseline.
148    #[must_use]
149    pub fn new() -> Self {
150        Self { variant: Some(BaselineVariant::Strict), entries: BTreeMap::new() }
151    }
152
153    /// Generates a strict baseline from a collection of issues.
154    ///
155    /// The baseline will contain all issues organized by their file paths with exact line numbers.
156    /// File paths are normalized to ensure cross-platform compatibility.
157    #[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        // Sort issues within each entry for consistent output
188        for entry in entries.values_mut() {
189            entry.issues.sort();
190        }
191
192        Self { variant: Some(BaselineVariant::Strict), entries }
193    }
194
195    /// Filters an issue collection against this strict baseline.
196    ///
197    /// Returns a new issue collection containing only issues that are not in the baseline.
198    /// Issues are matched by exact file path, code, and line numbers.
199    #[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    /// Compares this strict baseline with a collection of current issues.
238    ///
239    /// Returns a comparison result with statistics about differences between the baseline
240    /// and current issues.
241    #[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        // Quick check - if they're exactly the same, we're done
250        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        // Analyze what's different
260        let mut new_issues = 0;
261        let mut removed_issues = 0;
262        let mut files_with_changes = HashSet::default();
263
264        // Check for new issues (in current but not in baseline)
265        for (file_path, current_entry) in &current_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(&current_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                // Entire file is new
280                new_issues += current_entry.issues.len();
281                files_with_changes.insert(file_path.as_ref());
282            }
283        }
284
285        // Check for files that were removed entirely
286        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    /// Creates a new empty loose baseline.
304    #[must_use]
305    pub fn new() -> Self {
306        Self { variant: BaselineVariant::Loose, issues: Vec::new() }
307    }
308
309    /// Generates a loose baseline from a collection of issues.
310    ///
311    /// Issues are grouped by (file, code, message) tuple and stored with a count.
312    /// File paths are normalized to ensure cross-platform compatibility.
313    #[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    /// Filters an issue collection against this loose baseline.
345    ///
346    /// Returns a new issue collection containing only issues that exceed the baseline counts.
347    /// For each (file, code, message) tuple, issues are filtered out up to the count in the baseline.
348    #[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    /// Compares this loose baseline with a collection of current issues.
384    ///
385    /// Returns a comparison result with statistics about differences between the baseline
386    /// and current issues.
387    #[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, &current_count) in &current_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    /// Generates a baseline from a collection of issues using the specified variant.
432    #[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    /// Filters an issue collection against this baseline.
445    ///
446    /// Returns a new issue collection containing only issues that are not in the baseline.
447    #[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    /// Compares this baseline with a collection of current issues.
456    ///
457    /// Returns a comparison result with statistics about differences between the baseline
458    /// and current issues.
459    #[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    /// Returns the variant of this baseline.
472    #[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}