1use 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 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 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 #[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 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}