refactored every component thoroughly.
This commit is contained in:
202
src/storage/cache.rs
Normal file
202
src/storage/cache.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use crate::models::{CacheEntry, FileMetadata, OrganizationPlan};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Cache {
|
||||
entries: HashMap<String, CacheEntry>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_or_create(cache_path: &Path) -> Self {
|
||||
if cache_path.exists() {
|
||||
match fs::read_to_string(cache_path) {
|
||||
Ok(content) => match serde_json::from_str::<Cache>(&content) {
|
||||
Ok(cache) => {
|
||||
println!("Loaded cache with {} entries", cache.entries.len());
|
||||
cache
|
||||
}
|
||||
Err(_) => {
|
||||
println!("Cache corrupted, creating new cache");
|
||||
Self::new()
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Failed to read cache, creating new cache");
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Creating new cache file");
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, cache_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(cache_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cached_response(
|
||||
&self,
|
||||
filenames: &[String],
|
||||
base_path: &Path,
|
||||
) -> Option<OrganizationPlan> {
|
||||
let cache_key = self.generate_cache_key(filenames);
|
||||
|
||||
if let Some(entry) = self.entries.get(&cache_key) {
|
||||
let mut all_files_unchanged = true;
|
||||
|
||||
for filename in filenames {
|
||||
let file_path = base_path.join(filename);
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
all_files_unchanged = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
) {
|
||||
let cache_key = self.generate_cache_key(filenames);
|
||||
let mut file_metadata = HashMap::new();
|
||||
|
||||
for filename in filenames {
|
||||
let file_path = base_path.join(filename);
|
||||
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_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 get_file_metadata(file_path: &Path) -> Result<FileMetadata, Box<dyn std::error::Error>> {
|
||||
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) {
|
||||
let current_time = SystemTime::now()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
src/storage/mod.rs
Normal file
100
src/storage/mod.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
pub mod cache;
|
||||
pub mod undo_log;
|
||||
|
||||
pub use cache::Cache;
|
||||
pub use undo_log::UndoLog;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::storage::{Cache, UndoLog};
|
||||
use crate::models::{FileMoveRecord, MoveStatus};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_cache_new() {
|
||||
let cache = Cache::new();
|
||||
// Just verify we can create a cache
|
||||
let _ = cache;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_with_max_entries() {
|
||||
let cache = Cache::with_max_entries(100);
|
||||
let _ = cache;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_log_new() {
|
||||
let log = UndoLog::new();
|
||||
assert!(!log.has_completed_moves());
|
||||
assert_eq!(log.get_completed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_log_with_max_entries() {
|
||||
let log = UndoLog::with_max_entries(500);
|
||||
assert!(!log.has_completed_moves());
|
||||
assert_eq!(log.get_completed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_log_record_move() {
|
||||
let mut log = UndoLog::new();
|
||||
let source = PathBuf::from("/from/file.txt");
|
||||
let dest = PathBuf::from("/to/file.txt");
|
||||
|
||||
log.record_move(source.clone(), dest.clone());
|
||||
|
||||
assert!(log.has_completed_moves());
|
||||
assert_eq!(log.get_completed_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_log_record_failed_move() {
|
||||
let mut log = UndoLog::new();
|
||||
let source = PathBuf::from("/from/file.txt");
|
||||
let dest = PathBuf::from("/to/file.txt");
|
||||
|
||||
log.record_failed_move(source.clone(), dest.clone());
|
||||
|
||||
assert!(!log.has_completed_moves());
|
||||
assert_eq!(log.get_completed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_log_mark_as_undone() {
|
||||
let mut log = UndoLog::new();
|
||||
let source = PathBuf::from("/from/file.txt");
|
||||
let dest = PathBuf::from("/to/file.txt");
|
||||
|
||||
log.record_move(source.clone(), dest.clone());
|
||||
assert_eq!(log.get_completed_count(), 1);
|
||||
|
||||
log.mark_as_undone(&dest);
|
||||
assert_eq!(log.get_completed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_move_record_status() {
|
||||
let record = FileMoveRecord::new(
|
||||
PathBuf::from("/from"),
|
||||
PathBuf::from("/to"),
|
||||
MoveStatus::Completed
|
||||
);
|
||||
assert_eq!(record.status, MoveStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_completed_moves_empty() {
|
||||
let log: UndoLog = UndoLog::new();
|
||||
let moves = log.get_completed_moves();
|
||||
assert!(moves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_directory_usage_empty() {
|
||||
let log: UndoLog = UndoLog::new();
|
||||
let usage = log.get_directory_usage(PathBuf::from("/").as_path());
|
||||
assert!(usage.is_empty());
|
||||
}
|
||||
}
|
||||
182
src/storage/undo_log.rs
Normal file
182
src/storage/undo_log.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use crate::models::{FileMoveRecord, MoveStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UndoLog {
|
||||
entries: Vec<FileMoveRecord>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl Default for UndoLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UndoLog {
|
||||
pub fn new() -> Self {
|
||||
Self::with_max_entries(1000)
|
||||
}
|
||||
|
||||
pub fn with_max_entries(max_entries: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
max_entries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_or_create(undo_log_path: &Path) -> Self {
|
||||
if undo_log_path.exists() {
|
||||
match fs::read_to_string(undo_log_path) {
|
||||
Ok(content) => match serde_json::from_str::<UndoLog>(&content) {
|
||||
Ok(log) => {
|
||||
println!("Loaded undo log with {} entries", log.get_completed_count());
|
||||
log
|
||||
}
|
||||
Err(_) => {
|
||||
println!("Undo log corrupted, creating new log");
|
||||
Self::new()
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Failed to read undo log, creating new log");
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Creating new undo log file");
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, undo_log_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = undo_log_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(undo_log_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record_move(&mut self, source_path: PathBuf, destination_path: PathBuf) {
|
||||
let record = FileMoveRecord::new(source_path, destination_path, MoveStatus::Completed);
|
||||
self.entries.push(record);
|
||||
|
||||
if self.entries.len() > self.max_entries {
|
||||
self.evict_oldest();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failed_move(&mut self, source_path: PathBuf, destination_path: PathBuf) {
|
||||
let record = FileMoveRecord::new(source_path, destination_path, MoveStatus::Failed);
|
||||
self.entries.push(record);
|
||||
|
||||
if self.entries.len() > self.max_entries {
|
||||
self.evict_oldest();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_completed_moves(&self) -> Vec<&FileMoveRecord> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.status == MoveStatus::Completed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn mark_as_undone(&mut self, source_path: &Path) {
|
||||
for entry in &mut self.entries {
|
||||
if entry.status == MoveStatus::Completed && entry.destination_path == source_path {
|
||||
entry.status = MoveStatus::Undone;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_completed_count(&self) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.status == MoveStatus::Completed)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn has_completed_moves(&self) -> bool {
|
||||
self.get_completed_count() > 0
|
||||
}
|
||||
|
||||
pub fn cleanup_old_entries(&mut self, max_age_seconds: u64) {
|
||||
let current_time = SystemTime::now()
|
||||
.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
|
||||
|| entry.status == MoveStatus::Completed
|
||||
});
|
||||
|
||||
let removed_count = initial_count - self.entries.len();
|
||||
if removed_count > 0 {
|
||||
println!("Cleaned up {} old undo log entries", removed_count);
|
||||
}
|
||||
|
||||
if self.entries.len() > self.max_entries {
|
||||
self.compact_log();
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
if let Some(oldest_index) = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.status == MoveStatus::Undone)
|
||||
.min_by_key(|(_, entry)| entry.timestamp)
|
||||
.map(|(i, _)| i)
|
||||
{
|
||||
self.entries.remove(oldest_index);
|
||||
println!("Evicted oldest undone log entry to maintain limit");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(oldest_index) = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, entry)| entry.timestamp)
|
||||
.map(|(i, _)| i)
|
||||
{
|
||||
self.entries.remove(oldest_index);
|
||||
println!("Evicted oldest log entry to maintain limit");
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_log(&mut self) {
|
||||
while self.entries.len() > self.max_entries {
|
||||
self.evict_oldest();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_directory_usage(&self, base_path: &Path) -> HashMap<String, usize> {
|
||||
let mut usage = HashMap::new();
|
||||
|
||||
for entry in &self.entries {
|
||||
if entry.status == MoveStatus::Completed
|
||||
&& let Ok(rel_path) = entry.destination_path.strip_prefix(base_path)
|
||||
&& let Some(parent) = rel_path.parent()
|
||||
{
|
||||
let dir_path = parent.to_string_lossy().into_owned();
|
||||
*usage.entry(dir_path).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user