cacher/
lib.rs

1use std::collections::HashMap;
2use std::process::Command;
3use std::io::{self, Error, ErrorKind, Read, Write};
4use std::fs::{self, File};
5use std::path::PathBuf;
6use sha2::{Sha256, Digest};
7use dirs::cache_dir;
8use std::time::{Duration, SystemTime};
9use std::env;
10use crate::hint_file::{HintFile, Dependency};
11
12pub struct CacheEntry {
13    pub command: String,
14    pub output: String,
15    pub timestamp: SystemTime,
16}
17
18pub struct CommandCache {
19    cache: HashMap<String, String>,
20    cache_dir: PathBuf,
21    hint_file: Option<HintFile>,
22    current_dir: PathBuf,
23}
24
25impl CommandCache {
26    pub fn new() -> Self {
27        let cache_dir = cache_dir()
28            .unwrap_or_else(|| PathBuf::from("."))
29            .join("cacher");
30        
31        // Create cache directory if it doesn't exist
32        let _ = fs::create_dir_all(&cache_dir);
33        
34        // Get current directory
35        let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
36        
37        // Try to load hint file
38        let hint_file = HintFile::find_hint_file(&current_dir);
39        
40        CommandCache {
41            cache: HashMap::new(),
42            cache_dir,
43            hint_file,
44            current_dir,
45        }
46    }
47
48    pub fn store(&mut self, command: &str, output: &str) {
49        self.cache.insert(command.to_string(), output.to_string());
50    }
51
52    pub fn get(&self, command: &str) -> Option<&String> {
53        self.cache.get(command)
54    }
55    
56    pub fn generate_id(&self, command: &str) -> String {
57        let mut hasher = Sha256::new();
58        
59        // Add the command itself to the hash
60        hasher.update(command.as_bytes());
61        
62        // If we have a hint file, check for command-specific settings
63        if let Some(hint_file) = &self.hint_file {
64            // Check if there's a matching command pattern
65            if let Some(command_hint) = hint_file.find_matching_command(command) {
66                // Include specified environment variables in the hash
67                for env_var in &command_hint.include_env {
68                    if let Ok(value) = env::var(env_var) {
69                        hasher.update(format!("{}={}", env_var, value).as_bytes());
70                    }
71                }
72                
73                // Include file dependencies in the hash
74                for dependency in &command_hint.depends_on {
75                    match dependency {
76                        Dependency::File { file } => {
77                            let path = self.current_dir.join(file);
78                            if path.exists() {
79                                if let Ok(metadata) = fs::metadata(&path) {
80                                    if let Ok(modified) = metadata.modified() {
81                                        if let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH) {
82                                            hasher.update(format!("{}={}", file, duration.as_secs()).as_bytes());
83                                        }
84                                    }
85                                }
86                            }
87                        },
88                        Dependency::Files { files } => {
89                            // Use glob pattern to find matching files
90                            if let Ok(entries) = glob::glob(&format!("{}/{}", self.current_dir.display(), files)) {
91                                for entry in entries {
92                                    if let Ok(path) = entry {
93                                        if let Ok(metadata) = fs::metadata(&path) {
94                                            if let Ok(modified) = metadata.modified() {
95                                                if let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH) {
96                                                    if let Some(path_str) = path.to_str() {
97                                                        hasher.update(format!("{}={}", path_str, duration.as_secs()).as_bytes());
98                                                    }
99                                                }
100                                            }
101                                        }
102                                    }
103                                }
104                            }
105                        },
106                        Dependency::Lines { lines } => {
107                            let path = self.current_dir.join(&lines.file);
108                            if path.exists() {
109                                if let Ok(content) = fs::read_to_string(&path) {
110                                    if let Ok(regex) = regex::Regex::new(&lines.pattern) {
111                                        let mut matching_lines = String::new();
112                                        for line in content.lines() {
113                                            if regex.is_match(line) {
114                                                matching_lines.push_str(line);
115                                                matching_lines.push('\n');
116                                            }
117                                        }
118                                        hasher.update(matching_lines.as_bytes());
119                                    }
120                                }
121                            }
122                        }
123                    }
124                }
125            } else {
126                // No specific command match, use default environment variables
127                for env_var in &hint_file.default.include_env {
128                    if let Ok(value) = env::var(env_var) {
129                        hasher.update(format!("{}={}", env_var, value).as_bytes());
130                    }
131                }
132            }
133        }
134        
135        format!("{:x}", hasher.finalize())
136    }
137    
138    pub fn get_cache_path(&self, id: &str) -> PathBuf {
139        self.cache_dir.join(format!("{}.cache", id))
140    }
141    
142    pub fn save_to_disk(&self, command: &str, output: &str) -> io::Result<()> {
143        let id = self.generate_id(command);
144        let path = self.get_cache_path(&id);
145        
146        let entry = CacheEntry {
147            command: command.to_string(),
148            output: output.to_string(),
149            timestamp: SystemTime::now(),
150        };
151        
152        let json = format!(
153            "{{\"command\":\"{}\",\"output\":\"{}\",\"timestamp\":{}}}",
154            entry.command.replace("\"", "\\\""),
155            entry.output.replace("\"", "\\\"").replace("\n", "\\n"),
156            entry.timestamp.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
157        );
158        
159        let mut file = File::create(path)?;
160        file.write_all(json.as_bytes())?;
161        
162        Ok(())
163    }
164    
165    pub fn load_from_disk(&self, command: &str) -> io::Result<Option<String>> {
166        let id = self.generate_id(command);
167        let path = self.get_cache_path(&id);
168        
169        if !path.exists() {
170            return Ok(None);
171        }
172        
173        let mut file = File::open(path)?;
174        let mut contents = String::new();
175        file.read_to_string(&mut contents)?;
176        
177        // Simple parsing to extract output field from JSON
178        if let Some(start) = contents.find("\"output\":\"") {
179            if let Some(end) = contents[start + 10..].find("\"") {
180                let output = &contents[start + 10..start + 10 + end];
181                return Ok(Some(output.replace("\\n", "\n").replace("\\\"", "\"")));
182            }
183        }
184        
185        Err(Error::new(ErrorKind::InvalidData, "Invalid cache file format"))
186    }
187    
188    pub fn execute_and_cache(&mut self, command: &str, ttl: Option<Duration>, force: bool) -> io::Result<String> {
189        // If force is true, skip cache lookup
190        if !force {
191            // First check in-memory cache
192            if let Some(output) = self.get(command) {
193                return Ok(output.clone());
194            }
195            
196            // Then check disk cache
197            if let Ok(Some((output, timestamp))) = self.load_from_disk_with_timestamp(command) {
198                // Get TTL from hint file if available
199                let effective_ttl = self.get_effective_ttl(command, ttl);
200                
201                // Check if cache is still valid based on TTL
202                if let Some(ttl_duration) = effective_ttl {
203                    if let Ok(age) = SystemTime::now().duration_since(timestamp) {
204                        if age > ttl_duration {
205                            // Cache is expired, don't use it
206                        } else {
207                            // Cache is still valid
208                            self.store(command, &output);
209                            return Ok(output);
210                        }
211                    }
212                } else {
213                    // No TTL specified, use cache regardless of age
214                    self.store(command, &output);
215                    return Ok(output);
216                }
217            }
218        }
219        
220        // Parse command into program and arguments
221        let parts: Vec<&str> = command.split_whitespace().collect();
222        if parts.is_empty() {
223            return Err(Error::new(ErrorKind::InvalidInput, "Empty command"));
224        }
225        
226        let program = parts[0];
227        let args = &parts[1..];
228        
229        // Execute command
230        let output = Command::new(program)
231            .args(args)
232            .output()?;
233            
234        if !output.status.success() {
235            return Err(Error::new(
236                ErrorKind::Other,
237                format!("Command failed with exit code: {:?}", output.status.code())
238            ));
239        }
240        
241        // Convert output to string
242        let output_str = String::from_utf8_lossy(&output.stdout).to_string();
243        
244        // Cache the result in memory
245        self.store(command, &output_str);
246        
247        // Cache the result on disk
248        self.save_to_disk(command, &output_str)?;
249        
250        Ok(output_str)
251    }
252    
253    // Helper method to get effective TTL from hint file or fallback to provided TTL
254    pub fn get_effective_ttl(&self, command: &str, default_ttl: Option<Duration>) -> Option<Duration> {
255        if let Some(hint_file) = &self.hint_file {
256            // Check for command-specific TTL
257            if let Some(command_hint) = hint_file.find_matching_command(command) {
258                if let Some(ttl_seconds) = command_hint.ttl {
259                    return Some(Duration::from_secs(ttl_seconds));
260                }
261            }
262            
263            // Fall back to default TTL from hint file
264            if let Some(ttl_seconds) = hint_file.default.ttl {
265                return Some(Duration::from_secs(ttl_seconds));
266            }
267        }
268        
269        // Fall back to provided TTL
270        default_ttl
271    }
272    
273    pub fn load_from_disk_with_timestamp(&self, command: &str) -> io::Result<Option<(String, SystemTime)>> {
274        let id = self.generate_id(command);
275        let path = self.get_cache_path(&id);
276        
277        if !path.exists() {
278            return Ok(None);
279        }
280        
281        let mut file = File::open(path)?;
282        let mut contents = String::new();
283        file.read_to_string(&mut contents)?;
284        
285        // Extract output and timestamp from JSON
286        let mut output = String::new();
287        let mut timestamp = SystemTime::UNIX_EPOCH;
288        
289        if let Some(start) = contents.find("\"output\":\"") {
290            if let Some(end) = contents[start + 10..].find("\"") {
291                output = contents[start + 10..start + 10 + end]
292                    .replace("\\n", "\n")
293                    .replace("\\\"", "\"");
294            }
295        }
296        
297        if let Some(start) = contents.find("\"timestamp\":") {
298            if let Some(end) = contents[start + 12..].find("}") {
299                if let Ok(secs) = contents[start + 12..start + 12 + end].trim().parse::<u64>() {
300                    timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
301                }
302            }
303        }
304        
305        if output.is_empty() {
306            return Err(Error::new(ErrorKind::InvalidData, "Invalid cache file format"));
307        }
308        
309        Ok(Some((output, timestamp)))
310    }
311    
312    pub fn list_cached_commands(&self) -> io::Result<Vec<(String, SystemTime)>> {
313        let mut entries = Vec::new();
314        
315        if !self.cache_dir.exists() {
316            return Ok(entries);
317        }
318        
319        for entry in fs::read_dir(&self.cache_dir)? {
320            let entry = entry?;
321            let path = entry.path();
322            
323            if path.extension().and_then(|ext| ext.to_str()) == Some("cache") {
324                if let Ok(mut file) = File::open(&path) {
325                    let mut contents = String::new();
326                    if file.read_to_string(&mut contents).is_ok() {
327                        // Simple parsing to extract command and timestamp fields from JSON
328                        let mut command = String::new();
329                        let mut timestamp = SystemTime::UNIX_EPOCH;
330                        
331                        if let Some(start) = contents.find("\"command\":\"") {
332                            if let Some(end) = contents[start + 11..].find("\"") {
333                                command = contents[start + 11..start + 11 + end]
334                                    .replace("\\\"", "\"")
335                                    .to_string();
336                            }
337                        }
338                        
339                        if let Some(start) = contents.find("\"timestamp\":") {
340                            if let Some(end) = contents[start + 12..].find("}") {
341                                if let Ok(secs) = contents[start + 12..start + 12 + end].trim().parse::<u64>() {
342                                    timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
343                                }
344                            }
345                        }
346                        
347                        if !command.is_empty() {
348                            entries.push((command, timestamp));
349                        }
350                    }
351                }
352            }
353        }
354        
355        // Sort by timestamp (newest first)
356        entries.sort_by(|a, b| b.1.cmp(&a.1));
357        
358        Ok(entries)
359    }
360    
361    pub fn clear_cache(&self, command: Option<&str>) -> io::Result<usize> {
362        let mut count = 0;
363        
364        if !self.cache_dir.exists() {
365            return Ok(count);
366        }
367        
368        if let Some(cmd) = command {
369            // Clear specific command
370            let id = self.generate_id(cmd);
371            let path = self.get_cache_path(&id);
372            
373            if path.exists() {
374                fs::remove_file(path)?;
375                count = 1;
376            }
377        } else {
378            // Clear all cache
379            for entry in fs::read_dir(&self.cache_dir)? {
380                let entry = entry?;
381                let path = entry.path();
382                
383                if path.extension().and_then(|ext| ext.to_str()) == Some("cache") {
384                    fs::remove_file(path)?;
385                    count += 1;
386                }
387            }
388        }
389        
390        Ok(count)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use std::fs;
398    use std::thread::sleep;
399
400    #[test]
401    fn test_store_and_retrieve() {
402        let mut cache = CommandCache::new();
403        let command = "ls -la";
404        let output = "file1\nfile2\nfile3";
405        
406        cache.store(command, output);
407        
408        assert_eq!(cache.get(command), Some(&output.to_string()));
409    }
410
411    #[test]
412    fn test_retrieve_nonexistent() {
413        let cache = CommandCache::new();
414        let command = "ls -la";
415        
416        assert_eq!(cache.get(command), None);
417    }
418    
419    #[test]
420    fn test_execute_and_cache() {
421        let mut cache = CommandCache::new();
422        
423        // Use a simple command that should work on any system
424        let command = "echo hello";
425        
426        // First execution should run the command
427        let result = cache.execute_and_cache(command, None, false);
428        assert!(result.is_ok());
429        let output = result.unwrap();
430        assert!(output.contains("hello"));
431        
432        // Second execution should use the cache
433        let cached_result = cache.execute_and_cache(command, None, false);
434        assert!(cached_result.is_ok());
435        assert_eq!(cached_result.unwrap(), output);
436    }
437
438    #[test]
439    fn test_generate_id() {
440        let cache = CommandCache::new();
441        let command1 = "echo hello";
442        let command2 = "echo world";
443        
444        // Same command should generate same id
445        let id1 = cache.generate_id(command1);
446        let id1_duplicate = cache.generate_id(command1);
447        assert_eq!(id1, id1_duplicate);
448        
449        // Different commands should generate different ids
450        let id2 = cache.generate_id(command2);
451        assert_ne!(id1, id2);
452        
453        // ID should be a valid hex string
454        assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
455        assert_eq!(id1.len(), 64); // SHA-256 produces 32 bytes = 64 hex chars
456    }
457    
458    #[test]
459    fn test_disk_cache() {
460        let cache = CommandCache::new();
461        let command = "test_disk_cache_command";
462        let output = "test_output";
463        
464        // Save to disk
465        let save_result = cache.save_to_disk(command, output);
466        assert!(save_result.is_ok());
467        
468        // Load from disk
469        let load_result = cache.load_from_disk(command);
470        assert!(load_result.is_ok());
471        assert_eq!(load_result.unwrap(), Some(output.to_string()));
472        
473        // Clean up
474        let id = cache.generate_id(command);
475        let path = cache.get_cache_path(&id);
476        let _ = fs::remove_file(path);
477    }
478    
479    #[test]
480    fn test_list_and_clear_cache() {
481        let cache = CommandCache::new();
482        
483        // Add some test entries
484        let commands = vec![
485            "test_command_1",
486            "test_command_2",
487            "test_command_3",
488        ];
489        
490        for cmd in &commands {
491            cache.save_to_disk(cmd, "test_output").unwrap();
492        }
493        
494        // List cache
495        let entries = cache.list_cached_commands().unwrap();
496        assert!(entries.len() >= commands.len());
497        
498        // Clear specific command
499        let cleared = cache.clear_cache(Some(commands[0])).unwrap();
500        assert_eq!(cleared, 1);
501        
502        // Verify it was cleared
503        let entries_after = cache.list_cached_commands().unwrap();
504        assert!(entries_after.len() < entries.len());
505        
506        // Clear all remaining test entries
507        for cmd in &commands[1..] {
508            let _ = cache.clear_cache(Some(cmd));
509        }
510    }
511    
512    #[test]
513    fn test_ttl_and_force() {
514        let mut cache = CommandCache::new();
515        let command = "echo ttl_test";
516        
517        // First execution
518        let result = cache.execute_and_cache(command, None, false);
519        assert!(result.is_ok());
520        
521        // Force execution (should not use cache)
522        let force_result = cache.execute_and_cache(command, None, true);
523        assert!(force_result.is_ok());
524        
525        // With very short TTL (1ms)
526        sleep(Duration::from_millis(10));
527        let ttl_result = cache.execute_and_cache(command, Some(Duration::from_millis(1)), false);
528        assert!(ttl_result.is_ok());
529        
530        // Clean up
531        let _ = cache.clear_cache(Some(command));
532    }
533}
534// Add the hint_file module
535pub mod hint_file;
536
537impl CommandCache {
538    /// Reload the hint file from the current directory
539    ///
540    /// This is useful when the hint file has been modified or when
541    /// the current directory has changed.
542    pub fn reload_hint_file(&mut self) {
543        let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
544        self.current_dir = current_dir;
545        self.hint_file = HintFile::find_hint_file(&self.current_dir);
546    }
547    
548    /// Get a reference to the current hint file, if one is loaded
549    ///
550    /// # Returns
551    ///
552    /// An Option containing a reference to the HintFile, or None if no hint file is loaded
553    pub fn get_hint_file(&self) -> Option<&HintFile> {
554        self.hint_file.as_ref()
555    }
556}