1use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11use rayon::prelude::*;
12
13use crate::derived::{DerivedIndex, SCHEMA_VERSION};
14use crate::index_store::{
15 manifest_root_key, open_freshness_manifest, open_store, remove_store, store_dir,
16 write_freshness_manifest, write_store, FileState, MmapIndexReader,
17};
18use crate::walk::find_markdown_files;
19
20pub const CACHE_DIR_ENV: &str = "DECIDED_CACHE_DIR";
21
22pub fn cache_enabled(cache_flag: bool) -> bool {
26 cache_flag && std::env::var("DECIDED_NO_CACHE").unwrap_or_default().is_empty()
27}
28
29pub fn default_cache_dir() -> PathBuf {
33 if let Ok(dir) = std::env::var(CACHE_DIR_ENV) {
34 if !dir.is_empty() {
35 return PathBuf::from(dir);
36 }
37 }
38 let base = match std::env::var("XDG_CACHE_HOME") {
39 Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg),
40 _ => match std::env::var("HOME") {
41 Ok(home) if !home.is_empty() => Path::new(&home).join(".cache"),
42 _ => std::env::temp_dir().join("decided-cache"),
43 },
44 };
45 base.join("decided").join("derived")
46}
47
48fn stat_pair(path: &Path) -> Option<(u64, u64)> {
53 let meta = std::fs::metadata(path).ok()?;
54 let mtime_ns = meta
55 .modified()
56 .ok()?
57 .duration_since(std::time::UNIX_EPOCH)
58 .ok()?
59 .as_nanos() as u64;
60 Some((meta.len(), mtime_ns))
61}
62
63pub fn stat_scan(
66 root_str: &str,
67 prev_manifest: &[(String, FileState)],
68 content_confirm_all: bool,
69 recursive: bool,
70) -> (Vec<(String, FileState)>, BTreeSet<String>) {
71 let prev: std::collections::HashMap<&str, &FileState> = prev_manifest
72 .iter()
73 .map(|(rel, state)| (rel.as_str(), state))
74 .collect();
75 let discovery_started = crate::timing::start();
76 let entries = find_markdown_files(root_str, recursive);
77 crate::timing::emit_since(
78 "stat.discovery",
79 discovery_started,
80 &[("files", entries.len() as u64)],
81 );
82 let metadata_started = crate::timing::start();
86 let scan_entry = |entry: &crate::walk::WalkEntry| {
87 let rel = entry.components.join("/");
88 let Some((size, mtime_ns)) = stat_pair(&entry.abs) else {
89 return None; };
91 if !content_confirm_all {
92 if let Some(prev_state) = prev.get(rel.as_str()) {
93 if prev_state.size == size && prev_state.mtime_ns == mtime_ns {
94 return Some((rel, (*prev_state).clone(), false)); }
96 }
97 }
98 let digest = crate::index_store::content_hash(&entry.abs);
99 let changed_content = match prev.get(rel.as_str()) {
100 Some(prev_state) => prev_state.content_hash != digest,
101 None => true,
102 };
103 Some((
104 rel.clone(),
105 FileState {
106 content_hash: digest,
107 size,
108 mtime_ns,
109 },
110 changed_content,
111 ))
112 };
113 let scanned: Vec<Option<(String, FileState, bool)>> =
114 entries.par_iter().map(scan_entry).collect();
115 crate::timing::emit_since(
116 "stat.metadata",
117 metadata_started,
118 &[("files", entries.len() as u64)],
119 );
120 let mut changed: BTreeSet<String> = BTreeSet::new();
121 let mut new_manifest: Vec<(String, FileState)> = Vec::with_capacity(scanned.len());
122 for (rel, state, changed_content) in scanned.into_iter().flatten() {
123 if changed_content {
124 changed.insert(rel.clone());
125 }
126 new_manifest.push((rel, state));
127 }
128 let present: std::collections::HashSet<&str> =
129 new_manifest.iter().map(|(rel, _)| rel.as_str()).collect();
130 for (rel, _) in prev_manifest {
131 if !present.contains(rel.as_str()) {
132 changed.insert(rel.clone()); }
134 }
135 (new_manifest, changed)
136}
137
138pub fn corpus_hash_from_manifest(
140 root_str: &str,
141 manifest: &[(String, FileState)],
142 recursive: bool,
143) -> String {
144 let by_rel: std::collections::HashMap<&str, &FileState> = manifest
145 .iter()
146 .map(|(rel, state)| (rel.as_str(), state))
147 .collect();
148 let mut hasher = crate::sha256::Sha256::new();
149 for entry in find_markdown_files(root_str, recursive) {
150 let rel = entry.components.join("/");
151 let digest = match by_rel.get(rel.as_str()) {
152 Some(state) => state.content_hash.clone(),
153 None => crate::index_store::content_hash(&entry.abs),
154 };
155 hasher.update(rel.as_bytes());
156 hasher.update(b"\0");
157 hasher.update(digest.as_bytes());
158 hasher.update(b"\0");
159 }
160 hasher.hexdigest()
161}
162
163pub fn corpus_hash_from_complete_manifest(manifest: &[(String, FileState)]) -> String {
166 let mut hasher = crate::sha256::Sha256::new();
167 for (rel, state) in manifest {
168 hasher.update(rel.as_bytes());
169 hasher.update(b"\0");
170 hasher.update(state.content_hash.as_bytes());
171 hasher.update(b"\0");
172 }
173 hasher.hexdigest()
174}
175
176fn marker_path(cache_dir: &Path, corpus_hash: &str) -> PathBuf {
181 cache_dir.join(format!("{corpus_hash}.json"))
182}
183
184fn marker_valid(cache_dir: &Path, corpus_hash: &str) -> bool {
185 let Ok(text) = std::fs::read_to_string(marker_path(cache_dir, corpus_hash)) else {
186 return false;
187 };
188 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
189 return false;
190 };
191 value
192 .as_object()
193 .and_then(|obj| obj.get("schema_version"))
194 .and_then(|v| v.as_str())
195 == Some(SCHEMA_VERSION)
196}
197
198pub fn write_marker_public(cache_dir: &Path, corpus_hash: &str) -> bool {
201 write_marker(cache_dir, corpus_hash, true)
202}
203
204fn write_marker(cache_dir: &Path, corpus_hash: &str, store_written: bool) -> bool {
205 if !store_written {
206 return false;
207 }
208 if std::fs::create_dir_all(cache_dir).is_err() {
209 return false;
210 }
211 let payload =
213 format!("{{\"schema_version\": \"{SCHEMA_VERSION}\", \"corpus_hash\": \"{corpus_hash}\"}}");
214 let tmp = cache_dir.join(format!(
215 ".{corpus_hash}.{}.tmp",
216 std::process::id()
217 ));
218 if std::fs::write(&tmp, payload).is_err() {
219 let _ = std::fs::remove_file(&tmp);
220 return false;
221 }
222 if std::fs::rename(&tmp, marker_path(cache_dir, corpus_hash)).is_err() {
223 let _ = std::fs::remove_file(&tmp);
224 return false;
225 }
226 true
227}
228
229pub enum ReadModel {
237 View(MmapIndexReader),
238 Fresh(DerivedIndex),
239}
240
241pub struct DerivedIndexCache {
242 pub cache_dir: PathBuf,
243}
244
245impl Default for DerivedIndexCache {
246 fn default() -> Self {
247 Self {
248 cache_dir: default_cache_dir(),
249 }
250 }
251}
252
253impl DerivedIndexCache {
254 pub fn load_or_build(&self, directory: &str, recursive: bool, verify: bool) -> ReadModel {
255 let root_key = manifest_root_key(directory, recursive);
259 let prev = if verify {
260 None
261 } else {
262 open_freshness_manifest(&self.cache_dir, &root_key)
263 };
264 let manifest_missing = prev.is_none();
265 let confirm_all = verify || manifest_missing;
266 let prev_manifest = prev.unwrap_or_default();
267 let scan_started = crate::timing::start();
268 let (manifest, changed) = stat_scan(directory, &prev_manifest, confirm_all, recursive);
269 crate::timing::emit_since(
270 "cache.discovery_stat",
271 scan_started,
272 &[
273 ("files", manifest.len() as u64),
274 ("changed", changed.len() as u64),
275 ],
276 );
277 let hash_started = crate::timing::start();
278 let corpus_hash = corpus_hash_from_complete_manifest(&manifest);
279 crate::timing::emit_since(
280 "cache.corpus_hash",
281 hash_started,
282 &[("files", manifest.len() as u64)],
283 );
284 let manifest_started = crate::timing::start();
286 let manifest_dirty = manifest_missing || manifest != prev_manifest;
287 let manifest_written =
288 !manifest_dirty || write_freshness_manifest(&self.cache_dir, &root_key, &manifest);
289 crate::timing::emit_since(
290 "cache.manifest_write",
291 manifest_started,
292 &[
293 ("files", manifest.len() as u64),
294 ("dirty", u64::from(manifest_dirty)),
295 ("success", u64::from(manifest_written)),
296 ],
297 );
298 if marker_valid(&self.cache_dir, &corpus_hash) {
299 let open_started = crate::timing::start();
300 if let Some(view) = open_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION) {
301 crate::timing::emit_since(
302 "cache.store_open",
303 open_started,
304 &[("hit", 1), ("documents", u64::from(view.doc_count))],
305 );
306 return ReadModel::View(view);
307 }
308 crate::timing::emit_since("cache.store_open", open_started, &[("hit", 0)]);
309 remove_store(&self.cache_dir, &corpus_hash);
312 }
313 let build_started = crate::timing::start();
317 let (derived, mut stats) =
318 crate::parallel_build::build_derived_index_parallel(directory, recursive, None);
319 crate::timing::emit_since(
320 "cache.cold_build",
321 build_started,
322 &[("documents", derived.index_entries.len() as u64)],
323 );
324 let write_start = std::time::Instant::now();
325 let store_write_started = crate::timing::start();
326 let store_written = write_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION, &derived);
327 crate::timing::emit_since(
328 "cache.store_write",
329 store_write_started,
330 &[("written", u64::from(store_written))],
331 );
332 stats.write_ms = write_start.elapsed().as_secs_f64() * 1000.0;
333 crate::parallel_build::emit_build_timing(&stats);
334 if write_marker(&self.cache_dir, &corpus_hash, store_written) {
335 if let Some(view) = open_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION) {
336 return ReadModel::View(view);
337 }
338 }
339 ReadModel::Fresh(derived)
340 }
341
342 pub fn store_present(&self, corpus_hash: &str) -> bool {
344 store_dir(&self.cache_dir, corpus_hash).is_dir()
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn complete_manifest_hash_uses_scan_order_and_content_only() {
354 let manifest = vec![
355 (
356 "a.md".to_string(),
357 FileState {
358 content_hash: "hash-a".to_string(),
359 size: 10,
360 mtime_ns: 20,
361 },
362 ),
363 (
364 "nested/b.md".to_string(),
365 FileState {
366 content_hash: "hash-b".to_string(),
367 size: 30,
368 mtime_ns: 40,
369 },
370 ),
371 ];
372
373 assert_eq!(
374 corpus_hash_from_complete_manifest(&manifest),
375 crate::sha256::hexdigest(b"a.md\0hash-a\0nested/b.md\0hash-b\0")
376 );
377
378 let mut stat_only_change = manifest.clone();
379 stat_only_change[0].1.size += 1;
380 stat_only_change[0].1.mtime_ns += 1;
381 assert_eq!(
382 corpus_hash_from_complete_manifest(&manifest),
383 corpus_hash_from_complete_manifest(&stat_only_change)
384 );
385 }
386}