use std::path::PathBuf;
use super::{Edge, GraphDb};
use crate::config::GraphConfig;
const FORMAT_VERSION: u32 = 2;
#[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 strip_modifies_edges(graph: &GraphDb) -> GraphDb {
let mut filtered = graph.clone();
filtered.retain_edges(|g, edge_idx| !matches!(g.edge_weight(edge_idx), Some(Edge::Modifies)));
filtered
}
#[must_use]
pub fn encode_graph(graph: &GraphDb) -> Option<Vec<u8>> {
let stripped = strip_modifies_edges(graph);
let payload = postcard::to_allocvec(&stripped).ok()?;
let mut bytes = Vec::with_capacity(4 + payload.len());
bytes.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
bytes.extend_from_slice(&payload);
Some(bytes)
}
#[must_use]
pub fn decode_graph(bytes: &[u8]) -> Option<GraphDb> {
if bytes.len() < 4 {
return None;
}
let version = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if version != FORMAT_VERSION {
return None;
}
let graph: GraphDb = postcard::from_bytes(&bytes[4..]).ok()?;
Some(graph)
}
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn load_or_build(
owner: &str,
repo: &str,
sha: &str,
ast: &str,
call_graph: &str,
cfg: &GraphConfig,
) -> (GraphDb, bool) {
let path = cache_path(owner, repo, sha);
if let Ok(Some(cached)) = try_load_cached(&path, cfg) {
return (cached, true);
}
let mut graph = super::builder::parse_ast_context_string(ast);
super::builder::parse_call_graph_string(call_graph, &mut graph);
persist_graph(&path, &graph);
(graph, false)
}
#[cfg(target_arch = "wasm32")]
#[must_use]
pub fn load_or_build(
_owner: &str,
_repo: &str,
_sha: &str,
ast: &str,
call_graph: &str,
_cfg: &GraphConfig,
) -> (GraphDb, bool) {
let mut graph = super::builder::parse_ast_context_string(ast);
super::builder::parse_call_graph_string(call_graph, &mut graph);
(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 tmp_path = path.with_extension("tmp");
if let Err(e) = std::fs::write(&tmp_path, &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) = 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"
);
let _ = std::fs::remove_file(&tmp_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[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_strip_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 stripped = strip_modifies_edges(&graph);
let has_modifies = stripped
.edge_indices()
.any(|idx| matches!(stripped.edge_weight(idx), Some(Edge::Modifies)));
assert!(!has_modifies, "Modifies edges should be stripped");
assert_eq!(stripped.edge_count(), 1, "only Calls edge should remain");
}
#[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");
}
}