1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fs::{self, File, OpenOptions};
11use std::io::{BufRead, BufReader, Write};
12use std::path::PathBuf;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tracing::{debug, info, warn};
16use uuid::Uuid;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TrashEntry {
21 pub id: Uuid,
23 pub original_path: PathBuf,
25 pub trash_path: PathBuf,
27 pub deleted_at: DateTime<Utc>,
29 pub expires_at: DateTime<Utc>,
31 pub content_hash: String,
33 pub size: u64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum HistoryOperation {
41 Create {
42 path: PathBuf,
43 },
44 Delete {
45 path: PathBuf,
46 trash_id: Option<Uuid>,
47 },
48 Move {
49 src: PathBuf,
50 dst: PathBuf,
51 },
52 Copy {
53 src: PathBuf,
54 dst: PathBuf,
55 },
56 Write {
57 path: PathBuf,
58 append: bool,
59 },
60 Restore {
61 trash_id: Uuid,
62 path: PathBuf,
63 },
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct HistoryEntry {
69 pub id: Uuid,
71 pub operation: HistoryOperation,
73 pub timestamp: DateTime<Utc>,
75 pub success: bool,
77 pub reversible: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub undo_data: Option<UndoData>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub error: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum UndoData {
91 Create { path: PathBuf },
93 Delete { trash_id: Uuid },
95 Move { src: PathBuf, dst: PathBuf },
97 Copy { path: PathBuf },
99}
100
101#[derive(Debug, Clone)]
103pub struct SafetyConfig {
104 pub data_dir: PathBuf,
106 pub trash_retention_days: u32,
108 pub soft_delete: bool,
110}
111
112impl Default for SafetyConfig {
113 fn default() -> Self {
114 let data_dir = dirs::data_local_dir()
115 .unwrap_or_else(|| PathBuf::from("."))
116 .join("ragfs");
117
118 Self {
119 data_dir,
120 trash_retention_days: 7,
121 soft_delete: true,
122 }
123 }
124}
125
126pub struct SafetyManager {
128 config: SafetyConfig,
130 index_hash: String,
132 trash_dir: PathBuf,
134 history_file: PathBuf,
136 trash_cache: Arc<RwLock<Vec<TrashEntry>>>,
138}
139
140impl SafetyManager {
141 pub fn new(source: &PathBuf, config: Option<SafetyConfig>) -> Self {
143 let config = config.unwrap_or_default();
144
145 let index_hash = blake3::hash(source.to_string_lossy().as_bytes())
147 .to_hex()
148 .chars()
149 .take(16)
150 .collect::<String>();
151
152 let trash_dir = config.data_dir.join("trash").join(&index_hash);
153 let history_file = config
154 .data_dir
155 .join("history")
156 .join(format!("{index_hash}.jsonl"));
157
158 if let Err(e) = fs::create_dir_all(&trash_dir) {
160 warn!("Failed to create trash directory: {e}");
161 }
162 if let Some(parent) = history_file.parent()
163 && let Err(e) = fs::create_dir_all(parent)
164 {
165 warn!("Failed to create history directory: {e}");
166 }
167
168 let entries = Self::load_trash_entries(&trash_dir).unwrap_or_default();
170
171 Self {
172 config,
173 index_hash,
174 trash_dir,
175 history_file,
176 trash_cache: Arc::new(RwLock::new(entries)),
177 }
178 }
179
180 #[must_use]
182 pub fn index_hash(&self) -> &str {
183 &self.index_hash
184 }
185
186 fn load_trash_entries(trash_dir: &PathBuf) -> std::io::Result<Vec<TrashEntry>> {
188 let manifest_path = trash_dir.join("manifest.json");
189 if !manifest_path.exists() {
190 return Ok(Vec::new());
191 }
192
193 let content = fs::read_to_string(&manifest_path)?;
194 let entries: Vec<TrashEntry> = serde_json::from_str(&content).unwrap_or_default();
195 Ok(entries)
196 }
197
198 async fn save_trash_entries(&self) -> std::io::Result<()> {
200 let entries = self.trash_cache.read().await;
201 let manifest_path = self.trash_dir.join("manifest.json");
202 let content = serde_json::to_string_pretty(&*entries)?;
203 fs::write(&manifest_path, content)?;
204 Ok(())
205 }
206
207 pub async fn soft_delete(&self, path: &PathBuf) -> Result<TrashEntry, String> {
209 if !path.exists() {
210 return Err("File not found".into());
211 }
212
213 if path.is_dir() {
214 return Err("Cannot soft delete directories".into());
215 }
216
217 let content = fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
219 let content_hash = blake3::hash(&content).to_hex().to_string();
220 let size = content.len() as u64;
221
222 let id = Uuid::new_v4();
224 let trash_entry_dir = self.trash_dir.join(id.to_string());
225 fs::create_dir_all(&trash_entry_dir)
226 .map_err(|e| format!("Failed to create trash entry dir: {e}"))?;
227
228 let trash_content_path = trash_entry_dir.join("content");
229 let trash_meta_path = trash_entry_dir.join("meta.json");
230
231 fs::rename(path, &trash_content_path)
233 .or_else(|_| {
234 fs::copy(path, &trash_content_path)?;
236 fs::remove_file(path)
237 })
238 .map_err(|e| format!("Failed to move file to trash: {e}"))?;
239
240 let now = Utc::now();
241 let expires_at = now + chrono::Duration::days(i64::from(self.config.trash_retention_days));
242
243 let entry = TrashEntry {
244 id,
245 original_path: path.clone(),
246 trash_path: trash_content_path,
247 deleted_at: now,
248 expires_at,
249 content_hash,
250 size,
251 };
252
253 let meta_content = serde_json::to_string_pretty(&entry)
255 .map_err(|e| format!("Failed to serialize meta: {e}"))?;
256 fs::write(&trash_meta_path, meta_content)
257 .map_err(|e| format!("Failed to write meta: {e}"))?;
258
259 {
261 let mut cache = self.trash_cache.write().await;
262 cache.push(entry.clone());
263 }
264
265 if let Err(e) = self.save_trash_entries().await {
267 warn!("Failed to save trash manifest: {e}");
268 }
269
270 info!("Soft deleted {:?} -> trash/{}", path, id);
271 Ok(entry)
272 }
273
274 pub async fn restore(&self, trash_id: Uuid) -> Result<PathBuf, String> {
276 let entry = {
277 let cache = self.trash_cache.read().await;
278 cache.iter().find(|e| e.id == trash_id).cloned()
279 };
280
281 let entry = entry.ok_or_else(|| "Trash entry not found".to_string())?;
282
283 if !entry.trash_path.exists() {
284 return Err("Trash content not found".into());
285 }
286
287 let restore_path = &entry.original_path;
289
290 if let Some(parent) = restore_path.parent() {
292 fs::create_dir_all(parent)
293 .map_err(|e| format!("Failed to create parent directory: {e}"))?;
294 }
295
296 if restore_path.exists() {
298 return Err("Destination already exists".into());
299 }
300
301 fs::rename(&entry.trash_path, restore_path)
303 .or_else(|_| {
304 fs::copy(&entry.trash_path, restore_path)?;
305 fs::remove_file(&entry.trash_path)
306 })
307 .map_err(|e| format!("Failed to restore file: {e}"))?;
308
309 let trash_entry_dir = self.trash_dir.join(trash_id.to_string());
311 if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
312 warn!("Failed to remove trash entry dir: {e}");
313 }
314
315 {
317 let mut cache = self.trash_cache.write().await;
318 cache.retain(|e| e.id != trash_id);
319 }
320
321 if let Err(e) = self.save_trash_entries().await {
323 warn!("Failed to save trash manifest: {e}");
324 }
325
326 info!("Restored {:?} from trash/{}", restore_path, trash_id);
327 Ok(restore_path.clone())
328 }
329
330 pub async fn list_trash(&self) -> Vec<TrashEntry> {
332 self.trash_cache.read().await.clone()
333 }
334
335 pub async fn get_trash_entry(&self, id: Uuid) -> Option<TrashEntry> {
337 self.trash_cache
338 .read()
339 .await
340 .iter()
341 .find(|e| e.id == id)
342 .cloned()
343 }
344
345 pub fn get_trash_content(&self, id: Uuid) -> Result<Vec<u8>, String> {
347 let trash_content_path = self.trash_dir.join(id.to_string()).join("content");
348 if !trash_content_path.exists() {
349 return Err("Trash content not found".into());
350 }
351 fs::read(&trash_content_path).map_err(|e| format!("Failed to read trash content: {e}"))
352 }
353
354 pub async fn purge_expired(&self) -> usize {
356 let now = Utc::now();
357 let expired: Vec<Uuid> = {
358 let cache = self.trash_cache.read().await;
359 cache
360 .iter()
361 .filter(|e| e.expires_at < now)
362 .map(|e| e.id)
363 .collect()
364 };
365
366 let mut purged = 0;
367 for id in expired {
368 let trash_entry_dir = self.trash_dir.join(id.to_string());
369 if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
370 warn!("Failed to purge trash entry {}: {e}", id);
371 } else {
372 purged += 1;
373 }
374 }
375
376 {
378 let mut cache = self.trash_cache.write().await;
379 cache.retain(|e| e.expires_at >= now);
380 }
381
382 if let Err(e) = self.save_trash_entries().await {
384 warn!("Failed to save trash manifest: {e}");
385 }
386
387 if purged > 0 {
388 info!("Purged {} expired trash entries", purged);
389 }
390
391 purged
392 }
393
394 pub fn log(&self, entry: HistoryEntry) -> std::io::Result<()> {
396 let line = serde_json::to_string(&entry)?;
397
398 let mut file = OpenOptions::new()
399 .create(true)
400 .append(true)
401 .open(&self.history_file)?;
402
403 writeln!(file, "{line}")?;
404 debug!("Logged history entry: {:?}", entry.operation);
405 Ok(())
406 }
407
408 pub fn log_success(&self, operation: HistoryOperation, undo_data: Option<UndoData>) {
410 let entry = HistoryEntry {
411 id: Uuid::new_v4(),
412 operation,
413 timestamp: Utc::now(),
414 success: true,
415 reversible: undo_data.is_some(),
416 undo_data,
417 error: None,
418 };
419
420 if let Err(e) = self.log(entry) {
421 warn!("Failed to log history: {e}");
422 }
423 }
424
425 pub fn log_failure(&self, operation: HistoryOperation, error: String) {
427 let entry = HistoryEntry {
428 id: Uuid::new_v4(),
429 operation,
430 timestamp: Utc::now(),
431 success: false,
432 reversible: false,
433 undo_data: None,
434 error: Some(error),
435 };
436
437 if let Err(e) = self.log(entry) {
438 warn!("Failed to log history: {e}");
439 }
440 }
441
442 pub fn read_history(&self, limit: Option<usize>) -> Vec<HistoryEntry> {
444 let file = match File::open(&self.history_file) {
445 Ok(f) => f,
446 Err(_) => return Vec::new(),
447 };
448
449 let reader = BufReader::new(file);
450 let mut entries: Vec<HistoryEntry> = reader
451 .lines()
452 .map_while(Result::ok)
453 .filter_map(|line| serde_json::from_str(&line).ok())
454 .collect();
455
456 entries.reverse();
458
459 if let Some(limit) = limit {
460 entries.truncate(limit);
461 }
462
463 entries
464 }
465
466 pub fn get_history_json(&self, limit: Option<usize>) -> Vec<u8> {
468 let entries = self.read_history(limit);
469 serde_json::to_string_pretty(&entries)
470 .unwrap_or_else(|_| "[]".to_string())
471 .into_bytes()
472 }
473
474 pub fn find_operation(&self, id: Uuid) -> Option<HistoryEntry> {
476 self.read_history(None).into_iter().find(|e| e.id == id)
477 }
478
479 pub async fn undo(&self, operation_id: Uuid) -> Result<String, String> {
481 let entry = self
482 .find_operation(operation_id)
483 .ok_or_else(|| "Operation not found".to_string())?;
484
485 if !entry.reversible {
486 return Err("Operation is not reversible".into());
487 }
488
489 let undo_data = entry
490 .undo_data
491 .ok_or_else(|| "No undo data available".to_string())?;
492
493 match undo_data {
494 UndoData::Create { path } => {
495 if path.exists() {
497 fs::remove_file(&path).map_err(|e| format!("Failed to undo create: {e}"))?;
498 self.log_success(
499 HistoryOperation::Delete {
500 path: path.clone(),
501 trash_id: None,
502 },
503 None,
504 );
505 Ok(format!("Undone: deleted {}", path.display()))
506 } else {
507 Err("File no longer exists".into())
508 }
509 }
510 UndoData::Delete { trash_id } => {
511 let restored = self.restore(trash_id).await?;
513 Ok(format!("Undone: restored {}", restored.display()))
514 }
515 UndoData::Move { src, dst } => {
516 if dst.exists() {
518 fs::rename(&dst, &src).map_err(|e| format!("Failed to undo move: {e}"))?;
519 self.log_success(
520 HistoryOperation::Move {
521 src: dst.clone(),
522 dst: src.clone(),
523 },
524 Some(UndoData::Move {
525 src: src.clone(),
526 dst,
527 }),
528 );
529 Ok(format!("Undone: moved back to {}", src.display()))
530 } else {
531 Err("Destination file no longer exists".into())
532 }
533 }
534 UndoData::Copy { path } => {
535 if path.exists() {
537 fs::remove_file(&path).map_err(|e| format!("Failed to undo copy: {e}"))?;
538 self.log_success(
539 HistoryOperation::Delete {
540 path: path.clone(),
541 trash_id: None,
542 },
543 None,
544 );
545 Ok(format!("Undone: deleted copy {}", path.display()))
546 } else {
547 Err("Copy file no longer exists".into())
548 }
549 }
550 }
551 }
552
553 #[must_use]
555 pub fn soft_delete_enabled(&self) -> bool {
556 self.config.soft_delete
557 }
558
559 #[must_use]
561 pub fn trash_dir(&self) -> &PathBuf {
562 &self.trash_dir
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use tempfile::TempDir;
570
571 fn create_test_manager() -> (SafetyManager, TempDir, TempDir) {
572 let source_dir = TempDir::new().unwrap();
573 let data_dir = TempDir::new().unwrap();
574
575 let config = SafetyConfig {
576 data_dir: data_dir.path().to_path_buf(),
577 trash_retention_days: 7,
578 soft_delete: true,
579 };
580
581 let manager = SafetyManager::new(&source_dir.path().to_path_buf(), Some(config));
582 (manager, source_dir, data_dir)
583 }
584
585 #[tokio::test]
586 async fn test_soft_delete_and_restore() {
587 let (manager, source_dir, _data_dir) = create_test_manager();
588
589 let test_file = source_dir.path().join("test.txt");
591 fs::write(&test_file, "Hello, World!").unwrap();
592 assert!(test_file.exists());
593
594 let entry = manager.soft_delete(&test_file).await.unwrap();
596 assert!(!test_file.exists());
597 assert!(entry.trash_path.exists());
598
599 let trash = manager.list_trash().await;
601 assert_eq!(trash.len(), 1);
602 assert_eq!(trash[0].id, entry.id);
603
604 let restored = manager.restore(entry.id).await.unwrap();
606 assert_eq!(restored, test_file);
607 assert!(test_file.exists());
608
609 let content = fs::read_to_string(&test_file).unwrap();
611 assert_eq!(content, "Hello, World!");
612
613 let trash = manager.list_trash().await;
615 assert!(trash.is_empty());
616 }
617
618 #[tokio::test]
619 async fn test_soft_delete_nonexistent() {
620 let (manager, _source_dir, _data_dir) = create_test_manager();
621
622 let result = manager
623 .soft_delete(&PathBuf::from("/nonexistent.txt"))
624 .await;
625 assert!(result.is_err());
626 assert!(result.unwrap_err().contains("not found"));
627 }
628
629 #[tokio::test]
630 async fn test_restore_nonexistent() {
631 let (manager, _source_dir, _data_dir) = create_test_manager();
632
633 let result = manager.restore(Uuid::new_v4()).await;
634 assert!(result.is_err());
635 }
636
637 #[test]
638 fn test_history_logging() {
639 let (manager, _source_dir, _data_dir) = create_test_manager();
640
641 manager.log_success(
643 HistoryOperation::Create {
644 path: PathBuf::from("/test.txt"),
645 },
646 Some(UndoData::Create {
647 path: PathBuf::from("/test.txt"),
648 }),
649 );
650
651 manager.log_failure(
652 HistoryOperation::Delete {
653 path: PathBuf::from("/fail.txt"),
654 trash_id: None,
655 },
656 "Permission denied".to_string(),
657 );
658
659 let history = manager.read_history(None);
661 assert_eq!(history.len(), 2);
662
663 assert!(!history[0].success);
665 assert!(history[1].success);
666 }
667
668 #[test]
669 fn test_get_history_json() {
670 let (manager, _source_dir, _data_dir) = create_test_manager();
671
672 manager.log_success(
673 HistoryOperation::Create {
674 path: PathBuf::from("/test.txt"),
675 },
676 None,
677 );
678
679 let json = manager.get_history_json(None);
680 let json_str = String::from_utf8(json).unwrap();
681 assert!(json_str.contains("create"));
682 assert!(json_str.contains("/test.txt"));
683 }
684
685 #[tokio::test]
686 async fn test_get_trash_content() {
687 let (manager, source_dir, _data_dir) = create_test_manager();
688
689 let test_file = source_dir.path().join("content_test.txt");
690 fs::write(&test_file, "Test content for trash").unwrap();
691
692 let entry = manager.soft_delete(&test_file).await.unwrap();
693
694 let content = manager.get_trash_content(entry.id).unwrap();
695 assert_eq!(
696 String::from_utf8(content).unwrap(),
697 "Test content for trash"
698 );
699 }
700
701 #[test]
702 fn test_safety_config_default() {
703 let config = SafetyConfig::default();
704 assert_eq!(config.trash_retention_days, 7);
705 assert!(config.soft_delete);
706 }
707
708 #[test]
709 fn test_trash_entry_serialization() {
710 let entry = TrashEntry {
711 id: Uuid::new_v4(),
712 original_path: PathBuf::from("/test.txt"),
713 trash_path: PathBuf::from("/trash/test.txt"),
714 deleted_at: Utc::now(),
715 expires_at: Utc::now(),
716 content_hash: "abc123".to_string(),
717 size: 1024,
718 };
719
720 let json = serde_json::to_string(&entry).unwrap();
721 let parsed: TrashEntry = serde_json::from_str(&json).unwrap();
722 assert_eq!(parsed.id, entry.id);
723 assert_eq!(parsed.original_path, entry.original_path);
724 }
725
726 #[test]
727 fn test_history_entry_serialization() {
728 let entry = HistoryEntry {
729 id: Uuid::new_v4(),
730 operation: HistoryOperation::Create {
731 path: PathBuf::from("/test.txt"),
732 },
733 timestamp: Utc::now(),
734 success: true,
735 reversible: true,
736 undo_data: Some(UndoData::Create {
737 path: PathBuf::from("/test.txt"),
738 }),
739 error: None,
740 };
741
742 let json = serde_json::to_string(&entry).unwrap();
743 let parsed: HistoryEntry = serde_json::from_str(&json).unwrap();
744 assert_eq!(parsed.id, entry.id);
745 assert!(parsed.success);
746 }
747}