lean_ctx/core/
content_handle.rs1use std::collections::HashMap;
11use std::sync::{Mutex, PoisonError};
12use std::time::SystemTime;
13
14static HANDLES: Mutex<Option<HandleStore>> = Mutex::new(None);
15
16pub fn global() -> std::sync::MutexGuard<'static, Option<HandleStore>> {
18 HANDLES.lock().unwrap_or_else(PoisonError::into_inner)
19}
20
21#[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 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 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#[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 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 pub fn get(&self, handle_id: &str) -> Option<&ContentHandle> {
97 self.handles.get(handle_id)
98 }
99
100 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}