Skip to main content

lean_ctx/core/
content_handle.rs

1//! Content-addressed handles for zero-cost re-reads (#1315).
2//!
3//! Instead of re-delivering file content, return a handle that references
4//! the already-delivered content. The handle includes a staleness check
5//! so the agent can verify the content hasn't changed.
6//!
7//! Based on CCF (arXiv 2509.09199): content-addressed hierarchical
8//! representations avoid re-processing.
9
10use std::collections::HashMap;
11use std::sync::{Mutex, PoisonError};
12use std::time::SystemTime;
13
14static HANDLES: Mutex<Option<HandleStore>> = Mutex::new(None);
15
16/// Access the global handle store.
17pub fn global() -> std::sync::MutexGuard<'static, Option<HandleStore>> {
18    HANDLES.lock().unwrap_or_else(PoisonError::into_inner)
19}
20
21/// A content handle that references previously-delivered content.
22#[derive(Debug, Clone)]
23pub struct ContentHandle {
24    pub hash: String,
25    pub path: String,
26    pub line_count: usize,
27    pub token_count: usize,
28    pub stored_mtime: Option<SystemTime>,
29}
30
31impl ContentHandle {
32    /// Check if the referenced content is still fresh.
33    pub fn is_fresh(&self) -> bool {
34        let Some(stored) = self.stored_mtime else {
35            return false;
36        };
37        std::fs::metadata(&self.path)
38            .ok()
39            .and_then(|m| m.modified().ok())
40            .is_some_and(|current| current == stored)
41    }
42
43    /// Format as a compact reference for the agent.
44    pub fn format_reference(&self) -> String {
45        let status = if self.is_fresh() { "fresh" } else { "stale" };
46        format!(
47            "[handle:{} {} {}L/{}tok {}]",
48            &self.hash[..8.min(self.hash.len())],
49            self.path,
50            self.line_count,
51            self.token_count,
52            status
53        )
54    }
55}
56
57/// Session-scoped store of content handles.
58#[derive(Debug, Clone, Default)]
59pub struct HandleStore {
60    handles: HashMap<String, ContentHandle>,
61}
62
63impl HandleStore {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Create and store a handle for delivered content.
69    pub fn create_handle(
70        &mut self,
71        path: &str,
72        content: &str,
73        line_count: usize,
74        token_count: usize,
75    ) -> String {
76        let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
77        let short_hash = hash[..12].to_string();
78
79        let stored_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
80
81        self.handles.insert(
82            short_hash.clone(),
83            ContentHandle {
84                hash: short_hash.clone(),
85                path: path.to_string(),
86                line_count,
87                token_count,
88                stored_mtime,
89            },
90        );
91
92        short_hash
93    }
94
95    /// Look up a handle.
96    pub fn get(&self, handle_id: &str) -> Option<&ContentHandle> {
97        self.handles.get(handle_id)
98    }
99
100    /// Invalidate handles for a modified file.
101    pub fn invalidate(&mut self, path: &str) {
102        self.handles.retain(|_, h| h.path != path);
103    }
104
105    pub fn handle_count(&self) -> usize {
106        self.handles.len()
107    }
108
109    pub fn reset(&mut self) {
110        self.handles.clear();
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn create_and_lookup_handle() {
120        let mut store = HandleStore::new();
121        let id = store.create_handle("/tmp/test.rs", "fn main() {}", 1, 5);
122        assert_eq!(id.len(), 12);
123        let handle = store.get(&id).unwrap();
124        assert_eq!(handle.path, "/tmp/test.rs");
125        assert_eq!(handle.line_count, 1);
126        assert_eq!(handle.token_count, 5);
127    }
128
129    #[test]
130    fn same_content_same_handle() {
131        let mut store = HandleStore::new();
132        let id1 = store.create_handle("/a.rs", "content", 1, 3);
133        let id2 = store.create_handle("/b.rs", "content", 1, 3);
134        assert_eq!(id1, id2, "same content → same hash handle");
135    }
136
137    #[test]
138    fn invalidate_removes_file_handles() {
139        let mut store = HandleStore::new();
140        store.create_handle("/a.rs", "aaa", 1, 3);
141        store.create_handle("/b.rs", "bbb", 1, 3);
142        assert_eq!(store.handle_count(), 2);
143        store.invalidate("/a.rs");
144        assert_eq!(store.handle_count(), 1);
145    }
146
147    #[test]
148    fn format_reference_includes_key_info() {
149        let handle = ContentHandle {
150            hash: "abcdef123456".to_string(),
151            path: "src/lib.rs".to_string(),
152            line_count: 100,
153            token_count: 400,
154            stored_mtime: None,
155        };
156        let ref_str = handle.format_reference();
157        assert!(ref_str.contains("abcdef12"));
158        assert!(ref_str.contains("src/lib.rs"));
159        assert!(ref_str.contains("100L"));
160        assert!(ref_str.contains("400tok"));
161        assert!(ref_str.contains("stale"));
162    }
163}