use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::snapshot::{self, DeltaSnapshot, Index, Snapshot};
#[derive(Debug, Clone)]
pub struct CompactionResult {
pub converted_count: usize,
pub dropped_count: usize,
pub bytes_freed: u64,
pub skipped_no_benefit: usize,
pub dry_run: bool,
}
pub fn compact_to_level1(
repo_root: &Path,
dry_run: bool,
keep_recent: usize,
) -> Result<CompactionResult> {
let index_path = snapshot::index_path(repo_root);
let index = Index::load_or_new(&index_path)?;
let commits = index.commits.clone();
let total = commits.len();
if total <= 1 {
return Ok(CompactionResult {
converted_count: 0,
dropped_count: 0,
bytes_freed: 0,
skipped_no_benefit: 0,
dry_run,
});
}
let mut keep_full: HashSet<usize> =
(0..keep_recent.min(total)).map(|i| total - 1 - i).collect();
keep_full.insert(0);
let mut converted_count = 0usize;
let mut bytes_freed = 0u64;
let mut skipped_no_benefit = 0usize;
for i in 1..total {
if keep_full.contains(&i) {
continue;
}
match convert_one_to_delta(repo_root, &commits[i].sha, &commits[i - 1].sha, dry_run)? {
Some(ConvertOutcome::Converted(freed)) => {
bytes_freed += freed;
converted_count += 1;
}
Some(ConvertOutcome::NotWorthwhile) => {
skipped_no_benefit += 1;
}
None => {}
}
}
if !dry_run {
let mut index = Index::load_or_new(&index_path)?;
index.set_compaction_level(1);
snapshot::atomic_write(&index_path, &index.to_json()?)?;
}
Ok(CompactionResult {
converted_count,
dropped_count: 0,
bytes_freed,
skipped_no_benefit,
dry_run,
})
}
pub fn compact_to_level2(repo_root: &Path, dry_run: bool) -> Result<CompactionResult> {
let index_path = snapshot::index_path(repo_root);
let index = Index::load_or_new(&index_path)?;
let commits = index.commits.clone();
let total = commits.len();
if total <= 1 {
return Ok(CompactionResult {
converted_count: 0,
dropped_count: 0,
bytes_freed: 0,
skipped_no_benefit: 0,
dry_run,
});
}
let loaded = load_all_snapshots(repo_root, &commits)?;
let keep_shas = select_keep_shas(&loaded, total);
let drop_shas: Vec<String> = commits
.iter()
.map(|e| e.sha.clone())
.filter(|sha| !keep_shas.contains(sha))
.collect();
let bytes_freed = delete_snapshot_files(repo_root, &drop_shas, dry_run)?;
if !dry_run && !drop_shas.is_empty() {
let dropped_set: HashSet<&str> = drop_shas.iter().map(|s| s.as_str()).collect();
fix_orphaned_deltas(repo_root, &commits, &dropped_set, &loaded)?;
let mut index = Index::load_or_new(&index_path)?;
for sha in &drop_shas {
index.remove_commit(sha);
}
index.set_compaction_level(2);
snapshot::atomic_write(&index_path, &index.to_json()?)?;
}
Ok(CompactionResult {
converted_count: 0,
dropped_count: drop_shas.len(),
bytes_freed,
skipped_no_benefit: 0,
dry_run,
})
}
enum ConvertOutcome {
Converted(u64),
NotWorthwhile,
}
fn convert_one_to_delta(
repo_root: &Path,
sha: &str,
prev_sha: &str,
dry_run: bool,
) -> Result<Option<ConvertOutcome>> {
if snapshot::delta_snapshot_path(repo_root, sha).exists() {
return Ok(None);
}
let full_path = match snapshot::snapshot_path_existing(repo_root, sha) {
Some(p) => p,
None => return Ok(None),
};
let current = match snapshot::load_snapshot(repo_root, sha)? {
Some(s) => s,
None => return Ok(None),
};
let base = match snapshot::load_snapshot(repo_root, prev_sha)? {
Some(s) => s,
None => return Ok(None),
};
let full_size = std::fs::metadata(&full_path).map(|m| m.len()).unwrap_or(0);
let delta = snapshot::compute_delta(&base, ¤t);
let json = serde_json::to_string_pretty(&delta).context("failed to serialize delta")?;
let compressed = zstd::encode_all(json.as_bytes(), 3)
.context("failed to compress delta for size estimate")?;
let delta_size = compressed.len() as u64;
if delta_size >= full_size {
return Ok(Some(ConvertOutcome::NotWorthwhile));
}
if !dry_run {
snapshot::persist_delta(repo_root, &delta)?;
std::fs::remove_file(&full_path)
.with_context(|| format!("failed to remove full snapshot: {}", full_path.display()))?;
}
Ok(Some(ConvertOutcome::Converted(full_size - delta_size)))
}
fn load_all_snapshots(
repo_root: &Path,
commits: &[crate::snapshot::IndexEntry],
) -> Result<Vec<(String, Option<Snapshot>)>> {
let mut loaded = Vec::with_capacity(commits.len());
for entry in commits {
let snap = snapshot::load_snapshot(repo_root, &entry.sha)?;
loaded.push((entry.sha.clone(), snap));
}
Ok(loaded)
}
fn select_keep_shas(loaded: &[(String, Option<Snapshot>)], total: usize) -> HashSet<String> {
let mut keep: HashSet<String> = HashSet::new();
keep.insert(loaded[0].0.clone());
keep.insert(loaded[total - 1].0.clone());
let mut last_kept_idx = 0usize;
for i in 1..total {
let is_last = i == total - 1;
let should_keep = is_last
|| match (&loaded[i].1, &loaded[last_kept_idx].1) {
(Some(curr), Some(prev)) => has_band_change(prev, curr),
_ => true,
};
if should_keep {
keep.insert(loaded[i].0.clone());
last_kept_idx = i;
}
}
keep
}
fn delete_snapshot_files(repo_root: &Path, drop_shas: &[String], dry_run: bool) -> Result<u64> {
let mut bytes_freed = 0u64;
for sha in drop_shas {
if let Some(p) = snapshot::snapshot_path_existing(repo_root, sha) {
bytes_freed += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
if !dry_run {
std::fs::remove_file(&p)
.with_context(|| format!("failed to remove snapshot: {}", p.display()))?;
}
}
let dp = snapshot::delta_snapshot_path(repo_root, sha);
if dp.exists() {
bytes_freed += std::fs::metadata(&dp).map(|m| m.len()).unwrap_or(0);
if !dry_run {
std::fs::remove_file(&dp)
.with_context(|| format!("failed to remove delta: {}", dp.display()))?;
}
}
}
Ok(bytes_freed)
}
fn fix_orphaned_deltas(
repo_root: &Path,
commits: &[crate::snapshot::IndexEntry],
dropped_set: &HashSet<&str>,
loaded: &[(String, Option<Snapshot>)],
) -> Result<()> {
let reconstructed: HashMap<&str, &Snapshot> = loaded
.iter()
.filter_map(|(sha, s)| s.as_ref().map(|snap| (sha.as_str(), snap)))
.collect();
for entry in commits {
if dropped_set.contains(entry.sha.as_str()) {
continue;
}
let delta_path = snapshot::delta_snapshot_path(repo_root, &entry.sha);
if !delta_path.exists() {
continue;
}
let base_sha = read_delta_base_sha(&delta_path)?;
if !dropped_set.contains(base_sha.as_str()) {
continue;
}
if let Some(&snap) = reconstructed.get(entry.sha.as_str()) {
let json = snap
.to_json()
.context("failed to serialize reconstructed snapshot")?;
let compressed = zstd::encode_all(json.as_bytes(), 3)
.context("failed to compress reconstructed snapshot")?;
let full_path = snapshot::snapshot_path(repo_root, &entry.sha);
snapshot::atomic_write_bytes(&full_path, &compressed).with_context(|| {
format!(
"failed to write reconstructed snapshot: {}",
full_path.display()
)
})?;
std::fs::remove_file(&delta_path).with_context(|| {
format!("failed to remove orphaned delta: {}", delta_path.display())
})?;
}
}
Ok(())
}
fn has_band_change(prev: &Snapshot, curr: &Snapshot) -> bool {
let prev_bands: HashMap<&str, crate::risk::RiskBand> = prev
.functions
.iter()
.map(|f| (f.function_id.as_str(), f.band))
.collect();
let curr_bands: HashMap<&str, crate::risk::RiskBand> = curr
.functions
.iter()
.map(|f| (f.function_id.as_str(), f.band))
.collect();
curr_bands
.iter()
.any(|(id, band)| prev_bands.get(id).map(|pb| pb != band).unwrap_or(true))
|| prev_bands.keys().any(|id| !curr_bands.contains_key(id))
}
fn read_delta_base_sha(delta_path: &Path) -> Result<String> {
let compressed = std::fs::read(delta_path)
.with_context(|| format!("failed to read delta: {}", delta_path.display()))?;
let bytes = zstd::decode_all(compressed.as_slice())
.with_context(|| format!("failed to decompress delta: {}", delta_path.display()))?;
let json = String::from_utf8(bytes).context("delta contains invalid UTF-8")?;
let delta: DeltaSnapshot = serde_json::from_str(&json)
.with_context(|| format!("failed to parse delta: {}", delta_path.display()))?;
Ok(delta.base_sha)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GitContext;
use crate::language::Language;
use crate::report::{FunctionRiskReport, MetricsReport, RiskReport};
use crate::risk::RiskBand;
use crate::snapshot::{append_to_index, persist_snapshot};
fn tiny_snapshot(sha: &str, parent: &str, timestamp: i64, func_name: &str) -> Snapshot {
let git_context = GitContext {
head_sha: sha.to_string(),
parent_shas: vec![parent.to_string()],
timestamp,
branch: Some("main".to_string()),
is_detached: false,
message: Some("test commit".to_string()),
author: Some("Test Author".to_string()),
is_fix_commit: Some(false),
is_revert_commit: Some(false),
ticket_ids: vec![],
};
let report = FunctionRiskReport {
file: "src/foo.ts".to_string(),
function: func_name.to_string(),
line: 1,
language: Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 1,
fo: 1,
ns: 1,
loc: 3,
},
risk: RiskReport {
r_cc: 1.0,
r_nd: 1.0,
r_fo: 1.0,
r_ns: 1.0,
},
lrs: 1.0,
band: RiskBand::Low,
suppression_reason: None,
patterns: vec![],
pattern_details: None,
callees: vec![],
explanation: None,
};
Snapshot::new(git_context, vec![report])
}
#[test]
fn compact_level1_skips_conversions_that_would_not_shrink_storage() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path();
std::fs::create_dir_all(snapshot::snapshots_dir(repo_root)).unwrap();
let snapshots = [
tiny_snapshot("sha1", "sha0", 1000, "a"),
tiny_snapshot("sha2", "sha1", 2000, "b"),
tiny_snapshot("sha3", "sha2", 3000, "c"),
];
for snap in &snapshots {
persist_snapshot(repo_root, snap, false).unwrap();
append_to_index(repo_root, snap).unwrap();
}
let size_before: u64 = std::fs::read_dir(snapshot::snapshots_dir(repo_root))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum();
let result = compact_to_level1(repo_root, false, 1).unwrap();
let size_after: u64 = std::fs::read_dir(snapshot::snapshots_dir(repo_root))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum();
assert_eq!(
result.converted_count, 0,
"tiny snapshot should not be converted: {:?}",
result
);
assert_eq!(result.skipped_no_benefit, 1);
assert!(
size_after <= size_before,
"compaction must never increase total snapshot storage: before={size_before} after={size_after}"
);
}
#[test]
fn compact_level1_dry_run_reports_same_skip_as_real_run() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path();
std::fs::create_dir_all(snapshot::snapshots_dir(repo_root)).unwrap();
let snapshots = [
tiny_snapshot("sha1", "sha0", 1000, "a"),
tiny_snapshot("sha2", "sha1", 2000, "b"),
tiny_snapshot("sha3", "sha2", 3000, "c"),
];
for snap in &snapshots {
persist_snapshot(repo_root, snap, false).unwrap();
append_to_index(repo_root, snap).unwrap();
}
let dry = compact_to_level1(repo_root, true, 1).unwrap();
assert_eq!(dry.converted_count, 0);
assert_eq!(dry.skipped_no_benefit, 1);
assert_eq!(dry.bytes_freed, 0);
assert!(!snapshot::delta_snapshot_path(repo_root, "sha2").exists());
assert!(snapshot::snapshot_path_existing(repo_root, "sha2").is_some());
}
}