1use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::ledger::Record;
12use crate::model::{Edge, Symbol};
13
14pub const DEFAULT_TTL: u64 = 300;
16
17#[derive(Debug, Default, Serialize, Deserialize)]
18struct Meta {
19 versions: Option<(u64, u64)>,
21}
22
23pub use crate::storage::is_remote;
24
25pub fn dir(index_root: &str) -> Option<PathBuf> {
27 let home = std::env::var_os("HOME")?;
28 let key = blake3::hash(index_root.as_bytes()).to_hex();
29 Some(
30 PathBuf::from(home)
31 .join(".cache/comindly/remote")
32 .join(&key[..16]),
33 )
34}
35
36pub fn read(cache: &Path) -> Option<(Vec<Symbol>, Vec<Edge>, Vec<Record>)> {
38 let graph = cache.join("_graph");
39 if !graph.is_dir() {
40 return None;
41 }
42 let (symbols, edges) = crate::index::read_graph_blocking(graph.to_str()?).ok()?;
43 let records = std::fs::read(cache.join("ledger.json"))
44 .ok()
45 .and_then(|b| serde_json::from_slice(&b).ok())
46 .unwrap_or_default();
47 Some((symbols, edges, records))
48}
49
50pub fn stale(cache: &Path, ttl: Duration) -> bool {
52 std::fs::metadata(cache.join("checked"))
53 .and_then(|m| m.modified())
54 .ok()
55 .and_then(|t| SystemTime::now().duration_since(t).ok())
56 .is_none_or(|age| age > ttl)
57}
58
59pub fn invalidate(index_root: &str) {
61 if let Some(d) = dir(index_root) {
62 let _ = std::fs::remove_file(d.join("checked"));
63 }
64}
65
66pub fn refresh(index_root: &str, cache: &Path) -> Result<bool> {
69 std::fs::create_dir_all(cache)?;
70 let lock = cache.join("refresh.lock");
71 let held = std::fs::metadata(&lock)
73 .and_then(|m| m.modified())
74 .ok()
75 .and_then(|t| SystemTime::now().duration_since(t).ok())
76 .is_some_and(|age| age < Duration::from_secs(600));
77 if held {
78 return Ok(false);
79 }
80 std::fs::write(&lock, b"")?;
81 let out = sync(index_root, cache);
82 let _ = std::fs::remove_file(&lock);
83 out
84}
85
86fn sync(index_root: &str, cache: &Path) -> Result<bool> {
87 let remote_graph = format!("{}/_graph", index_root.trim_end_matches('/'));
88 let meta_path = cache.join("meta.json");
89 let mut meta: Meta = std::fs::read(&meta_path)
90 .ok()
91 .and_then(|b| serde_json::from_slice(&b).ok())
92 .unwrap_or_default();
93 let now = crate::index::latest_versions_blocking(&remote_graph).context("remote index")?;
94 let copied = meta.versions != Some(now) || !cache.join("_graph").is_dir();
95 if copied {
96 let (symbols, edges) = crate::index::read_graph_blocking(&remote_graph)?;
97 let tmp = cache.join("_graph.new");
98 let _ = std::fs::remove_dir_all(&tmp);
99 crate::index::write_graph_blocking(tmp.to_str().context("cache path")?, &symbols, &edges)?;
100 let _ = std::fs::remove_dir_all(cache.join("_graph"));
101 std::fs::rename(&tmp, cache.join("_graph"))?;
102 meta.versions = Some(now);
103 }
104 let records = crate::ledger::all_blocking(index_root)?;
105 let tmp = cache.join("ledger.json.new");
106 std::fs::write(&tmp, serde_json::to_vec(&records)?)?;
107 std::fs::rename(&tmp, cache.join("ledger.json"))?;
108 std::fs::write(&meta_path, serde_json::to_vec(&meta)?)?;
109 std::fs::write(cache.join("checked"), b"")?;
110 Ok(copied)
111}
112
113pub fn refresh_in_background(index_root: &str) {
115 let Ok(exe) = std::env::current_exe() else {
116 return;
117 };
118 let _ = std::process::Command::new(exe)
119 .args(["cache", "refresh", "--index-dir", index_root])
120 .stdin(std::process::Stdio::null())
121 .stdout(std::process::Stdio::null())
122 .stderr(std::process::Stdio::null())
123 .spawn();
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn mirror_follows_the_remote() {
132 let base = std::env::temp_dir().join(format!("comindly-cache-{}", std::process::id()));
133 let remote = base.join("remote");
134 let cache = base.join("cache");
135 let root = remote.to_str().unwrap();
136 let sym = |name: &str| crate::model::Symbol {
137 id: crate::model::GlobalSymbolId {
138 scheme: "t".into(),
139 package_manager: ".".into(),
140 package: "r".into(),
141 version: ".".into(),
142 descriptor: format!("m/{name}()."),
143 },
144 name: name.into(),
145 kind: crate::model::SymbolKind::Function,
146 language: crate::model::Language::Python,
147 repo: crate::model::RepoId("r".into()),
148 file_path: "m.py".into(),
149 range: crate::model::Range {
150 start: crate::model::Position { line: 1, column: 0 },
151 end: crate::model::Position { line: 2, column: 0 },
152 },
153 signature: None,
154 docstring: None,
155 fingerprint: None,
156 };
157 let graph = format!("{root}/_graph");
158 crate::index::write_graph_blocking(&graph, &[sym("a")], &[]).unwrap();
159 assert!(stale(&cache, Duration::from_secs(300)));
160 assert!(refresh(root, &cache).unwrap(), "first sync copies");
161 assert!(!stale(&cache, Duration::from_secs(300)));
162 assert!(
163 !refresh(root, &cache).unwrap(),
164 "unchanged remote is not copied again"
165 );
166 crate::index::write_graph_blocking(&graph, &[sym("a"), sym("b")], &[]).unwrap();
167 assert!(
168 refresh(root, &cache).unwrap(),
169 "a new remote version is copied"
170 );
171 let (symbols, _, records) = read(&cache).unwrap();
172 assert_eq!(symbols.len(), 2);
173 assert!(records.is_empty());
174 std::fs::remove_dir_all(&base).ok();
175 }
176}