use serde_json::json; use std::path::Path; use std::time::SystemTime; use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; use crate::utils::deduplicate_list; /// Maximum number of recent folders to keep. pub const RECENTS_MAX: usize = 50; /// Name of the recents file within the state directory. const RECENTS_FILENAME: &str = "recent_folders.json"; /// Load the list of recently opened folders. /// /// Reads from `{state_dir}/recent_folders.json`. Returns an empty vec if the /// file is missing, corrupt, or has an unexpected shape. pub fn load_recents(state_dir: &Path) -> Vec { let path = state_dir.join(RECENTS_FILENAME); let data = match load_json_with_fallbacks(&path, BACKUP_COUNT) { Some(v) => v, None => return Vec::new(), }; // Top-level must be an object let obj = match data.as_object() { Some(o) => o, None => return Vec::new(), }; // "items" must be an array let items_val = match obj.get("items") { Some(v) => v, None => return Vec::new(), }; let arr = match items_val.as_array() { Some(a) => a, None => return Vec::new(), }; // Collect only string elements let strings: Vec = arr .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); let deduped = deduplicate_list(&strings); deduped.into_iter().take(RECENTS_MAX).collect() } /// Save the list of recently opened folders. /// /// Deduplicates and truncates to `RECENTS_MAX`, then writes JSON with version /// and timestamp metadata. pub fn save_recents(state_dir: &Path, paths: &[String]) { let cleaned: Vec = deduplicate_list(paths) .into_iter() .take(RECENTS_MAX) .collect(); let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let data = json!({ "version": 2, "updated_at": timestamp, "items": cleaned, }); let path = state_dir.join(RECENTS_FILENAME); atomic_write_json(&path, &data, BACKUP_COUNT); } /// Add a folder to the top of the recent folders list. /// /// If the path already exists in the list it is moved to the front rather than /// duplicated. pub fn push_recent(state_dir: &Path, path_str: &str) { let mut paths = load_recents(state_dir); let trimmed = path_str.trim().to_string(); paths.retain(|p| p != &trimmed); paths.insert(0, trimmed); save_recents(state_dir, &paths); } /// Remove a specific path from the recent folders list and save. pub fn remove_recent(state_dir: &Path, path_str: &str) { let mut paths = load_recents(state_dir); let trimmed = path_str.trim().to_string(); paths.retain(|p| p != &trimmed); save_recents(state_dir, &paths); } // =========================================================================== // Tests // =========================================================================== #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_load_empty_returns_empty_vec() { let dir = TempDir::new().unwrap(); let result = load_recents(dir.path()); assert!(result.is_empty()); } #[test] fn test_push_adds_to_front() { let dir = TempDir::new().unwrap(); push_recent(dir.path(), "/folder/a"); push_recent(dir.path(), "/folder/b"); push_recent(dir.path(), "/folder/c"); let recents = load_recents(dir.path()); assert_eq!(recents[0], "/folder/c"); assert_eq!(recents[1], "/folder/b"); assert_eq!(recents[2], "/folder/a"); } #[test] fn test_push_deduplicates() { let dir = TempDir::new().unwrap(); push_recent(dir.path(), "/folder/a"); push_recent(dir.path(), "/folder/b"); push_recent(dir.path(), "/folder/a"); // duplicate — should move to front let recents = load_recents(dir.path()); assert_eq!(recents.len(), 2); assert_eq!(recents[0], "/folder/a"); assert_eq!(recents[1], "/folder/b"); } #[test] fn test_remove_works() { let dir = TempDir::new().unwrap(); push_recent(dir.path(), "/folder/a"); push_recent(dir.path(), "/folder/b"); push_recent(dir.path(), "/folder/c"); remove_recent(dir.path(), "/folder/b"); let recents = load_recents(dir.path()); assert_eq!(recents.len(), 2); assert_eq!(recents[0], "/folder/c"); assert_eq!(recents[1], "/folder/a"); } #[test] fn test_remove_nonexistent_is_noop() { let dir = TempDir::new().unwrap(); push_recent(dir.path(), "/folder/a"); remove_recent(dir.path(), "/folder/zzz"); let recents = load_recents(dir.path()); assert_eq!(recents.len(), 1); assert_eq!(recents[0], "/folder/a"); } #[test] fn test_max_50_limit() { let dir = TempDir::new().unwrap(); // Push 55 items for i in 0..55 { push_recent(dir.path(), &format!("/folder/{}", i)); } let recents = load_recents(dir.path()); assert_eq!(recents.len(), RECENTS_MAX); // Most recent should be at front assert_eq!(recents[0], "/folder/54"); } #[test] fn test_save_and_load_round_trip() { let dir = TempDir::new().unwrap(); let paths: Vec = vec![ "/home/user/videos".to_string(), "/home/user/tutorials".to_string(), ]; save_recents(dir.path(), &paths); let loaded = load_recents(dir.path()); assert_eq!(loaded, paths); } #[test] fn test_load_non_object_returns_empty() { let dir = TempDir::new().unwrap(); let path = dir.path().join(RECENTS_FILENAME); // Write a JSON array instead of an object std::fs::write(&path, "[1, 2, 3]").unwrap(); let result = load_recents(dir.path()); assert!(result.is_empty()); } #[test] fn test_load_missing_items_key_returns_empty() { let dir = TempDir::new().unwrap(); let path = dir.path().join(RECENTS_FILENAME); std::fs::write(&path, r#"{"version": 2}"#).unwrap(); let result = load_recents(dir.path()); assert!(result.is_empty()); } #[test] fn test_load_items_not_array_returns_empty() { let dir = TempDir::new().unwrap(); let path = dir.path().join(RECENTS_FILENAME); std::fs::write(&path, r#"{"items": "not an array"}"#).unwrap(); let result = load_recents(dir.path()); assert!(result.is_empty()); } }