use std::io::Write;
use std::path::PathBuf;
use super::{Edge, GraphDb};
use crate::config::GraphConfig;
const FORMAT_VERSION: u32 = 3;
const SCHEMA_STRING: &str = "File|Module|Function|Struct|Enum|Trait|Impl|Contains|Calls|Imports|Implements|HasMethod|Modifies|Tests";
#[must_use]
pub const fn schema_hash() -> u32 {
let bytes = SCHEMA_STRING.as_bytes();
let mut hash: u32 = 0x811c_9dc5;
let mut i = 0;
while i < bytes.len() {
hash ^= bytes[i] as u32;
hash = hash.wrapping_mul(0x0100_0193);
i += 1;
}
hash
}
#[must_use]
pub fn cache_path(owner: &str, repo: &str, sha: &str) -> PathBuf {
crate::config::data_dir()
.join("graph")
.join(owner)
.join(repo)
.join(format!("{sha}.bin"))
}
#[must_use]
pub fn encode_graph(graph: &GraphDb) -> Option<Vec<u8>> {
let mut filtered = GraphDb::new();
for idx in graph.node_indices() {
filtered.add_node(graph[idx].clone());
}
for idx in graph.edge_indices() {
let (a, b) = graph.edge_endpoints(idx)?;
if !matches!(graph[idx], Edge::Modifies) {
filtered.add_edge(a, b, graph[idx]);
}
}
let payload = postcard::to_allocvec(&filtered).ok()?;
let mut bytes = Vec::with_capacity(8 + payload.len());
bytes.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
bytes.extend_from_slice(&schema_hash().to_le_bytes());
bytes.extend_from_slice(&payload);
Some(bytes)
}
#[must_use]
pub fn decode_graph(bytes: &[u8]) -> Option<GraphDb> {
if bytes.len() < 8 {
return None;
}
let version = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if version != FORMAT_VERSION {
return None;
}
let hash = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
if hash != schema_hash() {
return None;
}
let graph: GraphDb = postcard::from_bytes(&bytes[8..]).ok()?;
Some(graph)
}
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn load_or_build(
owner: &str,
repo: &str,
sha: &str,
graph: GraphDb,
cfg: &GraphConfig,
) -> (GraphDb, bool) {
let path = cache_path(owner, repo, sha);
if let Ok(Some(cached)) = try_load_cached(&path, cfg) {
return (cached, true);
}
persist_graph(&path, &graph);
(graph, false)
}
#[cfg(target_arch = "wasm32")]
#[must_use]
pub fn load_or_build(
_owner: &str,
_repo: &str,
_sha: &str,
graph: GraphDb,
_cfg: &GraphConfig,
) -> (GraphDb, bool) {
(graph, false)
}
#[cfg(not(target_arch = "wasm32"))]
fn try_load_cached(path: &PathBuf, cfg: &GraphConfig) -> std::io::Result<Option<GraphDb>> {
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let modified = metadata
.modified()
.unwrap_or_else(|_| std::time::SystemTime::now());
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
let ttl = std::time::Duration::from_secs(cfg.cache_ttl_hours * 3600);
if age > ttl {
return Ok(None);
}
let bytes = std::fs::read(path)?;
Ok(decode_graph(&bytes))
}
#[cfg(not(target_arch = "wasm32"))]
fn persist_graph(path: &PathBuf, graph: &GraphDb) {
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!(
path = %parent.display(),
error = %e,
"graph cache: failed to create cache directory"
);
return;
}
let Some(bytes) = encode_graph(graph) else {
tracing::warn!(path = %path.display(), "graph cache: encode failed, skipping write");
return;
};
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let mut tmp = match tempfile::Builder::new().tempfile_in(parent) {
Ok(t) => t,
Err(e) => {
tracing::warn!(
path = %parent.display(),
error = %e,
"graph cache: failed to create temp file"
);
return;
}
};
if let Err(e) = tmp.write_all(&bytes) {
tracing::warn!(
path = %tmp.path().display(),
error = %e,
"graph cache: failed to write temp file"
);
let _ = std::fs::remove_file(tmp.path());
return;
}
if let Err(e) = tmp.flush() {
tracing::warn!(
path = %tmp.path().display(),
error = %e,
"graph cache: failed to flush temp file"
);
let _ = std::fs::remove_file(tmp.path());
return;
}
if let Err(e) = std::fs::rename(tmp.path(), path) {
tracing::warn!(
src = %tmp.path().display(),
dst = %path.display(),
error = %e,
"graph cache: failed to rename temp file to cache path"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_round_trip_serialize_deserialize() {
let mut graph = GraphDb::new();
let n1 = graph.add_node(super::super::Node::Function {
name: "foo".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
let n2 = graph.add_node(super::super::Node::Function {
name: "bar".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
graph.add_edge(n1, n2, Edge::Calls);
let bytes = encode_graph(&graph).expect("encode must succeed");
let decoded = decode_graph(&bytes).expect("should decode successfully");
assert_eq!(
graph.node_count(),
decoded.node_count(),
"node count should match"
);
assert_eq!(
graph.edge_count(),
decoded.edge_count(),
"edge count should match"
);
let names: Vec<String> = decoded
.node_indices()
.map(|idx| decoded[idx].name().to_string())
.collect();
assert!(names.contains(&"foo".to_string()));
assert!(names.contains(&"bar".to_string()));
}
#[test]
fn test_decode_graph_version_mismatch() {
let mut graph = GraphDb::new();
graph.add_node(super::super::Node::Function {
name: "foo".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
let mut bytes = encode_graph(&graph).expect("encode must succeed");
bytes[0] = 0xFF;
let result = decode_graph(&bytes);
assert!(result.is_none(), "version mismatch should return None");
}
#[test]
fn test_decode_graph_empty_bytes() {
let result = decode_graph(&[]);
assert!(result.is_none(), "empty bytes should return None");
}
#[test]
fn test_encode_decode_excludes_modifies_edges() {
let mut graph = GraphDb::new();
let n1 = graph.add_node(super::super::Node::Function {
name: "foo".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
let n2 = graph.add_node(super::super::Node::Function {
name: "bar".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
graph.add_edge(n1, n2, Edge::Calls);
graph.add_edge(n1, n2, Edge::Modifies);
let bytes = encode_graph(&graph).expect("encode must succeed");
let decoded = decode_graph(&bytes).expect("should decode successfully");
assert_eq!(decoded.edge_count(), 1, "only Calls edge should remain");
let has_modifies = decoded
.edge_indices()
.any(|idx| matches!(decoded.edge_weight(idx), Some(Edge::Modifies)));
assert!(!has_modifies, "Modifies edges should be excluded");
}
#[test]
fn test_schema_hash_changes_on_variant_change() {
assert_ne!(schema_hash(), 0, "schema hash must be non-zero");
const MUTATED: &str = "File|Module|Function|Struct|Enum|Trait|Impl|Contains|Calls|Imports|Implements|HasMethod|Modifies|Tests|NewVariant";
let mut hash: u32 = 0x811c_9dc5;
for &b in MUTATED.as_bytes() {
hash ^= b as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
assert_ne!(
schema_hash(),
hash,
"schema_hash must differ when SCHEMA_STRING gains a new variant"
);
}
#[test]
fn test_persist_graph_concurrent_writes_no_corruption() {
let path =
std::env::temp_dir().join(format!("aptu_cache_concurrent_{}.bin", std::process::id()));
let _ = std::fs::remove_file(&path);
let p1 = path.clone();
let handle1 = std::thread::spawn(move || {
let mut g = GraphDb::new();
let n = g.add_node(super::super::Node::Function {
name: "one".to_string(),
path: "src/a.rs".to_string(),
visibility: "pub".to_string(),
});
g.add_edge(n, n, Edge::Calls);
persist_graph(&p1, &g);
});
let p2 = path.clone();
let handle2 = std::thread::spawn(move || {
let mut g = GraphDb::new();
let n = g.add_node(super::super::Node::Function {
name: "two".to_string(),
path: "src/b.rs".to_string(),
visibility: "pub".to_string(),
});
g.add_edge(n, n, Edge::Calls);
persist_graph(&p2, &g);
});
handle1.join().expect("thread 1 must not panic");
handle2.join().expect("thread 2 must not panic");
let bytes = std::fs::read(&path).expect("cache file must exist");
let decoded = decode_graph(&bytes).expect("cache file must decode without corruption");
assert_eq!(decoded.node_count(), 1, "decoded graph must have one node");
assert_eq!(decoded.edge_count(), 1, "decoded graph must have one edge");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_cache_path_format() {
let path = cache_path("owner", "repo", "abc123");
let path_str = path.to_string_lossy();
assert!(path_str.contains("owner"), "path should contain owner");
assert!(path_str.contains("repo"), "path should contain repo");
assert!(path_str.contains("abc123"), "path should contain sha");
assert!(path_str.ends_with(".bin"), "path should end with .bin");
}
}