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