Skip to main content

gravityfile_ops/
conflict.rs

1//! Conflict detection and resolution for file operations.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7/// A conflict detected during a file operation.
8#[derive(Debug, Clone)]
9pub struct Conflict {
10    /// The source path being operated on.
11    pub source: PathBuf,
12    /// The destination path where the conflict exists.
13    pub destination: PathBuf,
14    /// The kind of conflict.
15    pub kind: ConflictKind,
16}
17
18impl Conflict {
19    /// Create a new conflict.
20    pub fn new(source: PathBuf, destination: PathBuf, kind: ConflictKind) -> Self {
21        Self {
22            source,
23            destination,
24            kind,
25        }
26    }
27
28    /// Create a file exists conflict.
29    pub fn file_exists(source: PathBuf, destination: PathBuf) -> Self {
30        Self::new(source, destination, ConflictKind::FileExists)
31    }
32
33    /// Create a directory exists conflict.
34    pub fn directory_exists(source: PathBuf, destination: PathBuf) -> Self {
35        Self::new(source, destination, ConflictKind::DirectoryExists)
36    }
37
38    /// Create a source is ancestor conflict.
39    pub fn source_is_ancestor(source: PathBuf, destination: PathBuf) -> Self {
40        Self::new(source, destination, ConflictKind::SourceIsAncestor)
41    }
42
43    /// Create a permission denied conflict.
44    pub fn permission_denied(source: PathBuf, destination: PathBuf) -> Self {
45        Self::new(source, destination, ConflictKind::PermissionDenied)
46    }
47}
48
49/// The kind of conflict encountered.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum ConflictKind {
52    /// A file already exists at the destination.
53    FileExists,
54    /// A directory already exists at the destination.
55    DirectoryExists,
56    /// Cannot move/copy a directory into itself.
57    SourceIsAncestor,
58    /// Permission denied.
59    PermissionDenied,
60    /// Source and destination are the same file.
61    SameFile,
62}
63
64impl std::fmt::Display for ConflictKind {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::FileExists => write!(f, "File already exists"),
68            Self::DirectoryExists => write!(f, "Directory already exists"),
69            Self::SourceIsAncestor => write!(f, "Cannot copy/move a directory into itself"),
70            Self::PermissionDenied => write!(f, "Permission denied"),
71            Self::SameFile => write!(f, "Source and destination are the same file"),
72        }
73    }
74}
75
76/// How to resolve a conflict.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub enum ConflictResolution {
79    /// Skip this item.
80    #[default]
81    Skip,
82    /// Overwrite the existing item.
83    Overwrite,
84    /// Automatically rename the new item (e.g., "file (1).txt").
85    AutoRename,
86    /// Skip all remaining conflicts.
87    SkipAll,
88    /// Overwrite all remaining conflicts.
89    OverwriteAll,
90    /// Abort the entire operation.
91    Abort,
92}
93
94impl ConflictResolution {
95    /// Check if this resolution applies to all remaining conflicts.
96    pub fn is_global(&self) -> bool {
97        matches!(self, Self::SkipAll | Self::OverwriteAll | Self::Abort)
98    }
99
100    /// Convert a global resolution to its single-item equivalent.
101    pub fn to_single(&self) -> Self {
102        match self {
103            Self::SkipAll => Self::Skip,
104            Self::OverwriteAll => Self::Overwrite,
105            _ => *self,
106        }
107    }
108}
109
110/// Generate an auto-renamed path to avoid conflicts.
111///
112/// For "file.txt", tries "file (1).txt", "file (2).txt", etc.
113pub fn auto_rename_path(path: &Path) -> PathBuf {
114    let parent = path.parent().unwrap_or(std::path::Path::new(""));
115    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
116    let extension = path.extension().and_then(|e| e.to_str());
117
118    for i in 1..1000 {
119        let new_name = if let Some(ext) = extension {
120            format!("{} ({}).{}", stem, i, ext)
121        } else {
122            format!("{} ({})", stem, i)
123        };
124
125        let new_path = parent.join(&new_name);
126        if !new_path.exists() {
127            return new_path;
128        }
129    }
130
131    // Fallback: use timestamp
132    let timestamp = std::time::SystemTime::now()
133        .duration_since(std::time::UNIX_EPOCH)
134        .map(|d| d.as_secs())
135        .unwrap_or(0);
136
137    let new_name = if let Some(ext) = extension {
138        format!("{}_{}.{}", stem, timestamp, ext)
139    } else {
140        format!("{}_{}", stem, timestamp)
141    };
142
143    parent.join(&new_name)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_auto_rename_path() {
152        let path = PathBuf::from("/tmp/test.txt");
153        let renamed = auto_rename_path(&path);
154        assert!(renamed.to_string_lossy().contains("test (1).txt"));
155    }
156
157    #[test]
158    fn test_auto_rename_no_extension() {
159        let path = PathBuf::from("/tmp/testfile");
160        let renamed = auto_rename_path(&path);
161        assert!(renamed.to_string_lossy().contains("testfile (1)"));
162    }
163}