1use 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#[cfg(test)]
23pub fn root() -> Option<PathBuf> {
24 rootle_provider::paths::cache_dir().map(|d| d.join("rootle").join("providers").join("github"))
25}
26
27fn 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
42fn 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
49fn 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
66fn 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
103fn 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 root = root_or_migrate()?;
113 let path = blob_path(&root, sha);
114 let bytes = std::fs::read(&path).ok();
115 let hit = &bytes;
116 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
117 serde_json::json!({
118 "op": "blob_read",
119 "sha": sha,
120 "hit": hit.is_some(),
121 "bytes": hit.as_ref().map_or(0, |b| b.len()),
122 })
123 });
124 let bytes = bytes?;
125 touch(&path); Some(bytes)
127}
128
129pub fn write_blob(sha: &str, bytes: &[u8]) -> io::Result<()> {
130 let Some(root) = root_or_migrate() else {
131 return Ok(());
132 };
133 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
134 serde_json::json!({
135 "op": "blob_write",
136 "sha": sha,
137 "bytes": bytes.len(),
138 })
139 });
140 atomic_write(&blob_path(&root, sha), bytes)
141}
142
143pub fn cached_branch(owner: &str, repo: &str) -> Option<String> {
144 let branch = root_or_migrate().and_then(|root| {
145 let dir = root
146 .join("index/refs")
147 .join(encode_component(owner))
148 .join(encode_component(repo));
149 let found = std::fs::read_dir(dir)
151 .ok()?
152 .filter_map(|entry| entry.ok())
153 .find(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp"));
154 found.and_then(|entry| {
155 entry
156 .file_name()
157 .into_string()
158 .ok()
159 .map(|name| decode_component(&name))
160 })
161 });
162 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
163 serde_json::json!({
164 "op": "cached_branch",
165 "owner": owner,
166 "repo": repo,
167 "hit": branch.is_some(),
168 })
169 });
170 branch
171}
172
173pub fn read_ref(owner: &str, repo: &str, branch: &str) -> Option<RefCache> {
174 let text = root_or_migrate()
175 .and_then(|root| std::fs::read_to_string(ref_path(&root, owner, repo, branch)).ok());
176 let entry = text
177 .as_deref()
178 .and_then(|text| serde_json::from_str(text).ok());
179 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
180 serde_json::json!({
181 "op": "ref_read",
182 "owner": owner,
183 "repo": repo,
184 "branch": branch,
185 "hit": entry.is_some(),
186 "miss_reason": if entry.is_some() { None } else if text.is_some() { Some("corrupt") } else { Some("unavailable") },
187 })
188 });
189 entry
190}
191
192pub fn write_ref(owner: &str, repo: &str, branch: &str, entry: &RefCache) -> io::Result<()> {
193 let Some(root) = root_or_migrate() else {
194 return Ok(());
195 };
196 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
197 serde_json::json!({
198 "op": "ref_write",
199 "owner": owner,
200 "repo": repo,
201 "branch": branch,
202 })
203 });
204 atomic_write(
205 &ref_path(&root, owner, repo, branch),
206 serde_json::to_string(entry)?.as_bytes(),
207 )
208}
209
210pub fn read_tree(sha: &str) -> Option<TreeResponse> {
211 let Some(root) = root_or_migrate() else {
212 rootle_trace::record_with(
213 rootle_trace::EventKind::Cache,
214 || serde_json::json!({"op":"tree_read","sha":sha,"hit":false,"miss_reason":"no_cache_directory"}),
215 );
216 return None;
217 };
218 let path = tree_path(&root, sha);
219 let (tree, miss_reason) = match std::fs::read_to_string(&path) {
220 Ok(text) => match serde_json::from_str::<TreeResponse>(&text) {
221 Ok(tree) => (Some(tree), None),
222 Err(_) => (None, Some("corrupt")),
226 },
227 Err(e) => (
228 None,
229 Some(if e.kind() == io::ErrorKind::NotFound {
230 "absent"
231 } else {
232 "unreadable"
233 }),
234 ),
235 };
236 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
237 serde_json::json!({
238 "op": "tree_read",
239 "sha": sha,
240 "hit": tree.is_some(),
241 "miss_reason": miss_reason,
242 })
243 });
244 tree
245}
246
247pub fn write_tree(tree: &TreeResponse) -> io::Result<()> {
248 let Some(root) = root_or_migrate() else {
249 return Ok(());
250 };
251 let text = serde_json::to_string(tree)?;
252 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
253 serde_json::json!({
254 "op": "tree_write",
255 "sha": tree.sha,
256 "bytes": text.len(),
257 })
258 });
259 atomic_write(&tree_path(&root, &tree.sha), text.as_bytes())
260}
261
262fn atomic_write(path: &PathBuf, bytes: &[u8]) -> io::Result<()> {
264 if let Some(parent) = path.parent() {
265 std::fs::create_dir_all(parent)?;
266 }
267 let tmp = path.with_extension("tmp");
268 std::fs::write(&tmp, bytes)?;
269 std::fs::rename(&tmp, path)?;
270 Ok(())
271}
272
273pub fn harden(max_bytes: u64) {
284 let Some(root) = root_or_migrate() else {
285 return;
286 };
287 let (trees_removed, blobs_removed) = sweep_orphans(&root);
288 let (evicted, reclaimed) = evict_blobs(&root, max_bytes);
289 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
290 serde_json::json!({
291 "op": "sweep",
292 "trees_removed": trees_removed,
293 "blobs_removed": blobs_removed,
294 })
295 });
296 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
297 serde_json::json!({
298 "op": "evict",
299 "evicted": evicted,
300 "reclaimed_bytes": reclaimed,
301 "budget_bytes": max_bytes,
302 })
303 });
304}
305
306fn touch(path: &PathBuf) {
308 let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
309}
310
311fn walk_files(dir: &PathBuf, out: &mut Vec<PathBuf>) {
312 let Ok(entries) = std::fs::read_dir(dir) else {
313 return;
314 };
315 for entry in entries.flatten() {
316 let path = entry.path();
317 if path.is_dir() {
318 walk_files(&path, out);
319 } else {
320 out.push(path);
321 }
322 }
323}
324
325fn sweep_orphans(root: &Path) -> (usize, usize) {
327 let index = root.join("index").join("refs");
328 let trees_dir = root.join("trees");
329 let blobs_dir = root.join("blobs");
330
331 let mut trees_removed = 0usize;
332 let mut blobs_removed = 0usize;
333 let mut referenced_trees = std::collections::HashSet::new();
335 let mut ref_files = Vec::new();
336 walk_files(&index, &mut ref_files);
337 for file in ref_files {
338 if let Ok(text) = std::fs::read_to_string(&file)
339 && let Ok(entry) = serde_json::from_str::<RefCache>(&text)
340 {
341 referenced_trees.insert(entry.tree_sha);
342 }
343 }
344
345 let mut live_trees = Vec::new();
347 let mut tree_files = Vec::new();
348 walk_files(&trees_dir, &mut tree_files);
349 for file in tree_files {
350 let sha = file
351 .file_stem()
352 .unwrap_or_default()
353 .to_string_lossy()
354 .into_owned();
355 if referenced_trees.contains(&sha) {
356 live_trees.push(file);
357 } else {
358 trees_removed += std::fs::remove_file(&file).is_ok() as usize;
359 }
360 }
361
362 let mut referenced_blobs = std::collections::HashSet::new();
364 for file in &live_trees {
365 if let Ok(text) = std::fs::read_to_string(file)
366 && let Ok(tree) = serde_json::from_str::<TreeResponse>(&text)
367 {
368 referenced_blobs.extend(
369 tree.tree
370 .iter()
371 .filter(|e| e.kind == "blob")
372 .map(|e| e.sha.clone()),
373 );
374 }
375 }
376
377 let mut blob_files = Vec::new();
379 walk_files(&blobs_dir, &mut blob_files);
380 for file in blob_files {
381 let sha = format!(
382 "{}{}",
383 file.parent()
384 .and_then(|p| p.file_name())
385 .unwrap_or_default()
386 .to_string_lossy(),
387 file.file_name().unwrap_or_default().to_string_lossy()
388 );
389 if !referenced_blobs.contains(&sha) {
390 blobs_removed += std::fs::remove_file(&file).is_ok() as usize;
391 }
392 }
393 (trees_removed, blobs_removed)
394}
395
396fn evict_blobs(root: &Path, max_bytes: u64) -> (usize, u64) {
398 let blobs_dir = root.join("blobs");
399 let mut files = Vec::new();
400 walk_files(&blobs_dir, &mut files);
401
402 let mut entries: Vec<(PathBuf, u64, i64)> = files
403 .into_iter()
404 .filter_map(|f| {
405 let meta = std::fs::metadata(&f).ok()?;
406 Some((f, meta.len(), meta.mtime()))
407 })
408 .collect();
409 let mut total: u64 = entries.iter().map(|(_, size, _)| size).sum();
410 if total <= max_bytes {
411 return (0, 0);
412 }
413
414 entries.sort_by_key(|(_, _, mtime)| *mtime);
416 let mut evicted = 0usize;
417 let mut reclaimed = 0u64;
418 for (path, size, _) in entries {
419 if total <= max_bytes {
420 break;
421 }
422 if std::fs::remove_file(&path).is_ok() {
423 total -= size;
424 evicted += 1;
425 reclaimed += size;
426 }
427 }
428 (evicted, reclaimed)
429}
430
431#[cfg(test)]
432mod tests;