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/// A conflict awaiting an answer from the caller.
111///
112/// The running operation stays alive and blocked on this request, so answering
113/// it resolves exactly one item and lets the rest of the operation continue.
114/// Dropping the request without answering is equivalent to [`ConflictResolution::Skip`].
115#[derive(Debug)]
116pub struct ConflictRequest {
117    conflict: Conflict,
118    responder: tokio::sync::oneshot::Sender<ConflictResolution>,
119}
120
121impl ConflictRequest {
122    pub(crate) fn new(
123        conflict: Conflict,
124        responder: tokio::sync::oneshot::Sender<ConflictResolution>,
125    ) -> Self {
126        Self {
127            conflict,
128            responder,
129        }
130    }
131
132    /// The conflict that needs resolving.
133    pub fn conflict(&self) -> &Conflict {
134        &self.conflict
135    }
136
137    /// The kind of conflict.
138    pub fn kind(&self) -> ConflictKind {
139        self.conflict.kind
140    }
141
142    /// Answer the request, unblocking the operation.
143    pub fn resolve(self, resolution: ConflictResolution) {
144        // A closed receiver means the operation already ended (e.g. it was
145        // cancelled); there is nothing left to answer.
146        let _ = self.responder.send(resolution);
147    }
148}
149
150/// Emit a conflict on `tx` and wait for the caller's answer.
151///
152/// A dropped request skips the item. Cancellation or a closed result channel
153/// aborts, including while delivery is blocked by a full channel.
154pub(crate) async fn request_resolution<T, F>(
155    tx: &tokio::sync::mpsc::Sender<T>,
156    conflict: Conflict,
157    wrap: F,
158    token: &tokio_util::sync::CancellationToken,
159) -> ConflictResolution
160where
161    F: FnOnce(ConflictRequest) -> T,
162{
163    let (responder, reply) = tokio::sync::oneshot::channel();
164    tokio::select! {
165        biased;
166        _ = token.cancelled() => return ConflictResolution::Abort,
167        result = tx.send(wrap(ConflictRequest::new(conflict, responder))) => {
168            if result.is_err() {
169                return ConflictResolution::Abort;
170            }
171        }
172    }
173    tokio::select! {
174        biased;
175        _ = token.cancelled() => ConflictResolution::Abort,
176        _ = tx.closed() => ConflictResolution::Abort,
177        result = reply => result.unwrap_or(ConflictResolution::Skip),
178    }
179}
180
181/// Returns true if nothing exists at `path` (including a dangling symlink).
182///
183/// Uses `symlink_metadata` rather than `exists()` so a broken symlink at the
184/// candidate name is treated as occupied — `exists()` follows the link and
185/// would report it free, leading us to clobber the link's would-be target.
186fn path_is_free(path: &Path) -> bool {
187    path.symlink_metadata().is_err()
188}
189
190/// Generate an auto-renamed path to avoid conflicts.
191///
192/// For "file.txt", tries "file (1).txt", "file (2).txt", etc.
193///
194/// This is a best-effort hint: callers still create the destination with
195/// `create_new`/exclusive semantics, since any existence check is inherently
196/// racy (TOCTOU).
197pub fn auto_rename_path(path: &Path) -> PathBuf {
198    let parent = path.parent().unwrap_or(std::path::Path::new(""));
199    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
200    let extension = path.extension().and_then(|e| e.to_str());
201
202    for i in 1..1000 {
203        let new_name = if let Some(ext) = extension {
204            format!("{} ({}).{}", stem, i, ext)
205        } else {
206            format!("{} ({})", stem, i)
207        };
208
209        let new_path = parent.join(&new_name);
210        if path_is_free(&new_path) {
211            return new_path;
212        }
213    }
214
215    // Fallback: use a timestamp, still probing for a free name.
216    let timestamp = std::time::SystemTime::now()
217        .duration_since(std::time::UNIX_EPOCH)
218        .map(|d| d.as_secs())
219        .unwrap_or(0);
220
221    for suffix in 0.. {
222        let base = if suffix == 0 {
223            format!("{}_{}", stem, timestamp)
224        } else {
225            format!("{}_{}_{}", stem, timestamp, suffix)
226        };
227        let new_name = match extension {
228            Some(ext) => format!("{}.{}", base, ext),
229            None => base,
230        };
231        let new_path = parent.join(&new_name);
232        if path_is_free(&new_path) {
233            return new_path;
234        }
235    }
236
237    // Unreachable in practice (the loop above is effectively unbounded).
238    parent.join(format!("{}_{}", stem, timestamp))
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[tokio::test]
246    async fn cancellation_interrupts_unanswered_conflict() {
247        let token = tokio_util::sync::CancellationToken::new();
248        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
249        let worker_token = token.clone();
250        let task = tokio::spawn(async move {
251            request_resolution(
252                &tx,
253                Conflict::file_exists("a".into(), "b".into()),
254                |r| r,
255                &worker_token,
256            )
257            .await
258        });
259        // Keep the answer channel alive to reproduce a stalled UI dialog.
260        let request = rx.recv().await.unwrap();
261        token.cancel();
262        let result = tokio::time::timeout(std::time::Duration::from_secs(2), task)
263            .await
264            .unwrap()
265            .unwrap();
266        assert_eq!(result, ConflictResolution::Abort);
267        request.resolve(ConflictResolution::Overwrite);
268    }
269
270    #[tokio::test]
271    async fn cancellation_interrupts_full_result_channel() {
272        let token = tokio_util::sync::CancellationToken::new();
273        let (tx, _rx) = tokio::sync::mpsc::channel(1);
274        tx.send(()).await.unwrap();
275        let pending = request_resolution(
276            &tx,
277            Conflict::file_exists("a".into(), "b".into()),
278            |_| (),
279            &token,
280        );
281        token.cancel();
282        assert_eq!(
283            tokio::time::timeout(std::time::Duration::from_secs(2), pending)
284                .await
285                .unwrap(),
286            ConflictResolution::Abort
287        );
288    }
289
290    #[test]
291    fn test_auto_rename_path() {
292        let path = PathBuf::from("/tmp/test.txt");
293        let renamed = auto_rename_path(&path);
294        assert!(renamed.to_string_lossy().contains("test (1).txt"));
295    }
296
297    #[test]
298    fn test_auto_rename_no_extension() {
299        let path = PathBuf::from("/tmp/testfile");
300        let renamed = auto_rename_path(&path);
301        assert!(renamed.to_string_lossy().contains("testfile (1)"));
302    }
303}