diff --git a/Cargo.lock b/Cargo.lock index 237a0e9..addc0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,6 +830,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.17", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 9eeaf65..a9a6e37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,6 @@ sha2 = "0.10.8" thiserror = "2.0.11" tokio = { version = "1.48.0", features = ["full"] } toml = "0.8.19" + +[dev-dependencies] +tempfile = "3.15" diff --git a/src/cache.rs b/src/cache.rs index 8164ce5..e8b8507 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -6,22 +6,40 @@ use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct FileMetadata { + size: u64, + modified: u64, +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CacheEntry { pub response: OrganizationPlan, pub timestamp: u64, - pub file_hashes: HashMap, + pub file_metadata: HashMap, } #[derive(Serialize, Deserialize, Debug)] pub struct Cache { entries: HashMap, + max_entries: usize, +} + +impl Default for Cache { + fn default() -> Self { + Self::new() + } } impl Cache { pub fn new() -> Self { + Self::with_max_entries(1000) + } + + pub fn with_max_entries(max_entries: usize) -> Self { Self { entries: HashMap::new(), + max_entries, } } @@ -63,16 +81,15 @@ impl Cache { pub fn get_cached_response(&self, filenames: &[String], base_path: &Path) -> Option { let cache_key = self.generate_cache_key(filenames); - + if let Some(entry) = self.entries.get(&cache_key) { - // Check if files have changed by comparing hashes let mut all_files_unchanged = true; - + for filename in filenames { let file_path = base_path.join(filename); - if let Ok(current_hash) = Self::hash_file(&file_path) { - if let Some(cached_hash) = entry.file_hashes.get(filename) { - if current_hash != *cached_hash { + if let Ok(current_metadata) = Self::get_file_metadata(&file_path) { + if let Some(cached_metadata) = entry.file_metadata.get(filename) { + if current_metadata != *cached_metadata { all_files_unchanged = false; break; } @@ -81,71 +98,80 @@ impl Cache { break; } } else { - // File doesn't exist or can't be read all_files_unchanged = false; break; } } - + if all_files_unchanged { println!("Using cached response (timestamp: {})", entry.timestamp); return Some(entry.response.clone()); } } - + None } - pub fn cache_response(&mut self, filenames: &[String], response: OrganizationPlan, base_path: &Path) { + pub fn cache_response( + &mut self, + filenames: &[String], + response: OrganizationPlan, + base_path: &Path, + ) { let cache_key = self.generate_cache_key(filenames); - let mut file_hashes = HashMap::new(); - - // Hash all files for future change detection + let mut file_metadata = HashMap::new(); + for filename in filenames { let file_path = base_path.join(filename); - if let Ok(hash) = Self::hash_file(&file_path) { - file_hashes.insert(filename.clone(), hash); + if let Ok(metadata) = Self::get_file_metadata(&file_path) { + file_metadata.insert(filename.clone(), metadata); } } - + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + let entry = CacheEntry { response, timestamp, - file_hashes, + file_metadata, }; - + self.entries.insert(cache_key, entry); + + if self.entries.len() > self.max_entries { + self.evict_oldest(); + } + println!("Cached response for {} files", filenames.len()); } fn generate_cache_key(&self, filenames: &[String]) -> String { let mut sorted_filenames = filenames.to_vec(); sorted_filenames.sort(); - + let mut hasher = Sha256::new(); for filename in &sorted_filenames { hasher.update(filename.as_bytes()); hasher.update(b"|"); } - + hex::encode(hasher.finalize()) } - fn hash_file(file_path: &Path) -> Result> { - if !file_path.exists() { - return Err("File does not exist".into()); - } - - let mut hasher = Sha256::new(); - let content = fs::read(file_path)?; - hasher.update(content); - - Ok(hex::encode(hasher.finalize())) + fn get_file_metadata(file_path: &Path) -> Result> { + let metadata = fs::metadata(file_path)?; + let modified = metadata + .modified()? + .duration_since(UNIX_EPOCH)? + .as_secs(); + + Ok(FileMetadata { + size: metadata.len(), + modified, + }) } pub fn cleanup_old_entries(&mut self, max_age_seconds: u64) { @@ -153,16 +179,243 @@ impl Cache { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + let initial_count = self.entries.len(); - + self.entries.retain(|_, entry| { current_time - entry.timestamp < max_age_seconds }); - + let removed_count = initial_count - self.entries.len(); if removed_count > 0 { println!("Cleaned up {} old cache entries", removed_count); } + + if self.entries.len() > self.max_entries { + self.compact_cache(); + } } -} \ No newline at end of file + + fn evict_oldest(&mut self) { + if let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.timestamp) + .map(|(k, _)| k.clone()) + { + self.entries.remove(&oldest_key); + println!("Evicted oldest cache entry to maintain limit"); + } + } + + fn compact_cache(&mut self) { + while self.entries.len() > self.max_entries { + self.evict_oldest(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::files::FileCategory; + use std::fs::File; + use std::io::Write; + + #[test] + fn test_cache_new() { + let cache = Cache::new(); + assert_eq!(cache.max_entries, 1000); + assert_eq!(cache.entries.len(), 0); + } + + #[test] + fn test_cache_with_max_entries() { + let cache = Cache::with_max_entries(5); + assert_eq!(cache.max_entries, 5); + } + + #[test] + fn test_cache_default() { + let cache = Cache::default(); + assert_eq!(cache.max_entries, 1000); + } + + #[test] + fn test_cache_response_and_retrieve() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_path = temp_dir.path(); + + let mut cache = Cache::new(); + let filenames = vec!["file1.txt".to_string(), "file2.txt".to_string()]; + + for filename in &filenames { + let file_path = base_path.join(filename); + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"test content").unwrap(); + } + + let response = OrganizationPlan { + files: vec![FileCategory { + filename: "file1.txt".to_string(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }], + }; + + cache.cache_response(&filenames, response.clone(), base_path); + + let cached = cache.get_cached_response(&filenames, base_path); + assert!(cached.is_some()); + assert_eq!(cached.unwrap().files[0].category, "Documents"); + } + + #[test] + fn test_cache_response_file_changed() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_path = temp_dir.path(); + + let mut cache = Cache::new(); + let filenames = vec!["file1.txt".to_string()]; + + let file_path = base_path.join("file1.txt"); + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"original content").unwrap(); + + let response = OrganizationPlan { + files: vec![FileCategory { + filename: "file1.txt".to_string(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }], + }; + + cache.cache_response(&filenames, response.clone(), base_path); + + std::thread::sleep(std::time::Duration::from_millis(100)); + + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"modified content longer than original").unwrap(); + + let cached = cache.get_cached_response(&filenames, base_path); + assert!(cached.is_none()); + } + + #[test] + fn test_cache_save_and_load() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let base_path = temp_dir.path(); + + let mut cache = Cache::new(); + let filenames = vec!["file1.txt".to_string()]; + + let file_path = base_path.join("file1.txt"); + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"test").unwrap(); + + let response = OrganizationPlan { + files: vec![FileCategory { + filename: "file1.txt".to_string(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }], + }; + + cache.cache_response(&filenames, response, base_path); + cache.save(&cache_path).unwrap(); + + let loaded_cache = Cache::load_or_create(&cache_path); + assert_eq!(loaded_cache.entries.len(), 1); + } + + #[test] + fn test_cache_cleanup_old_entries() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_path = temp_dir.path(); + + let mut cache = Cache::new(); + let filenames = vec!["file1.txt".to_string()]; + + let file_path = base_path.join("file1.txt"); + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"test").unwrap(); + + let response = OrganizationPlan { + files: vec![FileCategory { + filename: "file1.txt".to_string(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }], + }; + + cache.cache_response(&filenames, response, base_path); + + cache.cleanup_old_entries(0); + assert_eq!(cache.entries.len(), 0); + } + + #[test] + fn test_cache_max_entries_eviction() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_path = temp_dir.path(); + + let mut cache = Cache::with_max_entries(2); + + for i in 1..=3 { + let filename = format!("file{}.txt", i); + let file_path = base_path.join(&filename); + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"test").unwrap(); + + let response = OrganizationPlan { + files: vec![FileCategory { + filename: filename.clone(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }], + }; + + cache.cache_response(&vec![filename], response, base_path); + } + + assert_eq!(cache.entries.len(), 2); + } + + #[test] + fn test_cache_serialization() { + let cache = Cache::new(); + let json = serde_json::to_string(&cache).unwrap(); + let deserialized: Cache = serde_json::from_str(&json).unwrap(); + assert_eq!(cache.max_entries, deserialized.max_entries); + } + + #[test] + fn test_file_metadata_equality() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"test content").unwrap(); + + let metadata1 = Cache::get_file_metadata(&file_path).unwrap(); + let metadata2 = Cache::get_file_metadata(&file_path).unwrap(); + + assert_eq!(metadata1, metadata2); + } + + #[test] + fn test_cache_key_generation() { + let cache = Cache::new(); + let filenames1 = vec!["a.txt".to_string(), "b.txt".to_string()]; + let filenames2 = vec!["b.txt".to_string(), "a.txt".to_string()]; + let filenames3 = vec!["c.txt".to_string()]; + + let key1 = cache.generate_cache_key(&filenames1); + let key2 = cache.generate_cache_key(&filenames2); + let key3 = cache.generate_cache_key(&filenames3); + + assert_eq!(key1, key2); + assert_ne!(key1, key3); + } +} diff --git a/src/config.rs b/src/config.rs index 7af6409..e93dd31 100644 --- a/src/config.rs +++ b/src/config.rs @@ -269,3 +269,86 @@ fn expand_home(path: &str) -> String { path.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_serialization() { + let config = Config { + api_key: "test_key_12345".to_string(), + download_folder: PathBuf::from("/test/path"), + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!(toml_str.contains("test_key_12345")); + + let deserialized: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(config.api_key, deserialized.api_key); + assert_eq!(config.download_folder, deserialized.download_folder); + } + + #[test] + fn test_validate_api_key_valid() { + assert!(validate_api_key("AIzaSyB1234567890123456789012345678")); + assert!(validate_api_key("AIzaSyB123456789012345678901234567890")); + } + + #[test] + fn test_validate_api_key_invalid() { + assert!(!validate_api_key("")); + assert!(!validate_api_key("invalid_key")); + assert!(!validate_api_key("BizaSyB1234567890123456789012345678")); + assert!(!validate_api_key("short")); + } + + #[test] + fn test_validate_folder_path_valid() { + let temp_dir = tempfile::tempdir().unwrap(); + assert!(validate_folder_path(temp_dir.path())); + } + + #[test] + fn test_validate_folder_path_invalid() { + assert!(!validate_folder_path(Path::new("/nonexistent/path/that/does/not/exist"))); + + let temp_file = tempfile::NamedTempFile::new().unwrap(); + assert!(!validate_folder_path(temp_file.path())); + } + + #[test] + fn test_expand_home_with_tilde() { + if let Some(base_dirs) = BaseDirs::new() { + let home = base_dirs.home_dir(); + let expanded = expand_home("~/test/path"); + assert!(expanded.starts_with(home.to_string_lossy().as_ref())); + assert!(expanded.contains("test/path")); + } + } + + #[test] + fn test_expand_home_without_tilde() { + let expanded = expand_home("/absolute/path"); + assert_eq!(expanded, "/absolute/path"); + + let expanded = expand_home("relative/path"); + assert_eq!(expanded, "relative/path"); + } + + #[test] + fn test_get_default_downloads_folder() { + let path = get_default_downloads_folder(); + assert!(path.ends_with("Downloads")); + } + + #[test] + fn test_config_empty_api_key_error() { + let config = Config { + api_key: String::new(), + download_folder: PathBuf::from("/test/path"), + }; + + assert!(config.api_key.is_empty()); + } +} diff --git a/src/files.rs b/src/files.rs index 776a5dc..326fd3a 100644 --- a/src/files.rs +++ b/src/files.rs @@ -258,3 +258,133 @@ pub fn read_file_sample(path: &Path, max_chars: usize) -> Option { // Try to convert to UTF-8. If it fails (binary data), return None. String::from_utf8(buffer).ok() } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + + #[test] + fn test_is_text_file_with_text_extensions() { + assert!(is_text_file(Path::new("test.txt"))); + assert!(is_text_file(Path::new("test.rs"))); + assert!(is_text_file(Path::new("test.py"))); + assert!(is_text_file(Path::new("test.md"))); + assert!(is_text_file(Path::new("test.json"))); + } + + #[test] + fn test_is_text_file_with_binary_extensions() { + assert!(!is_text_file(Path::new("test.exe"))); + assert!(!is_text_file(Path::new("test.bin"))); + assert!(!is_text_file(Path::new("test.jpg"))); + assert!(!is_text_file(Path::new("test.pdf"))); + } + + #[test] + fn test_is_text_file_case_insensitive() { + assert!(is_text_file(Path::new("test.TXT"))); + assert!(is_text_file(Path::new("test.RS"))); + assert!(is_text_file(Path::new("test.Py"))); + } + + #[test] + fn test_file_batch_from_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let dir_path = temp_dir.path(); + + File::create(dir_path.join("file1.txt")).unwrap(); + File::create(dir_path.join("file2.rs")).unwrap(); + fs::create_dir(dir_path.join("subdir")).unwrap(); + + let batch = FileBatch::from_path(dir_path.to_path_buf()); + assert_eq!(batch.count(), 2); + assert!(batch.filenames.contains(&"file1.txt".to_string())); + assert!(batch.filenames.contains(&"file2.rs".to_string())); + } + + #[test] + fn test_file_batch_from_path_nonexistent() { + let batch = FileBatch::from_path(PathBuf::from("/nonexistent/path")); + assert_eq!(batch.count(), 0); + } + + #[test] + fn test_read_file_sample() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"Hello, World!").unwrap(); + + let content = read_file_sample(&file_path, 1000); + assert_eq!(content, Some("Hello, World!".to_string())); + } + + #[test] + fn test_read_file_sample_with_limit() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"Hello, World! This is a long text.").unwrap(); + + let content = read_file_sample(&file_path, 5); + assert_eq!(content, Some("Hello".to_string())); + } + + #[test] + fn test_read_file_sample_binary_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.bin"); + + let mut file = File::create(&file_path).unwrap(); + file.write_all(&[0x00, 0xFF, 0x80, 0x90]).unwrap(); + + let content = read_file_sample(&file_path, 1000); + assert_eq!(content, None); + } + + #[test] + fn test_read_file_sample_nonexistent() { + let content = read_file_sample(Path::new("/nonexistent/file.txt"), 1000); + assert_eq!(content, None); + } + + #[test] + fn test_organization_plan_serialization() { + let plan = OrganizationPlan { + files: vec![ + FileCategory { + filename: "test.txt".to_string(), + category: "Documents".to_string(), + sub_category: "Text".to_string(), + }, + ], + }; + + let json = serde_json::to_string(&plan).unwrap(); + assert!(json.contains("test.txt")); + assert!(json.contains("Documents")); + + let deserialized: OrganizationPlan = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.files[0].filename, "test.txt"); + } + + #[test] + fn test_file_category_serialization() { + let fc = FileCategory { + filename: "file.rs".to_string(), + category: "Code".to_string(), + sub_category: "Rust".to_string(), + }; + + let json = serde_json::to_string(&fc).unwrap(); + let deserialized: FileCategory = serde_json::from_str(&json).unwrap(); + + assert_eq!(fc.filename, deserialized.filename); + assert_eq!(fc.category, deserialized.category); + assert_eq!(fc.sub_category, deserialized.sub_category); + } +}