Skip to main content

aptu_coder_core/graph/
store.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Disk-backed structural graph cache with versioned postcard encoding, fs2
4//! per-shard locking, and atomic writes via NamedTempFile::persist. All I/O
5//! errors degrade silently via tracing::warn!.
6
7use super::structural::StructuralGraph;
8use blake3;
9use fs2::FileExt;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use tempfile::NamedTempFile;
13use tracing::warn;
14const FORMAT_VERSION: u32 = 1;
15
16struct ShardLockGuard {
17    _file: std::fs::File,
18}
19/// `.lock` files are 0-byte advisory control files, never written to.
20/// Shard count is bounded at 256 by the 2-hex-char blake3 key prefix (`&key[..2]`).
21fn lock_shard_shared(shard_dir: &Path) -> Option<ShardLockGuard> {
22    let lock_path = shard_dir.join(".lock");
23    let file = std::fs::OpenOptions::new()
24        .create(true)
25        .write(true)
26        .truncate(false)
27        .open(&lock_path)
28        .ok()?;
29    file.lock_shared().map_err(|e| {
30        warn!(error = %e, lock_path = %lock_path.display(), "graph store: shared lock failed")
31    }).ok()?;
32    Some(ShardLockGuard { _file: file })
33}
34
35/// `.lock` files are 0-byte advisory control files, never written to.
36/// Shard count is bounded at 256 by the 2-hex-char blake3 key prefix (`&key[..2]`).
37fn lock_shard_exclusive(shard_dir: &Path) -> Result<ShardLockGuard, std::io::Error> {
38    let lock_path = shard_dir.join(".lock");
39    let file = std::fs::OpenOptions::new()
40        .create(true)
41        .write(true)
42        .truncate(false)
43        .open(&lock_path)?;
44    file.lock_exclusive()?;
45    Ok(ShardLockGuard { _file: file })
46}
47
48fn write_entry_atomically(dir: &Path, path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
49    let _lock = lock_shard_exclusive(dir)?;
50    let mut tmp = NamedTempFile::new_in(dir)?;
51    tmp.write_all(data)?;
52    tmp.persist(path).map(|_| ()).map_err(|e| e.error)
53}
54pub struct GraphDiskStore {
55    base_dir: PathBuf,
56}
57
58impl GraphDiskStore {
59    pub fn new(base_dir: PathBuf) -> Self {
60        if let Err(e) = std::fs::create_dir_all(&base_dir) {
61            warn!(path = %base_dir.display(), error = %e, "graph store: failed to create base dir");
62        }
63        GraphDiskStore { base_dir }
64    }
65
66    pub fn cache_key(root: &Path, file_mtimes: &[(PathBuf, u64)]) -> String {
67        let mut hasher = blake3::Hasher::new();
68        hasher.update(root.to_string_lossy().as_bytes());
69        let mut sorted: Vec<&(PathBuf, u64)> = file_mtimes.iter().collect();
70        sorted.sort_by(|a, b| a.0.cmp(&b.0));
71        for (path, mtime) in &sorted {
72            hasher.update(path.to_string_lossy().as_bytes());
73            hasher.update(&mtime.to_le_bytes());
74        }
75        hasher.finalize().to_string()
76    }
77
78    fn entry_path(&self, key: &str) -> PathBuf {
79        self.base_dir.join(&key[..2]).join(format!("{}.bin", key))
80    }
81    pub fn get(&self, key: &str) -> Option<StructuralGraph> {
82        let path = self.entry_path(key);
83        let dir = path.parent()?;
84        let _lock = lock_shard_shared(dir)?;
85        let data = std::fs::read(&path).ok()?;
86        if data.len() < 4 {
87            return None;
88        }
89        let (hdr, payload) = data.split_at(4);
90        if u32::from_le_bytes(<[u8; 4]>::try_from(hdr).ok()?) != FORMAT_VERSION {
91            warn!(key, "graph store: format version mismatch");
92            return None;
93        }
94        postcard::from_bytes(payload).ok()
95    }
96
97    pub fn put(&self, key: &str, graph: &StructuralGraph) {
98        let payload = match postcard::to_allocvec(graph) {
99            Ok(p) => p,
100            Err(e) => {
101                warn!(key, error = %e, "graph store: serialize failed");
102                return;
103            }
104        };
105        let mut data = Vec::with_capacity(4 + payload.len());
106        data.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
107        data.extend_from_slice(&payload);
108        let path = self.entry_path(key);
109        let Some(dir) = path.parent().map(|d| d.to_path_buf()) else {
110            return;
111        };
112        if let Err(e) = std::fs::create_dir_all(&dir) {
113            warn!(key, error = %e, "graph store: mkdir failed");
114            return;
115        }
116        if let Err(e) = write_entry_atomically(&dir, &path, &data) {
117            warn!(key, error = %e, "graph store: write failed");
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use tempfile::TempDir;
126
127    fn make_test_graph() -> StructuralGraph {
128        use crate::graph::structural::Node;
129        let mut g = petgraph::graph::DiGraph::new();
130        g.add_node(Node::File {
131            path: "t.rs".into(),
132        });
133        StructuralGraph(g)
134    }
135
136    #[test]
137    fn test_put_and_get_roundtrip() {
138        let tmp = TempDir::new().expect("temp dir");
139        let store = GraphDiskStore::new(tmp.path().to_path_buf());
140        let graph = make_test_graph();
141        store.put("key1", &graph);
142        let got = store.get("key1");
143        assert!(got.is_some());
144        assert_eq!(got.unwrap().0.node_count(), 1);
145    }
146
147    #[test]
148    fn test_get_version_mismatch_returns_none() {
149        let tmp = TempDir::new().expect("temp dir");
150        let store = GraphDiskStore::new(tmp.path().to_path_buf());
151        let key = "vm";
152        let dir = tmp.path().join(&key[..2]);
153        let path = dir.join(format!("{}.bin", key));
154        std::fs::create_dir_all(&dir).ok();
155        let mut data = 99u32.to_le_bytes().to_vec();
156        data.extend_from_slice(b"x");
157        std::fs::write(&path, &data).ok();
158        assert!(store.get(key).is_none());
159    }
160}