Skip to main content

covy_core/
cache.rs

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
18/// File-system cache for coverage data keyed by hash.
19pub 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    /// Compute cache key from base hash, head hash, and coverage hash.
33    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    /// Try to load cached coverage data.
42    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        // Check age
49        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    /// Store a result in the cache.
66    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    /// Evict entries older than max_age.
76    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/// Cached gate evaluation result.
97#[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
121/// Serialize CoverageData to bytes for storage.
122pub fn serialize_coverage(data: &CoverageData) -> Result<Vec<u8>, CovyError> {
123    // We store a simplified version since RoaringBitmap isn't directly bincode-serializable
124    let mut out = Vec::new();
125
126    // Write file count
127    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        // Write path
132        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        // Write covered bitmap
137        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        // Write instrumented bitmap
145        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
157/// Deserialize CoverageData from bytes.
158pub 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
248/// Serialize TestMapIndex to bytes for storage.
249pub 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
256/// Deserialize TestMapIndex from bytes.
257pub 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
314/// Serialize TestTimingHistory to bytes for storage.
315pub 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
324/// Deserialize TestTimingHistory from bytes.
325pub 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
377/// Serialize DiagnosticsData for storage.
378pub 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
442/// Deserialize DiagnosticsData from bytes.
443pub 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    // Legacy fallback
457    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}