1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::PathBuf;
4
5use crate::translation::TranslationRequest;
6
7pub struct CacheManager {
32 db_path: PathBuf,
33}
34
35impl CacheManager {
36 pub fn new() -> Result<Self> {
40 let cache_dir = dirs::cache_dir()
41 .context("Failed to determine cache directory")?
42 .join("tl");
43
44 std::fs::create_dir_all(&cache_dir).with_context(|| {
45 format!("Failed to create cache directory: {}", cache_dir.display())
46 })?;
47
48 let db_path = cache_dir.join("translations.db");
49 let manager = Self { db_path };
50
51 manager.init_db()?;
52
53 Ok(manager)
54 }
55
56 fn init_db(&self) -> Result<()> {
57 let conn = self.connect()?;
58
59 conn.execute(
60 "CREATE TABLE IF NOT EXISTS translations (
61 id INTEGER PRIMARY KEY AUTOINCREMENT,
62 cache_key TEXT UNIQUE NOT NULL,
63 source_text TEXT NOT NULL,
64 translated_text TEXT NOT NULL,
65 target_language TEXT NOT NULL,
66 model TEXT NOT NULL,
67 endpoint TEXT NOT NULL,
68 prompt_hash TEXT NOT NULL,
69 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
70 accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
71 )",
72 [],
73 )
74 .context("Failed to create translations table")?;
75
76 conn.execute(
77 "CREATE INDEX IF NOT EXISTS idx_cache_key ON translations(cache_key)",
78 [],
79 )
80 .context("Failed to create index")?;
81
82 Ok(())
83 }
84
85 fn connect(&self) -> Result<Connection> {
86 Connection::open(&self.db_path)
87 .with_context(|| format!("Failed to open cache database: {}", self.db_path.display()))
88 }
89
90 pub fn get(&self, request: &TranslationRequest) -> Result<Option<String>> {
95 let cache_key = request.cache_key();
96 let conn = self.connect()?;
97
98 let mut stmt =
99 conn.prepare("SELECT translated_text FROM translations WHERE cache_key = ?1")?;
100
101 let result: Option<String> = stmt.query_row([&cache_key], |row| row.get(0)).ok();
102
103 if result.is_some() {
104 conn.execute(
105 "UPDATE translations SET accessed_at = CURRENT_TIMESTAMP WHERE cache_key = ?1",
106 [&cache_key],
107 )?;
108 }
109
110 Ok(result)
111 }
112
113 pub fn put(&self, request: &TranslationRequest, translated_text: &str) -> Result<()> {
117 let cache_key = request.cache_key();
118 let prompt_hash = TranslationRequest::prompt_hash();
119 let conn = self.connect()?;
120
121 conn.execute(
122 "INSERT OR REPLACE INTO translations
123 (cache_key, source_text, translated_text, target_language, model, endpoint, prompt_hash)
124 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
125 [
126 &cache_key,
127 &request.source_text,
128 translated_text,
129 &request.target_language,
130 &request.model,
131 &request.endpoint,
132 &prompt_hash,
133 ],
134 )
135 .context("Failed to insert translation into cache")?;
136
137 Ok(())
138 }
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used)]
143mod tests {
144 use super::*;
145 use tempfile::TempDir;
146
147 fn create_test_manager(temp_dir: &TempDir) -> CacheManager {
148 let db_path = temp_dir.path().join("translations.db");
149 let manager = CacheManager { db_path };
150 manager.init_db().unwrap();
151 manager
152 }
153
154 fn create_test_request() -> TranslationRequest {
155 TranslationRequest {
156 source_text: "Hello, World!".to_string(),
157 target_language: "ja".to_string(),
158 model: "gpt-oss:20b".to_string(),
159 endpoint: "http://localhost:11434".to_string(),
160 }
161 }
162
163 #[test]
164 fn test_cache_miss() {
165 let temp_dir = TempDir::new().unwrap();
166 let manager = create_test_manager(&temp_dir);
167 let request = create_test_request();
168
169 let result = manager.get(&request).unwrap();
170 assert!(result.is_none());
171 }
172
173 #[test]
174 fn test_cache_hit() {
175 let temp_dir = TempDir::new().unwrap();
176 let manager = create_test_manager(&temp_dir);
177 let request = create_test_request();
178
179 manager.put(&request, "こんにちは、世界!").unwrap();
180
181 let result = manager.get(&request).unwrap();
182 assert_eq!(result, Some("こんにちは、世界!".to_string()));
183 }
184
185 #[test]
186 fn test_different_requests_different_keys() {
187 let temp_dir = TempDir::new().unwrap();
188 let manager = create_test_manager(&temp_dir);
189
190 let request1 = TranslationRequest {
191 source_text: "Hello".to_string(),
192 target_language: "ja".to_string(),
193 model: "model1".to_string(),
194 endpoint: "http://localhost:11434".to_string(),
195 };
196
197 let request2 = TranslationRequest {
198 source_text: "Hello".to_string(),
199 target_language: "en".to_string(),
200 model: "model1".to_string(),
201 endpoint: "http://localhost:11434".to_string(),
202 };
203
204 manager.put(&request1, "Translation 1").unwrap();
205 manager.put(&request2, "Translation 2").unwrap();
206
207 assert_eq!(
208 manager.get(&request1).unwrap(),
209 Some("Translation 1".to_string())
210 );
211 assert_eq!(
212 manager.get(&request2).unwrap(),
213 Some("Translation 2".to_string())
214 );
215 }
216
217 #[test]
218 fn test_cache_key_includes_endpoint() {
219 let temp_dir = TempDir::new().unwrap();
220 let manager = create_test_manager(&temp_dir);
221
222 let request1 = TranslationRequest {
223 source_text: "Hello".to_string(),
224 target_language: "ja".to_string(),
225 model: "model1".to_string(),
226 endpoint: "http://localhost:11434".to_string(),
227 };
228
229 let request2 = TranslationRequest {
230 source_text: "Hello".to_string(),
231 target_language: "ja".to_string(),
232 model: "model1".to_string(),
233 endpoint: "http://production:11434".to_string(),
234 };
235
236 manager.put(&request1, "Local Translation").unwrap();
237 manager.put(&request2, "Production Translation").unwrap();
238
239 assert_eq!(
240 manager.get(&request1).unwrap(),
241 Some("Local Translation".to_string())
242 );
243 assert_eq!(
244 manager.get(&request2).unwrap(),
245 Some("Production Translation".to_string())
246 );
247 }
248}