Skip to main content

logmv/
lib.rs

1//! Logged atomic file move and trash with an append-only JSON-Lines audit trail.
2//!
3//! `logmv` renames a file, or moves it to a trash directory, and records the
4//! operation as one compact JSON line per action so every change is auditable.
5//! Moves are atomic renames only (never a copy fallback), never overwrite an
6//! existing destination, and trash never unlinks. See [`run`] for the
7//! orchestration and [`Op`] for the operations it performs.
8//!
9//! # Platform support
10//!
11//! logmv supports only macOS and Linux. The crate bakes in macOS/Linux-specific
12//! assumptions: `EXDEV` == raw OS error 18, `'/'` path separators (see
13//! `ends_with_sep`), and the `~/.Trash` move model (resolved in `main.rs`). Other
14//! platforms differ on all three, so a non-macOS/Linux build is rejected at
15//! compile time via `compile_error!` rather than silently misbehaving. The guard
16//! lives here; the binary depends on the library, so it is guarded transitively.
17//!
18//! One further Linux-only divergence affects log fidelity, not the move itself.
19//! The rename is byte-faithful: it uses the real `OsStr` bytes (see
20//! `path_to_cstring`), so a non-UTF-8 filename is moved correctly. The log,
21//! however, records `src` and `dst` via `to_string_lossy`, so on Linux each
22//! invalid UTF-8 byte is written as U+FFFD (the Unicode replacement character);
23//! JSON strings must be valid Unicode, so the raw bytes cannot be stored
24//! verbatim. A log line whose `src` or `dst` contains U+FFFD is therefore not a
25//! byte-exact record and is not reliably reversible. macOS (APFS/HFS+)
26//! enforces UTF-8, so this case is unreachable there.
27
28#![warn(missing_docs)]
29
30#[cfg(not(any(target_os = "macos", target_os = "linux")))]
31compile_error!("logmv supports only macOS and Linux");
32
33use std::ffi::CString;
34use std::ffi::OsStr;
35use std::ffi::c_char;
36use std::ffi::c_int;
37use std::ffi::c_uint;
38use std::fs;
39use std::fs::OpenOptions;
40use std::io;
41use std::io::Write;
42use std::path::Path;
43use std::path::PathBuf;
44
45use chrono::DateTime;
46use chrono::FixedOffset;
47use chrono::SecondsFormat;
48use serde_json::Map;
49use serde_json::Value;
50
51/// EXDEV (cross-device link) is raw os error 18 on macOS and Linux.
52const EXDEV: i32 = 18;
53
54/// EEXIST (destination already exists) is raw os error 17 on macOS and Linux.
55const EEXIST: i32 = 17;
56
57/// The four keys `logmv` stamps itself; metadata pairs may never use them (AC13).
58const CANONICAL_KEYS: [&str; 4] = ["ts", "act", "src", "dst"];
59
60/// A file-system operation for logmv to perform.
61pub enum Op {
62    /// Move `src` to `dst` via an atomic rename, then log a `move` line.
63    Move {
64        /// Source path to move (canonicalized to absolute when logged).
65        src: PathBuf,
66        /// Destination path; an existing dir (or trailing `/`) means move *into* it.
67        dst: PathBuf,
68    },
69    /// Move `path` into `trash_dir`, disambiguating on name collision, then log a `trash` line.
70    Trash {
71        /// Path to send to the trash.
72        path: PathBuf,
73        /// Trash directory to move `path` into (e.g. `~/.Trash`).
74        trash_dir: PathBuf,
75    },
76}
77
78/// Typed errors for logmv operations.
79#[derive(Debug, thiserror::Error)]
80pub enum Error {
81    /// The resolved destination already exists; the move was refused (never overwrite).
82    #[error("destination already exists: {0}")]
83    DestinationExists(PathBuf),
84    /// The rename crossed filesystems (`EXDEV`); logmv never falls back to a copy.
85    #[error("cross-volume rename not supported (EXDEV)")]
86    CrossVolume,
87    /// The `rename` syscall failed for a reason other than a cross-volume move.
88    #[error("rename failed: {0}")]
89    Rename(#[source] io::Error),
90    /// The move succeeded but the log append failed: filesystem and log may have drifted.
91    #[error("move succeeded but log append failed: filesystem and log may have drifted: {0}")]
92    DriftAfterMove(#[source] io::Error),
93    /// A `--mkdir` directory creation failed; any partially created directories are unlogged.
94    #[error("directory creation failed; any partially created directories are unlogged: {0}")]
95    MkdirCreate(#[source] io::Error),
96    /// A `--mkdir` directory was created but its log append failed: filesystem and log may have drifted.
97    #[error("directories created but log append failed: filesystem and log may have drifted: {0}")]
98    DriftAfterMkdir(#[source] io::Error),
99    /// A `--rmdir` directory removal's log append failed: filesystem and log may have drifted.
100    #[error(
101        "directory removed but its log append failed: filesystem and log may have drifted: {0}"
102    )]
103    DriftAfterRmdir(#[source] io::Error),
104    /// Resolving a path to its canonical absolute form failed.
105    #[error("canonicalize failed: {0}")]
106    Canonicalize(#[source] io::Error),
107    /// A metadata key collided with a canonical key (`ts`/`act`/`src`/`dst`).
108    #[error("metadata key collides with canonical key: {0}")]
109    MetadataKeyCollision(String),
110}
111
112/// Assemble one compact JSON-Lines entry from the given fields.
113///
114/// `ts` is injected (not read from a clock) so this function is pure and
115/// deterministically testable. `src`/`dst` must already be absolute strings.
116/// For trash ops, `dst` is the canonical landing path under the trash dir
117/// (the disambiguated target), just like move.
118/// Refuses any pair whose key is a canonical key (ts/act/src/dst) via
119/// `Error::MetadataKeyCollision` (AC13). Canonical four are written first,
120/// then pairs in the given order, all values as JSON strings.
121fn build_entry(
122    ts: DateTime<FixedOffset>,
123    act: &str,
124    src: &str,
125    dst: &str,
126    pairs: &[(&str, &str)],
127) -> Result<String, Error> {
128    // Refuse before assembling anything: a colliding key must yield no line
129    // (and, via `run`, no move and no log).
130    if let Some(key) = first_colliding_key(pairs) {
131        return Err(Error::MetadataKeyCollision(key.to_string()));
132    }
133
134    let mut map = Map::new();
135    map.insert(
136        "ts".to_string(),
137        Value::String(ts.to_rfc3339_opts(SecondsFormat::Secs, false)),
138    );
139    map.insert("act".to_string(), Value::String(act.to_string()));
140    map.insert("src".to_string(), Value::String(src.to_string()));
141    map.insert("dst".to_string(), Value::String(dst.to_string()));
142    // Pairs follow the canonical four in given order (serde_json `preserve_order`),
143    // every value a JSON string; serde owns all key/value escaping (AC6).
144    for (k, v) in pairs {
145        map.insert((*k).to_string(), Value::String((*v).to_string()));
146    }
147
148    Ok(Value::Object(map).to_string())
149}
150
151/// Return the first metadata key that collides with a canonical key, if any.
152/// Single source of truth for the collision guard: `build_entry` runs it before
153/// assembling a line, and `run` runs it early so a bad pair aborts before any
154/// `--mkdir` mutation (AC11).
155fn first_colliding_key<'a>(pairs: &'a [(&str, &str)]) -> Option<&'a str> {
156    pairs
157        .iter()
158        .find(|(k, _)| CANONICAL_KEYS.contains(k))
159        .map(|(k, _)| *k)
160}
161
162/// Map an `io::Error` from `fs::rename` to a typed `Error`.
163///
164/// EXDEV (raw os error 18) → `Error::CrossVolume`.
165/// Anything else → `Error::Rename`.
166fn classify_rename_err(err: io::Error) -> Error {
167    if err.raw_os_error() == Some(EXDEV) {
168        Error::CrossVolume
169    } else {
170        Error::Rename(err)
171    }
172}
173
174/// `AT_FDCWD`: resolve a relative `renameat2` path against the current working
175/// directory, matching the relative-path semantics `fs::rename` has today.
176#[cfg(target_os = "linux")]
177const AT_FDCWD: c_int = -100;
178
179/// `RENAME_NOREPLACE`: fail with `EEXIST` rather than clobbering an existing
180/// destination entry (name-level; a symlink is not followed).
181#[cfg(target_os = "linux")]
182const RENAME_NOREPLACE: c_uint = 1;
183
184/// `RENAME_EXCL`: macOS exclusive-create flag from `<sys/stdio.h>`
185/// (`RENAME_SECLUDE 0x1`, `RENAME_SWAP 0x2`, `RENAME_EXCL 0x4`); fail with
186/// `EEXIST` rather than clobbering an existing destination entry.
187#[cfg(target_os = "macos")]
188const RENAME_EXCL: c_uint = 0x0000_0004;
189
190/// Convert a path to a NUL-terminated C string for the `renameat2`/`renamex_np`
191/// FFI, via raw bytes (never a lossy `String`, so non-UTF-8 paths pass through
192/// unchanged). An interior NUL yields a clean `InvalidInput` error rather than
193/// an `unwrap`/`expect` on the write path.
194fn path_to_cstring(p: &Path) -> io::Result<CString> {
195    use std::os::unix::ffi::OsStrExt;
196    CString::new(p.as_os_str().as_bytes())
197        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
198}
199
200/// Atomically rename `from` to `to`, refusing to clobber an existing destination
201/// entry (name-level; a symlink is not followed).
202///
203/// Returns `Ok(())` on success, or `Err(io::Error::last_os_error())` carrying the
204/// raw `errno` so callers can test `raw_os_error() == Some(EEXIST)` and still route
205/// `EXDEV` and others through [`classify_rename_err`]. Classifies nothing itself,
206/// mirroring today's `fs::rename(...).map_err(classify_rename_err)` split of
207/// mechanism from policy.
208#[cfg(target_os = "linux")]
209fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
210    // NOTE: libc renameat2 wrapper (glibc >=2.28); drop to a raw syscall() only
211    // if a target without the wrapper (e.g. an old musl) is ever added to CI.
212    unsafe extern "C" {
213        fn renameat2(
214            olddirfd: c_int,
215            oldpath: *const c_char,
216            newdirfd: c_int,
217            newpath: *const c_char,
218            flags: c_uint,
219        ) -> c_int;
220    }
221
222    let from_c = path_to_cstring(from)?;
223    let to_c = path_to_cstring(to)?;
224
225    // SAFETY:
226    // - `from_c`/`to_c` are `CString` locals that outlive the call; `.as_ptr()`
227    //   yields non-null pointers to valid, readable, NUL-terminated bytes.
228    // - `renameat2` only reads (never retains) the two `*const c_char` pointers;
229    //   no Rust aliasing/mutability invariant is exposed (only `*const` passed).
230    // - `AT_FDCWD` and `RENAME_NOREPLACE` are the documented kernel sentinel/flag.
231    // - the return value is checked immediately and `errno` is read via
232    //   `io::Error::last_os_error()` with no intervening libc call between.
233    let rc = unsafe {
234        renameat2(
235            AT_FDCWD,
236            from_c.as_ptr(),
237            AT_FDCWD,
238            to_c.as_ptr(),
239            RENAME_NOREPLACE,
240        )
241    };
242    if rc < 0 {
243        return Err(io::Error::last_os_error());
244    }
245    Ok(())
246}
247
248/// Atomically rename `from` to `to`, refusing to clobber an existing destination
249/// entry (name-level; a symlink is not followed).
250///
251/// Returns `Ok(())` on success, or `Err(io::Error::last_os_error())` carrying the
252/// raw `errno` so callers can test `raw_os_error() == Some(EEXIST)` and still route
253/// `EXDEV` and others through [`classify_rename_err`]. Classifies nothing itself,
254/// mirroring today's `fs::rename(...).map_err(classify_rename_err)` split of
255/// mechanism from policy.
256#[cfg(target_os = "macos")]
257fn rename_noclobber(from: &Path, to: &Path) -> io::Result<()> {
258    unsafe extern "C" {
259        fn renamex_np(from: *const c_char, to: *const c_char, flags: c_uint) -> c_int;
260    }
261
262    let from_c = path_to_cstring(from)?;
263    let to_c = path_to_cstring(to)?;
264
265    // SAFETY:
266    // - `from_c`/`to_c` are `CString` locals that outlive the call; `.as_ptr()`
267    //   yields non-null pointers to valid, readable, NUL-terminated bytes.
268    // - `renamex_np` only reads (never retains) the two `*const c_char` pointers;
269    //   no Rust aliasing/mutability invariant is exposed (only `*const` passed).
270    // - `RENAME_EXCL` is the documented exclusive-create flag from `<sys/stdio.h>`.
271    // - the return value is checked immediately and `errno` is read via
272    //   `io::Error::last_os_error()` with no intervening libc call between.
273    let rc = unsafe { renamex_np(from_c.as_ptr(), to_c.as_ptr(), RENAME_EXCL) };
274    if rc < 0 {
275        return Err(io::Error::last_os_error());
276    }
277    Ok(())
278}
279
280/// Canonicalize a destination path that must NOT yet exist: canonicalize the
281/// parent directory (which does exist) and rejoin the file name.
282fn canonicalize_new(dst: &Path) -> Result<PathBuf, Error> {
283    let file_name = dst.file_name().ok_or_else(|| {
284        Error::Canonicalize(io::Error::new(
285            io::ErrorKind::InvalidInput,
286            "destination has no file name",
287        ))
288    })?;
289    let parent = match dst.parent() {
290        Some(p) if !p.as_os_str().is_empty() => p,
291        _ => Path::new("."),
292    };
293    let parent_abs = fs::canonicalize(parent).map_err(Error::Canonicalize)?;
294    Ok(parent_abs.join(file_name))
295}
296
297/// Atomically move `path` into `trash_dir`, never clobbering an existing entry:
298/// try `name` as-is, then `<stem>-<n>[.<ext>]` for n = 1,2,3,…, bumping `n` only
299/// on `EEXIST` and returning the winning path on the first successful rename.
300/// Any other errno is classified via [`classify_rename_err`]. The disambiguation
301/// is driven by the atomic rename result (not a pre-scan), so the returned path is
302/// exactly the candidate that won the rename.
303fn rename_into_trash(path: &Path, trash_dir: &Path, name: &OsStr) -> Result<PathBuf, Error> {
304    let first = trash_dir.join(name);
305    match rename_noclobber(path, &first) {
306        Ok(()) => return Ok(first),
307        Err(e) if e.raw_os_error() == Some(EEXIST) => {}
308        Err(e) => return Err(classify_rename_err(e)),
309    }
310
311    let as_path = Path::new(name);
312    let stem = as_path
313        .file_stem()
314        .unwrap_or(name)
315        .to_string_lossy()
316        .into_owned();
317    let ext = as_path
318        .extension()
319        .map(|e| e.to_string_lossy().into_owned());
320
321    let mut n = 1u32;
322    loop {
323        let candidate_name = match &ext {
324            Some(ext) => format!("{stem}-{n}.{ext}"),
325            None => format!("{stem}-{n}"),
326        };
327        let candidate = trash_dir.join(candidate_name);
328        match rename_noclobber(path, &candidate) {
329            Ok(()) => return Ok(candidate),
330            Err(e) if e.raw_os_error() == Some(EEXIST) => {}
331            Err(e) => return Err(classify_rename_err(e)),
332        }
333        n += 1;
334    }
335}
336
337/// Write `line` plus one trailing newline to `w` in a single `write` call, so a
338/// log entry can never be split across two syscalls (which two concurrent runs
339/// could interleave). Generic over `Write` so it is unit-testable.
340fn write_log_line<W: Write>(w: &mut W, line: &str) -> io::Result<()> {
341    w.write_all(format!("{line}\n").as_bytes())
342}
343
344/// Append exactly one line (with trailing newline) to `log`, creating it if absent,
345/// then flush it to disk so the record is at least as durable as the event it logs.
346fn append_log(log: &Path, line: &str) -> io::Result<()> {
347    let mut file = OpenOptions::new().create(true).append(true).open(log)?;
348    write_log_line(&mut file, line)?;
349    // NOTE: unconditional sync_data; add --no-sync only if a batch caller measurably needs it.
350    file.sync_data()
351}
352
353/// Does the path end with a directory separator (explicit directory intent)?
354// NOTE: macOS/Linux `'/'` only, consistent with the hardcoded EXDEV const.
355fn ends_with_sep(p: &Path) -> bool {
356    p.to_string_lossy().ends_with('/')
357}
358
359/// Resolve the final move target. When `dst` is an existing directory (or ends
360/// with a separator, signalling directory intent), move `src` *into* it keeping
361/// its basename: `dst/basename(src)`. Otherwise `dst` is the full target path
362/// (today's behavior).
363fn resolve_move_target(src: &Path, dst: &Path) -> PathBuf {
364    match (ends_with_sep(dst) || dst.is_dir(), src.file_name()) {
365        (true, Some(name)) => dst.join(name),
366        _ => dst.to_path_buf(),
367    }
368}
369
370/// `--mkdir`: create the move destination's missing parent chain (`mkdir -p`) and
371/// append one `mkdir` line per directory actually created, parent → child, before
372/// the move line. A fully-present chain creates nothing and logs nothing (AC6).
373/// `mkdir` line carries `src:"-"`, `dst:<created dir>` (Q1 directional sentinel).
374fn mkdir_chain(parent: &Path, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
375    // Missing ancestors, collected child → parent then reversed to parent → child.
376    let mut missing: Vec<PathBuf> = Vec::new();
377    let mut cur = Some(parent);
378    while let Some(dir) = cur {
379        if dir.as_os_str().is_empty() || dir.exists() {
380            break;
381        }
382        missing.push(dir.to_path_buf());
383        cur = dir.parent();
384    }
385    if missing.is_empty() {
386        return Ok(());
387    }
388    missing.reverse();
389
390    // create_dir_all is idempotent and never clobbers an existing dir (AC4). A
391    // failure may leave partially-created, unlogged dirs, but the requested chain
392    // is not confirmed present, so this is not post-create drift.
393    fs::create_dir_all(parent).map_err(Error::MkdirCreate)?;
394
395    for dir in &missing {
396        // The dirs now exist but are not yet logged; a canonicalize failure here is
397        // created-but-not-logged drift, not a generic path-resolution failure.
398        let abs = fs::canonicalize(dir).map_err(Error::DriftAfterMkdir)?;
399        let line = build_entry(ts, "mkdir", "-", &abs.to_string_lossy(), &[])?;
400        // Logged only after the dir exists; an append failure here is real drift.
401        append_log(log, &line).map_err(Error::DriftAfterMkdir)?;
402    }
403    Ok(())
404}
405
406/// `--rmdir`: after a successful, logged move/trash, remove the source's now-empty
407/// parent and cascade upward, removing each now-empty ancestor and stopping at the
408/// first non-empty one (`rmdir -p`). Truly-empty-only: a dir holding `.DS_Store`
409/// (or anything) is left in place (AC9). `start` paths come from the canonicalized
410/// `abs_src`, so the cascade walks real (non-symlink) ancestors only.
411/// `rmdir` line carries `src:<removed dir>`, `dst:"-"` (Q1 directional sentinel).
412fn rmdir_cascade(start: Option<&Path>, log: &Path, ts: DateTime<FixedOffset>) -> Result<(), Error> {
413    let mut cur = start.map(Path::to_path_buf);
414    while let Some(dir) = cur {
415        if dir.as_os_str().is_empty() {
416            break;
417        }
418        // Cascade boundary gate: stop at a non-empty (or unreadable) directory.
419        match fs::read_dir(&dir) {
420            Ok(mut entries) => {
421                if entries.next().is_some() {
422                    break;
423                }
424            }
425            Err(_) => break,
426        }
427        // remove_dir is the atomic safety guard: it removes only empty dirs.
428        match fs::remove_dir(&dir) {
429            Ok(()) => {
430                let line = build_entry(ts, "rmdir", &dir.to_string_lossy(), "-", &[])?;
431                // Logged only after removal; an append failure here is real drift.
432                append_log(log, &line).map_err(Error::DriftAfterRmdir)?;
433                cur = dir.parent().map(Path::to_path_buf);
434            }
435            // NOTE: a remove_dir failure (race repopulation, EACCES) stops the
436            // cascade silently; rmdir is best-effort cleanup after a logged move and
437            // the dir simply remaining is safe. Upgrade to a stderr warning if
438            // cleanup visibility ever matters.
439            Err(_) => break,
440        }
441    }
442    Ok(())
443}
444
445/// Orchestrate: resolve final dst → never-overwrite + pair-collision check →
446/// `--mkdir` (create + log each) → atomic rename → log move/trash → `--rmdir`
447/// (remove + log each, cascading).
448///
449/// `log` is the path to the JSON-Lines file to append to (created if absent).
450/// `pairs` are free K/V metadata pairs inserted into the line after the canonical four.
451/// `mkdir`/`rmdir` gate the directory-creation / cascading-removal behaviors.
452///
453/// # Errors
454///
455/// - [`Error::MetadataKeyCollision`] if any pair key is a canonical key
456///   (`ts`/`act`/`src`/`dst`), checked before any mutation, so nothing moves.
457/// - [`Error::DestinationExists`] if the resolved target already exists (never overwrite).
458/// - [`Error::Canonicalize`] if a source or destination path cannot be resolved.
459/// - [`Error::MkdirCreate`] if `--mkdir` fails to create the destination's parent
460///   chain; any partially created directories are unlogged.
461/// - [`Error::CrossVolume`] or [`Error::Rename`] if the atomic rename fails.
462/// - [`Error::DriftAfterMove`], [`Error::DriftAfterMkdir`], or [`Error::DriftAfterRmdir`]
463///   if the rename succeeded but a subsequent log append or directory operation
464///   failed: the filesystem and the log have drifted, and the error is loud.
465///
466/// # Examples
467///
468/// ```no_run
469/// use std::path::{Path, PathBuf};
470/// use logmv::{run, Op};
471///
472/// let op = Op::Move {
473///     src: PathBuf::from("report.txt"),
474///     dst: PathBuf::from("archive.txt"),
475/// };
476/// run(op, Path::new("ops.log"), &[("by", "cc")], false, false)?;
477/// # Ok::<(), logmv::Error>(())
478/// ```
479pub fn run(
480    op: Op,
481    log: &Path,
482    pairs: &[(&str, &str)],
483    mkdir: bool,
484    rmdir: bool,
485) -> Result<(), Error> {
486    let ts = chrono::Local::now().fixed_offset();
487
488    // Early pair-collision check: a colliding key must abort before any --mkdir
489    // mutation and before the move (AC11). build_entry re-checks (idempotent).
490    if let Some(key) = first_colliding_key(pairs) {
491        return Err(Error::MetadataKeyCollision(key.to_string()));
492    }
493
494    // Each arm performs its own atomic no-clobber rename (move: once; trash: in a
495    // retry loop), then yields the line to append and the canonical source path.
496    let (line, abs_src) = match op {
497        Op::Move { src, dst } => {
498            let abs_src = fs::canonicalize(&src).map_err(Error::Canonicalize)?;
499            // Resolve the final target: into an existing / trailing-slash directory.
500            let target = resolve_move_target(&src, &dst);
501            // --mkdir: create the destination's missing parent chain, logging each.
502            // A colliding destination implies its parent exists, so mkdir_chain is a
503            // no-op on collision and a refused move creates no orphan dirs (AC11).
504            if mkdir {
505                if let Some(parent) = target.parent() {
506                    mkdir_chain(parent, log, ts)?;
507                }
508            }
509            let abs_dst = canonicalize_new(&target)?;
510            let line = build_entry(
511                ts,
512                "move",
513                &abs_src.to_string_lossy(),
514                &abs_dst.to_string_lossy(),
515                pairs,
516            )?;
517            // Atomic no-clobber rename: EEXIST names the colliding target (never
518            // overwrite); EXDEV and others keep CrossVolume/Rename. No log on refusal.
519            rename_noclobber(&src, &target).map_err(|e| {
520                if e.raw_os_error() == Some(EEXIST) {
521                    Error::DestinationExists(target.clone())
522                } else {
523                    classify_rename_err(e)
524                }
525            })?;
526            (line, abs_src)
527        }
528        Op::Trash { path, trash_dir } => {
529            let abs_src = fs::canonicalize(&path).map_err(Error::Canonicalize)?;
530            let name = path.file_name().ok_or_else(|| {
531                Error::Canonicalize(io::Error::new(
532                    io::ErrorKind::InvalidInput,
533                    "trash source has no file name",
534                ))
535            })?;
536            // never-overwrite for trash: disambiguate rather than clobber. The
537            // winning candidate is chosen by the atomic rename, then logged.
538            let winner = rename_into_trash(&path, &trash_dir, name)?;
539            let abs_dst = canonicalize_new(&winner)?;
540            let line = build_entry(
541                ts,
542                "trash",
543                &abs_src.to_string_lossy(),
544                &abs_dst.to_string_lossy(),
545                pairs,
546            )?;
547            (line, abs_src)
548        }
549    };
550
551    // Rename happened: an append failure now is real drift, reported loudly (AC9).
552    append_log(log, &line).map_err(Error::DriftAfterMove)?;
553
554    // --rmdir runs only after a successful move AND its log append (AC11), for both
555    // move and trash; it walks the now-empty source parent upward, cascading.
556    if rmdir {
557        rmdir_cascade(abs_src.parent(), log, ts)?;
558    }
559    Ok(())
560}
561
562// ---------------------------------------------------------------------------
563// Unit tests: T1, T2, T3, T_pairs, T_collide_u, T4
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use chrono::TimeZone;
570    use std::io;
571
572    // Shared fixed timestamp for U tests: 2024-01-15T10:30:00+00:00
573    fn fixed_ts() -> DateTime<FixedOffset> {
574        FixedOffset::east_opt(0)
575            .unwrap()
576            .with_ymd_and_hms(2024, 1, 15, 10, 30, 0)
577            .unwrap()
578    }
579
580    // T1: AC1, AC5
581    // build_entry(move, empty pairs) → exact compact JSON line with ONLY the four
582    // canonical keys ts/act/src/dst in that order, act="move", absolute src/dst,
583    // ts ISO-8601 second-precision with offset. Parses as valid JSON.
584    #[test]
585    fn t1_build_entry_move_exact_line() {
586        let ts = fixed_ts();
587        let line = build_entry(ts, "move", "/abs/src/file.txt", "/abs/dst/file.txt", &[])
588            .expect("build_entry must succeed with no pairs");
589
590        // Must parse as valid JSON.
591        let v: serde_json::Value =
592            serde_json::from_str(&line).expect("build_entry output must be valid JSON");
593
594        // Field values.
595        assert_eq!(v["ts"], "2024-01-15T10:30:00+00:00");
596        assert_eq!(v["act"], "move");
597        assert_eq!(v["src"], "/abs/src/file.txt");
598        assert_eq!(v["dst"], "/abs/dst/file.txt");
599
600        // Exact compact line: only 4 keys, in order, no placeholders.
601        let expected = r#"{"ts":"2024-01-15T10:30:00+00:00","act":"move","src":"/abs/src/file.txt","dst":"/abs/dst/file.txt"}"#;
602        assert_eq!(line, expected);
603    }
604
605    // AC2: non-UTF-8 path converted the way run() converts it
606    // (Path::to_string_lossy) is logged with each invalid byte as U+FFFD, and
607    // the line stays valid JSON. Pins that a faithful undo of such a line is
608    // impossible: the raw 0xFF byte is not recoverable from the log.
609    #[cfg(unix)]
610    #[test]
611    fn t_non_utf8_path_logs_replacement_char() {
612        use std::os::unix::ffi::OsStrExt;
613
614        let ts = fixed_ts();
615        let lossy = Path::new(OsStr::from_bytes(b"file\xFF.txt")).to_string_lossy();
616
617        let line = build_entry(ts, "move", &lossy, "/abs/dst", &[])
618            .expect("build_entry must succeed with a lossily-converted src");
619
620        // Must parse as valid JSON.
621        let v: serde_json::Value =
622            serde_json::from_str(&line).expect("build_entry output must be valid JSON");
623
624        // The invalid byte is recorded as U+FFFD, not the original byte.
625        assert_eq!(v["src"], "file\u{FFFD}.txt");
626    }
627
628    // T3: AC6  [no-JSON-corruption invariant]
629    // build_entry with a pair whose KEY AND VALUE each contain quote + backslash +
630    // unicode → line is still valid JSON and both key and value round-trip
631    // byte-exact (serde_json escapes object keys too).
632    #[test]
633    fn t3_build_entry_special_chars_in_key_and_value_round_trips() {
634        let ts = fixed_ts();
635        // Key and value each contain a double-quote, backslash, and unicode snowman.
636        let tricky_key = r#"k"ey\ ☃"#;
637        let tricky_val = r#"say "hello" \ ☃"#;
638
639        let line = build_entry(
640            ts,
641            "move",
642            "/abs/src",
643            "/abs/dst",
644            &[(tricky_key, tricky_val)],
645        )
646        .expect("build_entry must succeed with special-char pair");
647
648        // Must parse as valid JSON (not corrupted).
649        let v: serde_json::Value = serde_json::from_str(&line)
650            .expect("line with special chars in key and value must still be valid JSON");
651
652        // Both key and value must round-trip byte-exact.
653        let obj = v.as_object().unwrap();
654        assert_eq!(
655            obj.get(tricky_key)
656                .and_then(|v| v.as_str())
657                .expect("tricky key must be present in JSON object"),
658            tricky_val
659        );
660    }
661
662    // T_pairs: AC5
663    // build_entry with multiple pairs → pairs appear AFTER the canonical four,
664    // in the given order; every value is a JSON string (even a numeric-looking one).
665    #[test]
666    fn t_pairs_appear_after_canonical_in_order_as_strings() {
667        let ts = fixed_ts();
668        let pairs: &[(&str, &str)] = &[("by", "cc"), ("ac", "p"), ("num", "42")];
669
670        let line = build_entry(ts, "move", "/abs/src", "/abs/dst", pairs)
671            .expect("build_entry must succeed with pairs");
672
673        let v: serde_json::Value =
674            serde_json::from_str(&line).expect("line with pairs must be valid JSON");
675
676        let obj = v.as_object().unwrap();
677        let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
678
679        // First four must be the canonical keys in order.
680        assert_eq!(
681            &keys[..4],
682            &["ts", "act", "src", "dst"],
683            "first four keys must be canonical in order, got: {keys:?}"
684        );
685        // Pairs follow in the given order.
686        assert_eq!(
687            &keys[4..],
688            &["by", "ac", "num"],
689            "pairs must follow canonical keys in given order, got: {keys:?}"
690        );
691
692        // Every value is a JSON string (not number, bool, etc.)
693        assert_eq!(v["by"], serde_json::Value::String("cc".into()));
694        assert_eq!(v["ac"], serde_json::Value::String("p".into()));
695        // "42" must stay a string, not be coerced to a number.
696        assert_eq!(v["num"], serde_json::Value::String("42".into()));
697    }
698
699    // T_collide_u: AC13  [canonical-unspoofable invariant]
700    // build_entry with a pair key in {ts,act,src,dst} → Err(MetadataKeyCollision),
701    // no line produced.
702    #[test]
703    fn t_collide_u_build_entry_refuses_canonical_key() {
704        let ts = fixed_ts();
705
706        for &canonical in &["ts", "act", "src", "dst"] {
707            let result = build_entry(
708                ts,
709                "move",
710                "/abs/src",
711                "/abs/dst",
712                &[(canonical, "spoofed")],
713            );
714            assert!(
715                matches!(&result, Err(Error::MetadataKeyCollision(k)) if k == canonical),
716                "expected MetadataKeyCollision({canonical}), got: {result:?}"
717            );
718        }
719    }
720
721    // T4: AC8  [atomic-only / no-copy-fallback]
722    // classify_rename_err maps raw os error 18 (EXDEV) to Error::CrossVolume;
723    // any other error maps to Error::Rename.
724    // REUSE: behavior identical to old contract; stays green, no edit.
725    #[test]
726    fn t4_classify_rename_err_exdev_and_other() {
727        // EXDEV is raw os error 18 on macOS and Linux.
728        let exdev = io::Error::from_raw_os_error(18);
729        let result = classify_rename_err(exdev);
730        assert!(
731            matches!(result, Error::CrossVolume),
732            "os error 18 must map to CrossVolume, got: {result:?}"
733        );
734
735        // A different os error (e.g. EACCES = 13) maps to Rename.
736        let other = io::Error::from_raw_os_error(13);
737        let result2 = classify_rename_err(other);
738        assert!(
739            matches!(result2, Error::Rename(_)),
740            "non-EXDEV error must map to Rename, got: {result2:?}"
741        );
742    }
743
744    // T_write_log_line_single_write_call: AC1 + AC3
745    // write_log_line must emit line + newline in a SINGLE Write::write call
746    // (locks out any reintroduction of a two-piece writeln!-style write).
747    #[derive(Default)]
748    struct CountingWriter {
749        writes: usize,
750        buf: Vec<u8>,
751    }
752
753    impl io::Write for CountingWriter {
754        fn write(&mut self, data: &[u8]) -> io::Result<usize> {
755            self.writes += 1;
756            self.buf.extend_from_slice(data);
757            Ok(data.len())
758        }
759
760        fn flush(&mut self) -> io::Result<()> {
761            Ok(())
762        }
763    }
764
765    #[test]
766    fn t_write_log_line_single_write_call() {
767        let mut w = CountingWriter::default();
768        let line = "{\"ts\":\"x\",\"act\":\"move\"}";
769
770        let r = write_log_line(&mut w, line);
771
772        assert!(r.is_ok());
773        assert_eq!(
774            w.writes, 1,
775            "line + newline must be a single write, not two"
776        );
777        assert_eq!(w.buf, format!("{line}\n").into_bytes());
778    }
779}