tl_cli/cache/
sqlite.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::PathBuf;
4
5use crate::paths;
6use crate::translation::TranslationRequest;
7
8/// Manages translation caching using a `SQLite` database.
9///
10/// The cache stores translations keyed by source text, target language,
11/// model, endpoint, and prompt hash to avoid redundant API calls.
12///
13/// # Example
14///
15/// ```no_run
16/// use tl_cli::cache::CacheManager;
17/// use tl_cli::translation::TranslationRequest;
18///
19/// let cache = CacheManager::new().unwrap();
20/// let request = TranslationRequest {
21///     source_text: "Hello".to_string(),
22///     target_language: "ja".to_string(),
23///     model: "gpt-4".to_string(),
24///     endpoint: "https://api.openai.com".to_string(),
25///     style: None,
26/// };
27///
28/// // Check cache
29/// if let Some(cached) = cache.get(&request).unwrap() {
30///     println!("Cached: {}", cached);
31/// }
32/// ```
33pub struct CacheManager {
34    db_path: PathBuf,
35}
36
37impl CacheManager {
38    /// Creates a new cache manager.
39    ///
40    /// Initializes the `SQLite` database at `$XDG_CACHE_HOME/tl/translations.db`
41    /// or `~/.cache/tl/translations.db` if `XDG_CACHE_HOME` is not set.
42    pub fn new() -> Result<Self> {
43        let cache_dir = paths::cache_dir()?;
44
45        std::fs::create_dir_all(&cache_dir).with_context(|| {
46            format!("Failed to create cache directory: {}", cache_dir.display())
47        })?;
48
49        let db_path = cache_dir.join("translations.db");
50        let manager = Self { db_path };
51
52        manager.init_db()?;
53
54        Ok(manager)
55    }
56
57    fn init_db(&self) -> Result<()> {
58        let conn = self.connect()?;
59
60        conn.execute(
61            "CREATE TABLE IF NOT EXISTS translations (
62                id INTEGER PRIMARY KEY AUTOINCREMENT,
63                cache_key TEXT UNIQUE NOT NULL,
64                source_text TEXT NOT NULL,
65                translated_text TEXT NOT NULL,
66                target_language TEXT NOT NULL,
67                model TEXT NOT NULL,
68                endpoint TEXT NOT NULL,
69                prompt_hash TEXT NOT NULL,
70                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
71                accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
72            )",
73            [],
74        )
75        .context("Failed to create translations table")?;
76
77        conn.execute(
78            "CREATE INDEX IF NOT EXISTS idx_cache_key ON translations(cache_key)",
79            [],
80        )
81        .context("Failed to create index")?;
82
83        Ok(())
84    }
85
86    fn connect(&self) -> Result<Connection> {
87        Connection::open(&self.db_path)
88            .with_context(|| format!("Failed to open cache database: {}", self.db_path.display()))
89    }
90
91    /// Retrieves a cached translation if available.
92    ///
93    /// Returns `None` if no cached translation exists for the request.
94    /// Updates the `accessed_at` timestamp on cache hit.
95    pub fn get(&self, request: &TranslationRequest) -> Result<Option<String>> {
96        let cache_key = request.cache_key();
97        let conn = self.connect()?;
98
99        let mut stmt =
100            conn.prepare("SELECT translated_text FROM translations WHERE cache_key = ?1")?;
101
102        let result: Option<String> = stmt.query_row([&cache_key], |row| row.get(0)).ok();
103
104        if result.is_some() {
105            conn.execute(
106                "UPDATE translations SET accessed_at = CURRENT_TIMESTAMP WHERE cache_key = ?1",
107                [&cache_key],
108            )?;
109        }
110
111        Ok(result)
112    }
113
114    /// Stores a translation in the cache.
115    ///
116    /// If a translation with the same cache key already exists, it is replaced.
117    pub fn put(&self, request: &TranslationRequest, translated_text: &str) -> Result<()> {
118        let cache_key = request.cache_key();
119        let prompt_hash = TranslationRequest::prompt_hash();
120        let conn = self.connect()?;
121
122        conn.execute(
123            "INSERT OR REPLACE INTO translations
124             (cache_key, source_text, translated_text, target_language, model, endpoint, prompt_hash)
125             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
126            [
127                &cache_key,
128                &request.source_text,
129                translated_text,
130                &request.target_language,
131                &request.model,
132                &request.endpoint,
133                &prompt_hash,
134            ],
135        )
136        .context("Failed to insert translation into cache")?;
137
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145    use super::*;
146    use tempfile::TempDir;
147
148    fn create_test_manager(temp_dir: &TempDir) -> CacheManager {
149        let db_path = temp_dir.path().join("translations.db");
150        let manager = CacheManager { db_path };
151        manager.init_db().unwrap();
152        manager
153    }
154
155    fn create_test_request() -> TranslationRequest {
156        TranslationRequest {
157            source_text: "Hello, World!".to_string(),
158            target_language: "ja".to_string(),
159            model: "gpt-oss:20b".to_string(),
160            endpoint: "http://localhost:11434".to_string(),
161            style: None,
162        }
163    }
164
165    #[test]
166    fn test_cache_miss() {
167        let temp_dir = TempDir::new().unwrap();
168        let manager = create_test_manager(&temp_dir);
169        let request = create_test_request();
170
171        let result = manager.get(&request).unwrap();
172        assert!(result.is_none());
173    }
174
175    #[test]
176    fn test_cache_hit() {
177        let temp_dir = TempDir::new().unwrap();
178        let manager = create_test_manager(&temp_dir);
179        let request = create_test_request();
180
181        manager.put(&request, "こんにちは、世界!").unwrap();
182
183        let result = manager.get(&request).unwrap();
184        assert_eq!(result, Some("こんにちは、世界!".to_string()));
185    }
186
187    #[test]
188    fn test_different_requests_different_keys() {
189        let temp_dir = TempDir::new().unwrap();
190        let manager = create_test_manager(&temp_dir);
191
192        let request1 = TranslationRequest {
193            source_text: "Hello".to_string(),
194            target_language: "ja".to_string(),
195            model: "model1".to_string(),
196            endpoint: "http://localhost:11434".to_string(),
197            style: None,
198        };
199
200        let request2 = TranslationRequest {
201            source_text: "Hello".to_string(),
202            target_language: "en".to_string(),
203            model: "model1".to_string(),
204            endpoint: "http://localhost:11434".to_string(),
205            style: None,
206        };
207
208        manager.put(&request1, "Translation 1").unwrap();
209        manager.put(&request2, "Translation 2").unwrap();
210
211        assert_eq!(
212            manager.get(&request1).unwrap(),
213            Some("Translation 1".to_string())
214        );
215        assert_eq!(
216            manager.get(&request2).unwrap(),
217            Some("Translation 2".to_string())
218        );
219    }
220
221    #[test]
222    fn test_cache_key_includes_endpoint() {
223        let temp_dir = TempDir::new().unwrap();
224        let manager = create_test_manager(&temp_dir);
225
226        let request1 = TranslationRequest {
227            source_text: "Hello".to_string(),
228            target_language: "ja".to_string(),
229            model: "model1".to_string(),
230            endpoint: "http://localhost:11434".to_string(),
231            style: None,
232        };
233
234        let request2 = TranslationRequest {
235            source_text: "Hello".to_string(),
236            target_language: "ja".to_string(),
237            model: "model1".to_string(),
238            endpoint: "http://production:11434".to_string(),
239            style: None,
240        };
241
242        manager.put(&request1, "Local Translation").unwrap();
243        manager.put(&request2, "Production Translation").unwrap();
244
245        assert_eq!(
246            manager.get(&request1).unwrap(),
247            Some("Local Translation".to_string())
248        );
249        assert_eq!(
250            manager.get(&request2).unwrap(),
251            Some("Production Translation".to_string())
252        );
253    }
254}