Skip to main content

agentmux_srv/backend/fs_ops/
jobs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Copy and move as background jobs: `fs.op.start`, `fs.op.resolve`,
5//! `fs.op.cancel`, with progress published as `files:op` events.
6//!
7//! Each op runs on its own std thread, never on the async runtime: copying
8//! blocks for as long as the disk takes, and a conflict blocks until the
9//! user answers. At most `JobConfig::max_concurrent` ops work at once; the
10//! rest wait their turn, reported as `running` with no progress.
11//!
12//! The rules every step follows, because each guards against losing data:
13//! - A file is written under a temporary name in the destination folder and
14//!   only then renamed into place, so a partial file never carries the real
15//!   name, and canceling or failing removes it.
16//! - Replace never truncates: a file over a file is a rename over it; any
17//!   other replace first moves the old entry aside and puts it back if the
18//!   new one can't be put in its place.
19//! - A move deletes a source only after its copy fully succeeded; a folder
20//!   is removed only once everything inside it was moved.
21//! - No step follows a link: links are copied as links, never walked.
22//! - Nothing that exists is overwritten without an answer: every create is
23//!   a create-new or a no-replace rename, so a name taken in the meantime is
24//!   a fresh conflict, not a silent overwrite.
25//!
26//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §7.1, §7.2, §9.
27
28use std::collections::{HashMap, HashSet};
29use std::ffi::{OsStr, OsString};
30use std::io::{self, Read, Write};
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
34use std::time::{Duration, Instant};
35
36use crate::backend::mps::Broker;
37use crate::backend::rpc_types::{
38    FsOpChoice, FsOpConflict, FsOpEvent, FsOpEventState, FsOpKind, FsOpResult, FsOpStartReq, FsOpStartResult,
39};
40
41use super::platform::{self, display_path};
42use super::{path_key, resolve_entry_path, resolve_request_path, ProtectedPaths};
43
44/// MPS event carrying an op's progress. Scoped to `block:<id>`; payload
45/// `FsOpEvent`.
46pub const EVENT_FILES_OP: &str = "files:op";
47
48/// `running` events are sent at most this often (spec §10: one patch per
49/// ~100 ms). Every other state goes out at once.
50const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
51
52/// How many `name (n)` candidates keep-both tries before giving up.
53const MAX_KEEP_BOTH: u32 = 10_000;
54
55/// How many times one item is retried when its destination keeps appearing
56/// between the check and the write.
57const MAX_RACE_RETRIES: usize = 8;
58
59/// Delivers an op's events. In production, a `files:op` publish scoped to
60/// the block that started it.
61pub type Emit = Arc<dyn Fn(&FsOpEvent) + Send + Sync>;
62
63/// Called after each chunk of a file is written, with the bytes written so
64/// far. Tests use it to cancel mid-file.
65type ChunkHook = Arc<dyn Fn(&OpControl, u64) + Send + Sync>;
66
67/// The `Emit` that publishes `files:op` to `block:<block_id>`.
68pub fn broker_emitter(broker: Arc<Broker>, block_id: String) -> Emit {
69    let scope = format!("block:{block_id}");
70    Arc::new(move |event: &FsOpEvent| {
71        broker.publish_event::<crate::backend::mps_events::FilesOp>(event, vec![scope.clone()], 0);
72    })
73}
74
75// ── Registry ─────────────────────────────────────────────────────────────
76
77/// Tunables, with test seams that production leaves unset.
78pub struct JobConfig {
79    /// Bytes per read/write: bounds memory, and is how often a large file
80    /// checks for cancel.
81    pub chunk_size: usize,
82    /// Ops working at once; later ones queue.
83    pub max_concurrent: usize,
84    /// How long a conflict waits for an answer before the op stops by
85    /// itself. A pane closed mid-question never answers; without a limit
86    /// its op would hold a slot, and its thread, until srv exits.
87    pub conflict_wait_limit: Duration,
88    protect: Option<ProtectedPaths>,
89    /// Treat every move as crossing volumes, so tests reach copy-then-delete
90    /// without a second volume.
91    assume_cross_volume: bool,
92    after_chunk: Option<ChunkHook>,
93}
94
95impl Default for JobConfig {
96    fn default() -> Self {
97        Self {
98            chunk_size: 1 << 20,
99            max_concurrent: 4,
100            conflict_wait_limit: Duration::from_secs(60 * 60),
101            protect: None,
102            assume_cross_volume: false,
103            after_chunk: None,
104        }
105    }
106}
107
108/// One op's controls, shared by its thread and the RPC handlers.
109#[derive(Default)]
110pub struct OpControl {
111    canceled: AtomicBool,
112    question: Mutex<Question>,
113    answered: Condvar,
114}
115
116#[derive(Default)]
117struct Question {
118    /// The op is blocked in the `conflict` state.
119    waiting: bool,
120    answer: Option<(FsOpChoice, bool)>,
121}
122
123impl OpControl {
124    fn is_canceled(&self) -> bool {
125        self.canceled.load(Ordering::SeqCst)
126    }
127
128    /// Set the flag, then wake a conflict wait. The flag is set before the
129    /// lock is taken and the waiter checks it under the lock, so the wake
130    /// can't be missed.
131    pub fn cancel(&self) {
132        self.canceled.store(true, Ordering::SeqCst);
133        let _question = lock(&self.question);
134        self.answered.notify_all();
135    }
136}
137
138/// A lock recovered if a panic elsewhere poisoned it: every holder leaves
139/// the guarded state consistent, and a poisoned lock must not wedge every
140/// later op.
141fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
142    m.lock().unwrap_or_else(|e| e.into_inner())
143}
144
145/// The ops in flight, and the slots they take turns on.
146pub struct Jobs {
147    config: JobConfig,
148    ops: Mutex<HashMap<String, Arc<OpControl>>>,
149    active: Mutex<usize>,
150    slot_freed: Condvar,
151}
152
153/// The process-wide job registry behind the `fs.op.*` handlers. Process-wide
154/// rather than per connection, so a window reload doesn't orphan an op.
155pub static JOBS: LazyLock<Arc<Jobs>> = LazyLock::new(|| Arc::new(Jobs::new(JobConfig::default())));
156
157impl Jobs {
158    pub fn new(config: JobConfig) -> Self {
159        Self { config, ops: Mutex::new(HashMap::new()), active: Mutex::new(0), slot_freed: Condvar::new() }
160    }
161
162    fn protected(&self) -> ProtectedPaths {
163        self.config.protect.clone().unwrap_or_else(ProtectedPaths::current)
164    }
165
166    /// `fs.op.start`: validate, then run in the background. Blocking (it
167    /// stats and canonicalizes), so call it from `spawn_blocking`.
168    pub fn start(self: &Arc<Self>, req: &FsOpStartReq, emit: Emit) -> Result<FsOpStartResult, String> {
169        let plan = plan(req, &self.protected())?;
170        let op_id = uuid::Uuid::new_v4().to_string();
171        let control = Arc::new(OpControl::default());
172        lock(&self.ops).insert(op_id.clone(), control.clone());
173
174        let jobs = self.clone();
175        let id = op_id.clone();
176        let spawned = std::thread::Builder::new()
177            .name("fs-op".to_string())
178            .spawn(move || jobs.run(id, plan, control, emit));
179        if let Err(e) = spawned {
180            lock(&self.ops).remove(&op_id);
181            return Err(format!("Couldn't start the operation: {e}"));
182        }
183        Ok(FsOpStartResult { op_id })
184    }
185
186    /// Run `work` as an op of `kind` in this registry, the way [`Self::start`]
187    /// runs a local one: its own thread, a slot, cancel and conflict answers
188    /// through `fs.op.cancel` and `fs.op.resolve`, progress as `files:op`
189    /// events, and the audit line at the end (`dest` and `requested` are for
190    /// it). For transfers that touch an SSH host (`host_jobs`).
191    pub(super) fn start_host(
192        self: &Arc<Self>,
193        kind: FsOpKind,
194        requested: usize,
195        dest: String,
196        emit: Emit,
197        work: Box<dyn FnOnce(&mut HostJob<'_, '_>) -> Result<(), HostStop> + Send>,
198    ) -> Result<FsOpStartResult, String> {
199        let op_id = uuid::Uuid::new_v4().to_string();
200        let control = Arc::new(OpControl::default());
201        lock(&self.ops).insert(op_id.clone(), control.clone());
202        let jobs = self.clone();
203        let id = op_id.clone();
204        let spawned = std::thread::Builder::new().name("fs-op-host".to_string()).spawn(move || {
205            let _registered = Registered { jobs: &jobs, op_id: &id };
206            let mut job = Job::new(&jobs.config, id.clone(), kind, control.clone(), emit);
207            job.emit_now(FsOpEventState::Running);
208            let ending = match jobs.take_slot(&control) {
209                None => Ending::Canceled,
210                Some(_slot) => {
211                    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
212                        let mut host = HostJob { job: &mut job };
213                        work(&mut host)
214                    }));
215                    match result {
216                        Ok(Ok(())) => Ending::Done,
217                        Ok(Err(HostStop::Canceled)) => Ending::Canceled,
218                        Err(_) => {
219                            tracing::error!(op_id = %id, "fs.op: a host transfer panicked");
220                            Ending::Failed("The operation stopped unexpectedly.".to_string())
221                        }
222                    }
223                }
224            };
225            job.finish(ending, requested, &dest);
226        });
227        if let Err(e) = spawned {
228            lock(&self.ops).remove(&op_id);
229            return Err(format!("Couldn't start the operation: {e}"));
230        }
231        Ok(FsOpStartResult { op_id })
232    }
233
234    /// `fs.op.resolve`. Only an op blocked on a conflict takes an answer: one
235    /// stored ahead of time would silently decide whatever conflict came
236    /// next.
237    pub fn resolve(&self, op_id: &str, choice: FsOpChoice, apply_to_all: bool) -> Result<(), String> {
238        let control = lock(&self.ops)
239            .get(op_id)
240            .cloned()
241            .ok_or_else(|| "That operation has already finished.".to_string())?;
242        let mut question = lock(&control.question);
243        if !question.waiting || question.answer.is_some() {
244            return Err("That operation isn't waiting for an answer.".to_string());
245        }
246        question.answer = Some((choice, apply_to_all));
247        control.answered.notify_all();
248        Ok(())
249    }
250
251    /// `fs.op.cancel`. Wakes the op whether it is copying, waiting on a
252    /// conflict or queued for a slot. Unknown or finished ops are ignored.
253    pub fn cancel(&self, op_id: &str) {
254        let Some(control) = lock(&self.ops).get(op_id).cloned() else {
255            return;
256        };
257        control.cancel();
258        // A queued op waits on `slot_freed`, not on its own condvar.
259        let _active = lock(&self.active);
260        self.slot_freed.notify_all();
261    }
262
263    #[cfg(test)]
264    fn op_count(&self) -> usize {
265        lock(&self.ops).len()
266    }
267
268    /// Block until a slot is free, or return `None` if canceled first.
269    fn take_slot(&self, control: &OpControl) -> Option<Slot<'_>> {
270        let mut active = lock(&self.active);
271        loop {
272            if control.is_canceled() {
273                return None;
274            }
275            if *active < self.config.max_concurrent {
276                *active += 1;
277                return Some(Slot { jobs: self });
278            }
279            active = self.slot_freed.wait(active).unwrap_or_else(|e| e.into_inner());
280        }
281    }
282
283    /// The op's thread. Its registry entry and slot are released by guards,
284    /// so they go however the op ends, a panic included.
285    fn run(self: Arc<Self>, op_id: String, plan: Plan, control: Arc<OpControl>, emit: Emit) {
286        let _registered = Registered { jobs: &self, op_id: &op_id };
287        let mut job = Job::new(&self.config, op_id.clone(), plan.kind, control.clone(), emit);
288        // The first event, at once, so the op shows up even while queued.
289        job.emit_now(FsOpEventState::Running);
290        let ending = match self.take_slot(&control) {
291            None => Ending::Canceled,
292            Some(_slot) => {
293                job.protect = self.protected();
294                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.execute(&plan))).unwrap_or_else(|_| {
295                    tracing::error!(op_id, "fs.op: the operation panicked");
296                    Ending::Failed("The operation stopped unexpectedly.".to_string())
297                })
298            }
299        };
300        job.finish(ending, plan.requested, &display_path(&plan.dest_dir));
301    }
302}
303
304/// How a host transfer stopped early.
305pub(super) enum HostStop {
306    /// Canceled, or nobody answered a conflict (the job says why).
307    Canceled,
308}
309
310/// A host transfer's handle on its op: the same progress, failures, cancel
311/// and conflict questions as a local copy's (`host_jobs`).
312pub(super) struct HostJob<'j, 'c> {
313    job: &'j mut Job<'c>,
314}
315
316impl HostJob<'_, '_> {
317    pub fn is_canceled(&self) -> bool {
318        self.job.control.is_canceled()
319    }
320
321    /// Bytes per read and write.
322    pub fn chunk_size(&self) -> usize {
323        self.job.config.chunk_size
324    }
325
326    /// What is about to be copied, counted as progress is.
327    pub fn add_total(&mut self, items: u64, bytes: u64) {
328        self.job.total_items += items;
329        self.job.total_bytes += bytes;
330        self.job.emit_now(FsOpEventState::Running);
331    }
332
333    pub fn set_current(&mut self, path: &str) {
334        self.job.current = Some(path.to_string());
335    }
336
337    pub fn advance(&mut self, items: u64, bytes: u64) {
338        self.job.advance(Measure { items, bytes });
339        self.job.tick();
340    }
341
342    pub fn fail(&mut self, path: &str, error: String) {
343        tracing::debug!(op_id = %self.job.op_id, path = %path, error = %error, "fs.op: an item failed");
344        self.job.failures.push(FsOpResult { path: path.to_string(), ok: false, error: Some(error) });
345    }
346
347    /// The user's answer about `dest` already being there; `None` when
348    /// canceled or nobody answered in time.
349    pub fn ask(&mut self, src: &str, src_side: EntrySide, dest: &str, dest_side: EntrySide) -> Option<FsOpChoice> {
350        self.job.ask_about(src, src_side, dest, dest_side)
351    }
352}
353
354/// Removes an op from the registry when its thread ends.
355struct Registered<'a> {
356    jobs: &'a Jobs,
357    op_id: &'a str,
358}
359
360impl Drop for Registered<'_> {
361    fn drop(&mut self) {
362        lock(&self.jobs.ops).remove(self.op_id);
363    }
364}
365
366/// One of the `max_concurrent` working slots.
367struct Slot<'a> {
368    jobs: &'a Jobs,
369}
370
371impl Drop for Slot<'_> {
372    fn drop(&mut self) {
373        *lock(&self.jobs.active) -= 1;
374        // Every waiter, not one: a woken waiter that was canceled leaves
375        // without taking the slot, and a single wake would be lost with it.
376        self.jobs.slot_freed.notify_all();
377    }
378}
379
380// ── Plan (validated before `fs.op.start` answers) ────────────────────────
381
382struct Plan {
383    kind: FsOpKind,
384    dest_dir: PathBuf,
385    items: Vec<PlanItem>,
386    /// Sources as requested, for the audit line (no-op moves included).
387    requested: usize,
388}
389
390struct PlanItem {
391    src: PathBuf,
392    dest: PathBuf,
393    /// Copying an entry into the folder it is already in: there's nothing
394    /// to replace or skip, so it is always kept as `name (2)`, like a
395    /// duplicate in a file manager.
396    duplicate: bool,
397}
398
399fn verb(kind: FsOpKind) -> &'static str {
400    match kind {
401        FsOpKind::Copy => "copy",
402        FsOpKind::Move => "move",
403    }
404}
405
406fn plan(req: &FsOpStartReq, protect: &ProtectedPaths) -> Result<Plan, String> {
407    let verb = verb(req.kind);
408    if req.sources.is_empty() {
409        return Err(format!("Nothing was given to {verb}."));
410    }
411    let dest_dir = resolve_request_path(&req.dest_dir)?.canonicalize().map_err(|e| match e.kind() {
412        io::ErrorKind::NotFound => "The destination folder doesn't exist.".to_string(),
413        io::ErrorKind::PermissionDenied => "You don't have permission to open the destination folder.".to_string(),
414        _ => format!("Couldn't open the destination folder: {e}"),
415    })?;
416    if !std::fs::metadata(&dest_dir).is_ok_and(|m| m.is_dir()) {
417        return Err("The destination isn't a folder.".to_string());
418    }
419    let dest_key = path_key(&dest_dir);
420
421    let mut items = Vec::with_capacity(req.sources.len());
422    for raw in &req.sources {
423        let src = resolve_entry_path(raw)?;
424        let name = src.file_name().ok_or_else(|| format!("“{raw}” isn't a valid path."))?.to_os_string();
425        let shown = name.to_string_lossy().into_owned();
426        std::fs::symlink_metadata(&src).map_err(|e| match e.kind() {
427            io::ErrorKind::NotFound => format!("“{shown}” doesn't exist anymore."),
428            io::ErrorKind::PermissionDenied => format!("You don't have permission to {verb} “{shown}”."),
429            _ => format!("Couldn't read “{shown}”: {e}"),
430        })?;
431        // Path keys: canonical parents, case-folded on Windows. The source
432        // keeps its own last component, so a link to an ancestor is not
433        // mistaken for the ancestor (it is copied as a link).
434        let src_key = path_key(&src);
435        if dest_key.starts_with(&src_key) {
436            return Err(format!("Can't {verb} a folder into itself."));
437        }
438        let in_dest_already = src.parent().map(path_key).as_ref() == Some(&dest_key);
439        if in_dest_already && req.kind == FsOpKind::Move {
440            // Already there: nothing to do.
441            continue;
442        }
443        let dest = dest_dir.join(&name);
444        protect.check(&dest)?;
445        if req.kind == FsOpKind::Move {
446            protect.check(&src)?;
447        }
448        items.push(PlanItem { src, dest, duplicate: in_dest_already });
449    }
450
451    // A destination entry must not hold another source: merging into it,
452    // or replacing something in it, would act on that source before (or
453    // while) it is copied, and a replace could delete it. E.g. copying
454    // `/a/d/d` into `/a` merges into `/a/d`, which holds the source itself.
455    let landing: HashSet<OsString> = items
456        .iter()
457        .filter(|i| !i.duplicate)
458        .filter_map(|i| path_key(&i.dest).file_name().map(OsStr::to_os_string))
459        .collect();
460    for item in &items {
461        let src_key = path_key(&item.src);
462        let Ok(rel) = src_key.strip_prefix(&dest_key) else { continue };
463        let mut parts = rel.components();
464        if let (Some(first), Some(_)) = (parts.next(), parts.next()) {
465            if landing.contains(first.as_os_str()) {
466                return Err(format!(
467                    "Can't {verb} there: “{}” would land on the folder that holds “{}”.",
468                    first.as_os_str().to_string_lossy(),
469                    file_name_lossy(&item.src)
470                ));
471            }
472        }
473    }
474
475    Ok(Plan { kind: req.kind, dest_dir, items, requested: req.sources.len() })
476}
477
478fn file_name_lossy(path: &Path) -> String {
479    path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| display_path(path))
480}
481
482// ── The job ──────────────────────────────────────────────────────────────
483
484/// What became of one entry and everything under it.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486enum Outcome {
487    /// Fully copied or moved.
488    Done,
489    /// Skipped, or failed (the failure is recorded). A move keeps the
490    /// source, and the folder holding it.
491    NotDone,
492    /// Canceled, or stopped waiting for an answer: end the op now.
493    Stopped,
494}
495
496/// The destination's name was taken between the check and the write: look
497/// again (it is now a conflict, or a folder to merge into).
498struct Raced;
499
500/// How to put an entry at its destination.
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502enum How {
503    /// Nothing is there; fail with `Raced` if something appears.
504    New,
505    /// Under the first free `name (n)`.
506    KeepBoth,
507    /// Over what is there, which had this type when the user was asked.
508    Replace(std::fs::FileType),
509}
510
511enum Ending {
512    Done,
513    Canceled,
514    Failed(String),
515}
516
517/// One side of a conflict, as the prompt shows it: a local entry's metadata
518/// or a host's (`host_jobs`).
519#[derive(Debug, Clone, Copy, Default)]
520pub(super) struct EntrySide {
521    pub is_dir: bool,
522    /// A file's size; `None` for a folder.
523    pub size: Option<u64>,
524    pub mtime: Option<u64>,
525}
526
527impl EntrySide {
528    fn of(meta: &std::fs::Metadata) -> Self {
529        Self { is_dir: meta.is_dir(), size: meta.is_file().then_some(meta.len()), mtime: mtime_ms(meta) }
530    }
531}
532
533/// Entries and file bytes under a path, counted the same way as progress.
534#[derive(Debug, Clone, Copy, Default)]
535struct Measure {
536    items: u64,
537    bytes: u64,
538}
539
540impl Measure {
541    fn of(meta: &std::fs::Metadata) -> Self {
542        Self { items: 1, bytes: if meta.is_file() { meta.len() } else { 0 } }
543    }
544}
545
546enum CopyError {
547    Stopped,
548    Failed(String),
549}
550
551struct Job<'a> {
552    config: &'a JobConfig,
553    op_id: String,
554    kind: FsOpKind,
555    control: Arc<OpControl>,
556    emit: Emit,
557    protect: ProtectedPaths,
558    done_items: u64,
559    total_items: u64,
560    done_bytes: u64,
561    total_bytes: u64,
562    current: Option<String>,
563    failures: Vec<FsOpResult>,
564    /// An answer given with "apply to all".
565    sticky: Option<FsOpChoice>,
566    /// Why the op stopped by itself, for the final `canceled` event.
567    stop_reason: Option<String>,
568    last_running: Option<Instant>,
569    buf: Vec<u8>,
570}
571
572impl<'a> Job<'a> {
573    fn new(config: &'a JobConfig, op_id: String, kind: FsOpKind, control: Arc<OpControl>, emit: Emit) -> Self {
574        Self {
575            config,
576            op_id,
577            kind,
578            control,
579            emit,
580            protect: ProtectedPaths::default(),
581            done_items: 0,
582            total_items: 0,
583            done_bytes: 0,
584            total_bytes: 0,
585            current: None,
586            failures: Vec::new(),
587            sticky: None,
588            stop_reason: None,
589            last_running: None,
590            buf: Vec::new(),
591        }
592    }
593
594    fn verb(&self) -> &'static str {
595        verb(self.kind)
596    }
597
598    // ── Events ──
599
600    fn event(&self, state: FsOpEventState) -> FsOpEvent {
601        FsOpEvent {
602            op_id: self.op_id.clone(),
603            kind: self.kind,
604            state,
605            done_items: self.done_items,
606            total_items: self.total_items,
607            done_bytes: self.done_bytes,
608            total_bytes: self.total_bytes,
609            current: self.current.clone(),
610            conflict: None,
611            error: None,
612            failures: None,
613        }
614    }
615
616    fn emit_now(&mut self, state: FsOpEventState) {
617        if state == FsOpEventState::Running {
618            self.last_running = Some(Instant::now());
619        }
620        (self.emit)(&self.event(state));
621    }
622
623    /// A `running` event, unless one went out within `PROGRESS_INTERVAL`.
624    /// Dropping one loses nothing: the next carries the counts, and the
625    /// final event always goes out.
626    fn tick(&mut self) {
627        if self.last_running.is_none_or(|t| t.elapsed() >= PROGRESS_INTERVAL) {
628            self.emit_now(FsOpEventState::Running);
629        }
630    }
631
632    fn set_current(&mut self, src: &Path) {
633        self.current = Some(display_path(src));
634    }
635
636    fn advance(&mut self, m: Measure) {
637        self.done_items += m.items;
638        self.done_bytes += m.bytes;
639    }
640
641    fn fail(&mut self, src: &Path, error: String) {
642        tracing::debug!(op_id = %self.op_id, path = %display_path(src), error = %error, "fs.op: an item failed");
643        self.failures.push(FsOpResult { path: display_path(src), ok: false, error: Some(error) });
644    }
645
646    /// Count `src`'s subtree as handled without copying it (skipped, or
647    /// failed before anything was written), so progress still reaches the
648    /// total.
649    fn pass_over(&mut self, src: &Path, meta: &std::fs::Metadata, hint: Option<Measure>) {
650        let m = match hint {
651            Some(m) => m,
652            None if meta.is_dir() => self.measure(src).unwrap_or_default(),
653            None => Measure::of(meta),
654        };
655        self.advance(m);
656    }
657
658    fn finish(&mut self, ending: Ending, requested: usize, dest: &str) {
659        let (state, error, outcome) = match ending {
660            Ending::Done => (FsOpEventState::Done, None, "done"),
661            Ending::Canceled => (FsOpEventState::Canceled, self.stop_reason.clone(), "canceled"),
662            Ending::Failed(e) => (FsOpEventState::Failed, Some(e), "failed"),
663        };
664        tracing::info!(
665            op = "fs.op",
666            op_id = %self.op_id,
667            kind = self.verb(),
668            sources = requested,
669            dest = %dest,
670            outcome,
671            done_items = self.done_items,
672            total_items = self.total_items,
673            failures = self.failures.len(),
674            error = error.as_deref().unwrap_or(""),
675            "fs mutation"
676        );
677        let mut event = self.event(state);
678        event.current = None;
679        event.error = error;
680        event.failures = (!self.failures.is_empty()).then(|| self.failures.clone());
681        (self.emit)(&event);
682    }
683
684    // ── The run ──
685
686    fn execute(&mut self, plan: &Plan) -> Ending {
687        // The folder may have gone while the op was queued.
688        if !std::fs::symlink_metadata(&plan.dest_dir).is_ok_and(|m| m.is_dir()) {
689            return Ending::Failed("The destination folder doesn't exist anymore.".to_string());
690        }
691        self.buf = vec![0; self.config.chunk_size.max(1)];
692
693        // Totals first, so the bar means something from the start.
694        let mut measures = Vec::with_capacity(plan.items.len());
695        for item in &plan.items {
696            let Some(m) = self.measure(&item.src) else { return Ending::Canceled };
697            self.total_items += m.items;
698            self.total_bytes += m.bytes;
699            measures.push(m);
700        }
701        self.emit_now(FsOpEventState::Running);
702
703        for (item, m) in plan.items.iter().zip(measures) {
704            let outcome = self.transfer(&item.src, &item.dest, item.duplicate, Some(m), false);
705            if outcome == Outcome::Stopped {
706                return Ending::Canceled;
707            }
708        }
709        Ending::Done
710    }
711
712    /// Count entries and file bytes under `path` without following links.
713    /// What can't be read counts as one entry; the copy reports it.
714    /// `None` if canceled meanwhile.
715    fn measure(&self, path: &Path) -> Option<Measure> {
716        let mut m = Measure::default();
717        let Ok(root) = std::fs::symlink_metadata(path) else { return Some(Measure { items: 1, bytes: 0 }) };
718        let mut stack = vec![(path.to_path_buf(), root)];
719        while let Some((p, meta)) = stack.pop() {
720            if self.control.is_canceled() {
721                return None;
722            }
723            let own = Measure::of(&meta);
724            m.items += own.items;
725            m.bytes += own.bytes;
726            if meta.is_dir() {
727                let Ok(entries) = std::fs::read_dir(&p) else { continue };
728                // `DirEntry::metadata` never follows a link.
729                for entry in entries.flatten() {
730                    if let Ok(meta) = entry.metadata() {
731                        stack.push((entry.path(), meta));
732                    }
733                }
734            }
735        }
736        Some(m)
737    }
738
739    /// Copy or move `src` to `dest`, asking about a conflict, merging a
740    /// folder into a folder. `hint` is `src`'s measure when already known.
741    /// `cross` is set inside a move that already found it crosses volumes,
742    /// so each entry doesn't retry the rename.
743    fn transfer(&mut self, src: &Path, dest: &Path, duplicate: bool, hint: Option<Measure>, cross: bool) -> Outcome {
744        if self.control.is_canceled() {
745            return Outcome::Stopped;
746        }
747        self.set_current(src);
748        self.tick();
749
750        let meta = match std::fs::symlink_metadata(src) {
751            Ok(meta) => meta,
752            Err(e) => {
753                let message = match e.kind() {
754                    io::ErrorKind::NotFound => format!("“{}” doesn't exist anymore.", file_name_lossy(src)),
755                    _ => describe(&e, "read", src),
756                };
757                self.fail(src, message);
758                self.advance(hint.unwrap_or(Measure { items: 1, bytes: 0 }));
759                return Outcome::NotDone;
760            }
761        };
762        // Re-checked at the moment of writing (spec §9): `fs.op.start`
763        // checked the top level, but the policy is computed afresh here and
764        // applies to every entry.
765        let checked = self.protect.check(dest).and_then(|()| match self.kind {
766            FsOpKind::Move => self.protect.check(src),
767            FsOpKind::Copy => Ok(()),
768        });
769        if let Err(message) = checked {
770            self.fail(src, message);
771            self.pass_over(src, &meta, hint);
772            return Outcome::NotDone;
773        }
774
775        for _ in 0..MAX_RACE_RETRIES {
776            let how = match std::fs::symlink_metadata(dest) {
777                Err(e) if e.kind() == io::ErrorKind::NotFound => How::New,
778                Err(e) => {
779                    self.fail(src, format!("Couldn't check the destination of “{}”: {e}", file_name_lossy(src)));
780                    self.pass_over(src, &meta, hint);
781                    return Outcome::NotDone;
782                }
783                Ok(_) if duplicate => How::KeepBoth,
784                // Both real folders (neither a link): merge, asking per
785                // entry inside (spec §7.2).
786                Ok(dest_meta) if meta.is_dir() && dest_meta.is_dir() => return self.merge(src, dest, cross),
787                Ok(dest_meta) => match self.ask(src, &meta, dest, &dest_meta) {
788                    None => return Outcome::Stopped,
789                    Some(FsOpChoice::Skip) => {
790                        self.pass_over(src, &meta, hint);
791                        return Outcome::NotDone;
792                    }
793                    Some(FsOpChoice::KeepBoth) => How::KeepBoth,
794                    Some(FsOpChoice::Replace) => How::Replace(dest_meta.file_type()),
795                },
796            };
797            match self.place(src, &meta, dest, how, hint, cross) {
798                Ok(outcome) => return outcome,
799                Err(Raced) => continue,
800            }
801        }
802        self.fail(src, format!("“{}” kept changing at the destination, so it was left alone.", file_name_lossy(src)));
803        self.pass_over(src, &meta, hint);
804        Outcome::NotDone
805    }
806
807    /// Block in `conflict` until answered. `None` if canceled, or if the
808    /// wait limit passed (then `stop_reason` says so).
809    fn ask(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, dest_meta: &std::fs::Metadata) -> Option<FsOpChoice> {
810        self.ask_about(&display_path(src), EntrySide::of(meta), &display_path(dest), EntrySide::of(dest_meta))
811    }
812
813    /// [`Self::ask`] for any two entries, local or on a host, by their display
814    /// paths and what they are.
815    fn ask_about(&mut self, src: &str, meta: EntrySide, dest: &str, dest_meta: EntrySide) -> Option<FsOpChoice> {
816        if let Some(choice) = self.sticky {
817            return Some(choice);
818        }
819        // `waiting` is set before the event goes out, so an answer sent the
820        // moment the prompt shows is never refused as "not waiting".
821        {
822            let mut question = lock(&self.control.question);
823            question.waiting = true;
824            question.answer = None;
825        }
826        let mut event = self.event(FsOpEventState::Conflict);
827        event.conflict = Some(FsOpConflict {
828            source: src.to_string(),
829            dest: dest.to_string(),
830            source_is_dir: meta.is_dir,
831            dest_is_dir: dest_meta.is_dir,
832            source_size: meta.size,
833            dest_size: dest_meta.size,
834            source_mtime: meta.mtime,
835            dest_mtime: dest_meta.mtime,
836        });
837        (self.emit)(&event);
838
839        let deadline = Instant::now() + self.config.conflict_wait_limit;
840        let answer = {
841            let mut question = lock(&self.control.question);
842            let answer = loop {
843                if self.control.is_canceled() {
844                    break None;
845                }
846                if let Some(answer) = question.answer.take() {
847                    break Some(answer);
848                }
849                let now = Instant::now();
850                if now >= deadline {
851                    let name = src.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(src);
852                    self.stop_reason = Some(format!("No one answered about “{name}”, so the operation stopped."));
853                    break None;
854                }
855                question = self.control.answered.wait_timeout(question, deadline - now).unwrap_or_else(|e| e.into_inner()).0;
856            };
857            question.waiting = false;
858            answer
859        };
860        let (choice, apply_to_all) = answer?;
861        if apply_to_all {
862            self.sticky = Some(choice);
863        }
864        self.emit_now(FsOpEventState::Running);
865        Some(choice)
866    }
867
868    /// Put `src` at `dest` the way `how` says.
869    fn place(
870        &mut self,
871        src: &Path,
872        meta: &std::fs::Metadata,
873        dest: &Path,
874        mut how: How,
875        hint: Option<Measure>,
876        cross: bool,
877    ) -> Result<Outcome, Raced> {
878        if let How::Replace(asked) = how {
879            // The answer may come long after the question. If what is there
880            // now isn't what the user agreed to replace (a file became a
881            // folder, say), ask again rather than delete something unseen.
882            match std::fs::symlink_metadata(dest) {
883                Ok(now) if now.file_type() == asked => {}
884                Err(e) if e.kind() == io::ErrorKind::NotFound => how = How::New,
885                _ => return Err(Raced),
886            }
887        }
888        let mut cross = cross || self.config.assume_cross_volume;
889        if self.kind == FsOpKind::Move && !cross {
890            match self.place_by_rename(src, meta, dest, how, hint) {
891                Renamed::Finished(outcome) => return Ok(outcome),
892                Renamed::Raced => return Err(Raced),
893                // Another volume: copy, then delete the source.
894                Renamed::CrossVolume => cross = true,
895            }
896        }
897        if meta.is_file() {
898            self.place_file(src, meta, dest, how)
899        } else if meta.is_symlink() {
900            self.place_link(src, meta, dest, how)
901        } else if meta.is_dir() {
902            self.place_dir(src, meta, dest, how, cross)
903        } else {
904            // A FIFO, socket or device: opening one to read could block
905            // forever or read without end.
906            self.fail(src, format!("“{}” isn't a file, folder or link, so it can't be copied.", file_name_lossy(src)));
907            self.advance(Measure::of(meta));
908            Ok(Outcome::NotDone)
909        }
910    }
911
912    // ── Move by rename (same volume) ──
913
914    fn place_by_rename(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How, hint: Option<Measure>) -> Renamed {
915        // Measured first: once renamed, there is nothing at `src` to count.
916        let m = match hint {
917            Some(m) => m,
918            None if meta.is_dir() => self.measure(src).unwrap_or_default(),
919            None => Measure::of(meta),
920        };
921        let result = match how {
922            How::New => platform::rename_no_replace(src, dest),
923            How::KeepBoth => keep_both(dest, meta.is_dir(), &self.protect, |c| platform::rename_no_replace(src, c)).map(drop),
924            How::Replace(_) => match std::fs::symlink_metadata(dest) {
925                // A file over a file: one atomic rename, nothing to put back.
926                Ok(d) if meta.is_file() && d.is_file() => std::fs::rename(src, dest),
927                Ok(_) => self.replace_aside(src, dest, |d| platform::rename_no_replace(src, d)),
928                Err(e) if e.kind() == io::ErrorKind::NotFound => platform::rename_no_replace(src, dest),
929                Err(e) => Err(e),
930            },
931        };
932        match result {
933            Ok(()) => {
934                self.advance(m);
935                Renamed::Finished(Outcome::Done)
936            }
937            Err(e) if e.kind() == io::ErrorKind::CrossesDevices => Renamed::CrossVolume,
938            Err(e) if e.kind() == io::ErrorKind::AlreadyExists && how == How::New => Renamed::Raced,
939            Err(e) => {
940                let message = describe(&e, "move", src);
941                self.fail(src, message);
942                self.advance(m);
943                Renamed::Finished(Outcome::NotDone)
944            }
945        }
946    }
947
948    // ── Copy (and move across volumes) ──
949
950    fn place_file(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How) -> Result<Outcome, Raced> {
951        let start_bytes = self.done_bytes;
952        let tmp = match self.copy_to_temp(src, meta, dest) {
953            Ok(tmp) => tmp,
954            Err(CopyError::Stopped) => return Ok(Outcome::Stopped),
955            Err(CopyError::Failed(message)) => {
956                self.fail(src, message);
957                self.settle_file(start_bytes, meta);
958                return Ok(Outcome::NotDone);
959            }
960        };
961        let placed = match how {
962            How::New => match platform::rename_no_replace(&tmp, dest) {
963                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
964                    discard(&tmp);
965                    self.done_bytes = start_bytes;
966                    return Err(Raced);
967                }
968                other => other,
969            },
970            How::KeepBoth => keep_both(dest, false, &self.protect, |c| platform::rename_no_replace(&tmp, c)).map(drop),
971            How::Replace(_) => match std::fs::symlink_metadata(dest) {
972                // A file over a file: renamed over it in one step, so the
973                // original is intact until the copy is complete.
974                Ok(d) if d.is_file() => std::fs::rename(&tmp, dest),
975                Ok(_) => self.replace_aside(src, dest, |d| platform::rename_no_replace(&tmp, d)),
976                Err(e) if e.kind() == io::ErrorKind::NotFound => platform::rename_no_replace(&tmp, dest),
977                Err(e) => Err(e),
978            },
979        };
980        self.settle_file(start_bytes, meta);
981        if let Err(e) = placed {
982            discard(&tmp);
983            let message = describe(&e, self.verb(), src);
984            self.fail(src, message);
985            return Ok(Outcome::NotDone);
986        }
987        if self.kind == FsOpKind::Move {
988            // Only now, with the copy complete and in place.
989            if let Err(e) = platform::remove_file_force(src) {
990                self.fail(src, format!("Copied “{}”, but couldn't remove the original: {e}", file_name_lossy(src)));
991                return Ok(Outcome::NotDone);
992            }
993        }
994        Ok(Outcome::Done)
995    }
996
997    /// The file's progress once it is handled, whatever happened: one item,
998    /// and its size (or what was written, if it grew).
999    fn settle_file(&mut self, start_bytes: u64, meta: &std::fs::Metadata) {
1000        self.done_bytes = self.done_bytes.max(start_bytes + meta.len());
1001        self.done_items += 1;
1002    }
1003
1004    /// Stream `src` into a new temporary file beside `dest`. On any error
1005    /// or cancel the temporary file is removed.
1006    fn copy_to_temp(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path) -> Result<PathBuf, CopyError> {
1007        let name = file_name_lossy(src);
1008        let mut from = platform::open_no_follow(src).map_err(|e| CopyError::Failed(describe(&e, "read", src)))?;
1009        // The opened file must still be a regular file: not a link or
1010        // anything else swapped in since it was listed.
1011        match from.metadata() {
1012            Ok(m) if m.is_file() => {}
1013            Ok(_) => return Err(CopyError::Failed(format!("“{name}” changed while it was being copied."))),
1014            Err(e) => return Err(CopyError::Failed(describe(&e, "read", src))),
1015        }
1016        let tmp = sibling_temp(dest, "part");
1017        let mut to = std::fs::OpenOptions::new()
1018            .write(true)
1019            .create_new(true)
1020            .open(&tmp)
1021            .map_err(|e| CopyError::Failed(describe(&e, self.verb(), src)))?;
1022
1023        let result = self.stream(&mut from, &mut to, src).and_then(|written| {
1024            // Best effort, as a file manager does: a filesystem that can't
1025            // keep a time or a mode still gets the contents.
1026            if let Ok(mtime) = meta.modified() {
1027                let _ = to.set_modified(mtime);
1028            }
1029            if self.kind == FsOpKind::Move {
1030                // The original is about to be deleted: the copy must be on
1031                // disk and whole first.
1032                to.sync_all().map_err(|e| CopyError::Failed(describe(&e, "move", src)))?;
1033                let now_len = from.metadata().map(|m| m.len()).unwrap_or(u64::MAX);
1034                let copied_len = to.metadata().map(|m| m.len()).unwrap_or(u64::MAX - 1);
1035                if now_len != written || copied_len != written {
1036                    return Err(CopyError::Failed(format!(
1037                        "“{name}” changed while it was being moved, so the original was kept."
1038                    )));
1039                }
1040            }
1041            // Last: a read-only mode would refuse the steps above.
1042            let _ = to.set_permissions(meta.permissions());
1043            Ok(())
1044        });
1045        drop(to);
1046        match result {
1047            Ok(()) => Ok(tmp),
1048            Err(e) => {
1049                discard(&tmp);
1050                Err(e)
1051            }
1052        }
1053    }
1054
1055    /// Copy all of `from` into `to` in `chunk_size` pieces, checking for
1056    /// cancel before each. Returns the bytes written.
1057    fn stream(&mut self, from: &mut std::fs::File, to: &mut std::fs::File, src: &Path) -> Result<u64, CopyError> {
1058        // The job's one buffer, lent out so `self` stays free for progress.
1059        let mut buf = std::mem::take(&mut self.buf);
1060        let result = self.stream_with(&mut buf, from, to, src);
1061        self.buf = buf;
1062        result
1063    }
1064
1065    fn stream_with(&mut self, buf: &mut [u8], from: &mut std::fs::File, to: &mut std::fs::File, src: &Path) -> Result<u64, CopyError> {
1066        let mut written = 0u64;
1067        loop {
1068            if self.control.is_canceled() {
1069                return Err(CopyError::Stopped);
1070            }
1071            let n = match from.read(buf) {
1072                Ok(0) => return Ok(written),
1073                Ok(n) => n,
1074                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
1075                Err(e) => return Err(CopyError::Failed(describe(&e, "read", src))),
1076            };
1077            to.write_all(&buf[..n]).map_err(|e| CopyError::Failed(describe(&e, self.verb(), src)))?;
1078            written += n as u64;
1079            self.done_bytes += n as u64;
1080            self.tick();
1081            if let Some(hook) = &self.config.after_chunk {
1082                hook(&self.control, written);
1083            }
1084        }
1085    }
1086
1087    fn place_link(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How) -> Result<Outcome, Raced> {
1088        let target = match std::fs::read_link(src) {
1089            Ok(target) => target,
1090            Err(e) => {
1091                let message = describe(&e, "read", src);
1092                self.fail(src, message);
1093                self.advance(Measure::of(meta));
1094                return Ok(Outcome::NotDone);
1095            }
1096        };
1097        let kind = meta.file_type();
1098        let make = |at: &Path| platform::create_symlink(&target, at, &kind);
1099        let made = match how {
1100            How::New => match make(dest) {
1101                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(Raced),
1102                other => other,
1103            },
1104            How::KeepBoth => keep_both(dest, false, &self.protect, make).map(drop),
1105            How::Replace(_) => self.replace_aside(src, dest, make),
1106        };
1107        self.advance(Measure::of(meta));
1108        if let Err(e) = made {
1109            let message = if platform::is_symlink_privilege_error(&e) {
1110                format!(
1111                    "Windows didn't allow creating the link “{}”. Turn on Developer Mode, or run AgentMux as an administrator.",
1112                    file_name_lossy(src)
1113                )
1114            } else {
1115                describe(&e, self.verb(), src)
1116            };
1117            self.fail(src, message);
1118            return Ok(Outcome::NotDone);
1119        }
1120        if self.kind == FsOpKind::Move {
1121            if let Err(e) = platform::remove_entry_no_follow(src) {
1122                self.fail(src, format!("Copied the link “{}”, but couldn't remove the original: {e}", file_name_lossy(src)));
1123                return Ok(Outcome::NotDone);
1124            }
1125        }
1126        Ok(Outcome::Done)
1127    }
1128
1129    fn place_dir(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How, cross: bool) -> Result<Outcome, Raced> {
1130        let mut aside = None;
1131        let created = match how {
1132            How::New => match std::fs::create_dir(dest) {
1133                Ok(()) => Ok(dest.to_path_buf()),
1134                // Now a folder (merge) or a conflict: look again.
1135                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(Raced),
1136                Err(e) => Err(e),
1137            },
1138            How::KeepBoth => keep_both(dest, true, &self.protect, |c| std::fs::create_dir(c)),
1139            // `dest` is a file or a link (a folder would have been merged):
1140            // move it aside until the new folder is complete.
1141            How::Replace(_) => {
1142                let spot = sibling_temp(dest, "replaced");
1143                match platform::rename_no_replace(dest, &spot) {
1144                    Ok(()) => {
1145                        aside = Some(spot);
1146                        std::fs::create_dir(dest).map(|()| dest.to_path_buf())
1147                    }
1148                    Err(e) if e.kind() == io::ErrorKind::NotFound => std::fs::create_dir(dest).map(|()| dest.to_path_buf()),
1149                    Err(e) => Err(e),
1150                }
1151            }
1152        };
1153        let created = match created {
1154            Ok(created) => created,
1155            Err(e) => {
1156                if let Some(spot) = aside {
1157                    self.restore_aside(src, &spot, dest);
1158                }
1159                let message = describe(&e, self.verb(), src);
1160                self.fail(src, message);
1161                self.pass_over(src, meta, None);
1162                return Ok(Outcome::NotDone);
1163            }
1164        };
1165        self.advance(Measure { items: 1, bytes: 0 });
1166        let outcome = self.copy_children(src, &created, cross);
1167        // Unix folder modes, applied once the contents are in (a read-only
1168        // mode would refuse them). Windows' read-only flag on a folder
1169        // means "customized", not read-only, so it isn't copied there.
1170        if cfg!(unix) && outcome == Outcome::Done {
1171            let _ = std::fs::set_permissions(&created, meta.permissions());
1172        }
1173
1174        if let Some(spot) = aside {
1175            if outcome == Outcome::Done {
1176                self.drop_aside(src, &spot);
1177            } else {
1178                // Put the original back. A copy's partial folder holds only
1179                // copies and can go; a move's holds moved files and stays
1180                // (the original then comes back under a free name).
1181                match self.kind {
1182                    FsOpKind::Copy => {
1183                        let _ = platform::remove_entry_no_follow(&created);
1184                    }
1185                    FsOpKind::Move => {
1186                        let _ = std::fs::remove_dir(&created);
1187                    }
1188                }
1189                self.restore_aside(src, &spot, dest);
1190            }
1191        }
1192        if outcome == Outcome::Done && self.kind == FsOpKind::Move {
1193            return Ok(self.remove_moved_dir(src));
1194        }
1195        Ok(outcome)
1196    }
1197
1198    /// Merge the folder `src` into the existing folder `dest` (spec §7.2).
1199    fn merge(&mut self, src: &Path, dest: &Path, cross: bool) -> Outcome {
1200        self.advance(Measure { items: 1, bytes: 0 });
1201        let outcome = self.copy_children(src, dest, cross);
1202        if outcome == Outcome::Done && self.kind == FsOpKind::Move {
1203            return self.remove_moved_dir(src);
1204        }
1205        outcome
1206    }
1207
1208    /// A moved folder's source, once everything inside it went. Only ever
1209    /// an empty folder: `remove_dir` refuses anything else.
1210    fn remove_moved_dir(&mut self, src: &Path) -> Outcome {
1211        match std::fs::remove_dir(src) {
1212            Ok(()) => Outcome::Done,
1213            Err(e) => {
1214                self.fail(src, format!("Moved what was in “{}”, but couldn't remove the folder itself: {e}", file_name_lossy(src)));
1215                Outcome::NotDone
1216            }
1217        }
1218    }
1219
1220    /// Each entry of `src` into `dest`. The names are read up front: a move
1221    /// takes entries out of `src` while going, and a directory changing
1222    /// under an open listing may skip entries on some filesystems.
1223    fn copy_children(&mut self, src: &Path, dest: &Path, cross: bool) -> Outcome {
1224        let mut names = Vec::new();
1225        let mut complete = true;
1226        match std::fs::read_dir(src) {
1227            Ok(entries) => {
1228                for entry in entries {
1229                    match entry {
1230                        Ok(entry) => names.push(entry.file_name()),
1231                        Err(_) => complete = false,
1232                    }
1233                }
1234            }
1235            Err(e) => {
1236                let message = describe(&e, "read", src);
1237                self.fail(src, message);
1238                return Outcome::NotDone;
1239            }
1240        }
1241        if !complete {
1242            self.fail(src, format!("Some of what's in “{}” couldn't be read, so it was left out.", file_name_lossy(src)));
1243        }
1244        for name in names {
1245            match self.transfer(&src.join(&name), &dest.join(&name), false, None, cross) {
1246                Outcome::Stopped => return Outcome::Stopped,
1247                Outcome::NotDone => complete = false,
1248                Outcome::Done => {}
1249            }
1250        }
1251        if complete {
1252            Outcome::Done
1253        } else {
1254            Outcome::NotDone
1255        }
1256    }
1257
1258    // ── Replace helpers ──
1259
1260    /// Replace `dest` with what `put` creates there: move `dest` aside under
1261    /// a temporary name in the same folder first, remove it once `put`
1262    /// succeeded, or put it back if `put` failed. Used whenever a rename
1263    /// can't atomically replace (a folder, a link, a type change).
1264    fn replace_aside(&mut self, src: &Path, dest: &Path, put: impl FnOnce(&Path) -> io::Result<()>) -> io::Result<()> {
1265        let spot = sibling_temp(dest, "replaced");
1266        match platform::rename_no_replace(dest, &spot) {
1267            Ok(()) => {}
1268            // Gone meanwhile: nothing left to replace.
1269            Err(e) if e.kind() == io::ErrorKind::NotFound => return put(dest),
1270            Err(e) => return Err(e),
1271        }
1272        match put(dest) {
1273            Ok(()) => {
1274                self.drop_aside(src, &spot);
1275                Ok(())
1276            }
1277            Err(e) => {
1278                self.restore_aside(src, &spot, dest);
1279                Err(e)
1280            }
1281        }
1282    }
1283
1284    /// Delete the replaced original. It was the user's choice to replace it;
1285    /// failing to delete it is reported but loses nothing.
1286    fn drop_aside(&mut self, src: &Path, spot: &Path) {
1287        if let Err(e) = platform::remove_entry_no_follow(spot) {
1288            self.fail(
1289                src,
1290                format!(
1291                    "Replaced “{}”, but couldn't remove the old one; it's still there as “{}”: {e}",
1292                    file_name_lossy(src),
1293                    file_name_lossy(spot)
1294                ),
1295            );
1296        }
1297    }
1298
1299    /// Put a set-aside original back at `dest`, or, if something took that
1300    /// name meanwhile, under the first free `name (n)` beside it rather
1301    /// than leave it under a hidden temporary name.
1302    fn restore_aside(&mut self, src: &Path, spot: &Path, dest: &Path) {
1303        if platform::rename_no_replace(spot, dest).is_ok() {
1304            return;
1305        }
1306        let is_dir = std::fs::symlink_metadata(spot).is_ok_and(|m| m.is_dir());
1307        match keep_both(dest, is_dir, &self.protect, |c| platform::rename_no_replace(spot, c)) {
1308            Ok(put) => self.fail(
1309                src,
1310                format!("The original “{}” couldn't go back under its name; it's now “{}”.", file_name_lossy(dest), file_name_lossy(&put)),
1311            ),
1312            Err(e) => self.fail(
1313                src,
1314                format!("The original “{}” couldn't be put back; it's still there as “{}”: {e}", file_name_lossy(dest), file_name_lossy(spot)),
1315            ),
1316        }
1317    }
1318}
1319
1320enum Renamed {
1321    Finished(Outcome),
1322    Raced,
1323    CrossVolume,
1324}
1325
1326// ── Helpers ──────────────────────────────────────────────────────────────
1327
1328/// A hidden, unique name beside `dest`, in the same folder so a rename
1329/// from it is never a cross-volume move.
1330fn sibling_temp(dest: &Path, tag: &str) -> PathBuf {
1331    dest.with_file_name(format!(".agentmux-{tag}-{}", uuid::Uuid::new_v4().simple()))
1332}
1333
1334/// Remove a temporary file this job wrote. Best effort: the failure that
1335/// led here is the one worth reporting.
1336fn discard(tmp: &Path) {
1337    if let Err(e) = platform::remove_file_force(tmp) {
1338        tracing::warn!(path = %display_path(tmp), error = %e, "fs.op: couldn't remove a partial copy");
1339    }
1340}
1341
1342/// `name (n).ext` for a file, `name (n)` for a folder (whose dots aren't an
1343/// extension) or a name with no extension. Works on the raw name, so a
1344/// name that isn't valid Unicode keeps its bytes.
1345fn numbered_name(name: &OsStr, n: u32, is_dir: bool) -> OsString {
1346    let path = Path::new(name);
1347    let mut out = OsString::new();
1348    match (is_dir, path.file_stem(), path.extension()) {
1349        (false, Some(stem), Some(ext)) => {
1350            out.push(stem);
1351            out.push(format!(" ({n})."));
1352            out.push(ext);
1353        }
1354        _ => {
1355            out.push(name);
1356            out.push(format!(" ({n})"));
1357        }
1358    }
1359    out
1360}
1361
1362/// Keep both: `put` at the first free `name (2)`, `name (3)`… beside `dest`.
1363/// Each attempt is itself the existence check (`put` must fail with
1364/// `AlreadyExists` on a taken name), so a name taken by another writer
1365/// between attempts moves on to the next number instead of overwriting.
1366fn keep_both(dest: &Path, is_dir: bool, protect: &ProtectedPaths, mut put: impl FnMut(&Path) -> io::Result<()>) -> io::Result<PathBuf> {
1367    let name = dest.file_name().ok_or_else(|| io::Error::other("That path has no name."))?;
1368    for n in 2..=MAX_KEEP_BOTH {
1369        let candidate = dest.with_file_name(numbered_name(name, n, is_dir));
1370        protect.check(&candidate).map_err(io::Error::other)?;
1371        match put(&candidate) {
1372            Ok(()) => return Ok(candidate),
1373            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
1374            Err(e) => return Err(e),
1375        }
1376    }
1377    Err(io::Error::other(format!(
1378        "There's no free name left for a copy of “{}”.",
1379        name.to_string_lossy()
1380    )))
1381}
1382
1383/// An I/O error as a sentence naming the item.
1384fn describe(err: &io::Error, verb: &str, src: &Path) -> String {
1385    let name = file_name_lossy(src);
1386    // Our own errors (`io::Error::other`) already are sentences.
1387    if err.raw_os_error().is_none() && err.get_ref().is_some() {
1388        return err.to_string();
1389    }
1390    match err.kind() {
1391        io::ErrorKind::NotFound => format!("“{name}” or its destination doesn't exist anymore."),
1392        io::ErrorKind::PermissionDenied => format!("You don't have permission to {verb} “{name}” there."),
1393        io::ErrorKind::StorageFull => format!("There isn't enough space to {verb} “{name}”."),
1394        _ => format!("Couldn't {verb} “{name}”: {err}"),
1395    }
1396}
1397
1398/// Unix millis of a modification time, for the conflict prompt.
1399fn mtime_ms(meta: &std::fs::Metadata) -> Option<u64> {
1400    meta.modified()
1401        .ok()
1402        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1403        .map(|d| d.as_millis() as u64)
1404}
1405
1406#[cfg(test)]
1407#[path = "jobs_tests.rs"]
1408mod tests;