1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{Read, Seek, SeekFrom};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::diagnostics::{DiagnosticsData, DiagnosticsFormat, Issue, Severity};
8use crate::error::CovyError;
9use crate::model::CoverageData;
10use crate::testmap::{TestMapIndex, TestTimingHistory};
11
12pub const DIAGNOSTICS_STATE_SCHEMA_VERSION: u16 = 2;
13pub const DIAGNOSTICS_PATH_NORM_VERSION: u16 = 1;
14pub const TESTMAP_SCHEMA_VERSION: u16 = 2;
15pub const TESTTIMINGS_SCHEMA_VERSION: u16 = 1;
16const DIAGNOSTICS_MAGIC: &[u8; 9] = b"COVYDIAG2";
17
18pub struct CoverageCache {
20 dir: PathBuf,
21 max_age: Duration,
22}
23
24impl CoverageCache {
25 pub fn new(dir: &Path, max_age_days: u32) -> Self {
26 Self {
27 dir: dir.to_path_buf(),
28 max_age: Duration::from_secs(max_age_days as u64 * 86400),
29 }
30 }
31
32 pub fn cache_key(base_hash: &str, head_hash: &str, coverage_hash: &str) -> String {
34 let mut hasher = blake3::Hasher::new();
35 hasher.update(base_hash.as_bytes());
36 hasher.update(head_hash.as_bytes());
37 hasher.update(coverage_hash.as_bytes());
38 hasher.finalize().to_hex().to_string()
39 }
40
41 pub fn get(&self, key: &str) -> Result<Option<CachedResult>, CovyError> {
43 let path = self.dir.join(key);
44 if !path.exists() {
45 return Ok(None);
46 }
47
48 let metadata = std::fs::metadata(&path)?;
50 if let Ok(modified) = metadata.modified() {
51 if let Ok(age) = SystemTime::now().duration_since(modified) {
52 if age > self.max_age {
53 let _ = std::fs::remove_file(&path);
54 return Ok(None);
55 }
56 }
57 }
58
59 let data = std::fs::read(&path)?;
60 let result: CachedResult = bincode::deserialize(&data)
61 .map_err(|e| CovyError::Cache(format!("Failed to deserialize cache: {e}")))?;
62 Ok(Some(result))
63 }
64
65 pub fn put(&self, key: &str, result: &CachedResult) -> Result<(), CovyError> {
67 std::fs::create_dir_all(&self.dir)?;
68 let path = self.dir.join(key);
69 let data = bincode::serialize(result)
70 .map_err(|e| CovyError::Cache(format!("Failed to serialize cache: {e}")))?;
71 std::fs::write(path, data)?;
72 Ok(())
73 }
74
75 pub fn evict(&self) -> Result<u32, CovyError> {
77 if !self.dir.exists() {
78 return Ok(0);
79 }
80 let mut count = 0;
81 for entry in std::fs::read_dir(&self.dir)? {
82 let entry = entry?;
83 if let Ok(modified) = entry.metadata()?.modified() {
84 if let Ok(age) = SystemTime::now().duration_since(modified) {
85 if age > self.max_age {
86 let _ = std::fs::remove_file(entry.path());
87 count += 1;
88 }
89 }
90 }
91 }
92 Ok(count)
93 }
94}
95
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98pub struct CachedResult {
99 pub passed: bool,
100 pub total_coverage_pct: Option<f64>,
101 pub changed_coverage_pct: Option<f64>,
102 pub new_file_coverage_pct: Option<f64>,
103 pub violations: Vec<String>,
104 #[serde(default)]
105 pub issue_counts: Option<crate::model::IssueGateCounts>,
106}
107
108impl From<&crate::model::QualityGateResult> for CachedResult {
109 fn from(r: &crate::model::QualityGateResult) -> Self {
110 Self {
111 passed: r.passed,
112 total_coverage_pct: r.total_coverage_pct,
113 changed_coverage_pct: r.changed_coverage_pct,
114 new_file_coverage_pct: r.new_file_coverage_pct,
115 violations: r.violations.clone(),
116 issue_counts: r.issue_counts.clone(),
117 }
118 }
119}
120
121pub fn serialize_coverage(data: &CoverageData) -> Result<Vec<u8>, CovyError> {
123 let mut out = Vec::new();
125
126 let file_count = data.files.len() as u32;
128 out.extend_from_slice(&file_count.to_le_bytes());
129
130 for (path, fc) in &data.files {
131 let path_bytes = path.as_bytes();
133 out.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
134 out.extend_from_slice(path_bytes);
135
136 let mut covered_buf = Vec::new();
138 fc.lines_covered
139 .serialize_into(&mut covered_buf)
140 .map_err(|e| CovyError::Cache(format!("bitmap serialize error: {e}")))?;
141 out.extend_from_slice(&(covered_buf.len() as u32).to_le_bytes());
142 out.extend_from_slice(&covered_buf);
143
144 let mut instr_buf = Vec::new();
146 fc.lines_instrumented
147 .serialize_into(&mut instr_buf)
148 .map_err(|e| CovyError::Cache(format!("bitmap serialize error: {e}")))?;
149 out.extend_from_slice(&(instr_buf.len() as u32).to_le_bytes());
150 out.extend_from_slice(&instr_buf);
151 }
152
153 out.extend_from_slice(&data.timestamp.to_le_bytes());
154 Ok(out)
155}
156
157pub fn deserialize_coverage(data: &[u8]) -> Result<CoverageData, CovyError> {
159 use roaring::RoaringBitmap;
160 use std::io::Cursor;
161
162 let mut pos = 0;
163 let read_u32 = |pos: &mut usize| -> Result<u32, CovyError> {
164 if *pos + 4 > data.len() {
165 return Err(CovyError::Cache("unexpected EOF".to_string()));
166 }
167 let val = u32::from_le_bytes(data[*pos..*pos + 4].try_into().unwrap());
168 *pos += 4;
169 Ok(val)
170 };
171
172 let file_count = read_u32(&mut pos)?;
173 let mut files = std::collections::BTreeMap::new();
174
175 for _ in 0..file_count {
176 let path_len = read_u32(&mut pos)? as usize;
177 if pos + path_len > data.len() {
178 return Err(CovyError::Cache("unexpected EOF".to_string()));
179 }
180 let path = String::from_utf8_lossy(&data[pos..pos + path_len]).to_string();
181 pos += path_len;
182
183 let covered_len = read_u32(&mut pos)? as usize;
184 if pos + covered_len > data.len() {
185 return Err(CovyError::Cache("unexpected EOF".to_string()));
186 }
187 let lines_covered =
188 RoaringBitmap::deserialize_from(Cursor::new(&data[pos..pos + covered_len]))
189 .map_err(|e| CovyError::Cache(format!("bitmap deserialize error: {e}")))?;
190 pos += covered_len;
191
192 let instr_len = read_u32(&mut pos)? as usize;
193 if pos + instr_len > data.len() {
194 return Err(CovyError::Cache("unexpected EOF".to_string()));
195 }
196 let lines_instrumented =
197 RoaringBitmap::deserialize_from(Cursor::new(&data[pos..pos + instr_len]))
198 .map_err(|e| CovyError::Cache(format!("bitmap deserialize error: {e}")))?;
199 pos += instr_len;
200
201 files.insert(
202 path,
203 crate::model::FileCoverage {
204 lines_covered,
205 lines_instrumented,
206 branches: std::collections::BTreeMap::new(),
207 functions: std::collections::BTreeMap::new(),
208 },
209 );
210 }
211
212 let timestamp = if pos + 8 <= data.len() {
213 u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap())
214 } else {
215 0
216 };
217
218 Ok(CoverageData {
219 files,
220 format: None,
221 timestamp,
222 })
223}
224
225#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
226struct StoredTestTimingHistory {
227 schema_version: u16,
228 timings: TestTimingHistory,
229}
230
231#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
232struct LegacyTestMapMetadataV1 {
233 schema_version: u16,
234 path_norm_version: u16,
235 repo_root_id: Option<String>,
236 generated_at: u64,
237 granularity: String,
238}
239
240#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
241struct LegacyTestMapIndexV1 {
242 metadata: LegacyTestMapMetadataV1,
243 test_language: std::collections::BTreeMap<String, String>,
244 test_to_files: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
245 file_to_tests: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
246}
247
248pub fn serialize_testmap(index: &TestMapIndex) -> Result<Vec<u8>, CovyError> {
250 let mut stored = index.clone();
251 stored.metadata.schema_version = TESTMAP_SCHEMA_VERSION;
252 bincode::serialize(&stored)
253 .map_err(|e| CovyError::Cache(format!("Failed to serialize testmap: {e}")))
254}
255
256pub fn deserialize_testmap(data: &[u8]) -> Result<TestMapIndex, CovyError> {
258 if let Ok(stored) = bincode::deserialize::<TestMapIndex>(data) {
259 if stored.metadata.schema_version == TESTMAP_SCHEMA_VERSION {
260 return Ok(stored);
261 }
262 if stored.metadata.schema_version == 1 {
263 return Ok(normalize_v1_testmap(stored));
264 }
265 return Err(CovyError::Cache(format!(
266 "Unsupported testmap schema version {} (expected {} or 1)",
267 stored.metadata.schema_version, TESTMAP_SCHEMA_VERSION
268 )));
269 }
270
271 let legacy: LegacyTestMapIndexV1 = bincode::deserialize(data)
272 .map_err(|e| CovyError::Cache(format!("Failed to deserialize testmap: {e}")))?;
273 Ok(normalize_v1_testmap(TestMapIndex {
274 metadata: crate::testmap::TestMapMetadata {
275 schema_version: legacy.metadata.schema_version,
276 path_norm_version: legacy.metadata.path_norm_version,
277 repo_root_id: legacy.metadata.repo_root_id,
278 generated_at: legacy.metadata.generated_at,
279 granularity: legacy.metadata.granularity,
280 commit_sha: None,
281 created_at: None,
282 toolchain_fingerprint: None,
283 },
284 test_language: legacy.test_language,
285 test_to_files: legacy.test_to_files,
286 file_to_tests: legacy.file_to_tests,
287 tests: Vec::new(),
288 file_index: Vec::new(),
289 coverage: Vec::new(),
290 }))
291}
292
293fn normalize_v1_testmap(index: TestMapIndex) -> TestMapIndex {
294 TestMapIndex {
295 metadata: crate::testmap::TestMapMetadata {
296 schema_version: index.metadata.schema_version,
297 path_norm_version: index.metadata.path_norm_version,
298 repo_root_id: index.metadata.repo_root_id,
299 generated_at: index.metadata.generated_at,
300 granularity: index.metadata.granularity,
301 commit_sha: None,
302 created_at: None,
303 toolchain_fingerprint: None,
304 },
305 test_language: index.test_language,
306 test_to_files: index.test_to_files,
307 file_to_tests: index.file_to_tests,
308 tests: Vec::new(),
309 file_index: Vec::new(),
310 coverage: Vec::new(),
311 }
312}
313
314pub fn serialize_test_timings(timings: &TestTimingHistory) -> Result<Vec<u8>, CovyError> {
316 let stored = StoredTestTimingHistory {
317 schema_version: TESTTIMINGS_SCHEMA_VERSION,
318 timings: timings.clone(),
319 };
320 bincode::serialize(&stored)
321 .map_err(|e| CovyError::Cache(format!("Failed to serialize test timings: {e}")))
322}
323
324pub fn deserialize_test_timings(data: &[u8]) -> Result<TestTimingHistory, CovyError> {
326 let stored: StoredTestTimingHistory = bincode::deserialize(data)
327 .map_err(|e| CovyError::Cache(format!("Failed to deserialize test timings: {e}")))?;
328 if stored.schema_version != TESTTIMINGS_SCHEMA_VERSION {
329 return Err(CovyError::Cache(format!(
330 "Unsupported test timings schema version {} (expected {})",
331 stored.schema_version, TESTTIMINGS_SCHEMA_VERSION
332 )));
333 }
334 Ok(stored.timings)
335}
336
337#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
338pub struct DiagnosticsStateMetadata {
339 pub schema_version: u16,
340 pub path_norm_version: u16,
341 pub normalized_paths: bool,
342 pub repo_root_id: Option<String>,
343}
344
345impl DiagnosticsStateMetadata {
346 pub fn normalized_for_repo_root(repo_root_id: Option<String>) -> Self {
347 Self {
348 schema_version: DIAGNOSTICS_STATE_SCHEMA_VERSION,
349 path_norm_version: DIAGNOSTICS_PATH_NORM_VERSION,
350 normalized_paths: true,
351 repo_root_id,
352 }
353 }
354
355 pub fn unversioned() -> Self {
356 Self {
357 schema_version: DIAGNOSTICS_STATE_SCHEMA_VERSION,
358 path_norm_version: DIAGNOSTICS_PATH_NORM_VERSION,
359 normalized_paths: false,
360 repo_root_id: None,
361 }
362 }
363}
364
365pub fn current_repo_root_id(source_root: Option<&Path>) -> Option<String> {
366 let cwd = std::env::current_dir().ok();
367 let root = source_root
368 .map(|p| p.to_path_buf())
369 .or_else(|| cwd.as_deref().and_then(git_toplevel_from))
370 .or(cwd)?;
371
372 let canonical = root.canonicalize().ok().unwrap_or(root);
373 let root_str = canonical.to_string_lossy();
374 Some(blake3::hash(root_str.as_bytes()).to_hex().to_string()[..16].to_string())
375}
376
377pub fn serialize_diagnostics(data: &DiagnosticsData) -> Result<Vec<u8>, CovyError> {
379 serialize_diagnostics_with_metadata(data, &DiagnosticsStateMetadata::unversioned())
380}
381
382pub fn serialize_diagnostics_with_metadata(
383 data: &DiagnosticsData,
384 metadata: &DiagnosticsStateMetadata,
385) -> Result<Vec<u8>, CovyError> {
386 let mut blocks: Vec<(String, Vec<u8>)> = Vec::with_capacity(data.issues_by_file.len());
387 for (path, issues) in &data.issues_by_file {
388 let stored: Vec<StoredIssue> = issues.iter().map(stored_issue_from_runtime).collect();
389 let bytes = bincode::serialize(&stored)
390 .map_err(|e| CovyError::Cache(format!("Failed to serialize diagnostics block: {e}")))?;
391 blocks.push((path.clone(), bytes));
392 }
393
394 let repo_root_bytes = metadata
395 .repo_root_id
396 .as_ref()
397 .map(|s| s.as_bytes())
398 .unwrap_or_default();
399
400 let header_len = DIAGNOSTICS_MAGIC.len() + 2 + 2 + 1 + 8 + 1 + 4 + repo_root_bytes.len() + 4;
401
402 let mut index_len = 0usize;
403 for (path, _) in &blocks {
404 index_len += 4 + path.len() + 8 + 4;
405 }
406
407 let payload_start = header_len + index_len;
408 let payload_len: usize = blocks.iter().map(|(_, b)| b.len()).sum();
409 let mut out = Vec::with_capacity(payload_start + payload_len);
410
411 out.extend_from_slice(DIAGNOSTICS_MAGIC);
412 out.extend_from_slice(&metadata.schema_version.to_le_bytes());
413 out.extend_from_slice(&metadata.path_norm_version.to_le_bytes());
414 out.push(if metadata.normalized_paths { 1 } else { 0 });
415 out.extend_from_slice(&data.timestamp.to_le_bytes());
416 out.push(match data.format {
417 Some(DiagnosticsFormat::Sarif) => 1,
418 None => 0,
419 });
420 out.extend_from_slice(&(repo_root_bytes.len() as u32).to_le_bytes());
421 out.extend_from_slice(repo_root_bytes);
422 out.extend_from_slice(&(blocks.len() as u32).to_le_bytes());
423
424 let mut offset = 0u64;
425 for (path, block) in &blocks {
426 let path_bytes = path.as_bytes();
427 out.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
428 out.extend_from_slice(path_bytes);
429 out.extend_from_slice(&offset.to_le_bytes());
430 out.extend_from_slice(&(block.len() as u32).to_le_bytes());
431 offset += block.len() as u64;
432 }
433
434 for (_, block) in blocks {
435 out.extend_from_slice(&block);
436 }
437
438 debug_assert_eq!(out.len(), payload_start + payload_len);
439 Ok(out)
440}
441
442pub fn deserialize_diagnostics(data: &[u8]) -> Result<DiagnosticsData, CovyError> {
444 deserialize_diagnostics_with_metadata(data).map(|(d, _)| d)
445}
446
447pub fn deserialize_diagnostics_with_metadata(
448 data: &[u8],
449) -> Result<(DiagnosticsData, Option<DiagnosticsStateMetadata>), CovyError> {
450 if is_new_diagnostics_format(data) {
451 let state = parse_diagnostics_state(data)?;
452 let diag = load_all_from_state(data, &state)?;
453 return Ok((diag, Some(state.meta)));
454 }
455
456 let stored: StoredDiagnosticsData = bincode::deserialize(data)
458 .map_err(|e| CovyError::Cache(format!("Failed to deserialize diagnostics: {e}")))?;
459 Ok((stored.into_runtime(), None))
460}
461
462pub fn deserialize_diagnostics_for_paths(
463 data: &[u8],
464 paths: &HashSet<String>,
465) -> Result<(DiagnosticsData, Option<DiagnosticsStateMetadata>), CovyError> {
466 if is_new_diagnostics_format(data) {
467 let state = parse_diagnostics_state(data)?;
468 let diag = load_selected_from_state(data, &state, paths)?;
469 return Ok((diag, Some(state.meta)));
470 }
471
472 let (mut all, meta) = deserialize_diagnostics_with_metadata(data)?;
473 all.issues_by_file.retain(|path, _| paths.contains(path));
474 Ok((all, meta))
475}
476
477pub fn deserialize_diagnostics_for_paths_from_file(
478 path: &Path,
479 paths: &HashSet<String>,
480) -> Result<(DiagnosticsData, Option<DiagnosticsStateMetadata>), CovyError> {
481 let mut file = File::open(path)?;
482 if !is_new_diagnostics_format_file(&mut file)? {
483 let bytes = std::fs::read(path)?;
484 return deserialize_diagnostics_for_paths(&bytes, paths);
485 }
486
487 file.seek(SeekFrom::Start(0))?;
488 let state = parse_diagnostics_state_from_reader(&mut file)?;
489 let meta = state.meta.clone();
490 let diagnostics = load_selected_from_reader(&mut file, &state, paths)?;
491 Ok((diagnostics, Some(meta)))
492}
493
494fn is_new_diagnostics_format(data: &[u8]) -> bool {
495 data.len() >= DIAGNOSTICS_MAGIC.len() && &data[..DIAGNOSTICS_MAGIC.len()] == DIAGNOSTICS_MAGIC
496}
497
498fn is_new_diagnostics_format_file(file: &mut File) -> Result<bool, CovyError> {
499 let mut magic = [0u8; 9];
500 match file.read_exact(&mut magic) {
501 Ok(()) => Ok(&magic == DIAGNOSTICS_MAGIC),
502 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
503 Err(e) => Err(e.into()),
504 }
505}
506
507#[derive(Debug)]
508struct DiagnosticsState {
509 meta: DiagnosticsStateMetadata,
510 timestamp: u64,
511 format: Option<DiagnosticsFormat>,
512 entries: Vec<DiagnosticsIndexEntry>,
513 payload_start: usize,
514}
515
516#[derive(Debug)]
517struct DiagnosticsIndexEntry {
518 path: String,
519 offset: u64,
520 len: u32,
521}
522
523fn parse_diagnostics_state(data: &[u8]) -> Result<DiagnosticsState, CovyError> {
524 let mut pos = 0usize;
525
526 let read_u8 = |buf: &[u8], pos: &mut usize| -> Result<u8, CovyError> {
527 if *pos + 1 > buf.len() {
528 return Err(CovyError::Cache(
529 "unexpected EOF while reading u8".to_string(),
530 ));
531 }
532 let v = buf[*pos];
533 *pos += 1;
534 Ok(v)
535 };
536
537 let read_u16 = |buf: &[u8], pos: &mut usize| -> Result<u16, CovyError> {
538 if *pos + 2 > buf.len() {
539 return Err(CovyError::Cache(
540 "unexpected EOF while reading u16".to_string(),
541 ));
542 }
543 let v = u16::from_le_bytes(buf[*pos..*pos + 2].try_into().unwrap());
544 *pos += 2;
545 Ok(v)
546 };
547
548 let read_u32 = |buf: &[u8], pos: &mut usize| -> Result<u32, CovyError> {
549 if *pos + 4 > buf.len() {
550 return Err(CovyError::Cache(
551 "unexpected EOF while reading u32".to_string(),
552 ));
553 }
554 let v = u32::from_le_bytes(buf[*pos..*pos + 4].try_into().unwrap());
555 *pos += 4;
556 Ok(v)
557 };
558
559 let read_u64 = |buf: &[u8], pos: &mut usize| -> Result<u64, CovyError> {
560 if *pos + 8 > buf.len() {
561 return Err(CovyError::Cache(
562 "unexpected EOF while reading u64".to_string(),
563 ));
564 }
565 let v = u64::from_le_bytes(buf[*pos..*pos + 8].try_into().unwrap());
566 *pos += 8;
567 Ok(v)
568 };
569
570 if !is_new_diagnostics_format(data) {
571 return Err(CovyError::Cache(
572 "invalid diagnostics state magic".to_string(),
573 ));
574 }
575 pos += DIAGNOSTICS_MAGIC.len();
576
577 let schema_version = read_u16(data, &mut pos)?;
578 let path_norm_version = read_u16(data, &mut pos)?;
579 let normalized_paths = read_u8(data, &mut pos)? != 0;
580 let timestamp = read_u64(data, &mut pos)?;
581 let format = match read_u8(data, &mut pos)? {
582 1 => Some(DiagnosticsFormat::Sarif),
583 _ => None,
584 };
585
586 let repo_root_len = read_u32(data, &mut pos)? as usize;
587 if pos + repo_root_len > data.len() {
588 return Err(CovyError::Cache(
589 "unexpected EOF while reading repo root id".to_string(),
590 ));
591 }
592 let repo_root_id = if repo_root_len > 0 {
593 Some(String::from_utf8_lossy(&data[pos..pos + repo_root_len]).to_string())
594 } else {
595 None
596 };
597 pos += repo_root_len;
598
599 let file_count = read_u32(data, &mut pos)? as usize;
600 let mut entries = Vec::with_capacity(file_count);
601 for _ in 0..file_count {
602 let path_len = read_u32(data, &mut pos)? as usize;
603 if pos + path_len > data.len() {
604 return Err(CovyError::Cache(
605 "unexpected EOF while reading diagnostics path".to_string(),
606 ));
607 }
608 let path = String::from_utf8_lossy(&data[pos..pos + path_len]).to_string();
609 pos += path_len;
610
611 let offset = read_u64(data, &mut pos)?;
612 let len = read_u32(data, &mut pos)?;
613
614 entries.push(DiagnosticsIndexEntry { path, offset, len });
615 }
616
617 Ok(DiagnosticsState {
618 meta: DiagnosticsStateMetadata {
619 schema_version,
620 path_norm_version,
621 normalized_paths,
622 repo_root_id,
623 },
624 timestamp,
625 format,
626 entries,
627 payload_start: pos,
628 })
629}
630
631fn load_all_from_state(
632 data: &[u8],
633 state: &DiagnosticsState,
634) -> Result<DiagnosticsData, CovyError> {
635 let mut issues_by_file = std::collections::BTreeMap::new();
636 for entry in &state.entries {
637 let issues = decode_issues_block(data, state.payload_start, entry)?;
638 if !issues.is_empty() {
639 issues_by_file.insert(entry.path.clone(), issues);
640 }
641 }
642 Ok(DiagnosticsData {
643 issues_by_file,
644 format: state.format,
645 timestamp: state.timestamp,
646 })
647}
648
649fn load_selected_from_state(
650 data: &[u8],
651 state: &DiagnosticsState,
652 selected_paths: &HashSet<String>,
653) -> Result<DiagnosticsData, CovyError> {
654 let mut issues_by_file = std::collections::BTreeMap::new();
655 if selected_paths.is_empty() {
656 return Ok(DiagnosticsData {
657 issues_by_file,
658 format: state.format,
659 timestamp: state.timestamp,
660 });
661 }
662
663 for entry in &state.entries {
664 if !selected_paths.contains(&entry.path) {
665 continue;
666 }
667 let issues = decode_issues_block(data, state.payload_start, entry)?;
668 if !issues.is_empty() {
669 issues_by_file.insert(entry.path.clone(), issues);
670 }
671 }
672
673 Ok(DiagnosticsData {
674 issues_by_file,
675 format: state.format,
676 timestamp: state.timestamp,
677 })
678}
679
680fn load_selected_from_reader(
681 file: &mut File,
682 state: &DiagnosticsState,
683 selected_paths: &HashSet<String>,
684) -> Result<DiagnosticsData, CovyError> {
685 let mut issues_by_file = std::collections::BTreeMap::new();
686 if selected_paths.is_empty() {
687 return Ok(DiagnosticsData {
688 issues_by_file,
689 format: state.format,
690 timestamp: state.timestamp,
691 });
692 }
693
694 for entry in &state.entries {
695 if !selected_paths.contains(&entry.path) {
696 continue;
697 }
698 let issues = decode_issues_block_from_reader(file, state.payload_start, entry)?;
699 if !issues.is_empty() {
700 issues_by_file.insert(entry.path.clone(), issues);
701 }
702 }
703
704 Ok(DiagnosticsData {
705 issues_by_file,
706 format: state.format,
707 timestamp: state.timestamp,
708 })
709}
710
711fn decode_issues_block(
712 data: &[u8],
713 payload_start: usize,
714 entry: &DiagnosticsIndexEntry,
715) -> Result<Vec<Issue>, CovyError> {
716 let start = payload_start
717 .checked_add(entry.offset as usize)
718 .ok_or_else(|| CovyError::Cache("diagnostics block offset overflow".to_string()))?;
719 let end = start
720 .checked_add(entry.len as usize)
721 .ok_or_else(|| CovyError::Cache("diagnostics block length overflow".to_string()))?;
722
723 if end > data.len() {
724 return Err(CovyError::Cache(
725 "diagnostics block exceeds file length".to_string(),
726 ));
727 }
728
729 let stored: Vec<StoredIssue> = bincode::deserialize(&data[start..end])
730 .map_err(|e| CovyError::Cache(format!("Failed to deserialize diagnostics block: {e}")))?;
731
732 Ok(stored.into_iter().map(runtime_issue_from_stored).collect())
733}
734
735fn decode_issues_block_from_reader(
736 file: &mut File,
737 payload_start: usize,
738 entry: &DiagnosticsIndexEntry,
739) -> Result<Vec<Issue>, CovyError> {
740 let start = payload_start
741 .checked_add(entry.offset as usize)
742 .ok_or_else(|| CovyError::Cache("diagnostics block offset overflow".to_string()))?;
743 file.seek(SeekFrom::Start(start as u64))?;
744
745 let mut block = vec![0u8; entry.len as usize];
746 file.read_exact(&mut block)?;
747
748 let stored: Vec<StoredIssue> = bincode::deserialize(&block)
749 .map_err(|e| CovyError::Cache(format!("Failed to deserialize diagnostics block: {e}")))?;
750 Ok(stored.into_iter().map(runtime_issue_from_stored).collect())
751}
752
753fn stored_issue_from_runtime(issue: &Issue) -> StoredIssue {
754 StoredIssue {
755 path: issue.path.clone(),
756 line: issue.line,
757 column: issue.column,
758 end_line: issue.end_line,
759 severity: issue.severity,
760 rule_id: issue.rule_id.clone(),
761 message: issue.message.clone(),
762 source: issue.source.clone(),
763 fingerprint: issue.fingerprint.clone(),
764 }
765}
766
767fn runtime_issue_from_stored(issue: StoredIssue) -> Issue {
768 Issue {
769 path: issue.path,
770 line: issue.line,
771 column: issue.column,
772 end_line: issue.end_line,
773 severity: issue.severity,
774 rule_id: issue.rule_id,
775 message: issue.message,
776 source: issue.source,
777 fingerprint: issue.fingerprint,
778 }
779}
780
781fn parse_diagnostics_state_from_reader(file: &mut File) -> Result<DiagnosticsState, CovyError> {
782 let mut magic = [0u8; 9];
783 file.read_exact(&mut magic)?;
784 if &magic != DIAGNOSTICS_MAGIC {
785 return Err(CovyError::Cache(
786 "invalid diagnostics state magic".to_string(),
787 ));
788 }
789
790 let schema_version = read_u16(file)?;
791 let path_norm_version = read_u16(file)?;
792 let normalized_paths = read_u8(file)? != 0;
793 let timestamp = read_u64(file)?;
794 let format = match read_u8(file)? {
795 1 => Some(DiagnosticsFormat::Sarif),
796 _ => None,
797 };
798
799 let repo_root_len = read_u32(file)? as usize;
800 let mut repo_root_bytes = vec![0u8; repo_root_len];
801 if repo_root_len > 0 {
802 file.read_exact(&mut repo_root_bytes)?;
803 }
804 let repo_root_id = if repo_root_len > 0 {
805 Some(String::from_utf8_lossy(&repo_root_bytes).to_string())
806 } else {
807 None
808 };
809
810 let file_count = read_u32(file)? as usize;
811 let mut entries = Vec::with_capacity(file_count);
812 for _ in 0..file_count {
813 let path_len = read_u32(file)? as usize;
814 let mut path_bytes = vec![0u8; path_len];
815 if path_len > 0 {
816 file.read_exact(&mut path_bytes)?;
817 }
818 let path = String::from_utf8_lossy(&path_bytes).to_string();
819 let offset = read_u64(file)?;
820 let len = read_u32(file)?;
821 entries.push(DiagnosticsIndexEntry { path, offset, len });
822 }
823
824 let payload_start = file.stream_position()? as usize;
825 Ok(DiagnosticsState {
826 meta: DiagnosticsStateMetadata {
827 schema_version,
828 path_norm_version,
829 normalized_paths,
830 repo_root_id,
831 },
832 timestamp,
833 format,
834 entries,
835 payload_start,
836 })
837}
838
839fn read_u8(file: &mut File) -> Result<u8, CovyError> {
840 let mut buf = [0u8; 1];
841 file.read_exact(&mut buf)?;
842 Ok(buf[0])
843}
844
845fn read_u16(file: &mut File) -> Result<u16, CovyError> {
846 let mut buf = [0u8; 2];
847 file.read_exact(&mut buf)?;
848 Ok(u16::from_le_bytes(buf))
849}
850
851fn read_u32(file: &mut File) -> Result<u32, CovyError> {
852 let mut buf = [0u8; 4];
853 file.read_exact(&mut buf)?;
854 Ok(u32::from_le_bytes(buf))
855}
856
857fn read_u64(file: &mut File) -> Result<u64, CovyError> {
858 let mut buf = [0u8; 8];
859 file.read_exact(&mut buf)?;
860 Ok(u64::from_le_bytes(buf))
861}
862
863fn git_toplevel_from(start: &Path) -> Option<PathBuf> {
864 let mut dir = start.to_path_buf();
865 loop {
866 if dir.join(".git").exists() {
867 return Some(dir);
868 }
869 if !dir.pop() {
870 return None;
871 }
872 }
873}
874
875#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
876struct StoredIssue {
877 path: String,
878 line: u32,
879 column: Option<u32>,
880 end_line: Option<u32>,
881 severity: Severity,
882 rule_id: String,
883 message: String,
884 source: String,
885 fingerprint: String,
886}
887
888#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
889struct StoredDiagnosticsData {
890 issues_by_file: std::collections::BTreeMap<String, Vec<StoredIssue>>,
891 format: Option<DiagnosticsFormat>,
892 timestamp: u64,
893}
894
895impl StoredDiagnosticsData {
896 fn into_runtime(self) -> DiagnosticsData {
897 let mut issues_by_file = std::collections::BTreeMap::new();
898 for (path, issues) in self.issues_by_file {
899 let runtime: Vec<Issue> = issues.into_iter().map(runtime_issue_from_stored).collect();
900 issues_by_file.insert(path, runtime);
901 }
902
903 DiagnosticsData {
904 issues_by_file,
905 format: self.format,
906 timestamp: self.timestamp,
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use tempfile::TempDir;
915
916 #[test]
917 fn test_cache_roundtrip() {
918 let dir = TempDir::new().unwrap();
919 let cache = CoverageCache::new(dir.path(), 30);
920
921 let result = CachedResult {
922 passed: true,
923 total_coverage_pct: Some(85.0),
924 changed_coverage_pct: Some(90.0),
925 new_file_coverage_pct: None,
926 violations: vec![],
927 issue_counts: None,
928 };
929
930 let key = CoverageCache::cache_key("abc", "def", "ghi");
931 cache.put(&key, &result).unwrap();
932 let loaded = cache.get(&key).unwrap().unwrap();
933 assert!(loaded.passed);
934 assert_eq!(loaded.total_coverage_pct, Some(85.0));
935 }
936
937 #[test]
938 fn test_coverage_serialization_roundtrip() {
939 let mut data = CoverageData::new();
940 let mut fc = crate::model::FileCoverage::new();
941 fc.lines_covered.insert(1);
942 fc.lines_covered.insert(5);
943 fc.lines_instrumented.insert(1);
944 fc.lines_instrumented.insert(2);
945 fc.lines_instrumented.insert(5);
946 data.files.insert("test.rs".to_string(), fc);
947
948 let bytes = serialize_coverage(&data).unwrap();
949 let restored = deserialize_coverage(&bytes).unwrap();
950 assert_eq!(restored.files.len(), 1);
951 let rfc = &restored.files["test.rs"];
952 assert_eq!(rfc.lines_covered.len(), 2);
953 assert_eq!(rfc.lines_instrumented.len(), 3);
954 }
955
956 #[test]
957 fn test_diagnostics_serialization_roundtrip() {
958 let mut data = DiagnosticsData::new();
959 data.issues_by_file.insert(
960 "src/main.rs".to_string(),
961 vec![crate::diagnostics::Issue {
962 path: "src/main.rs".to_string(),
963 line: 10,
964 column: Some(2),
965 end_line: Some(10),
966 severity: crate::diagnostics::Severity::Error,
967 rule_id: "R001".to_string(),
968 message: "boom".to_string(),
969 source: "tool".to_string(),
970 fingerprint: "fp-1".to_string(),
971 }],
972 );
973
974 let bytes = serialize_diagnostics_with_metadata(
975 &data,
976 &DiagnosticsStateMetadata::normalized_for_repo_root(Some("abc".to_string())),
977 )
978 .unwrap();
979
980 let (restored, meta) = deserialize_diagnostics_with_metadata(&bytes).unwrap();
981 assert_eq!(restored.total_issues(), 1);
982 assert_eq!(restored.issues_by_file["src/main.rs"][0].rule_id, "R001");
983 let meta = meta.unwrap();
984 assert_eq!(meta.schema_version, DIAGNOSTICS_STATE_SCHEMA_VERSION);
985 assert!(meta.normalized_paths);
986 assert_eq!(meta.repo_root_id.as_deref(), Some("abc"));
987 }
988
989 #[test]
990 fn test_diagnostics_selective_deserialize() {
991 let mut data = DiagnosticsData::new();
992 data.issues_by_file.insert(
993 "src/a.rs".to_string(),
994 vec![crate::diagnostics::Issue {
995 path: "src/a.rs".to_string(),
996 line: 1,
997 column: None,
998 end_line: None,
999 severity: crate::diagnostics::Severity::Warning,
1000 rule_id: "A".to_string(),
1001 message: "a".to_string(),
1002 source: "tool".to_string(),
1003 fingerprint: "fpa".to_string(),
1004 }],
1005 );
1006 data.issues_by_file.insert(
1007 "src/b.rs".to_string(),
1008 vec![crate::diagnostics::Issue {
1009 path: "src/b.rs".to_string(),
1010 line: 2,
1011 column: None,
1012 end_line: None,
1013 severity: crate::diagnostics::Severity::Error,
1014 rule_id: "B".to_string(),
1015 message: "b".to_string(),
1016 source: "tool".to_string(),
1017 fingerprint: "fpb".to_string(),
1018 }],
1019 );
1020
1021 let bytes = serialize_diagnostics_with_metadata(
1022 &data,
1023 &DiagnosticsStateMetadata::normalized_for_repo_root(None),
1024 )
1025 .unwrap();
1026
1027 let mut selected = HashSet::new();
1028 selected.insert("src/b.rs".to_string());
1029 let (restored, _) = deserialize_diagnostics_for_paths(&bytes, &selected).unwrap();
1030 assert_eq!(restored.total_issues(), 1);
1031 assert!(restored.issues_by_file.contains_key("src/b.rs"));
1032 assert!(!restored.issues_by_file.contains_key("src/a.rs"));
1033 }
1034
1035 #[test]
1036 fn test_diagnostics_selective_deserialize_from_file() {
1037 let dir = TempDir::new().unwrap();
1038 let path = dir.path().join("issues.bin");
1039
1040 let mut data = DiagnosticsData::new();
1041 data.issues_by_file.insert(
1042 "src/a.rs".to_string(),
1043 vec![crate::diagnostics::Issue {
1044 path: "src/a.rs".to_string(),
1045 line: 1,
1046 column: None,
1047 end_line: None,
1048 severity: crate::diagnostics::Severity::Warning,
1049 rule_id: "A".to_string(),
1050 message: "a".to_string(),
1051 source: "tool".to_string(),
1052 fingerprint: "fpa".to_string(),
1053 }],
1054 );
1055 data.issues_by_file.insert(
1056 "src/b.rs".to_string(),
1057 vec![crate::diagnostics::Issue {
1058 path: "src/b.rs".to_string(),
1059 line: 2,
1060 column: None,
1061 end_line: None,
1062 severity: crate::diagnostics::Severity::Error,
1063 rule_id: "B".to_string(),
1064 message: "b".to_string(),
1065 source: "tool".to_string(),
1066 fingerprint: "fpb".to_string(),
1067 }],
1068 );
1069
1070 let bytes = serialize_diagnostics_with_metadata(
1071 &data,
1072 &DiagnosticsStateMetadata::normalized_for_repo_root(None),
1073 )
1074 .unwrap();
1075 std::fs::write(&path, bytes).unwrap();
1076
1077 let mut selected = HashSet::new();
1078 selected.insert("src/a.rs".to_string());
1079
1080 let (restored, meta) =
1081 deserialize_diagnostics_for_paths_from_file(&path, &selected).unwrap();
1082 assert_eq!(restored.total_issues(), 1);
1083 assert!(restored.issues_by_file.contains_key("src/a.rs"));
1084 assert!(!restored.issues_by_file.contains_key("src/b.rs"));
1085 assert!(meta.is_some());
1086 }
1087
1088 #[test]
1089 fn test_testmap_serialization_roundtrip() {
1090 let mut index = TestMapIndex::default();
1091 index
1092 .test_to_files
1093 .entry("com.foo.BarTest".to_string())
1094 .or_default()
1095 .insert("src/main/java/com/foo/Bar.java".to_string());
1096 index
1097 .file_to_tests
1098 .entry("src/main/java/com/foo/Bar.java".to_string())
1099 .or_default()
1100 .insert("com.foo.BarTest".to_string());
1101
1102 let bytes = serialize_testmap(&index).unwrap();
1103 let restored = deserialize_testmap(&bytes).unwrap();
1104 assert_eq!(
1105 restored.metadata.schema_version,
1106 TESTMAP_SCHEMA_VERSION
1107 );
1108 assert_eq!(restored.test_to_files.len(), 1);
1109 assert_eq!(restored.file_to_tests.len(), 1);
1110 }
1111
1112 #[test]
1113 fn test_testmap_deserialize_legacy_v1_payload() {
1114 let legacy = LegacyTestMapIndexV1 {
1115 metadata: LegacyTestMapMetadataV1 {
1116 schema_version: 1,
1117 path_norm_version: 1,
1118 repo_root_id: Some("deadbeef".to_string()),
1119 generated_at: 123,
1120 granularity: "file".to_string(),
1121 },
1122 test_language: {
1123 let mut m = std::collections::BTreeMap::new();
1124 m.insert("com.foo.BarTest".to_string(), "java".to_string());
1125 m
1126 },
1127 test_to_files: {
1128 let mut m = std::collections::BTreeMap::new();
1129 m.entry("com.foo.BarTest".to_string())
1130 .or_insert_with(std::collections::BTreeSet::new)
1131 .insert("src/main/java/com/foo/Bar.java".to_string());
1132 m
1133 },
1134 file_to_tests: {
1135 let mut m = std::collections::BTreeMap::new();
1136 m.entry("src/main/java/com/foo/Bar.java".to_string())
1137 .or_insert_with(std::collections::BTreeSet::new)
1138 .insert("com.foo.BarTest".to_string());
1139 m
1140 },
1141 };
1142
1143 let bytes = bincode::serialize(&legacy).unwrap();
1144 let restored = deserialize_testmap(&bytes).unwrap();
1145 assert_eq!(restored.metadata.schema_version, 1);
1146 assert_eq!(
1147 restored
1148 .test_to_files
1149 .get("com.foo.BarTest")
1150 .map(|s| s.len())
1151 .unwrap_or_default(),
1152 1
1153 );
1154 assert!(restored.tests.is_empty());
1155 assert!(restored.coverage.is_empty());
1156 }
1157
1158 #[test]
1159 fn test_testmap_deserialize_struct_v1_payload_is_normalized() {
1160 let mut index = TestMapIndex::default();
1161 index.metadata.schema_version = 1;
1162 index.metadata.path_norm_version = 1;
1163 index.metadata.repo_root_id = Some("deadbeef".to_string());
1164 index.metadata.generated_at = 123;
1165 index.metadata.granularity = "file".to_string();
1166 index.metadata.commit_sha = Some("abc123".to_string());
1167 index.metadata.created_at = Some(321);
1168 index.metadata.toolchain_fingerprint = Some("toolchain".to_string());
1169 index
1170 .test_to_files
1171 .entry("com.foo.BarTest".to_string())
1172 .or_default()
1173 .insert("src/main/java/com/foo/Bar.java".to_string());
1174 index
1175 .file_to_tests
1176 .entry("src/main/java/com/foo/Bar.java".to_string())
1177 .or_default()
1178 .insert("com.foo.BarTest".to_string());
1179 index.tests.push("com.foo.BarTest".to_string());
1180 index.file_index.push("src/main/java/com/foo/Bar.java".to_string());
1181 index.coverage = vec![vec![vec![10]]];
1182
1183 let bytes = bincode::serialize(&index).unwrap();
1184 let restored = deserialize_testmap(&bytes).unwrap();
1185 assert_eq!(restored.metadata.schema_version, 1);
1186 assert!(restored.metadata.commit_sha.is_none());
1187 assert!(restored.metadata.created_at.is_none());
1188 assert!(restored.metadata.toolchain_fingerprint.is_none());
1189 assert!(restored.tests.is_empty());
1190 assert!(restored.file_index.is_empty());
1191 assert!(restored.coverage.is_empty());
1192 assert_eq!(restored.test_to_files.len(), 1);
1193 assert_eq!(restored.file_to_tests.len(), 1);
1194 }
1195
1196 #[test]
1197 fn test_testtimings_serialization_roundtrip() {
1198 let mut timings = TestTimingHistory::default();
1199 timings.duration_ms.insert("test_a".to_string(), 1200);
1200 timings.sample_count.insert("test_a".to_string(), 3);
1201 timings.last_seen.insert("test_a".to_string(), 100);
1202
1203 let bytes = serialize_test_timings(&timings).unwrap();
1204 let restored = deserialize_test_timings(&bytes).unwrap();
1205 assert_eq!(restored.duration_ms.get("test_a"), Some(&1200));
1206 assert_eq!(restored.sample_count.get("test_a"), Some(&3));
1207 }
1208}