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 let _ = fs::create_dir_all(&cache_dir);
33
34 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
36
37 let hint_file = HintFile::find_hint_file(¤t_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 hasher.update(command.as_bytes());
61
62 if let Some(hint_file) = &self.hint_file {
64 if let Some(command_hint) = hint_file.find_matching_command(command) {
66 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 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 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 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 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 {
191 if let Some(output) = self.get(command) {
193 return Ok(output.clone());
194 }
195
196 if let Ok(Some((output, timestamp))) = self.load_from_disk_with_timestamp(command) {
198 let effective_ttl = self.get_effective_ttl(command, ttl);
200
201 if let Some(ttl_duration) = effective_ttl {
203 if let Ok(age) = SystemTime::now().duration_since(timestamp) {
204 if age > ttl_duration {
205 } else {
207 self.store(command, &output);
209 return Ok(output);
210 }
211 }
212 } else {
213 self.store(command, &output);
215 return Ok(output);
216 }
217 }
218 }
219
220 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 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 let output_str = String::from_utf8_lossy(&output.stdout).to_string();
243
244 self.store(command, &output_str);
246
247 self.save_to_disk(command, &output_str)?;
249
250 Ok(output_str)
251 }
252
253 pub fn get_effective_ttl(&self, command: &str, default_ttl: Option<Duration>) -> Option<Duration> {
255 if let Some(hint_file) = &self.hint_file {
256 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 if let Some(ttl_seconds) = hint_file.default.ttl {
265 return Some(Duration::from_secs(ttl_seconds));
266 }
267 }
268
269 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 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 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 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 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 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 let command = "echo hello";
425
426 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 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 let id1 = cache.generate_id(command1);
446 let id1_duplicate = cache.generate_id(command1);
447 assert_eq!(id1, id1_duplicate);
448
449 let id2 = cache.generate_id(command2);
451 assert_ne!(id1, id2);
452
453 assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
455 assert_eq!(id1.len(), 64); }
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 let save_result = cache.save_to_disk(command, output);
466 assert!(save_result.is_ok());
467
468 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 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 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 let entries = cache.list_cached_commands().unwrap();
496 assert!(entries.len() >= commands.len());
497
498 let cleared = cache.clear_cache(Some(commands[0])).unwrap();
500 assert_eq!(cleared, 1);
501
502 let entries_after = cache.list_cached_commands().unwrap();
504 assert!(entries_after.len() < entries.len());
505
506 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 let result = cache.execute_and_cache(command, None, false);
519 assert!(result.is_ok());
520
521 let force_result = cache.execute_and_cache(command, None, true);
523 assert!(force_result.is_ok());
524
525 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 let _ = cache.clear_cache(Some(command));
532 }
533}
534pub mod hint_file;
536
537impl CommandCache {
538 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 pub fn get_hint_file(&self) -> Option<&HintFile> {
554 self.hint_file.as_ref()
555 }
556}