hard-sync is a Rust CLI tool that syncs files between two paths — typically a local folder and a removable drive. One path is the source (truth), the other the target (follows source). Supports watch mode that auto-syncs when a specific drive is detected by UUID/label — no path typing after setup.
The core use case: user sets up a pair once, plugs in drive, tool auto-detects it, syncs, plays a sound, user unplugs. Zero interaction after setup.
The killer scenario: time-critical one-way transfer — plug drive, hear start sound, hear done sound, unplug. Works even if you can't be at a keyboard.
Cargo workspace. Three members — folder name vs published crate name:
hard-sync/
├── Cargo.toml ← [workspace] members = ["core", "cli", "ui"]
├── CLAUDE.md ← this file
├── core/ → published crate: hard-sync-core
│ ├── Cargo.toml
│ ├── AGENTS.md ← core-specific agent context
│ └── src/
├── cli/ → published crate: hard-sync-cli (binary: hsync)
│ ├── Cargo.toml
│ ├── AGENTS.md ← cli-specific agent context
│ └── src/
└── ui/ → Tauri app (not published yet, deferred)
└── AGENTS.md
Rule: core/ contains all business logic. cli/ and ui/ are thin wrappers that call core and format output. Never put sync logic, drive detection, or config logic in cli/ or ui/.
base= the local/primary pathtarget= the other path (often a removable drive)source= which side is the truth (either"base"or"target")- Either side can be source — configurable per pair, swappable at any time
- Location:
~/.config/hard-sync/config.json - Format: JSON
- Named pairs — user inits once, references by name forever
- No per-directory state files (
.hard_sync_cli/approach is dropped)
Two pair types — both supported. Drive type is auto-detected on init, never specified by user.
{
"version": 1,
"pairs": [
{
"name": "my-backup",
"base": "/home/user/projects",
"target": "/media/usb/projects",
"source": "base",
"drive_id": {
"label": "MY_USB",
"uuid": "xxxx-xxxx"
},
"ignore": ["node_modules", ".git", "target", "dist"],
"delete_behavior": "trash",
"sounds": {
"sync_start": null,
"sync_done": null,
"sync_error": null
},
"created_at": "2026-03-07T12:00:00Z"
},
{
"name": "local-mirror",
"base": "/home/user/project-a",
"target": "/home/user/project-b",
"source": "base",
"drive_id": null,
"ignore": ["node_modules", ".git"],
"delete_behavior": "trash",
"sounds": {
"sync_start": null,
"sync_done": null,
"sync_error": null
},
"created_at": "2026-03-07T12:00:00Z"
}
]
}drive_id: null = same-drive pair. No drive polling in watch mode.
- v1 default: mtime + file size — fast, no memory overhead
--verifyflag: SHA256 checksum — opt-in only, used before overwrite decision- Never load file content into memory for comparison
"trash"— move to.hard-sync-trash/<timestamp>_<filename>on target (recommended default)"delete"— permanently remove from target"ignore"— leave orphaned files on target, log them only
- Identify drives by UUID and/or volume label — never by path
- Drive letters (Windows) and mount points (Linux) shift depending on plug order
- On
init: detect and store the drive's stable identifier from the target path - On
watch: poll for a drive matching the stored UUID/label regardless of where it mounted - Abstract behind a trait — Windows and Linux implementations are separate
- Per-pair list in config JSON
- Optional
.hardsyncignorefile in base dir (gitignore-style lines,#comments) - Always auto-ignore:
.hard-sync-trash/,.hard_sync_cli/,.hardsyncignore
- Opt-in — null by default (sound in CLI is unusual, should never surprise)
- Events:
sync_start,sync_done,sync_error - User provides custom WAV/MP3 path per event, per pair, in config
- Crate:
rodio(pure Rust, cross-platform)
| Crate | Purpose | Notes |
|---|---|---|
fli |
CLI argument parsing | User-authored — use latest version |
notify |
Cross-platform file watching | Wraps inotify/FSEvents/ReadDirectoryChangesW |
serde + serde_json |
Config serialization | |
walkdir |
Recursive directory traversal | |
chrono |
Timestamps for logging and trash naming | |
sysinfo |
Drive detection and disk info | |
rodio |
Cross-platform audio notifications | |
sha2 |
SHA256 for --verify mode |
Optional, not default comparison |
regex |
Ignore pattern matching |
Do NOT use clap. Use fli.
- Workspace scaffold (Cargo.toml, folder structure)
core/— config modulecore/— sync enginecore/— ignore patternscore/— drive detectioncore/— watchercore/— soundscli/— all commands wired to coreui/— deferred until cli is stable
- Check for uncommitted changes. If any: request the user run the commit command via their local CLI — do not use MCP or any tool to commit.
- Checkout a new branch named after the task before touching code.
- Check memory files (
~/.claude/projects/.../memory/) for prior context.
- Break the task into a todo list first — before writing any code.
- Draft an implementation plan and present it to the user. Wait for explicit go-ahead.
- After go-ahead: finalize the todo, then start implementation.
- Mark todo items complete immediately when done — never batch.
- Request a commit (via user's local CLI) after each major milestone.
- A "major milestone" = a module is complete and compiles cleanly.
core/is a pure library — no CLI I/O, no printing, no user interaction.cli/calls core functions and formats output. Nothing else.- Never load file content into memory for comparison.
- Default comparison is mtime + size. SHA256 only on
--verify. - Drive paths are never the stable identifier — always UUID/label.
- Always use
cargo add <crate>to install dependencies — never write dependency entries intoCargo.tomlmanually, and never pin specific versions in docs or code.cargo addpicks the latest compatible version. Run it from inside the relevant workspace member directory (core/orcli/).