lean_ctx/core/ocla/
compose_cache.rs1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use crate::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
8
9#[derive(Clone, Debug)]
10struct ComposeRecord {
11 source_paths: Vec<PathBuf>,
12 source_digests: Vec<String>,
13 text: String,
14}
15
16#[derive(Default)]
19pub struct ComposeSectionCache {
20 records: Mutex<BTreeMap<(String, String), ComposeRecord>>,
21}
22
23impl ComposeSectionCache {
24 pub fn check(&self, task: &str, path: &str) -> Option<String> {
25 let key = (task.trim().to_string(), path.to_string());
26 let record = self.records.lock().ok()?.get(&key)?.clone();
27 let source_digests = source_digests(&record.source_paths)?;
28 let builder = ComposedContextKey {
29 task: key.0,
30 path: key.1,
31 source_digests,
32 };
33 (builder.source_digests == record.source_digests).then_some(record.text)
34 }
35
36 pub fn record(&self, task: &str, path: &str, text: String) {
37 let source_paths = source_paths(path, &text);
38 let Some(source_digests) = source_digests(&source_paths) else {
39 return;
40 };
41 let builder = ComposedContextKey {
42 task: task.trim().to_string(),
43 path: path.to_string(),
44 source_digests: source_digests.clone(),
45 };
46 let _cache_key = builder.cache_key();
47 let key = (builder.task, builder.path);
48 if let Ok(mut records) = self.records.lock() {
49 records.insert(
50 key,
51 ComposeRecord {
52 source_paths,
53 source_digests,
54 text,
55 },
56 );
57 }
58 }
59}
60
61pub fn global() -> &'static ComposeSectionCache {
62 static CACHE: OnceLock<ComposeSectionCache> = OnceLock::new();
63 CACHE.get_or_init(ComposeSectionCache::default)
64}
65
66fn source_paths(project_root: &str, text: &str) -> Vec<PathBuf> {
67 let root = Path::new(project_root);
68 let mut paths = text
69 .lines()
70 .filter_map(|line| line.trim().strip_prefix("File: "))
71 .filter_map(|raw| raw.split_whitespace().next())
72 .map(|raw| {
73 let path = PathBuf::from(raw);
74 if path.is_absolute() {
75 path
76 } else {
77 root.join(path)
78 }
79 })
80 .filter(|path| path.is_file())
81 .collect::<Vec<_>>();
82 paths.sort();
83 paths.dedup();
84 paths
85}
86
87fn source_digests(paths: &[PathBuf]) -> Option<Vec<String>> {
88 let mut digests = paths
89 .iter()
90 .map(|path| {
91 std::fs::read(path)
92 .ok()
93 .map(|bytes| blake3::hash(&bytes).to_hex().to_string())
94 })
95 .collect::<Option<Vec<_>>>()?;
96 digests.sort();
97 Some(digests)
98}
99
100#[cfg(test)]
101mod tests {
102 use super::ComposeSectionCache;
103
104 #[test]
105 fn section_cache_hits_only_while_all_sources_match() {
106 let dir = tempfile::tempdir().unwrap();
107 let first = dir.path().join("first.rs");
108 let second = dir.path().join("second.rs");
109 std::fs::write(&first, "one").unwrap();
110 std::fs::write(&second, "two").unwrap();
111 let root = dir.path().to_string_lossy();
112 let text = "File: first.rs\nbody one\nFile: second.rs\nbody two".to_string();
113 let cache = ComposeSectionCache::default();
114 cache.record("task", &root, text.clone());
115 assert_eq!(cache.check("task", &root), Some(text));
116 std::fs::write(&second, "changed").unwrap();
117 assert_eq!(cache.check("task", &root), None);
118 }
119}