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 path = blob_path(&root_or_migrate()?, sha);
113 let bytes = std::fs::read(&path).ok()?;
114 touch(&path); 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 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
172fn 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
183pub 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
201fn 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 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 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 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 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 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;