use crate::shared::state::AppState; use log::error; use opendal::Operator; use tokio_stream::StreamExt; use std::collections::HashMap; use std::error::Error; use std::sync::Arc; use tokio::time::{interval, Duration}; #[derive(Debug, Clone)] pub struct FileState { pub path: String, pub size: i64, pub etag: String, pub last_modified: Option, } pub struct MinIOHandler { state: Arc, watched_prefixes: Arc>>, file_states: Arc>>, } impl MinIOHandler { pub fn new(state: Arc) -> Self { Self { state, watched_prefixes: Arc::new(tokio::sync::RwLock::new(Vec::new())), file_states: Arc::new(tokio::sync::RwLock::new(HashMap::new())), } } pub async fn watch_prefix(&self, prefix: String) { let mut prefixes = self.watched_prefixes.write().await; if !prefixes.contains(&prefix) { prefixes.push(prefix.clone()); } } pub async fn unwatch_prefix(&self, prefix: &str) { let mut prefixes = self.watched_prefixes.write().await; prefixes.retain(|p| p != prefix); } pub fn spawn( self: Arc, change_callback: Arc, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut tick = interval(Duration::from_secs(15)); loop { tick.tick().await; if let Err(e) = self.check_for_changes(&change_callback).await { error!("Error checking for MinIO changes: {}", e); } } }) } async fn check_for_changes( &self, callback: &Arc, ) -> Result<(), Box> { let op = match &self.state.s3_operator { Some(op) => op, None => { return Ok(()); } }; let prefixes = self.watched_prefixes.read().await; for prefix in prefixes.iter() { if let Err(e) = self.check_prefix_changes(op, prefix, callback).await { error!("Error checking prefix {}: {}", prefix, e); } } Ok(()) } async fn check_prefix_changes( &self, op: &Operator, prefix: &str, callback: &Arc, ) -> Result<(), Box> { let mut current_files = HashMap::new(); let mut lister = op.lister_with(prefix).recursive(true).await?; while let Some(entry) = lister.try_next().await? { let path = entry.path().to_string(); if path.ends_with('/') { continue; } let meta = op.stat(&path).await?; let file_state = FileState { path: path.clone(), size: meta.content_length() as i64, etag: meta.etag().unwrap_or_default().to_string(), last_modified: meta.last_modified().map(|dt| dt.to_rfc3339()), }; current_files.insert(path, file_state); } let mut file_states = self.file_states.write().await; for (path, current_state) in current_files.iter() { if let Some(previous_state) = file_states.get(path) { if current_state.etag != previous_state.etag || current_state.size != previous_state.size { callback(FileChangeEvent::Modified { path: path.clone(), size: current_state.size, etag: current_state.etag.clone(), }); } } else { callback(FileChangeEvent::Created { path: path.clone(), size: current_state.size, etag: current_state.etag.clone(), }); } } let previous_paths: Vec = file_states .keys() .filter(|k| k.starts_with(prefix)) .cloned() .collect(); for path in previous_paths { if !current_files.contains_key(&path) { callback(FileChangeEvent::Deleted { path: path.clone() }); file_states.remove(&path); } } for (path, state) in current_files { file_states.insert(path, state); } Ok(()) } pub async fn get_file_state(&self, path: &str) -> Option { let states = self.file_states.read().await; states.get(path).cloned() } pub async fn clear_state(&self) { let mut states = self.file_states.write().await; states.clear(); } pub async fn get_files_by_prefix(&self, prefix: &str) -> Vec { let states = self.file_states.read().await; states .values() .filter(|state| state.path.starts_with(prefix)) .cloned() .collect() } } #[derive(Debug, Clone)] pub enum FileChangeEvent { Created { path: String, size: i64, etag: String, }, Modified { path: String, size: i64, etag: String, }, Deleted { path: String, }, } impl FileChangeEvent { pub fn path(&self) -> &str { match self { FileChangeEvent::Created { path, .. } => path, FileChangeEvent::Modified { path, .. } => path, FileChangeEvent::Deleted { path } => path, } } pub fn event_type(&self) -> &str { match self { FileChangeEvent::Created { .. } => "created", FileChangeEvent::Modified { .. } => "modified", FileChangeEvent::Deleted { .. } => "deleted", } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_file_change_event_path() { let event = FileChangeEvent::Created { path: "test.txt".to_string(), size: 100, etag: "abc123".to_string(), }; assert_eq!(event.path(), "test.txt"); assert_eq!(event.event_type(), "created"); } #[test] fn test_file_change_event_types() { let created = FileChangeEvent::Created { path: "file1.txt".to_string(), size: 100, etag: "abc".to_string(), }; let modified = FileChangeEvent::Modified { path: "file2.txt".to_string(), size: 200, etag: "def".to_string(), }; let deleted = FileChangeEvent::Deleted { path: "file3.txt".to_string(), }; assert_eq!(created.event_type(), "created"); assert_eq!(modified.event_type(), "modified"); assert_eq!(deleted.event_type(), "deleted"); } }