logmv 0.7.1

Logged atomic file move and trash with an append-only JSON-Lines audit trail
Documentation
# logmv

[![CI](https://github.com/johanthoren/logmv/actions/workflows/ci.yml/badge.svg)](https://github.com/johanthoren/logmv/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/logmv.svg)](https://crates.io/crates/logmv)
[![docs.rs](https://img.shields.io/docsrs/logmv)](https://docs.rs/logmv)

Logged atomic file move and trash, with an append-only JSON-Lines audit trail.

## What it is

`logmv` moves a file with `mv`-style ergonomics, but every operation is recorded
as one compact JSON line in a log you choose. Instead of an untraceable `mv` or
`rm`, you get a durable, machine-readable history of what moved where and when.

It solves the "where did that file go?" problem for scripts, agents, and cleanup
jobs: each move, trash, directory creation, and directory removal is appended to
a JSON-Lines log, so the filesystem's history is auditable after the fact.

Guarantees it holds:

- **Atomic move only.** A single `rename` syscall; never a copy-then-delete
  fallback. A cross-volume move fails loudly (`EXDEV`) rather than silently
  degrading.
- **Never overwrite.** If the destination already exists, the move is refused
  and nothing is logged.
- **Trash never unlinks.** `--trash` relocates into `~/.Trash`, disambiguating
  the name on collision; it never deletes.
- **No silent drift.** If a rename succeeds but the log append then fails, the
  error is loud so the filesystem and log are never quietly out of sync.

## Platform support

logmv supports only macOS and Linux. The crate bakes in macOS/Linux-specific
assumptions: `EXDEV` == raw OS error 18 (the cross-volume rename signal), `'/'`
path separators, and the `~/.Trash` move model. Other platforms differ on all
three, so a non-macOS/Linux build is rejected at compile time rather than
silently misbehaving.

## Install

From crates.io (once published):

```console
$ cargo install logmv
```

From source:

```console
$ git clone https://github.com/johanthoren/logmv
$ cd logmv
$ cargo install --path .
```

## Usage

The first positional argument is always the log file to append to. Flags
(`--trash`, `--mkdir`, `--rmdir`) must precede the source/destination.

### Move `SRC DST`

```console
$ logmv ops.log report.txt archive.txt
```

Appends one `move` line (source and destination canonicalized to absolute paths):

```json
{"ts":"2026-07-02T22:00:40+08:00","act":"move","src":"/Users/alice/work/report.txt","dst":"/Users/alice/work/archive.txt"}
```

If `DST` is an existing directory (or ends with `/`), `SRC` is moved *into* it as
`DST/basename(SRC)`.

### Trash `--trash`

Moves the path into `~/.Trash`, disambiguating the name if one already exists
(e.g. `obsolete-1.txt`); it never unlinks.

```console
$ logmv ops.log --trash obsolete.txt
```

```json
{"ts":"2026-07-02T22:00:49+08:00","act":"trash","src":"/Users/alice/work/obsolete.txt","dst":"/Users/alice/.Trash/obsolete.txt"}
```

### Create missing parents `--mkdir`

Creates the destination's missing parent directories (like `mkdir -p`) and logs
one `mkdir` line per directory actually created, parent to child, before the move.

```console
$ logmv ops.log --mkdir deep.txt archive/2026/reports/deep.txt
```

```json
{"ts":"2026-07-02T22:00:40+08:00","act":"mkdir","src":"-","dst":"/Users/alice/work/archive/2026"}
{"ts":"2026-07-02T22:00:40+08:00","act":"mkdir","src":"-","dst":"/Users/alice/work/archive/2026/reports"}
{"ts":"2026-07-02T22:00:40+08:00","act":"move","src":"/Users/alice/work/deep.txt","dst":"/Users/alice/work/archive/2026/reports/deep.txt"}
```

### Remove emptied parents `--rmdir`

After a successful, logged move, removes the source's now-empty parent
directories, cascading upward and stopping at the first non-empty one (like
`rmdir -p`). Only truly-empty directories are removed.

```console
$ logmv ops.log --rmdir project/tmp/cache.txt out
```

```json
{"ts":"2026-07-02T22:00:49+08:00","act":"move","src":"/Users/alice/work/project/tmp/cache.txt","dst":"/Users/alice/work/out/cache.txt"}
{"ts":"2026-07-02T22:00:49+08:00","act":"rmdir","src":"/Users/alice/work/project/tmp","dst":"-"}
{"ts":"2026-07-02T22:00:49+08:00","act":"rmdir","src":"/Users/alice/work/project","dst":"-"}
```

### Trailing metadata `[K V]`

Any trailing `K V` pairs are recorded on the log line after the canonical keys.
They must come in complete pairs (an odd number of trailing arguments is a usage
error), and a key may not shadow a canonical key (`ts`/`act`/`src`/`dst`).

```console
$ logmv ops.log q3.txt q3-archived.txt by cc reason quarterly-cleanup
```

```json
{"ts":"2026-07-02T22:00:40+08:00","act":"move","src":"/Users/alice/work/q3.txt","dst":"/Users/alice/work/q3-archived.txt","by":"cc","reason":"quarterly-cleanup"}
```

## Log schema

The log is [JSON Lines](https://jsonlines.org/): one compact JSON object per
line, appended, never rewritten. Each line begins with four canonical keys in a
fixed order, followed by any free `K V` metadata pairs in the order given:

| Key   | Meaning                                                            |
|-------|-------------------------------------------------------------------|
| `ts`  | RFC 3339 timestamp, second precision, with the local UTC offset   |
| `act` | one of `move`, `trash`, `mkdir`, `rmdir`                           |
| `src` | canonical absolute source path (`"-"` for `mkdir`)                |
| `dst` | canonical absolute destination path (`"-"` for `rmdir`)           |

Every metadata value is written as a JSON string (a numeric-looking `"42"` stays
a string). serde owns all key/value escaping, so arbitrary characters in
metadata never corrupt the line.

The move itself is byte-faithful, but the log is not a byte-exact record for
non-UTF-8 paths: on Linux each invalid byte in `src` or `dst` is written as the
Unicode replacement character (U+FFFD), so a line containing it is not reliably
reversible. macOS enforces UTF-8, so the case cannot arise there.

## Exit codes

- **0**: success. stdout is empty.
- **1**: failure. A single `logmv: <error>` line is written to stderr.

For example, refusing to overwrite an existing destination:

```console
$ logmv ops.log s.txt d.txt
logmv: destination already exists: d.txt
$ echo $?
1
```

## License

Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE or
  <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT]LICENSE-MIT or
  <http://opensource.org/licenses/MIT>)

at your option.

### Contribution

Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.