Rust CLI for photo rename, dedupe, and resumable backup
Rust CLI that renames photos by EXIF date and location, moves duplicates to a trash folder, and backs up to S3-compatible storage with resumable uploads.
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add rust-cli-for-photo-rename-dedupe-and-resumable-backup-b90f4aCLAUDE.md# the app
Rust CLI `the app` with subcommands `rename`, `dedupe`, `backup`. It renames photos by EXIF date and place, removes duplicates safely, and backs up to S3-compatible storage with resumable uploads.
## Commands
- `cargo build`
- `cargo test`
- `cargo clippy --all-targets -- -D warnings`
- `cargo fmt --check`
- `cargo run -- <subcommand> --dry-run`
## Layout
- `src/main.rs`: clap entry point
- `src/exif.rs`: metadata reading
- `src/rename.rs`
- `src/dedupe.rs`
- `src/backup/{mod,provider,state}.rs`
- `tests/fixtures/`: tiny synthetic JPEGs only
- `tests/common/`: fixture generator helper
## Crates
Allowed: clap, tokio, reqwest, kamadak-exif or nom-exif, blake3, walkdir, serde, thiserror, anyhow, indicatif.
Ask before adding any other crate.
## Safety rules
- Every destructive operation (rename, delete) supports `--dry-run` and defaults to dry-run unless `--apply` is passed.
- Dedupe moves duplicates to `.the app-trash/`. It never deletes outright.
- Never overwrite an existing file.
## Behavior notes
- Rename format: `YYYY-MM-DD_HHMMSS_<place>_<shortcounter>.ext`.
- Missing EXIF: fall back to file mtime and flag it in the output.
- Location: offline GPS-to-place lookup first. Network reverse geocoding is opt-in, rate-limited and cached.
- Dedupe: group by size, then hash with blake3. Perceptual dedupe is out of scope for v1.
- Backup: chunked, resumable upload. The state file `.the app-state.json` records uploaded chunks and the content hash.
- The backup must survive `kill -9` and resume cleanly.
- Verify the remote checksum at the end of each upload.
## Credentials
- Read from `PHOTOFLOW_ACCESS_KEY` and `PHOTOFLOW_SECRET_KEY`, or from a config file outside the repo.
- Never commit or log credentials, including in error messages.
## Conventions
- Use thiserror in libraries and anyhow in `main`.
- No `unwrap` outside tests.
- Provide a `--json` flag for scripting.
- Use `PathBuf` for paths and handle non-UTF8 filenames.
## Testing
- Fixtures are tiny generated JPEGs with synthetic EXIF. See the `exif-fixtures` skill.
- Backup tests run against a local mock server. Never use real cloud storage.
- Never run commands against real photos.
## Workflow
- Explore, plan, code, then run the `verify` skill before saying a task is done.
- Use `/clear` between unrelated tasks.
## Imports
@SPEC.md
@REVIEW.md
.claude/agents/test-runner.md---
name: test-runner
description: Runs a cargo test, clippy, or fmt command for the the app project and returns only the failures. Use it for long test output so the main context stays small.
model: haiku
tools: Bash, Read, Grep
maxTurns: 8
---
You run one cargo command and report only what failed.
1. Run the cargo command you were given exactly as written, from the repo root. If none was given, run `cargo test --all`.
2. If the command exits 0, reply with one line: `PASS: <command>`.
3. If it fails, list each failing test name or lint, then the key error lines (the assertion, panic message, or compiler error with file and line). Use Grep on the output or re-run with a narrower filter if a failure is cut off.
4. Keep the reply under 40 lines. Drop passing tests, compile progress, and warnings unrelated to a failure.
5. Never edit, create, or delete files. Never run `cargo fix`, `cargo fmt` without `--check`, or any git command that changes state.
6. Never print environment variables or the contents of `.env` or credential files, even if a test output contains them. Replace any secret-looking value with `[redacted]`.
Report format:
- `FAIL: <command>`
- One bullet per failure: name, then the error line(s).
.claude/settings.json{
"permissions": {
"allow": [
"Bash(cargo build:*)",
"Bash(cargo test:*)",
"Bash(cargo clippy:*)",
"Bash(cargo fmt:*)",
"Bash(cargo run:*)",
"Bash(git status)",
"Bash(git diff:*)"
],
"ask": [
"Bash(cargo add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
],
"deny": [
"Bash(rm -rf:*)",
"Read(./.env)",
"Read(./**/*.pem)",
"Bash(curl:*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | grep -q '\\.rs$' && cargo fmt || true"
}
]
}
]
}
}
.claude/skills/exif-fixtures/SKILL.md---
name: exif-fixtures
description: Create and maintain tiny synthetic JPEG fixtures with fake EXIF date and GPS for the app tests. Use when a test needs a photo with specific metadata.
---
# EXIF fixtures
Use this skill when a test needs a photo with particular metadata, or when someone asks to add or change files in `tests/fixtures/`.
## Where the helper lives
- Helper code is in `tests/common/` (for example `tests/common/mod.rs`).
- Open that file first and use its function names and signatures. Do not invent new helper names. If a helper you need does not exist, add it to `tests/common/` rather than writing ad hoc JPEG bytes inside a test.
## Creating a fixture
1. Pick the metadata you need: capture datetime, optional offset (for example `+02:00`), optional GPS latitude and longitude, optional camera make and model.
2. Call the helper from a test. Write output into a temp directory with `tempfile` or a similar test-only helper, unless the test needs a committed file.
3. For a committed fixture in `tests/fixtures/`, call the helper's writer for the standard set, then check the file sizes.
4. Keep the bytes minimal: a single small grayscale or solid-color image is enough. Do not use real photos.
5. Build EXIF as an APP1 segment directly (TIFF header, IFD0 with DateTime and Make/Model, Exif sub-IFD with DateTimeOriginal and OffsetTimeOriginal, GPS IFD for coordinates). Insert it right after the SOI marker.
6. Do not add an image or EXIF crate without asking first. CLAUDE.md lists the allowed crates.
## Standard cases
Every fixture set must cover these cases. Name files so the purpose is clear.
| Case | Purpose |
|------|---------|
| `with_exif.jpg` | Full EXIF: datetime, make/model, GPS |
| `no_exif.jpg` | Valid JPEG with no EXIF block; rename must fall back to mtime and flag it |
| `wrong_tz.jpg` | Offset that differs from the local zone, to test timezone handling |
| `dup_a.jpg` and `dup_b.jpg` | Identical bytes under different names, for dedupe |
| `same_name_a/IMG_0001.jpg` and `same_name_b/IMG_0001.jpg` | Same name, different content, for collision handling |
| `IMG_0002.jpg` plus `IMG_0002.xmp` | Sidecar pair; the sidecar must move with the photo |
| `corrupt.jpg` | Truncated or garbage bytes; reading must return an error, not a panic |
## Rules
- Each fixture file must be under 50 KB. Check with `ls -l tests/fixtures`. If a file is over the limit, make the image smaller.
- No real personal photos, no real GPS coordinates of a home or workplace. Use made-up coordinates such as `0.0, 0.0` or a public landmark.
- Do not commit generated temp files. Only commit fixture files that the standard set produces.
- If you regenerate `tests/fixtures/`, commit only the files that actually changed.
- Tests must use fixtures only. Do not read from user directories.
- After changing fixtures, run the `verify` skill.
.claude/skills/verify/SKILL.md---
name: verify
description: Run the the app check sequence (fmt, clippy, tests, dry-run smoke test, backup resume test) before declaring any task done.
---
# Verify
Run this before saying any task is done. Run the steps in order and stop at the first failing step. Never run against real photos or a real cloud account. Use only `tests/fixtures` and the local mock server.
## Steps
1. Formatting
- `cargo fmt --check`
- Pass if exit code is 0.
2. Lints
- `cargo clippy --all-targets -- -D warnings`
- Pass if exit code is 0.
3. Tests
- `cargo test --all`
- Pass if exit code is 0.
- On failure, use the `test-runner` subagent to pull failing test names and key error lines. Do not paste the full log.
4. Smoke run on fixtures
- Create a temp copy: `tmp=$(mktemp -d) && cp -r tests/fixtures/. "$tmp"/`
- Rename dry-run: `cargo run -- rename "$tmp" --dry-run`
- Dedupe dry-run: `cargo run -- dedupe "$tmp" --dry-run`
- Pass if both exit with code 0 and the output lists planned actions without errors.
- Confirm nothing changed in `$tmp` (compare with `diff -r tests/fixtures "$tmp"`). A dry run must not modify or move files.
- Remove the temp dir afterward: `rm -rf "$tmp"`. This is the only allowed `rm -rf`, and only on the temp dir.
5. Backup changes only
- Run this step only if files under `src/backup/` or `tests/backup*` changed.
- `cargo test --test backup_resume`
- Pass if exit code is 0. This test kills and resumes an upload against the local mock server.
## Report format
Return one line per step:
```
1 fmt: PASS
2 clippy: PASS
3 test: FAIL
4 smoke: not run
5 backup: not run
```
- Use PASS, FAIL, or "not run" (with the reason, such as an earlier failure or step 5 not applicable).
- For the first failing step only, add the first error message and its file and line if shown. Do not list later errors.
- Do not add other commentary.
## Rules
- Never run `the app` with `--apply`. Verification uses dry runs only.
- Never read or write `.env` files, `.pem` files or credentials to get through a check.
- If a step needs a crate that is not in CLAUDE.md, stop and ask first.
INSTALL.md# Install the app
## Prerequisites
- Rust stable, installed with rustup. Confirm with `cargo --version`.
- Claude Code 2.1.296 or newer. Use 2.1.314 or newer, because `.claude/settings.json` sets `permissions.blockWritesOutsideWorkingDirectories`.
- Check with `claude --version`. Update with `claude update`.
## Set up the repo
1. In an empty folder, run `git init`.
2. If there is no Rust project yet, run `cargo init --name the app`.
3. Copy CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md, INSTALL.md, and `.claude/` into the repo root.
4. Run `rustup component add rust-analyzer`. The LSP plugin needs it.
## Install plugins
Run these in your shell:
```
claude plugin install rust-analyzer-lsp@claude-plugins-official
claude plugin install code-review@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
```
## Set credentials
Keep credentials out of the repo. For now, set them as env vars:
```
export PHOTOFLOW_ACCESS_KEY=YOUR_ACCESS_KEY
export PHOTOFLOW_SECRET_KEY=YOUR_SECRET_KEY
```
Replace the placeholders with your own values in your shell profile. Never put real values in a project file, and never commit them. Phase 4 also accepts a config file outside the repo.
## Start Claude Code
1. Run `claude` in the repo root.
2. Run `/skill-doctor` to confirm the skills load.
3. Paste the Phase 1 `/goal` prompt from PLAN.md.
## Work through the phases
- Run one phase at a time. Let the goal finish, or stop it early with `/goal clear`.
- Run `/clear` between phases and between unrelated tasks.
- Review each phase with `/code-review` before you commit. Commit with `/commit`.
- Run `/usage` to see where your tokens go.
PLAN.md# the app plan
Run one phase per session. Run `/clear` before starting each phase. Paste that phase's `/goal` prompt to start it. Commits happen after code review, not inside the goal.
Every phase ends with the same loop: explore, plan, code, run the `verify` skill, run `/code-review`, then commit with `/commit`.
## Phase 1: Scaffold and EXIF reading
Scope:
- Cargo project with clap subcommands `rename`, `dedupe`, `backup`.
- `src/exif.rs` exposes `read_metadata(path) -> Result<PhotoMeta>`.
- `tests/common` generates tiny synthetic JPEGs: with EXIF, without EXIF, with GPS.
- Tests cover valid read, missing EXIF (None fields, no error), and corrupt file (Err, no panic).
Goal prompt:
```
/goal Complete Phase 1 of PLAN.md for the the app Rust CLI.
End state: a cargo project named the app with clap subcommands rename,
dedupe and backup. dedupe and backup print "not implemented". rename
--dry-run prints the files it finds. src/exif.rs exposes
read_metadata(path) -> Result<PhotoMeta> with capture time, optional
offset, optional GPS, make and model. tests/common generates tiny
synthetic JPEGs (with EXIF, without EXIF, with GPS). Tests cover valid
EXIF read, missing EXIF returning None fields without error, and corrupt
file returning Err without panic.
Check: cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test --all, and cargo run -- rename tests/fixtures --dry-run must
all exit 0. Show each command's final output in the conversation.
Constraints: read CLAUDE.md and SPEC.md first and state a short plan
before coding. Use only crates listed in CLAUDE.md and ask before adding
any other. No unwrap or expect outside tests. No real network. Never
touch files outside the repo. Fixtures are synthetic and under 50KB.
Commit nothing. Use the test-runner subagent for long test output.
Stop after 35 turns and report what is done, what fails, and open
questions (add open questions to SPEC.md).
```
## Phase 2: Rename with dry-run, collisions and sidecars
Scope:
- Name format and counter rules from SPEC.md.
- mtime fallback, flagged in output.
- Timezone offset handling with a warning when absent.
- Offline GPS to place name, using a small bundled table until SPEC open questions are resolved.
- Collisions never overwrite. Sidecars move with their photo.
- Symlinks skipped. Read-only files reported and skipped.
Goal prompt:
```
/goal Complete Phase 2 of PLAN.md: the app rename.
End state: rename implements the rules in SPEC.md. Dry-run is the
default, and --apply performs the renames. Tests exist for each of
these: missing EXIF falls back to mtime and is flagged, timezone offset
used when present, same name with different content both survive,
.xmp sidecar moves with its photo (both naming patterns), symlink
skipped, read-only file skipped and reported, non-UTF8 filename handled,
dry-run leaves a snapshot of the tree unchanged, apply produces the
planned names.
Check: cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test --all, cargo run -- rename tests/fixtures --dry-run (exit 0),
and a rename --apply run on a temp copy of tests/fixtures. Show each
command's output in the conversation.
Constraints: use only approved crates and ask before adding any other.
No unwrap or expect outside tests. No real network, and no touching
files outside the repo or a temp dir. Fixtures synthetic and under 50KB.
Commit nothing. Stop after 40 turns and report what is done, what
fails, and open questions.
```
## Phase 3: Dedupe with blake3 and trash folder
Scope:
- Group by size, then blake3 hash computed in spawn_blocking.
- `--keep oldest|newest|largest`, with path-sort tie-breaking.
- Losers move to `.the app-trash/` with relative paths and sidecars.
- Trash collisions get a numeric suffix. Nothing is deleted.
Goal prompt:
```
/goal Complete Phase 3 of PLAN.md: the app dedupe.
End state: dedupe implements SPEC.md. Files are grouped by size, then
by full-content blake3 hash computed on spawn_blocking. --keep oldest,
newest and largest choose the survivor. Losers move to
<dir>/.the app-trash/ with their relative path. Sidecars move with
their photo. Nothing is deleted outright. Tests prove no data loss:
after --apply, every distinct content hash still exists in the tree or
the trash; same-size different-content files are not grouped; trash
name collisions get a suffix; a second run is a no-op; symlinks are not
followed. Dry-run prints the plan and changes nothing.
Check: cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test --all, and cargo run -- dedupe tests/fixtures (exit 0). Show
each command's output in the conversation.
Constraints: use only approved crates and ask before adding any other.
No unwrap or expect outside tests. No real network. Never touch files
outside the repo or a temp dir. Fixtures synthetic and under 50KB.
Commit nothing. Stop after 40 turns and report what is done, what
fails, and open questions.
```
## Phase 4: Backup with chunked resumable upload
Scope:
- `StorageProvider` trait and an S3-compatible implementation with reqwest.
- Fixed 8 MiB chunks, retries with exponential backoff.
- Atomic state file writes and `--resume`.
- Remote SHA-256 verification at the end.
- The `serde_json` crate is needed for the state file. It is not in the CLAUDE.md crate list, so ask before adding it. Ask before adding a SigV4 signing crate too.
- Mock server: a hand-written tokio TcpListener in tests/common. It can stall after N parts and inject errors. Use no new crates for this.
Goal prompt:
```
/goal Complete Phase 4 of PLAN.md: the app backup.
End state: StorageProvider trait with an S3-compatible implementation.
Chunked 8 MiB upload, retries with exponential backoff, state file
.the app-state.json written atomically (temp file, fsync, rename)
after each chunk, --resume skipping completed chunks, and remote SHA-256
verification before marking a file verified. Credentials come from
PHOTOFLOW_ACCESS_KEY and PHOTOFLOW_SECRET_KEY only, never logged.
Tests use a hand-written mock server in tests/common (tokio
TcpListener, no new crates). Create tests/backup_resume.rs with a
kill-and-resume test: start the binary via env!("CARGO_BIN_EXE_photoflow")
against a mock that stalls mid-upload, kill it with Child::kill, rerun
with --resume, then assert completed chunks were not re-sent, the
checksum verified, and the state file parses as JSON.
Check: cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test --all, and cargo test --test backup_resume. Show each
command's output in the conversation.
Constraints: no real cloud and no real network; the mock server only.
Ask before adding serde_json or any SigV4 crate. No unwrap or expect
outside tests. Never touch files outside the repo or a temp dir. Commit
nothing. Stop after 40 turns and report what is done, what fails, and
open questions.
```
## Phase 5: Polish and release
Scope:
- indicatif progress bars on stderr, hidden with `--json` or when stderr is not a TTY.
- `--json` output for all subcommands.
- Clear error messages with no secrets.
- README and `cargo build --release`.
Goal prompt:
```
/goal Complete Phase 5 of PLAN.md: polish and release.
End state: progress bars (indicatif, stderr, hidden with --json or
non-TTY). Every subcommand supports --json and emits valid JSON.
Error messages are clear and contain no credentials. README.md covers
install, the three subcommands, and safety defaults. cargo build
--release succeeds.
Check: cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test --all, cargo build --release. Smoke run: copy tests/fixtures
to a temp dir, run rename and dedupe dry-runs with --json, and run
backup against the mock server with --json. Confirm the JSON parses
using a serde_json test. Set PHOTOFLOW_SECRET_KEY to a fake value and
confirm it appears in no stdout, stderr or state file. Show each
command's output in the conversation.
Constraints: no new crates without asking. No real network or real
cloud. No unwrap or expect outside tests. Never touch files outside the
repo or a temp dir. Commit nothing. Stop after 40 turns and report what
is done, what fails, and open questions.
```
REVIEW.md# Review criteria
These criteria apply to every change. CLAUDE.md imports this file, so /code-review uses them.
## Data loss
- Gate every `fs::remove_*`, rename, and overwrite behind `--apply`.
- Check for collisions before each rename or move. Never overwrite an existing file.
- Dedupe moves files to `.the app-trash/`. Flag any outright delete.
- Sidecar files (.xmp, .aae) must move with their photo.
## Resumability
- Write `.the app-state.json` atomically: write a temp file, then rename it.
- Update the state file after each uploaded chunk.
- A resumed run must not re-upload chunks already recorded in the state file.
## Errors and panics
- No `unwrap` or `expect` in non-test code.
- Libraries use thiserror. `main` uses anyhow.
- Malformed or corrupt EXIF returns an error or `None`. It must never panic.
## Secrets
- Credentials never appear in logs, error messages, `--json` output, or test snapshots.
- Flag any key committed to the repo. Credentials come only from env vars or a config file outside the repo.
## Async
- Hash large files and run other blocking IO through `spawn_blocking`.
- Never block a tokio worker thread.
## Tests
- Each new behavior has a test.
- Tests use synthetic fixtures under `tests/fixtures/` only.
- Backup tests run against the local mock server. No real cloud or network calls.
## Paths
- Use `PathBuf` for all paths.
- Handle non-UTF8 file names without lossy conversion that changes the path.
- Do not hardcode path separators.
SPEC.md# the app spec
## Goal
Rust CLI that renames photos by capture date and place, removes duplicates without data loss, and backs up to S3-compatible storage with resumable uploads.
## Non-goals
- No GUI.
- No RAW editing. RAW files are read for metadata, and rename may move them, but file contents are never modified.
- No perceptual (near-duplicate) dedupe in v1.
- No sync deletion. Backup never deletes remote objects.
## Supported formats
- Metadata read: JPEG, HEIC, PNG, TIFF, CR2, NEF, ARW, DNG.
- PNG and some other formats may lack EXIF. These fall back to file mtime.
- Rename and dedupe act on every format above plus sidecars.
## CLI surface
```
the app rename <dir> [--apply] [--format default] [--geocode offline|online]
the app dedupe <dir> [--apply] [--keep oldest|newest|largest]
the app backup <dir> --dest <url> [--resume]
```
- `--dry-run` is the default for rename and dedupe. `--apply` turns it off. Passing both is an error.
- Backup has no dry-run. It only writes to the destination.
- `--json` on every subcommand prints machine-readable output to stdout.
- `--format` accepts only `default` in v1. Other presets are an open question.
- `--dest` is `https://<endpoint>/<bucket>/<prefix>`. Region comes from the config file.
- Exit codes: 0 success (including dry-runs that find work), 1 runtime error, 2 invalid arguments.
## Rename rules
- Name format: `YYYY-MM-DD_HHMMSS_<place>_<NNN>.<ext>`. The extension is lowercased.
- Date comes from EXIF DateTimeOriginal.
- Timezone: use OffsetTimeOriginal when present. Otherwise use the naive time and print a warning.
- Missing EXIF: use file mtime (local time) and mark `source=mtime` in output.
- Place: offline lookup from GPS. No GPS gives `noplace`.
- Counter `NNN` starts at 001 for each date and place group, in sorted path order.
- Collisions: never overwrite. If the target exists on disk or in the plan, increase the counter.
- Sidecars `.xmp` and `.aae` move with their photo. Match both `IMG_1.xmp` and `IMG_1.JPG.xmp`. Keep the sidecar suffix.
- Symlinks are not followed. They are reported and skipped.
- Read-only files are reported and skipped.
- Geocoding `online` is opt-in, rate-limited to one request per second, and cached on disk under the user cache directory.
## Dedupe rules
- Group files by size, then by full-content blake3 hash. Only groups with two or more members count.
- `--keep` picks the survivor: oldest or newest by capture date (mtime if no EXIF), or largest by size. Ties break by path sort.
- Other members move to `<dir>/.the app-trash/`, preserving their relative path.
- Sidecars move with their photo.
- Nothing is deleted outright. A name collision in the trash gets a numeric suffix.
- Dry-run lists each group, the survivor, and each planned move.
- Symlinks are not followed or deduped.
- Files named `.the app-*` and the trash folder are ignored by rename and dedupe.
## Backup protocol
- Chunk size: 8 MiB, fixed in v1.
- Retries: up to 5 per chunk, with exponential backoff of 1, 2, 4, 8 and 16 seconds plus jitter.
- State file: `.the app-state.json` in the source root. Writes are atomic: write a temp file, fsync, then rename.
- State schema (version 1):
```json
{
"version": 1,
"dest": "https://example.com",
"files": {
"relative/path.jpg": {
"size": 1234,
"blake3": "hex",
"sha256": "hex",
"upload_id": "string or null",
"chunks_done": [0, 1],
"status": "in_progress | uploaded | verified"
}
}
}
```
- Credentials are never written to the state file.
- Without `--resume`, an existing state file with in-progress entries is an error. The message says to use `--resume`.
- With `--resume`, completed chunks are not re-sent.
- After the last chunk, verify the remote SHA-256 against the local value. Mark the file `verified` only on match.
## Provider abstraction
- Trait `StorageProvider` with methods for begin upload, upload part, complete upload, and remote checksum.
- First implementation: S3-compatible, using reqwest.
- Credentials come from `PHOTOFLOW_ACCESS_KEY` and `PHOTOFLOW_SECRET_KEY`, or from a config file outside the repo.
- Credentials are never logged or printed in errors.
## Acceptance criteria
rename:
- A file with EXIF gets the correct name from its DateTimeOriginal.
- A file without EXIF gets its mtime and `source=mtime` in output.
- With an offset tag, the offset is used. Without one, a warning is printed.
- Two files with the same name and different content both survive. Neither is overwritten.
- A `.xmp` sidecar is renamed along with its photo.
- A dry-run changes no files.
- `--apply` renames as planned and exits 0.
dedupe:
- Byte-identical files are grouped. Same-size, different-content files are not.
- `--keep oldest` keeps the oldest capture date.
- Every losing file appears in `.the app-trash/` with its relative path.
- After apply, every distinct content hash still exists in the tree or the trash.
- A second run is a no-op.
backup:
- A 20 MiB file uploads in three chunks.
- A killed run resumes with `--resume` and sends only missing chunks.
- A checksum mismatch marks the file failed and exits 1.
- The state file is valid JSON after a kill -9.
- No credentials appear in stdout, stderr, or the state file.
## Open questions
- Storage provider: which S3-compatible service is the first target?
- Geocoding source: which offline place dataset, and what is its license?
- Crates not yet in the approved list: `serde_json` (state file and `--json`) and a config file format.
- If the destination lacks SHA-256 support, is size plus ETag an acceptable fallback?
- Should `--format` gain presets beyond `default`?