Skip to main content

rustquty_core/
baseline.rs

1//! Baseline file management.
2
3use crate::schema::{
4    AuditThreshold, Baseline, ClippyThreshold, ComplexityThreshold, CoverageThreshold,
5    DenyThreshold, DuplicatesThreshold, FmtThreshold, HackThreshold, LocThreshold,
6    MutantsThreshold, SizeThreshold, TestThreshold, Thresholds,
7};
8use std::path::Path;
9
10pub struct BaselineWriter;
11
12impl BaselineWriter {
13    /// Initialize a new baseline file from a metrics summary.
14    pub fn init(
15        summary: &crate::schema::MetricsSummary,
16        output_path: &Path,
17        force: bool,
18    ) -> anyhow::Result<()> {
19        if output_path.exists() && !force {
20            anyhow::bail!("baseline file already exists; use --force to overwrite");
21        }
22
23        let thresholds = Thresholds {
24            fmt: FmtThreshold {
25                must_pass: summary.collectors.fmt.status == crate::schema::CollectorStatus::Pass,
26            },
27            clippy: ClippyThreshold {
28                max_warnings: summary.collectors.clippy.warning_count,
29            },
30            tests: TestThreshold {
31                max_failures: summary.collectors.tests.failed,
32            },
33            coverage: CoverageThreshold {
34                min_line_percent: summary.collectors.coverage.line_percent,
35            },
36            deny: DenyThreshold {
37                max_banned: summary.collectors.deny.banned_count,
38                max_license_violations: summary.collectors.deny.license_violations,
39            },
40            audit: AuditThreshold {
41                max_vulnerabilities: summary.collectors.audit.vulnerability_count,
42                max_critical: summary.collectors.audit.critical_count,
43            },
44            hack: HackThreshold {
45                must_pass: summary.collectors.hack.status == crate::schema::CollectorStatus::Pass,
46            },
47            mutants: MutantsThreshold {
48                min_score: summary.collectors.mutants.mutation_score,
49            },
50            duplicates: DuplicatesThreshold {
51                max_duplicate_lines: summary.collectors.duplicates.duplicate_lines,
52            },
53            loc: LocThreshold {
54                max_line_length: summary.collectors.loc.max_line_length_found.max(120),
55            },
56            size: SizeThreshold {
57                max_lines_per_file: summary.collectors.size.max_lines_per_file.into(),
58                max_code_lines_per_file: summary.collectors.size.max_code_lines_per_file.into(),
59                max_lines_per_function: summary.collectors.size.max_lines_per_function.into(),
60                max_parameters_per_function: summary
61                    .collectors
62                    .size
63                    .max_parameters_per_function
64                    .into(),
65            },
66            complexity: ComplexityThreshold {
67                max_cyclomatic_per_function: summary
68                    .collectors
69                    .complexity
70                    .max_cyclomatic_complexity
71                    .into(),
72                max_nesting_depth: summary.collectors.complexity.max_nesting_depth.into(),
73            },
74        };
75
76        let baseline = Baseline {
77            schema_version: "1".to_string(),
78            created_at: crate::util::chrono_now(),
79            rustquty_version: summary.rustquty_version.clone(),
80            thresholds,
81        };
82
83        let json = serde_json::to_string_pretty(&baseline)?;
84        std::fs::write(output_path, json)?;
85        Ok(())
86    }
87
88    /// Update an existing baseline file, printing a diff of what changed.
89    pub fn update(
90        summary: &crate::schema::MetricsSummary,
91        output_path: &Path,
92    ) -> anyhow::Result<()> {
93        let existing = if output_path.exists() {
94            let contents = std::fs::read_to_string(output_path)?;
95            Some(serde_json::from_str::<Baseline>(&contents)?)
96        } else {
97            None
98        };
99
100        let thresholds = Thresholds {
101            fmt: FmtThreshold {
102                must_pass: summary.collectors.fmt.status == crate::schema::CollectorStatus::Pass,
103            },
104            clippy: ClippyThreshold {
105                max_warnings: summary.collectors.clippy.warning_count,
106            },
107            tests: TestThreshold {
108                max_failures: summary.collectors.tests.failed,
109            },
110            coverage: CoverageThreshold {
111                min_line_percent: summary.collectors.coverage.line_percent,
112            },
113            deny: DenyThreshold {
114                max_banned: summary.collectors.deny.banned_count,
115                max_license_violations: summary.collectors.deny.license_violations,
116            },
117            audit: AuditThreshold {
118                max_vulnerabilities: summary.collectors.audit.vulnerability_count,
119                max_critical: summary.collectors.audit.critical_count,
120            },
121            hack: HackThreshold {
122                must_pass: summary.collectors.hack.status == crate::schema::CollectorStatus::Pass,
123            },
124            mutants: MutantsThreshold {
125                min_score: summary.collectors.mutants.mutation_score,
126            },
127            duplicates: DuplicatesThreshold {
128                max_duplicate_lines: summary.collectors.duplicates.duplicate_lines,
129            },
130            loc: LocThreshold {
131                max_line_length: summary.collectors.loc.max_line_length_found.max(120),
132            },
133            size: SizeThreshold {
134                max_lines_per_file: summary.collectors.size.max_lines_per_file.into(),
135                max_code_lines_per_file: summary.collectors.size.max_code_lines_per_file.into(),
136                max_lines_per_function: summary.collectors.size.max_lines_per_function.into(),
137                max_parameters_per_function: summary
138                    .collectors
139                    .size
140                    .max_parameters_per_function
141                    .into(),
142            },
143            complexity: ComplexityThreshold {
144                max_cyclomatic_per_function: summary
145                    .collectors
146                    .complexity
147                    .max_cyclomatic_complexity
148                    .into(),
149                max_nesting_depth: summary.collectors.complexity.max_nesting_depth.into(),
150            },
151        };
152
153        let baseline = Baseline {
154            schema_version: "1".to_string(),
155            created_at: crate::util::chrono_now(),
156            rustquty_version: summary.rustquty_version.clone(),
157            thresholds,
158        };
159
160        if let Some(ref old) = existing {
161            print_threshold_diff(&old.thresholds, &baseline.thresholds);
162        }
163
164        let json = serde_json::to_string_pretty(&baseline)?;
165        std::fs::write(output_path, json)?;
166        Ok(())
167    }
168}
169
170fn print_threshold_diff(old: &Thresholds, new: &Thresholds) {
171    let mut changed = Vec::new();
172
173    if old.fmt.must_pass != new.fmt.must_pass {
174        changed.push(format!(
175            "fmt.must_pass: {} -> {}",
176            old.fmt.must_pass, new.fmt.must_pass
177        ));
178    }
179    if old.clippy.max_warnings != new.clippy.max_warnings {
180        changed.push(format!(
181            "clippy.max_warnings: {} -> {}",
182            old.clippy.max_warnings, new.clippy.max_warnings
183        ));
184    }
185    if old.tests.max_failures != new.tests.max_failures {
186        changed.push(format!(
187            "tests.max_failures: {} -> {}",
188            old.tests.max_failures, new.tests.max_failures
189        ));
190    }
191    if (old.coverage.min_line_percent - new.coverage.min_line_percent).abs() > f64::EPSILON {
192        changed.push(format!(
193            "coverage.min_line_percent: {} -> {}",
194            old.coverage.min_line_percent, new.coverage.min_line_percent
195        ));
196    }
197
198    if changed.is_empty() {
199        println!("No threshold changes detected.");
200    } else {
201        println!("Threshold changes:");
202        for line in &changed {
203            println!("  {}", line);
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_baseline_writer_init() {
214        use crate::schema::{
215            AuditResult, ClippyResult, CollectorStatus, CollectorsSummary, CoverageResult,
216            DenyResult, DuplicatesResult, FmtResult, HackResult, LocResult, MutantsResult,
217            SizeResult, TestResult,
218        };
219
220        let summary = crate::schema::MetricsSummary {
221            schema_version: "1".to_string(),
222            generated_at: "2026-05-04T12:00:00Z".to_string(),
223            rustquty_version: "0.1.0".to_string(),
224            project: crate::schema::ProjectInfo {
225                name: "test".to_string(),
226                rust_edition: "2021".to_string(),
227                workspace_root: "/tmp".to_string(),
228            },
229            collectors: CollectorsSummary {
230                fmt: FmtResult {
231                    status: CollectorStatus::Pass,
232                    details: Default::default(),
233                },
234                clippy: ClippyResult {
235                    status: CollectorStatus::Pass,
236                    warning_count: 3,
237                    details: vec![],
238                },
239                tests: TestResult {
240                    status: CollectorStatus::Pass,
241                    passed: 10,
242                    failed: 1,
243                    ignored: 0,
244                    runner: None,
245                },
246                coverage: CoverageResult {
247                    status: CollectorStatus::Pass,
248                    line_percent: 85.5,
249                },
250                deny: DenyResult {
251                    status: CollectorStatus::Pass,
252                    banned_count: 0,
253                    license_violations: 0,
254                },
255                audit: AuditResult {
256                    status: CollectorStatus::Pass,
257                    vulnerability_count: 0,
258                    critical_count: 0,
259                },
260                hack: HackResult {
261                    status: CollectorStatus::Pass,
262                    feature_combinations_tested: 16,
263                },
264                mutants: MutantsResult {
265                    status: CollectorStatus::Pass,
266                    mutation_score: 0.85,
267                    caught: 85,
268                    missed: 15,
269                },
270                duplicates: DuplicatesResult {
271                    status: CollectorStatus::Pass,
272                    total_lines: 1000,
273                    duplicate_lines: 5,
274                    files_with_duplicates: 2,
275                    duplicate_files: vec!["src/a.rs".to_string()],
276                },
277                loc: LocResult {
278                    status: CollectorStatus::Pass,
279                    total_lines: 1000,
280                    code_lines: 800,
281                    comment_lines: 100,
282                    blank_lines: 100,
283                    long_lines: 0,
284                    max_line_length_found: 100,
285                    max_line_length_allowed: 120,
286                    files: 10,
287                    files_with_long_lines: 0,
288                    long_line_files: vec![],
289                },
290                size: SizeResult {
291                    status: CollectorStatus::Pass,
292                    files: 10,
293                    max_lines_per_file: 500,
294                    max_code_lines_per_file: 400,
295                    max_lines_per_function: 80,
296                    max_parameters_per_function: 5,
297                    violations: vec![],
298                },
299                complexity: crate::schema::ComplexityResult {
300                    status: CollectorStatus::Pass,
301                    functions: 10,
302                    max_cyclomatic_complexity: 5,
303                    max_nesting_depth: 3,
304                    complex_functions: 0,
305                    violations: vec![],
306                },
307            },
308        };
309
310        let temp_dir = tempfile::TempDir::new().unwrap();
311        let baseline_path = temp_dir.path().join("baseline.json");
312
313        BaselineWriter::init(&summary, &baseline_path, false).unwrap();
314
315        let content = std::fs::read_to_string(&baseline_path).unwrap();
316        let baseline: Baseline = serde_json::from_str(&content).unwrap();
317
318        assert_eq!(baseline.thresholds.clippy.max_warnings, 3);
319        assert_eq!(baseline.thresholds.tests.max_failures, 1);
320        assert!((baseline.thresholds.coverage.min_line_percent - 85.5).abs() < f64::EPSILON);
321    }
322
323    // --- Regression tests ---
324
325    #[test]
326    fn test_baseline_regression_created_at_is_iso8601() {
327        use crate::schema::{
328            AuditResult, ClippyResult, CollectorStatus, CollectorsSummary, CoverageResult,
329            DenyResult, DuplicatesResult, FmtResult, HackResult, LocResult, MutantsResult,
330            SizeResult, TestResult,
331        };
332
333        let summary = crate::schema::MetricsSummary {
334            schema_version: "1".to_string(),
335            generated_at: "2026-06-01T12:00:00Z".to_string(),
336            rustquty_version: "0.3.1".to_string(),
337            project: crate::schema::ProjectInfo {
338                name: "test".to_string(),
339                rust_edition: "2021".to_string(),
340                workspace_root: "/tmp".to_string(),
341            },
342            collectors: CollectorsSummary {
343                fmt: FmtResult { status: CollectorStatus::Pass, details: Default::default() },
344                clippy: ClippyResult { status: CollectorStatus::Pass, warning_count: 0, details: vec![] },
345                tests: TestResult { status: CollectorStatus::Pass, passed: 10, failed: 0, ignored: 0, runner: None },
346                coverage: CoverageResult { status: CollectorStatus::Pass, line_percent: 90.0 },
347                deny: DenyResult { status: CollectorStatus::Pass, banned_count: 0, license_violations: 0 },
348                audit: AuditResult { status: CollectorStatus::Pass, vulnerability_count: 0, critical_count: 0 },
349                hack: HackResult { status: CollectorStatus::Pass, feature_combinations_tested: 8 },
350                mutants: MutantsResult { status: CollectorStatus::Pass, mutation_score: 0.9, caught: 90, missed: 10 },
351                duplicates: DuplicatesResult { status: CollectorStatus::Pass, total_lines: 500, duplicate_lines: 0, files_with_duplicates: 0, duplicate_files: vec![] },
352                loc: LocResult { status: CollectorStatus::Pass, total_lines: 500, code_lines: 400, comment_lines: 50, blank_lines: 50, long_lines: 0, max_line_length_found: 80, max_line_length_allowed: 120, files: 5, files_with_long_lines: 0, long_line_files: vec![] },
353                size: SizeResult { status: CollectorStatus::Pass, files: 5, max_lines_per_file: 200, max_code_lines_per_file: 150, max_lines_per_function: 40, max_parameters_per_function: 3, violations: vec![] },
354                complexity: crate::schema::ComplexityResult { status: CollectorStatus::Pass, functions: 5, max_cyclomatic_complexity: 3, max_nesting_depth: 2, complex_functions: 0, violations: vec![] },
355            },
356        };
357
358        let temp_dir = tempfile::TempDir::new().unwrap();
359        let baseline_path = temp_dir.path().join("baseline.json");
360
361        BaselineWriter::init(&summary, &baseline_path, false).unwrap();
362
363        let content = std::fs::read_to_string(&baseline_path).unwrap();
364        let baseline: Baseline = serde_json::from_str(&content).unwrap();
365
366        // created_at must be ISO-8601, not a raw Unix timestamp
367        assert!(
368            baseline.created_at.contains('T'),
369            "created_at should be ISO-8601: {}",
370            baseline.created_at
371        );
372        assert!(
373            baseline.created_at.ends_with('Z'),
374            "created_at should end with Z: {}",
375            baseline.created_at
376        );
377        assert_eq!(
378            baseline.created_at.len(),
379            20,
380            "created_at should be 20 chars: {}",
381            baseline.created_at
382        );
383    }
384
385    #[test]
386    fn test_baseline_regression_update_preserves_iso8601() {
387        use crate::schema::{
388            AuditResult, ClippyResult, CollectorStatus, CollectorsSummary, CoverageResult,
389            DenyResult, DuplicatesResult, FmtResult, HackResult, LocResult, MutantsResult,
390            SizeResult, TestResult,
391        };
392
393        let summary = crate::schema::MetricsSummary {
394            schema_version: "1".to_string(),
395            generated_at: "2026-06-01T12:00:00Z".to_string(),
396            rustquty_version: "0.3.1".to_string(),
397            project: crate::schema::ProjectInfo {
398                name: "test".to_string(),
399                rust_edition: "2021".to_string(),
400                workspace_root: "/tmp".to_string(),
401            },
402            collectors: CollectorsSummary {
403                fmt: FmtResult { status: CollectorStatus::Pass, details: Default::default() },
404                clippy: ClippyResult { status: CollectorStatus::Pass, warning_count: 0, details: vec![] },
405                tests: TestResult { status: CollectorStatus::Pass, passed: 10, failed: 0, ignored: 0, runner: None },
406                coverage: CoverageResult { status: CollectorStatus::Pass, line_percent: 90.0 },
407                deny: DenyResult { status: CollectorStatus::Pass, banned_count: 0, license_violations: 0 },
408                audit: AuditResult { status: CollectorStatus::Pass, vulnerability_count: 0, critical_count: 0 },
409                hack: HackResult { status: CollectorStatus::Pass, feature_combinations_tested: 8 },
410                mutants: MutantsResult { status: CollectorStatus::Pass, mutation_score: 0.9, caught: 90, missed: 10 },
411                duplicates: DuplicatesResult { status: CollectorStatus::Pass, total_lines: 500, duplicate_lines: 0, files_with_duplicates: 0, duplicate_files: vec![] },
412                loc: LocResult { status: CollectorStatus::Pass, total_lines: 500, code_lines: 400, comment_lines: 50, blank_lines: 50, long_lines: 0, max_line_length_found: 80, max_line_length_allowed: 120, files: 5, files_with_long_lines: 0, long_line_files: vec![] },
413                size: SizeResult { status: CollectorStatus::Pass, files: 5, max_lines_per_file: 200, max_code_lines_per_file: 150, max_lines_per_function: 40, max_parameters_per_function: 3, violations: vec![] },
414                complexity: crate::schema::ComplexityResult { status: CollectorStatus::Pass, functions: 5, max_cyclomatic_complexity: 3, max_nesting_depth: 2, complex_functions: 0, violations: vec![] },
415            },
416        };
417
418        let temp_dir = tempfile::TempDir::new().unwrap();
419        let baseline_path = temp_dir.path().join("baseline.json");
420
421        BaselineWriter::init(&summary, &baseline_path, false).unwrap();
422        BaselineWriter::update(&summary, &baseline_path).unwrap();
423
424        let content = std::fs::read_to_string(&baseline_path).unwrap();
425        let baseline: Baseline = serde_json::from_str(&content).unwrap();
426
427        assert!(baseline.created_at.contains('T'), "updated created_at should be ISO-8601");
428        assert!(baseline.created_at.ends_with('Z'), "updated created_at should end with Z");
429    }
430}