Skip to main content

lean_ctx/core/ocla/
compose_cache.rs

1//! Section-aware cache for `ctx_compose` results.
2
3use 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/// In-process composition cache. A record is valid only when every source file
17/// still has the digest used to build its `ComposedContextKey`.
18#[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}