Skip to main content

rootle_github/
cache.rs

1//! Content-addressable disk cache for the GitHub provider, under
2//! ~/.cache/rootle/providers/github (plans/0005: each provider owns its
3//! cache subtree; the TUI-level edit/ scratch stays at ~/.cache/rootle).
4//! Trees are immutable (sha-keyed) — never invalidated, only evicted.
5//! Ref mappings (owner/repo/branch → tree_sha + etag) are mutable and
6//! revalidated with If-None-Match on every open.
7
8use crate::types::TreeResponse;
9use serde::{Deserialize, Serialize};
10use std::io;
11use std::os::unix::fs::MetadataExt;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RefCache {
16    pub tree_sha: String,
17    pub etag: Option<String>,
18}
19
20/// The provider's cache subtree. Other providers must use their own
21/// (`~/.cache/rootle/providers/<name>`) — see doc/provider-protocol.md.
22#[cfg(test)]
23pub fn root() -> Option<PathBuf> {
24    rootle_provider::paths::cache_dir().map(|d| d.join("rootle").join("providers").join("github"))
25}
26
27/// One-time move from the pre-provider layout (~/.cache/rootle/{trees,
28/// blobs,index}) into this provider's subtree. Best-effort: partial
29/// moves just mean re-fetches.
30fn migrate_from_legacy(base: &Path) {
31    let mine = base.join("providers").join("github");
32    for dir in ["trees", "blobs", "index"] {
33        let old = base.join(dir);
34        let new = mine.join(dir);
35        if old.exists() && !new.exists() {
36            let _ = std::fs::create_dir_all(&mine);
37            let _ = std::fs::rename(&old, &new);
38        }
39    }
40}
41
42/// Resolve the cache root, migrating the legacy layout once.
43fn root_or_migrate() -> Option<PathBuf> {
44    let base = rootle_provider::paths::cache_dir().map(|d| d.join("rootle"))?;
45    migrate_from_legacy(&base);
46    Some(base.join("providers").join("github"))
47}
48
49/// Path components (owner, repo, branch, sha) come from API responses
50/// and are not trusted to be well-formed: percent-encode everything
51/// outside [A-Za-z0-9_-] so a `feature/foo` branch (a legitimate
52/// name) stays one path segment and separators / `..` can never
53/// become path structure. Dots encode too — a literal `..` must not
54/// survive as a component.
55fn encode_component(s: &str) -> String {
56    let mut out = String::with_capacity(s.len());
57    for b in s.bytes() {
58        match b {
59            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' => out.push(b as char),
60            _ => out.push_str(&format!("%{b:02X}")),
61        }
62    }
63    out
64}
65
66/// Inverse of `encode_component`; invalid sequences pass through —
67/// they can only come from files this module wrote.
68fn decode_component(s: &str) -> String {
69    let b = s.as_bytes();
70    let mut out = Vec::with_capacity(b.len());
71    let mut i = 0;
72    while i < b.len() {
73        if b[i] == b'%' && i + 2 < b.len() {
74            let hex = |c: u8| {
75                (c as char)
76                    .is_ascii_hexdigit()
77                    .then(|| (c as char).to_digit(16).unwrap() as u8)
78            };
79            if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
80                out.push((h << 4) | l);
81                i += 3;
82                continue;
83            }
84        }
85        out.push(b[i]);
86        i += 1;
87    }
88    String::from_utf8_lossy(&out).into_owned()
89}
90
91fn ref_path(root: &std::path::Path, owner: &str, repo: &str, branch: &str) -> PathBuf {
92    root.join("index/refs")
93        .join(encode_component(owner))
94        .join(encode_component(repo))
95        .join(encode_component(branch))
96}
97
98fn tree_path(root: &std::path::Path, sha: &str) -> PathBuf {
99    root.join("trees")
100        .join(format!("{}.json", encode_component(sha)))
101}
102
103/// Blobs fan out by the sha's first 2 chars: blobs/<ab>/<rest>. The
104/// encoded sha is pure ASCII, so the byte slices can't split a char.
105fn blob_path(root: &std::path::Path, sha: &str) -> PathBuf {
106    let sha = encode_component(sha);
107    let split = 2.min(sha.len());
108    root.join("blobs").join(&sha[..split]).join(&sha[split..])
109}
110
111pub fn read_blob(sha: &str) -> Option<Vec<u8>> {
112    let path = blob_path(&root_or_migrate()?, sha);
113    let bytes = std::fs::read(&path).ok()?;
114    touch(&path); // mtime = last-used, drives LRU eviction
115    Some(bytes)
116}
117
118pub fn write_blob(sha: &str, bytes: &[u8]) -> io::Result<()> {
119    let Some(root) = root_or_migrate() else {
120        return Ok(());
121    };
122    atomic_write(&blob_path(&root, sha), bytes)
123}
124
125pub fn cached_branch(owner: &str, repo: &str) -> Option<String> {
126    let dir = root_or_migrate()?
127        .join("index/refs")
128        .join(encode_component(owner))
129        .join(encode_component(repo));
130    // First cached ref — skips any .tmp left by an interrupted write.
131    // Entry names are encoded (a `feature/foo` branch is one entry);
132    // decode back to the real branch name.
133    let entry = std::fs::read_dir(dir)
134        .ok()?
135        .filter_map(|e| e.ok())
136        .find(|e| !e.file_name().to_string_lossy().ends_with(".tmp"))?;
137    Some(decode_component(&entry.file_name().into_string().ok()?))
138}
139
140pub fn read_ref(owner: &str, repo: &str, branch: &str) -> Option<RefCache> {
141    let path = ref_path(&root_or_migrate()?, owner, repo, branch);
142    let text = std::fs::read_to_string(path).ok()?;
143    serde_json::from_str(&text).ok()
144}
145
146pub fn write_ref(owner: &str, repo: &str, branch: &str, entry: &RefCache) -> io::Result<()> {
147    let Some(root) = root_or_migrate() else {
148        return Ok(());
149    };
150    atomic_write(
151        &ref_path(&root, owner, repo, branch),
152        serde_json::to_string(entry)?.as_bytes(),
153    )
154}
155
156pub fn read_tree(sha: &str) -> Option<TreeResponse> {
157    let path = tree_path(&root_or_migrate()?, sha);
158    let text = std::fs::read_to_string(path).ok()?;
159    serde_json::from_str(&text).ok()
160}
161
162pub fn write_tree(tree: &TreeResponse) -> io::Result<()> {
163    let Some(root) = root_or_migrate() else {
164        return Ok(());
165    };
166    atomic_write(
167        &tree_path(&root, &tree.sha),
168        serde_json::to_string(tree)?.as_bytes(),
169    )
170}
171
172/// tmp + rename — a kill mid-write never yields a corrupt entry.
173fn atomic_write(path: &PathBuf, bytes: &[u8]) -> io::Result<()> {
174    if let Some(parent) = path.parent() {
175        std::fs::create_dir_all(parent)?;
176    }
177    let tmp = path.with_extension("tmp");
178    std::fs::write(&tmp, bytes)?;
179    std::fs::rename(&tmp, path)?;
180    Ok(())
181}
182
183// ---------------------------------------------------------------------------
184// Hardening (PLAN.md §8 / milestone 7)
185//
186// LRU: file mtime is last-used — `read_blob` touches it, so eviction
187// needs no index file. Sweep: a tree not referenced by any cached ref,
188// and a blob not referenced by any cached tree, are orphans.
189// ---------------------------------------------------------------------------
190
191/// Startup hardening: sweep orphans, then evict least-recently-used
192/// blobs while total size exceeds `max_bytes`.
193pub fn harden(max_bytes: u64) {
194    let Some(root) = root_or_migrate() else {
195        return;
196    };
197    sweep_orphans(&root);
198    evict_blobs(&root, max_bytes);
199}
200
201/// Best-effort mtime bump so eviction sees the blob as recently used.
202fn touch(path: &PathBuf) {
203    let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
204}
205
206fn walk_files(dir: &PathBuf, out: &mut Vec<PathBuf>) {
207    let Ok(entries) = std::fs::read_dir(dir) else {
208        return;
209    };
210    for entry in entries.flatten() {
211        let path = entry.path();
212        if path.is_dir() {
213            walk_files(&path, out);
214        } else {
215            out.push(path);
216        }
217    }
218}
219
220fn sweep_orphans(root: &Path) {
221    let index = root.join("index").join("refs");
222    let trees_dir = root.join("trees");
223    let blobs_dir = root.join("blobs");
224
225    // Tree shas referenced by at least one cached ref.
226    let mut referenced_trees = std::collections::HashSet::new();
227    let mut ref_files = Vec::new();
228    walk_files(&index, &mut ref_files);
229    for file in ref_files {
230        if let Ok(text) = std::fs::read_to_string(&file)
231            && let Ok(entry) = serde_json::from_str::<RefCache>(&text)
232        {
233            referenced_trees.insert(entry.tree_sha);
234        }
235    }
236
237    // Delete unreferenced trees; keep the referenced ones' filenames.
238    let mut live_trees = Vec::new();
239    let mut tree_files = Vec::new();
240    walk_files(&trees_dir, &mut tree_files);
241    for file in tree_files {
242        let sha = file
243            .file_stem()
244            .unwrap_or_default()
245            .to_string_lossy()
246            .into_owned();
247        if referenced_trees.contains(&sha) {
248            live_trees.push(file);
249        } else {
250            let _ = std::fs::remove_file(&file);
251        }
252    }
253
254    // Blob shas referenced by a live tree.
255    let mut referenced_blobs = std::collections::HashSet::new();
256    for file in &live_trees {
257        if let Ok(text) = std::fs::read_to_string(file)
258            && let Ok(tree) = serde_json::from_str::<TreeResponse>(&text)
259        {
260            referenced_blobs.extend(
261                tree.tree
262                    .iter()
263                    .filter(|e| e.kind == "blob")
264                    .map(|e| e.sha.clone()),
265            );
266        }
267    }
268
269    // Delete unreferenced blobs (sha = <fanout>/<rest>).
270    let mut blob_files = Vec::new();
271    walk_files(&blobs_dir, &mut blob_files);
272    for file in blob_files {
273        let sha = format!(
274            "{}{}",
275            file.parent()
276                .and_then(|p| p.file_name())
277                .unwrap_or_default()
278                .to_string_lossy(),
279            file.file_name().unwrap_or_default().to_string_lossy()
280        );
281        if !referenced_blobs.contains(&sha) {
282            let _ = std::fs::remove_file(&file);
283        }
284    }
285}
286
287fn evict_blobs(root: &Path, max_bytes: u64) {
288    let blobs_dir = root.join("blobs");
289    let mut files = Vec::new();
290    walk_files(&blobs_dir, &mut files);
291
292    let mut entries: Vec<(PathBuf, u64, i64)> = files
293        .into_iter()
294        .filter_map(|f| {
295            let meta = std::fs::metadata(&f).ok()?;
296            Some((f, meta.len(), meta.mtime()))
297        })
298        .collect();
299    let mut total: u64 = entries.iter().map(|(_, size, _)| size).sum();
300    if total <= max_bytes {
301        return;
302    }
303
304    // Oldest (least-recently-used) first.
305    entries.sort_by_key(|(_, _, mtime)| *mtime);
306    for (path, size, _) in entries {
307        if total <= max_bytes {
308            break;
309        }
310        if std::fs::remove_file(&path).is_ok() {
311            total -= size;
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests;