use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::cli::format_duration_ms;
use crate::config::Config;
use crate::daemon::{TransferDirection, TransferEvent};
use crate::events::{self, BuildEvent, EventResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcStatsPersisted {
pub last_run: String,
pub entries_evicted: usize,
pub bytes_freed: u64,
pub blobs_removed: usize,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcSummary {
pub last_run: String,
pub entries_evicted: usize,
pub bytes_freed: u64,
pub blobs_removed: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BuildReport {
pub meta: ReportMeta,
pub summary: ReportSummary,
pub timing: TimingBreakdown,
pub storage: StorageBreakdown,
pub network: Option<NetworkAnalysis>,
pub prefetch: PrefetchAnalysis,
pub top_misses: Vec<CrateDetail>,
pub top_hits: Vec<CrateDetail>,
pub all_events: Vec<CrateDetail>,
#[serde(default)]
pub bypass: BypassAnalysis,
pub errors_detail: Vec<ErrorDetail>,
pub suggestions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gc: Option<GcSummary>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReportMeta {
pub kache_version: String,
pub generated_at: String,
pub since_hours: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReportSummary {
pub hit_rate_pct: f64,
pub weighted_hit_rate_pct: Option<f64>,
pub time_saved_ms: u64,
pub total_crates: usize,
pub local_hits: usize,
pub prefetch_hits: usize,
pub remote_hits: usize,
#[serde(default)]
pub dups: usize,
pub misses: usize,
pub errors: usize,
#[serde(default)]
pub passthroughs: usize,
#[serde(default)]
pub skipped: usize,
#[serde(default)]
pub fallbacks: usize,
pub total_duration_ms: u64,
#[serde(default)]
pub cache_efficiency_pct: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TimingBreakdown {
pub hit_time_ms: u64,
pub miss_time_ms: u64,
pub avg_hit_ms: f64,
pub avg_miss_ms: f64,
pub avg_hit_overhead_ms: f64,
pub miss_compile_time_ms: u64,
#[serde(default)]
pub avg_key_ms: f64,
#[serde(default)]
pub avg_lookup_ms: f64,
#[serde(default)]
pub avg_restore_ms: f64,
#[serde(default)]
pub avg_store_ms: f64,
#[serde(default)]
pub total_key_ms: u64,
#[serde(default)]
pub total_lookup_ms: u64,
#[serde(default)]
pub total_restore_ms: u64,
#[serde(default)]
pub total_store_ms: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageBreakdown {
pub reflinked_bytes: u64,
pub hardlinked_bytes: u64,
pub copied_bytes: u64,
pub restored_bytes: u64,
pub zero_copy_pct: f64,
pub store_blobs: u64,
pub logical_bytes: u64,
pub blob_bytes: u64,
pub dedup_saved_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NetworkAnalysis {
pub bytes_up: u64,
pub bytes_down: u64,
pub uploads_ok: usize,
pub uploads_failed: usize,
pub downloads_ok: usize,
pub downloads_failed: usize,
pub avg_download_ms: f64,
pub p95_download_ms: u64,
pub max_download_ms: u64,
pub throughput_mbps: f64,
pub network_throughput_mbps: f64,
#[serde(default)]
pub body_throughput_mbps: f64,
#[serde(default)]
pub dominant_download_phase: String,
#[serde(default)]
pub dominant_download_phase_ms: u64,
#[serde(default)]
pub dominant_download_phase_pct: f64,
#[serde(default)]
pub total_request_ms: u64,
#[serde(default)]
pub total_body_ms: u64,
#[serde(default)]
pub total_semaphore_wait_ms: u64,
#[serde(default)]
pub total_head_ms: u64,
#[serde(default)]
pub total_get_requests: u32,
pub compression_ratio: f64,
pub original_bytes_down: u64,
pub total_decompress_ms: u64,
#[serde(default)]
pub total_extract_ms: u64,
pub total_disk_io_ms: u64,
#[serde(default)]
pub total_import_ms: u64,
#[serde(default)]
pub total_compression_ms: u64,
#[serde(default)]
pub total_head_checks_ms: u64,
pub blobs_skipped: u32,
pub blobs_total: u32,
#[serde(default)]
pub v1_downloads: usize,
#[serde(default)]
pub v2_downloads: usize,
#[serde(default)]
pub v3_downloads: usize,
#[serde(default)]
pub unknown_format_downloads: usize,
pub slowest_downloads: Vec<TransferDetail>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PrefetchAnalysis {
pub prefetch_hits: usize,
pub total_hits: usize,
pub contribution_pct: f64,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BypassAnalysis {
pub passthroughs: usize,
pub skipped: usize,
pub fallbacks: usize,
pub direct_passthroughs: usize,
pub reasons: Vec<BypassReason>,
pub slowest: Vec<BypassDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BypassReason {
pub result: String,
pub route: String,
pub reason: String,
pub count: usize,
pub failures: usize,
pub max_elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BypassDetail {
pub crate_name: String,
pub result: String,
pub route: String,
pub reason: String,
pub elapsed_ms: u64,
pub exit_code: Option<i32>,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateDetail {
pub crate_name: String,
pub result: String,
pub elapsed_ms: u64,
pub compile_time_ms: u64,
pub overhead_ms: u64,
pub size: u64,
pub cache_key: String,
#[serde(default)]
pub store_output_blobs: u32,
#[serde(default)]
pub store_duplicate_blobs: u32,
#[serde(default)]
pub store_new_blobs: u32,
#[serde(default)]
pub compiler_runs: u32,
#[serde(default)]
pub preprocessor_runs: u32,
#[serde(default)]
pub probe_runs: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferDetail {
pub crate_name: String,
pub direction: String,
#[serde(default)]
pub format: String,
#[serde(default)]
pub cache_key: String,
#[serde(default)]
pub object_key: String,
pub compressed_bytes: u64,
pub elapsed_ms: u64,
#[serde(default)]
pub network_ms: u64,
#[serde(default)]
pub semaphore_wait_ms: u64,
#[serde(default)]
pub head_ms: u64,
#[serde(default)]
pub request_ms: u64,
#[serde(default)]
pub body_ms: u64,
#[serde(default)]
pub decompress_ms: u64,
#[serde(default)]
pub extract_ms: u64,
#[serde(default)]
pub disk_io_ms: u64,
#[serde(default)]
pub import_ms: u64,
#[serde(default)]
pub request_count: u32,
#[serde(default)]
pub blobs_skipped: u32,
#[serde(default)]
pub blobs_total: u32,
pub throughput_mbps: f64,
pub ok: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorDetail {
pub crate_name: String,
pub cache_key: String,
pub timestamp: String,
}
pub fn generate_report(config: &Config, hours: u64, top: usize) -> Result<BuildReport> {
let since = Utc::now() - chrono::Duration::hours(hours as i64);
let build_events = events::read_events_since(&config.event_log_path(), since)?;
let since_ts = since.timestamp() as u64;
let transfers =
events::read_transfers_since(&config.transfer_log_path(), since_ts).unwrap_or_default();
let stats = events::compute_stats(&build_events);
let total_compiled = stats.dups + stats.misses;
let total_cacheable =
stats.local_hits + stats.prefetch_hits + stats.remote_hits + total_compiled;
let total_hits = stats.local_hits + stats.prefetch_hits + stats.remote_hits;
let bypass = build_bypass_analysis(&build_events, top);
let hit_rate = if total_cacheable > 0 {
(total_hits as f64 / total_cacheable as f64) * 100.0
} else {
0.0
};
let total_compile = stats.hit_compile_time_ms + stats.miss_compile_time_ms;
let weighted = if total_compile > 0 {
Some((stats.hit_compile_time_ms as f64 / total_compile as f64) * 100.0)
} else {
None
};
let all_events: Vec<CrateDetail> = build_events
.iter()
.filter(|e| !matches!(e.result, EventResult::Skipped | EventResult::Passthrough))
.map(to_crate_detail)
.collect();
let mut misses: Vec<CrateDetail> = build_events
.iter()
.filter(|e| matches!(e.result, EventResult::Dup | EventResult::Miss))
.map(to_crate_detail)
.collect();
misses.sort_by_key(|entry| std::cmp::Reverse(entry.compile_time_ms));
let mut hits: Vec<CrateDetail> = build_events
.iter()
.filter(|e| {
matches!(
e.result,
EventResult::LocalHit | EventResult::PrefetchHit | EventResult::RemoteHit
)
})
.map(to_crate_detail)
.collect();
hits.sort_by_key(|entry| std::cmp::Reverse(entry.compile_time_ms));
let errors_detail: Vec<ErrorDetail> = build_events
.iter()
.filter(|e| matches!(e.result, EventResult::Error))
.map(|e| ErrorDetail {
crate_name: e.crate_name.clone(),
cache_key: e.cache_key.clone(),
timestamp: e.ts.to_rfc3339(),
})
.collect();
let avg_hit_ms = if total_hits > 0 {
stats.hit_elapsed_ms as f64 / total_hits as f64
} else {
0.0
};
let avg_miss_ms = if total_compiled > 0 {
stats.miss_elapsed_ms as f64 / total_compiled as f64
} else {
0.0
};
let avg_hit_overhead = if total_hits > 0 && stats.hit_compile_time_ms > 0 {
let overhead = stats.hit_elapsed_ms.saturating_sub(0); overhead as f64 / total_hits as f64
} else {
avg_hit_ms
};
let network = if transfers.is_empty() {
None
} else {
Some(build_network_analysis(&transfers, top))
};
let prefetch = PrefetchAnalysis {
prefetch_hits: stats.prefetch_hits,
total_hits,
contribution_pct: if total_hits > 0 {
(stats.prefetch_hits as f64 / total_hits as f64) * 100.0
} else {
0.0
},
};
let suggestions = generate_suggestions(
&stats,
&prefetch,
&network,
&misses,
total_cacheable,
total_hits,
);
let restored_bytes = stats.reflinked_bytes + stats.hardlinked_bytes + stats.copied_bytes;
let blob_stats = crate::store::Store::open(config)
.and_then(|s| s.blob_stats())
.unwrap_or_default();
let storage = StorageBreakdown {
reflinked_bytes: stats.reflinked_bytes,
hardlinked_bytes: stats.hardlinked_bytes,
copied_bytes: stats.copied_bytes,
restored_bytes,
zero_copy_pct: if restored_bytes > 0 {
let zero_copy = stats.reflinked_bytes + stats.hardlinked_bytes;
let pct = zero_copy as f64 / restored_bytes as f64 * 100.0;
(pct * 10.0).round() / 10.0
} else {
0.0
},
store_blobs: blob_stats.total_blobs as u64,
logical_bytes: blob_stats.total_logical_size,
blob_bytes: blob_stats.total_blob_size,
dedup_saved_bytes: blob_stats.savings,
};
Ok(BuildReport {
meta: ReportMeta {
kache_version: crate::VERSION.to_string(),
generated_at: Utc::now().to_rfc3339(),
since_hours: hours,
},
summary: ReportSummary {
hit_rate_pct: (hit_rate * 10.0).round() / 10.0,
weighted_hit_rate_pct: weighted.map(|w| (w * 10.0).round() / 10.0),
time_saved_ms: stats.hit_compile_time_ms,
total_crates: total_cacheable,
local_hits: stats.local_hits,
prefetch_hits: stats.prefetch_hits,
remote_hits: stats.remote_hits,
dups: stats.dups,
misses: stats.misses,
errors: stats.errors,
passthroughs: bypass.passthroughs,
skipped: bypass.skipped,
fallbacks: bypass.fallbacks,
total_duration_ms: stats.total_elapsed_ms,
cache_efficiency_pct: {
let denom = stats.hit_compile_time_ms + stats.miss_compile_time_ms;
if denom > 0 {
let raw = (stats.hit_compile_time_ms as f64 / denom as f64) * 100.0;
(raw * 10.0).round() / 10.0
} else {
0.0
}
},
},
timing: TimingBreakdown {
hit_time_ms: stats.hit_elapsed_ms,
miss_time_ms: stats.miss_elapsed_ms,
avg_hit_ms: (avg_hit_ms * 10.0).round() / 10.0,
avg_miss_ms: (avg_miss_ms * 10.0).round() / 10.0,
avg_hit_overhead_ms: (avg_hit_overhead * 10.0).round() / 10.0,
miss_compile_time_ms: stats.miss_compile_time_ms,
avg_key_ms: if total_cacheable > 0 {
(stats.total_key_ms as f64 / total_cacheable as f64 * 10.0).round() / 10.0
} else {
0.0
},
avg_lookup_ms: if total_cacheable > 0 {
(stats.total_lookup_ms as f64 / total_cacheable as f64 * 10.0).round() / 10.0
} else {
0.0
},
avg_restore_ms: if total_hits > 0 {
(stats.total_restore_ms as f64 / total_hits as f64 * 10.0).round() / 10.0
} else {
0.0
},
avg_store_ms: if total_compiled > 0 {
(stats.total_store_ms as f64 / total_compiled as f64 * 10.0).round() / 10.0
} else {
0.0
},
total_key_ms: stats.total_key_ms,
total_lookup_ms: stats.total_lookup_ms,
total_restore_ms: stats.total_restore_ms,
total_store_ms: stats.total_store_ms,
},
storage,
network,
prefetch,
top_misses: misses.into_iter().take(top).collect(),
top_hits: hits.into_iter().take(top).collect(),
all_events,
bypass,
errors_detail,
suggestions,
gc: load_gc_summary(&config.cache_dir, hours),
})
}
fn load_gc_summary(cache_dir: &std::path::Path, hours: u64) -> Option<GcSummary> {
let path = cache_dir.join("gc_stats.json");
let content = std::fs::read_to_string(&path).ok()?;
let persisted: GcStatsPersisted = serde_json::from_str(&content).ok()?;
let last_run = chrono::DateTime::parse_from_rfc3339(&persisted.last_run).ok()?;
let cutoff = Utc::now() - chrono::Duration::hours(hours as i64);
if last_run < cutoff {
return None;
}
Some(GcSummary {
last_run: persisted.last_run,
entries_evicted: persisted.entries_evicted,
bytes_freed: persisted.bytes_freed,
blobs_removed: persisted.blobs_removed,
})
}
fn to_crate_detail(e: &BuildEvent) -> CrateDetail {
let overhead = if matches!(
e.result,
EventResult::LocalHit | EventResult::PrefetchHit | EventResult::RemoteHit
) {
e.elapsed_ms
} else {
e.elapsed_ms.saturating_sub(e.compile_time_ms)
};
CrateDetail {
crate_name: e.crate_name.clone(),
result: e.result.to_string(),
elapsed_ms: e.elapsed_ms,
compile_time_ms: e.compile_time_ms,
overhead_ms: overhead,
size: e.size,
cache_key: e.cache_key.clone(),
store_output_blobs: e.store_output_blobs,
store_duplicate_blobs: e.store_duplicate_blobs,
store_new_blobs: e.store_new_blobs,
compiler_runs: e.compiler_runs,
preprocessor_runs: e.preprocessor_runs,
probe_runs: e.probe_runs,
}
}
fn build_bypass_analysis(events: &[BuildEvent], top: usize) -> BypassAnalysis {
let mut details: Vec<BypassDetail> = events
.iter()
.filter(|event| {
matches!(
event.result,
EventResult::Passthrough | EventResult::Skipped
)
})
.map(to_bypass_detail)
.collect();
let passthroughs = details
.iter()
.filter(|detail| detail.result == "passthrough")
.count();
let skipped = details
.iter()
.filter(|detail| detail.result == "skipped")
.count();
let fallbacks = details
.iter()
.filter(|detail| detail.route == "fallback")
.count();
let direct_passthroughs = details
.iter()
.filter(|detail| detail.route == "direct")
.count();
let mut grouped = std::collections::BTreeMap::<(String, String, String), BypassReason>::new();
for detail in &details {
let key = (
detail.result.clone(),
detail.route.clone(),
detail.reason.clone(),
);
let entry = grouped.entry(key).or_insert_with(|| BypassReason {
result: detail.result.clone(),
route: detail.route.clone(),
reason: detail.reason.clone(),
count: 0,
failures: 0,
max_elapsed_ms: 0,
});
entry.count += 1;
if detail.exit_code.is_some_and(|code| code != 0) {
entry.failures += 1;
}
entry.max_elapsed_ms = entry.max_elapsed_ms.max(detail.elapsed_ms);
}
let mut reasons: Vec<BypassReason> = grouped.into_values().collect();
reasons.sort_by_key(|reason| {
(
std::cmp::Reverse(reason.count),
std::cmp::Reverse(reason.max_elapsed_ms),
reason.reason.clone(),
)
});
reasons.truncate(top);
details.sort_by_key(|detail| std::cmp::Reverse(detail.elapsed_ms));
details.truncate(top);
BypassAnalysis {
passthroughs,
skipped,
fallbacks,
direct_passthroughs,
reasons,
slowest: details,
}
}
fn to_bypass_detail(e: &BuildEvent) -> BypassDetail {
BypassDetail {
crate_name: e.crate_name.clone(),
result: e.result.to_string(),
route: bypass_route(e).to_string(),
reason: bypass_reason(e),
elapsed_ms: e.elapsed_ms,
exit_code: e.exit_code,
timestamp: e.ts.to_rfc3339(),
}
}
fn bypass_route(e: &BuildEvent) -> &'static str {
match e.result {
EventResult::Passthrough if e.fallback => "fallback",
EventResult::Passthrough => "direct",
EventResult::Skipped => "skipped",
_ => "n/a",
}
}
fn bypass_reason(e: &BuildEvent) -> String {
let reason = e.passthrough_reason.trim();
if reason.is_empty() {
"unknown".to_string()
} else {
reason.to_string()
}
}
fn build_network_analysis(transfers: &[TransferEvent], top: usize) -> NetworkAnalysis {
let mut bytes_up = 0u64;
let mut bytes_down = 0u64;
let mut uploads_ok = 0usize;
let mut uploads_failed = 0usize;
let mut downloads_ok = 0usize;
let mut downloads_failed = 0usize;
let mut download_latencies: Vec<u64> = Vec::new();
let mut total_download_bytes = 0u64;
let mut total_download_ms = 0u64;
let mut total_network_ms = 0u64;
let mut total_request_ms = 0u64;
let mut total_body_ms = 0u64;
let mut total_semaphore_wait_ms = 0u64;
let mut total_head_ms = 0u64;
let mut total_get_requests = 0u32;
let mut total_original_bytes = 0u64;
let mut total_decompress_ms = 0u64;
let mut total_extract_ms = 0u64;
let mut total_disk_io_ms_measured = 0u64;
let mut has_disk_io_measurement = false;
let mut total_import_ms = 0u64;
let mut total_compression_ms = 0u64;
let mut total_head_checks_ms = 0u64;
let mut blobs_skipped = 0u32;
let mut blobs_total = 0u32;
let mut v1_downloads = 0usize;
let mut v2_downloads = 0usize;
let mut v3_downloads = 0usize;
let mut unknown_format_downloads = 0usize;
for t in transfers {
match t.direction {
TransferDirection::Upload => {
if t.ok {
uploads_ok += 1;
bytes_up += t.compressed_bytes;
total_compression_ms += t.compression_ms;
total_head_checks_ms += t.head_checks_ms;
} else {
uploads_failed += 1;
}
}
TransferDirection::Download => {
if t.ok {
downloads_ok += 1;
bytes_down += t.compressed_bytes;
download_latencies.push(t.elapsed_ms);
total_download_bytes += t.compressed_bytes;
total_original_bytes += t.original_bytes;
total_decompress_ms += t.decompress_ms;
total_extract_ms += t.extract_ms;
total_import_ms += t.import_ms;
total_semaphore_wait_ms += t.semaphore_wait_ms;
total_head_ms += t.head_ms;
total_request_ms += t.request_ms;
total_body_ms += t.body_ms;
total_get_requests += t.request_count;
if t.disk_io_ms > 0 {
total_disk_io_ms_measured += t.disk_io_ms;
has_disk_io_measurement = true;
}
blobs_skipped += t.blobs_skipped;
blobs_total += t.blobs_total;
match t.format.as_str() {
"v1" => v1_downloads += 1,
"v2" => v2_downloads += 1,
"v3" => v3_downloads += 1,
_ => unknown_format_downloads += 1,
}
total_download_ms += t.elapsed_ms;
total_network_ms += if t.network_ms > 0 {
t.network_ms
} else {
t.elapsed_ms
};
} else {
downloads_failed += 1;
}
}
}
}
download_latencies.sort_unstable();
let avg_download_ms = if !download_latencies.is_empty() {
total_download_ms as f64 / download_latencies.len() as f64
} else {
0.0
};
let p95_download_ms = if !download_latencies.is_empty() {
let idx = (download_latencies.len() * 95 / 100).min(download_latencies.len() - 1);
download_latencies[idx]
} else {
0
};
let max_download_ms = download_latencies.last().copied().unwrap_or(0);
let throughput_mbps = if total_download_ms > 0 {
(total_download_bytes as f64 / (1024.0 * 1024.0)) / (total_download_ms as f64 / 1000.0)
} else {
0.0
};
let network_throughput_mbps = if total_network_ms > 0 {
(total_download_bytes as f64 / (1024.0 * 1024.0)) / (total_network_ms as f64 / 1000.0)
} else {
0.0
};
let body_throughput_mbps = if total_body_ms > 0 {
(total_download_bytes as f64 / (1024.0 * 1024.0)) / (total_body_ms as f64 / 1000.0)
} else {
0.0
};
let mut download_details: Vec<TransferDetail> = transfers
.iter()
.filter(|t| matches!(t.direction, TransferDirection::Download) && t.ok)
.map(|t| {
let tp = if t.elapsed_ms > 0 {
(t.compressed_bytes as f64 / (1024.0 * 1024.0)) / (t.elapsed_ms as f64 / 1000.0)
} else {
0.0
};
TransferDetail {
crate_name: t.crate_name.clone(),
direction: "download".to_string(),
format: t.format.clone(),
cache_key: t.cache_key.clone(),
object_key: t.object_key.clone(),
compressed_bytes: t.compressed_bytes,
elapsed_ms: t.elapsed_ms,
network_ms: t.network_ms,
semaphore_wait_ms: t.semaphore_wait_ms,
head_ms: t.head_ms,
request_ms: t.request_ms,
body_ms: t.body_ms,
decompress_ms: t.decompress_ms,
extract_ms: t.extract_ms,
disk_io_ms: t.disk_io_ms,
import_ms: t.import_ms,
request_count: t.request_count,
blobs_skipped: t.blobs_skipped,
blobs_total: t.blobs_total,
throughput_mbps: (tp * 10.0).round() / 10.0,
ok: t.ok,
}
})
.collect();
download_details.sort_by_key(|entry| std::cmp::Reverse(entry.elapsed_ms));
let compression_ratio = if total_download_bytes > 0 && total_original_bytes > 0 {
total_original_bytes as f64 / total_download_bytes as f64
} else {
0.0
};
let total_disk_io_ms = if has_disk_io_measurement {
total_disk_io_ms_measured
} else {
total_download_ms.saturating_sub(total_network_ms + total_decompress_ms + total_extract_ms)
};
let phase_totals = [
("wait", total_semaphore_wait_ms),
("HEAD", total_head_ms),
("request", total_request_ms),
("body", total_body_ms),
("decompress", total_decompress_ms),
("extract", total_extract_ms),
("import", total_import_ms),
("disk", total_disk_io_ms),
];
let phase_total_ms: u64 = phase_totals.iter().map(|(_, ms)| *ms).sum();
let (dominant_phase, dominant_phase_ms) = phase_totals
.iter()
.copied()
.max_by_key(|(_, ms)| *ms)
.unwrap_or(("unknown", 0));
let dominant_phase_pct = if phase_total_ms > 0 {
dominant_phase_ms as f64 / phase_total_ms as f64 * 100.0
} else {
0.0
};
NetworkAnalysis {
bytes_up,
bytes_down,
uploads_ok,
uploads_failed,
downloads_ok,
downloads_failed,
avg_download_ms: (avg_download_ms * 10.0).round() / 10.0,
p95_download_ms,
max_download_ms,
throughput_mbps: (throughput_mbps * 10.0).round() / 10.0,
network_throughput_mbps: (network_throughput_mbps * 10.0).round() / 10.0,
body_throughput_mbps: (body_throughput_mbps * 10.0).round() / 10.0,
dominant_download_phase: dominant_phase.to_string(),
dominant_download_phase_ms: dominant_phase_ms,
dominant_download_phase_pct: (dominant_phase_pct * 10.0).round() / 10.0,
total_request_ms,
total_body_ms,
total_semaphore_wait_ms,
total_head_ms,
total_get_requests,
compression_ratio: (compression_ratio * 10.0).round() / 10.0,
original_bytes_down: total_original_bytes,
total_decompress_ms,
total_extract_ms,
total_disk_io_ms,
total_import_ms,
total_compression_ms,
total_head_checks_ms,
blobs_skipped,
blobs_total,
v1_downloads,
v2_downloads,
v3_downloads,
unknown_format_downloads,
slowest_downloads: download_details.into_iter().take(top).collect(),
}
}
fn generate_suggestions(
stats: &events::EventStats,
prefetch: &PrefetchAnalysis,
network: &Option<NetworkAnalysis>,
top_misses: &[CrateDetail],
total_cacheable: usize,
total_hits: usize,
) -> Vec<String> {
let mut suggestions = Vec::new();
let total_compiled = stats.dups + stats.misses;
if total_cacheable > 0 && stats.miss_compile_time_ms > 0 {
let miss_share = stats.miss_compile_time_ms as f64
/ (stats.miss_compile_time_ms + stats.hit_compile_time_ms) as f64
* 100.0;
if miss_share > 80.0 && total_compiled > 3 {
let top_names: Vec<&str> = top_misses
.iter()
.take(3)
.map(|c| c.crate_name.as_str())
.collect();
suggestions.push(format!(
"{:.0}% of compile time spent on compiled cache-key misses — improve hit rate for {}",
miss_share,
if top_names.is_empty() {
"top compiled cache-key misses".to_string()
} else {
top_names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
},
));
}
}
if total_hits > 0 {
let avg_overhead = stats.hit_elapsed_ms as f64 / total_hits as f64;
if avg_overhead > 50.0 {
suggestions.push(format!(
"Average cache hit overhead is {:.0}ms — check disk I/O or consider faster storage",
avg_overhead
));
}
}
if prefetch.total_hits > 10 && prefetch.contribution_pct < 20.0 {
suggestions.push(
"Prefetch contributed <20% of hits — check namespace/shard configuration".to_string(),
);
}
if let Some(net) = network {
let total_downloads = net.downloads_ok + net.downloads_failed;
if total_downloads > 0 {
let fail_rate = net.downloads_failed as f64 / total_downloads as f64 * 100.0;
if fail_rate > 10.0 {
suggestions.push(format!(
"{:.0}% of downloads failed — check network connectivity and S3 credentials",
fail_rate
));
}
}
if net.downloads_ok > 0 && net.total_get_requests > net.downloads_ok as u32 * 3 {
suggestions.push(format!(
"Downloads fan out to {:.1} GETs per cache hit — check remote layout granularity or prefer pack-first downloads on CI",
net.total_get_requests as f64 / net.downloads_ok as f64
));
}
if net.total_semaphore_wait_ms > 10_000 {
suggestions.push(format!(
"Aggregate S3 semaphore wait totaled {} — tune concurrency only if the object store can absorb it",
format_duration_ms(net.total_semaphore_wait_ms)
));
}
if net.total_request_ms > 30_000 && net.total_request_ms > net.total_body_ms {
suggestions.push(format!(
"Aggregate request/header latency ({}) exceeds body transfer ({}) — check RGW/request path, connection reuse, or object fan-out",
format_duration_ms(net.total_request_ms),
format_duration_ms(net.total_body_ms)
));
}
if net.total_extract_ms > 30_000 && net.total_extract_ms > net.total_body_ms {
suggestions.push(format!(
"Aggregate archive extract time ({}) exceeds body transfer ({}) — profile zstd/tar extraction and SQLite import separately",
format_duration_ms(net.total_extract_ms),
format_duration_ms(net.total_body_ms)
));
}
}
if network.is_none() {
suggestions.push("No network transfer data available for this session".to_string());
}
suggestions
}
fn bypass_total(bypass: &BypassAnalysis) -> usize {
bypass.passthroughs + bypass.skipped
}
fn format_bypass_summary(bypass: &BypassAnalysis) -> String {
let mut parts = Vec::new();
if bypass.passthroughs > 0 {
let mut part = format!("{} passthrough", bypass.passthroughs);
if bypass.passthroughs != 1 {
part.push('s');
}
if bypass.fallbacks > 0 {
part.push_str(&format!(" ({} via fallback)", bypass.fallbacks));
}
parts.push(part);
}
if bypass.skipped > 0 {
let mut part = format!("{} skipped", bypass.skipped);
if bypass.skipped == 1 {
part = "1 skipped".to_string();
}
parts.push(part);
}
if parts.is_empty() {
"none".to_string()
} else {
parts.join(" / ")
}
}
fn markdown_cell(value: &str) -> String {
value.replace('\n', " ").replace('|', "\\|")
}
fn format_exit_code(exit_code: Option<i32>) -> String {
exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "-".to_string())
}
fn cache_roi(report: &BuildReport) -> Option<f64> {
if report.summary.time_saved_ms > 0 && report.timing.hit_time_ms > 0 {
let raw = report.summary.time_saved_ms as f64 / report.timing.hit_time_ms as f64;
Some((raw * 10.0).round() / 10.0)
} else {
None
}
}
fn cache_overhead_summary(report: &BuildReport) -> Option<String> {
let total_hits =
report.summary.local_hits + report.summary.prefetch_hits + report.summary.remote_hits;
if total_hits == 0 || report.timing.hit_time_ms == 0 {
return None;
}
Some(format!(
"{} aggregate, avg {:.0}ms/hit",
format_duration_ms(report.timing.hit_time_ms),
report.timing.avg_hit_ms
))
}
fn has_storage_data(storage: &StorageBreakdown) -> bool {
storage.restored_bytes > 0
|| storage.logical_bytes > 0
|| storage.blob_bytes > 0
|| storage.dedup_saved_bytes > 0
}
fn push_storage_table(lines: &mut Vec<String>, storage: &StorageBreakdown) {
lines.push("| Metric | Value |".to_string());
lines.push("|---|---|".to_string());
if storage.restored_bytes > 0 {
lines.push(format!(
"| Restored bytes | {} total: {} reflink, {} hardlink, {} copied |",
format_bytes(storage.restored_bytes),
format_bytes(storage.reflinked_bytes),
format_bytes(storage.hardlinked_bytes),
format_bytes(storage.copied_bytes),
));
lines.push(format!(
"| Zero-copy restores | {:.1}% |",
storage.zero_copy_pct
));
}
if storage.logical_bytes > 0 || storage.blob_bytes > 0 {
lines.push(format!(
"| Store footprint | {} logical -> {} blobs, {} dedup saved |",
format_bytes(storage.logical_bytes),
format_bytes(storage.blob_bytes),
format_bytes(storage.dedup_saved_bytes),
));
lines.push(format!("| Store blobs | {} |", storage.store_blobs));
}
}
fn push_error_table(lines: &mut Vec<String>, errors: &[ErrorDetail]) {
lines.push("| Crate | Time | Key |".to_string());
lines.push("|---|---|---|".to_string());
for err in errors.iter().take(10) {
let key_short = if err.cache_key.len() > 12 {
&err.cache_key[..12]
} else {
&err.cache_key
};
lines.push(format!(
"| `{}` | {} | `{}` |",
markdown_cell(&err.crate_name),
err.timestamp,
markdown_cell(key_short),
));
}
if errors.len() > 10 {
lines.push(format!("| *... {} more* | | |", errors.len() - 10));
}
}
fn push_bypass_tables(lines: &mut Vec<String>, bypass: &BypassAnalysis) {
if !bypass.reasons.is_empty() {
lines.push("| Result | Route | Reason | Count | Failures | Max time |".to_string());
lines.push("|---|---|---|---:|---:|---:|".to_string());
for reason in &bypass.reasons {
lines.push(format!(
"| {} | {} | {} | {} | {} | {} |",
reason.result,
reason.route,
markdown_cell(&reason.reason),
reason.count,
reason.failures,
format_duration_ms(reason.max_elapsed_ms),
));
}
}
if !bypass.slowest.is_empty() {
lines.push(String::new());
lines.push("**Slowest bypassed invocations:**".to_string());
lines.push(String::new());
lines.push("| Crate | Result | Route | Time | Exit | Reason |".to_string());
lines.push("|---|---|---|---:|---:|---|".to_string());
for detail in &bypass.slowest {
lines.push(format!(
"| `{}` | {} | {} | {} | {} | {} |",
markdown_cell(&detail.crate_name),
detail.result,
detail.route,
format_duration_ms(detail.elapsed_ms),
format_exit_code(detail.exit_code),
markdown_cell(&detail.reason),
));
}
}
}
pub fn format_json(report: &BuildReport) -> Result<String> {
Ok(serde_json::to_string_pretty(report)?)
}
pub fn format_markdown(report: &BuildReport) -> String {
use crate::cli::format_duration_ms;
let mut lines = Vec::new();
let s = &report.summary;
let t = &report.timing;
let total_hits = s.local_hits + s.prefetch_hits + s.remote_hits;
let total_compiled = s.dups + s.misses;
lines.push("### kache build report".to_string());
lines.push(String::new());
lines.push(format!(
"**{:.1}% hit rate** — {}/{} cacheable crates from cache, {} compiled | **{} compile work avoided**",
s.hit_rate_pct,
total_hits,
s.total_crates,
total_compiled,
format_duration_ms(s.time_saved_ms),
));
lines.push(String::new());
lines.push("#### Summary".to_string());
lines.push("| Metric | Value |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!("| Window | last {}h |", report.meta.since_hours));
lines.push(format!("| Hit rate (count) | {:.1}% |", s.hit_rate_pct));
if let Some(w) = s.weighted_hit_rate_pct {
lines.push(format!("| Hit rate (compile-cost weighted) | {:.1}% |", w));
}
lines.push(format!(
"| Compile work avoided | {} aggregate |",
format_duration_ms(s.time_saved_ms)
));
if let Some(overhead) = cache_overhead_summary(report) {
lines.push(format!("| Cache hit overhead | {} |", overhead));
}
if let Some(roi) = cache_roi(report) {
lines.push(format!(
"| Cache ROI | {:.1}x compile work per cache-hit overhead |",
roi
));
}
if t.miss_compile_time_ms > 0 {
lines.push(format!(
"| Miss compile work | {} aggregate |",
format_duration_ms(t.miss_compile_time_ms)
));
}
lines.push(format!("| Total crates | {} |", s.total_crates));
lines.push(format!(
"| Hits | {} (local: {}, prefetch: {}, remote: {}) |",
total_hits, s.local_hits, s.prefetch_hits, s.remote_hits
));
if s.dups > 0 {
lines.push(format!("| Dups | {} |", s.dups));
}
lines.push(format!("| Misses | {} |", s.misses));
if s.errors > 0 {
lines.push(format!("| Errors | {} |", s.errors));
}
if s.passthroughs > 0 || s.skipped > 0 {
lines.push(format!(
"| Passthroughs / skipped | {} |",
format_bypass_summary(&report.bypass)
));
}
lines.push(String::new());
let total_ms = t.hit_time_ms + t.miss_time_ms;
lines.push("#### Timing".to_string());
lines.push("| Phase | Aggregate time | % of tracked wrapper time |".to_string());
lines.push("|---|---|---|".to_string());
let hit_pct = if total_ms > 0 {
t.hit_time_ms as f64 / total_ms as f64 * 100.0
} else {
0.0
};
let miss_pct = if total_ms > 0 {
t.miss_time_ms as f64 / total_ms as f64 * 100.0
} else {
0.0
};
lines.push(format!(
"| Cache hits (wrapper overhead) | {} | {:.1}% |",
format_duration_ms(t.hit_time_ms),
hit_pct
));
lines.push(format!(
"| Compiles (wrapper total) | {} | {:.1}% |",
format_duration_ms(t.miss_time_ms),
miss_pct
));
lines.push(String::new());
if let Some(net) = &report.network {
lines.push("#### Network".to_string());
lines.push("| Metric | Value |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!(
"| Downloaded | {} ({} crates) |",
format_bytes(net.bytes_down),
net.downloads_ok
));
lines.push(format!(
"| Uploaded | {} ({} crates) |",
format_bytes(net.bytes_up),
net.uploads_ok
));
lines.push(format!(
"| Avg download time | {:.0}ms |",
net.avg_download_ms
));
lines.push(format!("| P95 download time | {}ms |", net.p95_download_ms));
lines.push(format!(
"| Throughput (network) | {:.1} MB/s |",
net.network_throughput_mbps
));
lines.push(format!(
"| Throughput (body only) | {:.1} MB/s |",
net.body_throughput_mbps
));
lines.push(format!(
"| Throughput (incl. restore) | {:.1} MB/s |",
net.throughput_mbps
));
if !net.dominant_download_phase.is_empty() && net.dominant_download_phase_ms > 0 {
lines.push(format!(
"| Dominant aggregate download phase | {} — {} ({:.1}%) |",
net.dominant_download_phase,
format_duration_ms(net.dominant_download_phase_ms),
net.dominant_download_phase_pct
));
}
if net.compression_ratio > 0.0 {
lines.push(format!(
"| Compression ratio | {:.1}x ({} → {}) |",
net.compression_ratio,
format_bytes(net.original_bytes_down),
format_bytes(net.bytes_down)
));
}
if net.total_semaphore_wait_ms > 0
|| net.total_head_ms > 0
|| net.total_decompress_ms > 0
|| net.total_extract_ms > 0
|| net.total_import_ms > 0
|| net.total_disk_io_ms > 0
{
lines.push(format!(
"| Aggregate download phase time | wait {}ms, HEAD {}ms, request {}ms, body {}ms, decompress {}ms, extract {}ms, import {}ms, disk I/O {}ms |",
net.total_semaphore_wait_ms,
net.total_head_ms,
net.total_request_ms,
net.total_body_ms,
net.total_decompress_ms,
net.total_extract_ms,
net.total_import_ms,
net.total_disk_io_ms
));
}
if net.blobs_total > 0 {
lines.push(format!(
"| Blob dedup | {} / {} blobs already local ({:.0}% skipped) |",
net.blobs_skipped,
net.blobs_total,
if net.blobs_total > 0 {
net.blobs_skipped as f64 / net.blobs_total as f64 * 100.0
} else {
0.0
}
));
}
if net.downloads_failed > 0 {
lines.push(format!("| Failed downloads | {} |", net.downloads_failed));
}
if net.uploads_failed > 0 {
lines.push(format!("| Failed uploads | {} |", net.uploads_failed));
}
lines.push(String::new());
if !net.slowest_downloads.is_empty() {
lines.push("#### Slowest Downloads".to_string());
lines.push(
"| Crate | Size | Time | Key | Wait/HEAD | Req/Body | Extract/Import |".to_string(),
);
lines.push("|---|---|---|---|---|---|---|".to_string());
for d in &net.slowest_downloads {
let key = if d.cache_key.is_empty() {
"?"
} else {
&d.cache_key[..d.cache_key.len().min(12)]
};
lines.push(format!(
"| `{}` | {} | {}ms | `{}` | {}/{}ms | {}/{}ms | {}/{}ms |",
d.crate_name,
format_bytes(d.compressed_bytes),
d.elapsed_ms,
key,
d.semaphore_wait_ms,
d.head_ms,
d.request_ms,
d.body_ms,
d.extract_ms.max(d.decompress_ms),
d.import_ms,
));
}
let repro_keys: Vec<_> = net
.slowest_downloads
.iter()
.filter(|d| !d.object_key.is_empty())
.take(3)
.collect();
if !repro_keys.is_empty() {
lines.push(String::new());
lines.push("Raw object keys for reproduction:".to_string());
for d in repro_keys {
lines.push(format!("- `{}`: `{}`", d.crate_name, d.object_key));
}
}
lines.push(String::new());
}
}
if has_storage_data(&report.storage) {
lines.push("#### Storage".to_string());
push_storage_table(&mut lines, &report.storage);
lines.push(String::new());
}
let p = &report.prefetch;
lines.push("#### Prefetch".to_string());
lines.push("| Metric | Value |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!(
"| Prefetch hits | {} / {} total hits |",
p.prefetch_hits, p.total_hits
));
lines.push(format!("| Contribution | {:.1}% |", p.contribution_pct));
lines.push(String::new());
if bypass_total(&report.bypass) > 0 {
lines.push("#### Passthroughs & Skips".to_string());
push_bypass_tables(&mut lines, &report.bypass);
lines.push(String::new());
}
if !report.errors_detail.is_empty() {
lines.push("#### Errors".to_string());
push_error_table(&mut lines, &report.errors_detail);
lines.push(String::new());
}
if !report.top_misses.is_empty() {
lines.push("#### Top Compiled Cache-Key Misses".to_string());
lines.push("| Crate | Compile time | Size | Key |".to_string());
lines.push("|---|---|---|---|".to_string());
for c in &report.top_misses {
let key_short = if c.cache_key.len() > 12 {
&c.cache_key[..12]
} else {
&c.cache_key
};
lines.push(format!(
"| `{}` | {} | {} | `{}` |",
c.crate_name,
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
key_short,
));
}
lines.push(String::new());
}
if !report.top_hits.is_empty() {
lines.push("#### Top Cache Hits (most expensive cached)".to_string());
lines.push("| Crate | Compile cost | Size | Key |".to_string());
lines.push("|---|---|---|---|".to_string());
for c in &report.top_hits {
let key_short = if c.cache_key.len() > 12 {
&c.cache_key[..12]
} else {
&c.cache_key
};
lines.push(format!(
"| `{}` | {} | {} | `{}` |",
c.crate_name,
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
key_short,
));
}
lines.push(String::new());
}
if !report.suggestions.is_empty() {
lines.push("#### Suggestions".to_string());
for s in &report.suggestions {
lines.push(format!("- {s}"));
}
lines.push(String::new());
}
if let Some(gc) = &report.gc {
lines.push("#### GC".to_string());
lines.push("| Metric | Value |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!("| Last run | {} |", gc.last_run));
lines.push(format!("| Entries evicted | {} |", gc.entries_evicted));
lines.push(format!(
"| Bytes freed | {} |",
format_bytes(gc.bytes_freed)
));
lines.push(format!("| Blobs removed | {} |", gc.blobs_removed));
lines.push(String::new());
}
lines.join("\n")
}
pub fn format_github(report: &BuildReport) -> String {
use crate::cli::format_duration_ms;
let mut lines = Vec::new();
let s = &report.summary;
let t = &report.timing;
let total_hits = s.local_hits + s.prefetch_hits + s.remote_hits;
let total_compiled = s.dups + s.misses;
lines.push("### kache build cache".to_string());
lines.push(String::new());
lines.push(format!(
"**{:.1}%** hit rate — {}/{} cacheable crates from cache, {} compiled | **{} compile work avoided**",
s.hit_rate_pct,
total_hits,
s.total_crates,
total_compiled,
format_duration_ms(s.time_saved_ms),
));
lines.push(String::new());
lines.push("| | |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!(
"| **Window** | last {}h |",
report.meta.since_hours
));
lines.push(format!(
"| **Crates** | {} cached / {} compiled / {} total |",
total_hits, total_compiled, s.total_crates
));
if s.dups > 0 {
lines.push(format!(
"| **Dups** | {} storage duplicates after compile |",
s.dups
));
}
lines.push(format!(
"| **Hit rate** | {:.1}% count{} |",
s.hit_rate_pct,
s.weighted_hit_rate_pct
.map(|w| format!(" / {:.1}% by compile cost", w))
.unwrap_or_default()
));
lines.push(format!(
"| **Compile work avoided** | {} aggregate |",
format_duration_ms(s.time_saved_ms)
));
if let Some(overhead) = cache_overhead_summary(report) {
lines.push(format!("| **Cache hit overhead** | {} |", overhead));
}
if let Some(roi) = cache_roi(report) {
lines.push(format!(
"| **Cache ROI** | {:.1}x compile work per cache-hit overhead |",
roi
));
}
if t.miss_compile_time_ms > 0 {
lines.push(format!(
"| **Miss compile work** | {} aggregate |",
format_duration_ms(t.miss_compile_time_ms)
));
}
if s.errors > 0 {
lines.push(format!("| **Errors** | {} |", s.errors));
}
if s.passthroughs > 0 || s.skipped > 0 {
lines.push(format!(
"| **Passthroughs / skipped** | {} |",
format_bypass_summary(&report.bypass)
));
}
if !report.suggestions.is_empty() {
lines.push(String::new());
for sg in &report.suggestions {
lines.push(format!("> {sg}"));
}
}
if !report.top_misses.is_empty() {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push(format!(
"<summary><strong>Top compiled cache-key misses</strong> ({} compiled)</summary>",
total_compiled
));
lines.push(String::new());
lines.push("| Crate | Compile time | Size |".to_string());
lines.push("|-------|-------------|------|".to_string());
for c in report.top_misses.iter().take(10) {
lines.push(format!(
"| `{}` | {} | {} |",
c.crate_name,
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
));
}
if total_compiled > 10 {
lines.push(format!("| *... {} more* | | |", total_compiled - 10));
}
lines.push(String::new());
lines.push("</details>".to_string());
}
if !report.top_hits.is_empty() {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push(format!(
"<summary><strong>Expensive cache hits</strong> — top {} by avoided compile work</summary>",
report.top_hits.len().min(10)
));
lines.push(String::new());
lines.push("| Crate | Avoided compile work | Size |".to_string());
lines.push("|-------|----------------------|------|".to_string());
for c in report.top_hits.iter().take(10) {
lines.push(format!(
"| `{}` | {} | {} |",
markdown_cell(&c.crate_name),
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
));
}
lines.push(String::new());
lines.push("</details>".to_string());
}
if bypass_total(&report.bypass) > 0 {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push(format!(
"<summary><strong>Passthroughs & skips</strong> — {}</summary>",
format_bypass_summary(&report.bypass)
));
lines.push(String::new());
push_bypass_tables(&mut lines, &report.bypass);
lines.push(String::new());
lines.push("</details>".to_string());
}
if !report.errors_detail.is_empty() {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push(format!(
"<summary><strong>Errors</strong> — {}</summary>",
report.errors_detail.len()
));
lines.push(String::new());
push_error_table(&mut lines, &report.errors_detail);
lines.push(String::new());
lines.push("</details>".to_string());
}
if let Some(net) = &report.network {
let net_tp = if net.network_throughput_mbps > 0.0 {
net.network_throughput_mbps
} else {
net.throughput_mbps
};
lines.push(String::new());
lines.push("<details>".to_string());
let dominant_summary =
if !net.dominant_download_phase.is_empty() && net.dominant_download_phase_ms > 0 {
format!(", dominant aggregate {}", net.dominant_download_phase)
} else {
String::new()
};
lines.push(format!(
"<summary><strong>Network</strong> — {} downloaded, {:.0} MB/s body{}</summary>",
format_bytes(net.bytes_down),
net.body_throughput_mbps,
dominant_summary
));
lines.push(String::new());
lines.push("| | |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!(
"| Downloaded | {} ({} crates) |",
format_bytes(net.bytes_down),
net.downloads_ok
));
if net.uploads_ok > 0 || net.uploads_failed > 0 {
lines.push(format!(
"| Uploaded | {} ({} crates) |",
format_bytes(net.bytes_up),
net.uploads_ok
));
if net.total_compression_ms > 0 || net.total_head_checks_ms > 0 {
lines.push(format!(
"| Upload time split | compress {}ms + HEAD checks {}ms |",
net.total_compression_ms, net.total_head_checks_ms,
));
}
}
lines.push(format!(
"| Download time | avg {:.0}ms · p95 {}ms |",
net.avg_download_ms, net.p95_download_ms
));
if net.v1_downloads > 0
|| net.v2_downloads > 0
|| net.v3_downloads > 0
|| net.unknown_format_downloads > 0
{
lines.push(format!(
"| Download format | v1 {} · v2 {} · v3 {} · unknown {} |",
net.v1_downloads, net.v2_downloads, net.v3_downloads, net.unknown_format_downloads
));
}
if net.total_get_requests > 0 {
let req_per_download = net.total_get_requests as f64 / net.downloads_ok.max(1) as f64;
lines.push(format!(
"| GET fan-out | {} GETs total · {:.1} per download |",
net.total_get_requests, req_per_download
));
}
lines.push(format!(
"| Throughput | {:.1} MB/s body · {:.1} MB/s request+body · {:.1} MB/s end-to-end |",
net.body_throughput_mbps, net_tp, net.throughput_mbps
));
if !net.dominant_download_phase.is_empty() && net.dominant_download_phase_ms > 0 {
lines.push(format!(
"| Dominant aggregate download phase | {} — {} ({:.1}%) |",
net.dominant_download_phase,
format_duration_ms(net.dominant_download_phase_ms),
net.dominant_download_phase_pct
));
}
if net.compression_ratio > 0.0 {
lines.push(format!(
"| Compression | {:.1}x ({} → {}) |",
net.compression_ratio,
format_bytes(net.original_bytes_down),
format_bytes(net.bytes_down)
));
}
if net.total_semaphore_wait_ms > 0
|| net.total_head_ms > 0
|| net.total_decompress_ms > 0
|| net.total_extract_ms > 0
|| net.total_import_ms > 0
|| net.total_disk_io_ms > 0
{
lines.push(format!(
"| Aggregate download phase time | wait {}ms · HEAD {}ms · request {}ms · body {}ms · decompress {}ms · extract {}ms · import {}ms · disk {}ms |",
net.total_semaphore_wait_ms,
net.total_head_ms,
net.total_request_ms,
net.total_body_ms,
net.total_decompress_ms,
net.total_extract_ms,
net.total_import_ms,
net.total_disk_io_ms
));
}
if net.blobs_total > 0 {
let pct = if net.blobs_total > 0 {
net.blobs_skipped as f64 / net.blobs_total as f64 * 100.0
} else {
0.0
};
lines.push(format!(
"| Blob dedup | {}/{} already local ({:.0}% saved) |",
net.blobs_skipped, net.blobs_total, pct
));
}
if net.downloads_failed > 0 {
lines.push(format!("| Failed downloads | {} |", net.downloads_failed));
}
if net.uploads_failed > 0 {
lines.push(format!("| Failed uploads | {} |", net.uploads_failed));
}
if !net.slowest_downloads.is_empty() {
lines.push(String::new());
lines.push("**Slowest downloads:**".to_string());
lines.push(String::new());
lines.push(
"| Crate | Fmt | Size | Time | GETs | Key | Wait/HEAD | Req/Body | Extract/Import |"
.to_string(),
);
lines.push(
"|-------|-----|------|------|------|-----|-----------|----------|----------------|"
.to_string(),
);
for d in net.slowest_downloads.iter().take(5) {
let key = if d.cache_key.is_empty() {
"?"
} else {
&d.cache_key[..d.cache_key.len().min(12)]
};
lines.push(format!(
"| `{}` | {} | {} | {}ms | {} | `{}` | {}/{}ms | {}/{}ms | {}/{}ms |",
d.crate_name,
if d.format.is_empty() { "?" } else { &d.format },
format_bytes(d.compressed_bytes),
d.elapsed_ms,
d.request_count,
key,
d.semaphore_wait_ms,
d.head_ms,
d.request_ms,
d.body_ms,
d.extract_ms.max(d.decompress_ms),
d.import_ms,
));
}
let repro_keys: Vec<_> = net
.slowest_downloads
.iter()
.filter(|d| !d.object_key.is_empty())
.take(3)
.collect();
if !repro_keys.is_empty() {
lines.push(String::new());
lines.push("Raw object keys for reproduction:".to_string());
for d in repro_keys {
lines.push(format!("- `{}`: `{}`", d.crate_name, d.object_key));
}
}
}
lines.push(String::new());
lines.push("</details>".to_string());
}
if has_storage_data(&report.storage) {
lines.push(String::new());
lines.push("<details>".to_string());
let storage_summary = if report.storage.restored_bytes > 0 {
format!(
"{:.1}% zero-copy restores, {} restored",
report.storage.zero_copy_pct,
format_bytes(report.storage.restored_bytes)
)
} else {
format!(
"{} logical, {} blobs",
format_bytes(report.storage.logical_bytes),
format_bytes(report.storage.blob_bytes)
)
};
lines.push(format!(
"<summary><strong>Storage</strong> — {}</summary>",
storage_summary
));
lines.push(String::new());
push_storage_table(&mut lines, &report.storage);
lines.push(String::new());
lines.push("</details>".to_string());
}
let total_ms = t.hit_time_ms + t.miss_time_ms;
let p = &report.prefetch;
if total_ms > 0 || p.total_hits > 0 {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push("<summary><strong>Timing & Prefetch</strong></summary>".to_string());
lines.push(String::new());
if total_ms > 0 {
let hit_pct = t.hit_time_ms as f64 / total_ms as f64 * 100.0;
let miss_pct = t.miss_time_ms as f64 / total_ms as f64 * 100.0;
lines.push("| Phase | Aggregate time | % of tracked wrapper time |".to_string());
lines.push("|-------|------|---|".to_string());
lines.push(format!(
"| Cache hits (wrapper overhead) | {} | {:.1}% |",
format_duration_ms(t.hit_time_ms),
hit_pct
));
lines.push(format!(
"| Compiles (wrapper total) | {} | {:.1}% |",
format_duration_ms(t.miss_time_ms),
miss_pct
));
}
if t.total_key_ms > 0 || t.total_lookup_ms > 0 || t.total_restore_ms > 0 {
lines.push(format!(
"| Hit overhead | avg {:.0}ms key + {:.0}ms lookup + {:.0}ms restore |",
t.avg_key_ms, t.avg_lookup_ms, t.avg_restore_ms
));
}
if t.total_store_ms > 0 {
lines.push(format!(
"| Miss overhead | avg {:.0}ms key + {:.0}ms lookup + {:.0}ms store |",
t.avg_key_ms, t.avg_lookup_ms, t.avg_store_ms
));
}
if p.total_hits > 0 {
lines.push(String::new());
lines.push(format!(
"**Prefetch:** {}/{} hits ({:.1}%)",
p.prefetch_hits, p.total_hits, p.contribution_pct
));
}
lines.push(String::new());
lines.push("</details>".to_string());
}
if let Some(gc) = &report.gc {
lines.push(String::new());
lines.push("<details>".to_string());
lines.push(format!(
"<summary><strong>GC</strong> — {} entries evicted, {} freed</summary>",
gc.entries_evicted,
format_bytes(gc.bytes_freed),
));
lines.push(String::new());
lines.push("| | |".to_string());
lines.push("|---|---|".to_string());
lines.push(format!("| Last run | {} |", gc.last_run));
lines.push(format!("| Entries evicted | {} |", gc.entries_evicted));
lines.push(format!(
"| Bytes freed | {} |",
format_bytes(gc.bytes_freed)
));
lines.push(format!("| Blobs removed | {} |", gc.blobs_removed));
lines.push(String::new());
lines.push("</details>".to_string());
}
lines.push(String::new());
lines.push(format!(
"*Posted by [kache-action](https://github.com/kunobi-ninja/kache-action) · kache v{} · last {}h*",
report.meta.kache_version,
report.meta.since_hours,
));
lines.join("\n")
}
pub fn format_text(report: &BuildReport) -> String {
use crate::cli::format_duration_ms;
let mut lines = Vec::new();
let s = &report.summary;
let t = &report.timing;
let total_hits = s.local_hits + s.prefetch_hits + s.remote_hits;
let total_compiled = s.dups + s.misses;
lines.push(format!(
"kache build report (last {}h)",
report.meta.since_hours
));
lines.push(format!(
" {:.1}% hit rate — {}/{} cacheable crates cached, {} compiled",
s.hit_rate_pct, total_hits, s.total_crates, total_compiled,
));
if s.dups > 0 {
lines.push(format!(
" Dups: {} storage duplicates after compile",
s.dups
));
}
if let Some(w) = s.weighted_hit_rate_pct {
lines.push(format!(" {:.1}% by compile cost", w));
}
lines.push(format!(
" Compile work avoided: {} aggregate",
format_duration_ms(s.time_saved_ms)
));
if let Some(overhead) = cache_overhead_summary(report) {
lines.push(format!(" Cache hit overhead: {overhead}"));
}
if let Some(roi) = cache_roi(report) {
lines.push(format!(
" Cache ROI: {:.1}x compile work per cache-hit overhead",
roi
));
}
if t.miss_compile_time_ms > 0 {
lines.push(format!(
" Miss compile work: {} aggregate",
format_duration_ms(t.miss_compile_time_ms)
));
}
if s.errors > 0 {
lines.push(format!(" Errors: {}", s.errors));
}
if s.passthroughs > 0 || s.skipped > 0 {
lines.push(format!(
" Passthroughs/skipped: {}",
format_bypass_summary(&report.bypass)
));
}
lines.push(String::new());
lines.push("Timing:".to_string());
lines.push(format!(
" Hits overhead: {} aggregate (avg {:.0}ms/hit)",
format_duration_ms(t.hit_time_ms),
t.avg_hit_ms
));
lines.push(format!(
" Compiles: {} (avg {:.0}ms/crate)",
format_duration_ms(t.miss_time_ms),
t.avg_miss_ms
));
if t.total_key_ms > 0 || t.total_lookup_ms > 0 || t.total_restore_ms > 0 {
lines.push(format!(
" Hit overhead: avg {:.0}ms key + {:.0}ms lookup + {:.0}ms restore",
t.avg_key_ms, t.avg_lookup_ms, t.avg_restore_ms
));
}
if t.total_store_ms > 0 {
lines.push(format!(
" Miss overhead: avg {:.0}ms key + {:.0}ms lookup + {:.0}ms store",
t.avg_key_ms, t.avg_lookup_ms, t.avg_store_ms
));
}
lines.push(String::new());
if let Some(net) = &report.network {
lines.push("Network:".to_string());
lines.push(format!(
" Downloaded: {} ({} ok, {} failed)",
format_bytes(net.bytes_down),
net.downloads_ok,
net.downloads_failed
));
lines.push(format!(
" Uploaded: {} ({} ok, {} failed)",
format_bytes(net.bytes_up),
net.uploads_ok,
net.uploads_failed
));
lines.push(format!(
" Latency: avg {:.0}ms, p95 {}ms, max {}ms",
net.avg_download_ms, net.p95_download_ms, net.max_download_ms
));
lines.push(format!(
" Throughput: {:.1} MB/s body, {:.1} MB/s request+body, {:.1} MB/s incl. restore",
net.body_throughput_mbps, net.network_throughput_mbps, net.throughput_mbps
));
if !net.dominant_download_phase.is_empty() && net.dominant_download_phase_ms > 0 {
lines.push(format!(
" Dominant aggregate phase: {} — {} ({:.1}%)",
net.dominant_download_phase,
format_duration_ms(net.dominant_download_phase_ms),
net.dominant_download_phase_pct
));
}
if net.compression_ratio > 0.0 {
lines.push(format!(
" Compression: {:.1}x ratio ({} → {})",
net.compression_ratio,
format_bytes(net.original_bytes_down),
format_bytes(net.bytes_down)
));
}
if net.total_semaphore_wait_ms > 0
|| net.total_head_ms > 0
|| net.total_decompress_ms > 0
|| net.total_extract_ms > 0
|| net.total_import_ms > 0
|| net.total_disk_io_ms > 0
{
lines.push(format!(
" Aggregate phase time: wait {}ms, HEAD {}ms, request {}ms, body {}ms, decompress {}ms, extract {}ms, import {}ms, disk I/O {}ms",
net.total_semaphore_wait_ms,
net.total_head_ms,
net.total_request_ms,
net.total_body_ms,
net.total_decompress_ms,
net.total_extract_ms,
net.total_import_ms,
net.total_disk_io_ms
));
}
if net.blobs_total > 0 {
lines.push(format!(
" Blob dedup: {}/{} already local ({:.0}% skipped)",
net.blobs_skipped,
net.blobs_total,
net.blobs_skipped as f64 / net.blobs_total.max(1) as f64 * 100.0
));
}
lines.push(String::new());
}
if has_storage_data(&report.storage) {
lines.push("Storage:".to_string());
if report.storage.restored_bytes > 0 {
lines.push(format!(
" Restored: {} ({:.1}% zero-copy, {} copied)",
format_bytes(report.storage.restored_bytes),
report.storage.zero_copy_pct,
format_bytes(report.storage.copied_bytes)
));
}
if report.storage.logical_bytes > 0 || report.storage.blob_bytes > 0 {
lines.push(format!(
" Store: {} logical -> {} blobs ({} dedup saved)",
format_bytes(report.storage.logical_bytes),
format_bytes(report.storage.blob_bytes),
format_bytes(report.storage.dedup_saved_bytes)
));
}
lines.push(String::new());
}
lines.push(format!(
"Prefetch: {} / {} hits ({:.1}%)",
report.prefetch.prefetch_hits, report.prefetch.total_hits, report.prefetch.contribution_pct
));
lines.push(String::new());
if bypass_total(&report.bypass) > 0 {
lines.push("Passthroughs/skips:".to_string());
for reason in &report.bypass.reasons {
lines.push(format!(
" {} via {}: {} ({} total, {} failed, max {})",
reason.result,
reason.route,
reason.reason,
reason.count,
reason.failures,
format_duration_ms(reason.max_elapsed_ms)
));
}
if !report.bypass.slowest.is_empty() {
lines.push(" Slowest:".to_string());
for detail in &report.bypass.slowest {
lines.push(format!(
" {} — {} via {}, {}, exit {}, {}",
detail.crate_name,
detail.result,
detail.route,
format_duration_ms(detail.elapsed_ms),
format_exit_code(detail.exit_code),
detail.reason
));
}
}
lines.push(String::new());
}
if !report.top_misses.is_empty() {
lines.push("Top compiled cache-key misses:".to_string());
for c in &report.top_misses {
lines.push(format!(
" {} — {} ({})",
c.crate_name,
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
));
}
lines.push(String::new());
}
if !report.top_hits.is_empty() {
lines.push("Expensive cache hits:".to_string());
for c in &report.top_hits {
lines.push(format!(
" {} — {} avoided ({})",
c.crate_name,
format_duration_ms(c.compile_time_ms),
format_bytes(c.size),
));
}
lines.push(String::new());
}
if !report.errors_detail.is_empty() {
lines.push("Errors:".to_string());
for err in report.errors_detail.iter().take(10) {
lines.push(format!(" {} — {}", err.crate_name, err.timestamp));
}
lines.push(String::new());
}
if !report.suggestions.is_empty() {
lines.push("Suggestions:".to_string());
for s in &report.suggestions {
lines.push(format!(" - {s}"));
}
lines.push(String::new());
}
if let Some(gc) = &report.gc {
lines.push("GC:".to_string());
lines.push(format!(" Last run: {}", gc.last_run));
lines.push(format!(" Entries evicted: {}", gc.entries_evicted));
lines.push(format!(" Bytes freed: {}", format_bytes(gc.bytes_freed)));
lines.push(format!(" Blobs removed: {}", gc.blobs_removed));
lines.push(String::new());
}
lines.join("\n")
}
pub fn format_bytes(bytes: u64) -> String {
let b = bytes as f64;
if b >= 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
} else if b >= 1024.0 * 1024.0 {
format!("{:.1} MB", b / (1024.0 * 1024.0))
} else if b >= 1024.0 {
format!("{:.1} KB", b / 1024.0)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn test_event(
crate_name: &str,
result: EventResult,
elapsed_ms: u64,
compile_time_ms: u64,
size: u64,
cache_key: &str,
) -> BuildEvent {
BuildEvent {
ts: Utc::now(),
crate_name: crate_name.to_string(),
version: "0.1.0".to_string(),
result,
elapsed_ms,
compile_time_ms,
size,
cache_key: cache_key.to_string(),
schema: 8,
key_ms: 0,
key_hash_hits: 0,
key_hash_misses: 0,
key_hash_bytes: 0,
lookup_ms: 0,
restore_ms: 0,
store_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
compiler_runs: 0,
preprocessor_runs: 0,
probe_runs: 0,
reflinked_bytes: 0,
hardlinked_bytes: 0,
copied_bytes: 0,
passthrough_reason: String::new(),
fallback: false,
exit_code: None,
}
}
fn test_transfer(
crate_name: &str,
direction: TransferDirection,
format: &str,
compressed_bytes: u64,
elapsed_ms: u64,
ok: bool,
) -> TransferEvent {
TransferEvent {
schema: 2,
crate_name: crate_name.to_string(),
direction,
format: format.to_string(),
cache_key: format!("{crate_name}-key"),
object_key: format!("prefix/v3/packs/{crate_name}/{crate_name}-key.tar.zst"),
compressed_bytes,
elapsed_ms,
network_ms: elapsed_ms / 2, semaphore_wait_ms: 0,
head_ms: 0,
request_ms: elapsed_ms / 5,
body_ms: elapsed_ms / 3,
request_count: 4,
original_bytes: compressed_bytes * 3, decompress_ms: elapsed_ms / 4, extract_ms: 0,
disk_io_ms: 0,
import_ms: 0,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: 0,
blobs_total: 2,
ok,
timestamp: Utc::now().timestamp() as u64,
}
}
fn write_test_events(dir: &std::path::Path) -> Config {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
path_only_env_vars: Vec::new(),
cache_dir: dir.to_path_buf(),
max_size: 1024,
remote: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 10 * 1024 * 1024,
event_log_keep_lines: 1000,
compression_level: 3,
s3_concurrency: 16,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
};
let mut passthrough = test_event("build.rs", EventResult::Passthrough, 250, 0, 0, "");
passthrough.passthrough_reason = "refused: unsupported rustc invocation".to_string();
passthrough.fallback = true;
passthrough.exit_code = Some(0);
let mut skipped = test_event("doc-test", EventResult::Skipped, 0, 0, 0, "");
skipped.passthrough_reason = "explicitly skipped".to_string();
let mut dup = test_event(
"my_lib",
EventResult::Dup,
5000,
4800,
3 * 1024 * 1024,
"def456789012",
);
dup.store_output_blobs = 1;
dup.store_duplicate_blobs = 1;
let events = vec![
test_event(
"serde",
EventResult::LocalHit,
5,
300,
1024 * 1024,
"abc123def456",
),
test_event(
"tokio",
EventResult::PrefetchHit,
8,
500,
2 * 1024 * 1024,
"bcd234",
),
test_event(
"regex",
EventResult::RemoteHit,
120,
400,
512 * 1024,
"cde345",
),
dup,
test_event(
"my_app",
EventResult::Miss,
8000,
7500,
5 * 1024 * 1024,
"efg567",
),
test_event("broken", EventResult::Error, 10, 0, 0, "err001"),
passthrough,
skipped,
];
for e in &events {
events::log_event(&config.event_log_path(), e).unwrap();
}
let transfers = vec![
test_transfer(
"serde",
TransferDirection::Download,
"v3",
500_000,
150,
true,
),
test_transfer(
"tokio",
TransferDirection::Download,
"v3",
1_000_000,
300,
true,
),
test_transfer(
"regex",
TransferDirection::Download,
"v3",
200_000,
80,
true,
),
test_transfer(
"my_lib",
TransferDirection::Upload,
"v3",
2_000_000,
500,
true,
),
test_transfer(
"my_app",
TransferDirection::Upload,
"v3",
3_000_000,
700,
true,
),
test_transfer("fail_dl", TransferDirection::Download, "v3", 0, 50, false),
];
for t in &transfers {
events::log_transfer(&config.transfer_log_path(), t).unwrap();
}
config
}
#[test]
fn test_generate_report_with_all_result_types() {
let dir = tempfile::tempdir().unwrap();
let config = write_test_events(dir.path());
let report = generate_report(&config, 24, 10).unwrap();
assert_eq!(report.summary.total_crates, 5); assert_eq!(report.summary.local_hits, 1);
assert_eq!(report.summary.prefetch_hits, 1);
assert_eq!(report.summary.remote_hits, 1);
assert_eq!(report.summary.dups, 1);
assert_eq!(report.summary.misses, 1);
assert_eq!(report.summary.errors, 1);
assert_eq!(report.summary.passthroughs, 1);
assert_eq!(report.summary.skipped, 1);
assert_eq!(report.summary.fallbacks, 1);
assert_eq!(report.bypass.reasons.len(), 2);
assert!(report.summary.hit_rate_pct > 0.0);
assert!(report.summary.time_saved_ms > 0);
let network = report.network.as_ref().unwrap();
assert_eq!(network.v3_downloads, 3);
assert_eq!(network.v2_downloads, 0);
assert_eq!(network.total_get_requests, 12);
}
#[test]
fn test_json_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = write_test_events(dir.path());
let report = generate_report(&config, 24, 10).unwrap();
let json = format_json(&report).unwrap();
let parsed: BuildReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.summary.total_crates, report.summary.total_crates);
assert_eq!(parsed.summary.misses, report.summary.misses);
assert_eq!(parsed.top_misses.len(), report.top_misses.len());
}
#[test]
fn test_markdown_contains_sections() {
let dir = tempfile::tempdir().unwrap();
let config = write_test_events(dir.path());
let report = generate_report(&config, 24, 10).unwrap();
let md = format_markdown(&report);
assert!(md.contains("### kache build report"));
assert!(md.contains("#### Summary"));
assert!(md.contains("#### Timing"));
assert!(md.contains("#### Network"));
assert!(md.contains("#### Prefetch"));
assert!(md.contains("#### Passthroughs & Skips"));
assert!(md.contains("#### Top Compiled Cache-Key Misses"));
assert!(md.contains("#### Suggestions"));
}
#[test]
fn test_missing_transfer_data() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
path_only_env_vars: Vec::new(),
cache_dir: dir.path().to_path_buf(),
max_size: 1024,
remote: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 10 * 1024 * 1024,
event_log_keep_lines: 1000,
compression_level: 3,
s3_concurrency: 16,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
};
let event = test_event("serde", EventResult::LocalHit, 5, 300, 1024, "abc");
events::log_event(&config.event_log_path(), &event).unwrap();
let report = generate_report(&config, 24, 10).unwrap();
assert!(report.network.is_none());
assert!(report.suggestions.iter().any(|s| s.contains("No network")));
}
#[test]
fn test_suggestion_high_miss_share() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
path_only_env_vars: Vec::new(),
cache_dir: dir.path().to_path_buf(),
max_size: 1024,
remote: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 10 * 1024 * 1024,
event_log_keep_lines: 1000,
compression_level: 3,
s3_concurrency: 16,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
};
for i in 0..10 {
let e = test_event(
&format!("miss_{i}"),
EventResult::Miss,
5000,
4500,
1024 * 1024,
&format!("key_{i}"),
);
events::log_event(&config.event_log_path(), &e).unwrap();
}
let hit = test_event("hit", EventResult::LocalHit, 5, 100, 1024, "hk");
events::log_event(&config.event_log_path(), &hit).unwrap();
let report = generate_report(&config, 24, 10).unwrap();
assert!(
report
.suggestions
.iter()
.any(|s| s.contains("compile time spent on compiled cache-key misses"))
);
}
#[test]
fn test_github_format_has_collapsible_sections() {
let dir = tempfile::tempdir().unwrap();
let config = write_test_events(dir.path());
let report = generate_report(&config, 24, 10).unwrap();
let gh = format_github(&report);
assert!(gh.contains("### kache build cache"));
assert!(gh.contains("kache-action"));
assert!(gh.contains("**Crates**"));
assert!(gh.contains("**Hit rate**"));
assert!(gh.contains("**Compile work avoided**"));
assert!(gh.contains("**Cache hit overhead**"));
assert!(gh.contains("**Cache ROI**"));
assert!(gh.contains("**Passthroughs / skipped**"));
assert!(gh.contains("<details>"));
assert!(gh.contains("<summary><strong>Top compiled cache-key misses</strong>"));
assert!(gh.contains("<summary><strong>Passthroughs & skips</strong>"));
assert!(gh.contains("via fallback"));
assert!(gh.contains("refused: unsupported rustc invocation"));
assert!(gh.contains("<summary><strong>Network</strong>"));
assert!(gh.contains("<summary><strong>Timing & Prefetch</strong>"));
assert!(gh.contains("Download format"));
assert!(gh.contains("GET fan-out"));
assert!(gh.contains("v3 3"));
assert!(gh.contains("request"));
assert!(gh.contains("body"));
}
#[test]
fn test_text_output() {
let dir = tempfile::tempdir().unwrap();
let config = write_test_events(dir.path());
let report = generate_report(&config, 24, 10).unwrap();
let text = format_text(&report);
assert!(text.contains("kache build report"));
assert!(text.contains("hit rate"));
assert!(text.contains("Timing:"));
assert!(text.contains("Network:"));
assert!(text.contains("Passthroughs/skips:"));
}
#[test]
fn test_empty_report() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
path_only_env_vars: Vec::new(),
cache_dir: dir.path().to_path_buf(),
max_size: 1024,
remote: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 10 * 1024 * 1024,
event_log_keep_lines: 1000,
compression_level: 3,
s3_concurrency: 16,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
};
let report = generate_report(&config, 24, 10).unwrap();
assert_eq!(report.summary.total_crates, 0);
assert_eq!(report.summary.hit_rate_pct, 0.0);
assert!(report.network.is_none());
assert!(report.top_misses.is_empty());
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
}
}