hard-sync-core 1.0.0

Core library for hard-sync: file sync engine, drive detection, config, and watch mode.
Documentation
# hard-sync-core — Agent Context

## What This Crate Is

The published library crate. All business logic lives here. `cli/` and `ui/` are consumers only.

Crate name: `hard-sync-core`
Folder: `core/`

---

## Module Structure

```
core/src/
├── lib.rs           ← public API surface only — re-exports, no logic
├── config.rs        ← read/write config.json, PairConfig struct, CRUD ops
├── sync_engine.rs   ← diff + copy + trash/delete/ignore logic, SyncReport
├── watcher.rs       ← notify-based file watching, WatchHandle
├── drive.rs         ← drive detection, UUID/label matching, OS abstractions
├── ignore.rs        ← .hardsyncignore parsing + config list merging
├── sounds.rs        ← rodio audio notifications per event
└── logging.rs       ← structured operation log (what was copied/trashed/skipped)
```

---

## Public API (what lib.rs exposes)

```rust
// Pair management
pub fn init_pair(config: PairConfig) -> Result<()>
pub fn list_pairs() -> Result<Vec<PairConfig>>
pub fn remove_pair(name: &str) -> Result<()>
pub fn set_source(name: &str, source: SourceSide) -> Result<()>
pub fn get_pair(name: &str) -> Result<PairConfig>

// Sync
pub fn sync_pair(name: &str, options: SyncOptions) -> Result<SyncReport>

// Watch
pub fn watch_pair(name: &str, callbacks: WatchCallbacks) -> Result<WatchHandle>

// Trash
pub fn list_trash(name: &str) -> Result<Vec<TrashEntry>>
pub fn clear_trash(name: Option<&str>) -> Result<()>   // None = clear all
```

Keep internals `pub(crate)`. Only expose what callers genuinely need.

---

## Key Types

```rust
pub struct PairConfig {
    pub name: String,
    pub base: PathBuf,
    pub target: PathBuf,
    pub source: SourceSide,        // SourceSide::Base | SourceSide::Target
    pub drive_id: Option<DriveId>,
    pub ignore: Vec<String>,
    pub delete_behavior: DeleteBehavior,
    pub sounds: SoundConfig,
    pub created_at: DateTime<Utc>,
}

pub enum SourceSide { Base, Target }

pub struct DriveId {
    pub label: Option<String>,
    pub uuid: Option<String>,
}

pub enum DeleteBehavior { Trash, Delete, Ignore }

pub struct SyncOptions {
    pub dry_run: bool,
    pub verify: bool,        // SHA256 comparison instead of mtime+size
}

pub struct SyncReport {
    pub copied: usize,
    pub updated: usize,
    pub trashed: usize,
    pub deleted: usize,
    pub skipped: usize,
    pub ignored: usize,
    pub errors: Vec<SyncError>,
}

pub struct SoundConfig {
    pub sync_start: Option<PathBuf>,
    pub sync_done: Option<PathBuf>,
    pub sync_error: Option<PathBuf>,
}

pub struct TrashEntry {
    pub original_name: String,
    pub trashed_at: DateTime<Utc>,
    pub size: u64,
    pub path: PathBuf,
}
```

---

## Sync Engine Logic

```
1. Resolve which path is source and which is target from PairConfig
2. Walk source → HashMap<relative_path: String, DirEntry { size: u64, mtime: u64 }>
3. Walk target → same (apply ignore patterns during walk)
4. Diff:
   - In source, not target            → COPY to target
   - In target, not source            → apply delete_behavior
   - In both, source mtime newer      → OVERWRITE on target
   - In both, same mtime + size       → SKIP
   - --verify: also check SHA256 before overwrite decision
5. Create parent dirs as needed before copying
6. Log every operation with outcome
7. Fire sound notification: sync_start before loop, sync_done or sync_error after
8. Return SyncReport
```

**Never load file content into memory.** Use `fs::metadata()` for mtime/size. Only read bytes when computing SHA256 under `--verify`.

---

## Trash System

- Trash folder: `<target_path>/.hard-sync-trash/`
- Trash filename format: `<ISO8601_timestamp>_<original_filename>`
  - Example: `2026-03-07T14-23-01Z_old_notes.txt`
- On `clear_trash`: delete all files in `.hard-sync-trash/` for that pair
- Trash folder itself is always ignored during sync walks

---

## Pair Types

Two valid configurations — both fully supported:

**Cross-drive pair** (typical USB use case)
- `base` and `target` are on different physical drives
- `drive_id` is populated on `init`
- Watch mode polls for drive AND watches for file changes
- Drive may not always be mounted — tool waits for it

**Same-drive pair** (two folders on the same machine)
- `base` and `target` are on the same physical drive (including two different local repos)
- `drive_id` is `null` in config
- Watch mode only watches source for file changes — no drive polling
- Target is always assumed to be accessible — error immediately if missing

On `init`, detect automatically: if both paths resolve to the same mount point / disk, set `drive_id: null`. Do not ask the user to specify.

---

## Drive Detection

- On `init`: given the target path, detect what drive it lives on. Compare with the drive that `base` lives on.
  - If same drive → `drive_id: null`, skip drive detection
  - If different drive → extract UUID and label from target drive, store in `PairConfig.drive_id`
- On `watch` (cross-drive pairs only): poll mounted drives every N seconds. Compare UUID/label against stored `drive_id`. When match found, resolve current mount path and use as target.
- `sysinfo` crate provides disk info cross-platform. May need OS-specific calls for UUID on Windows vs Linux.
- Abstract behind a trait:
  ```rust
  pub(crate) trait DriveDetector {
      fn get_drive_id(path: &Path) -> Result<DriveId>;
      fn find_mounted_drive(id: &DriveId) -> Option<PathBuf>;
      fn same_drive(a: &Path, b: &Path) -> bool;
  }
  ```

---

## Ignore Pattern Matching

Priority order (later overrides earlier is NOT how this works — union of all patterns):
1. Built-in defaults: `.hard-sync-trash/`, `.hard_sync_cli/`, `.hardsyncignore`
2. Per-pair list from config JSON
3. `.hardsyncignore` file in base directory (gitignore line format, `#` = comment)

A file is ignored if it matches ANY pattern from any source.

---

## Config File Location

```rust
// Cross-platform:
dirs::config_dir()  // ~/.config on Linux, %APPDATA% on Windows
    .join("hard-sync")
    .join("config.json")
```

Use the `dirs` crate for this. Create the directory if it doesn't exist on first write.

---

## Crates for This Module

Install via `cargo add` from inside `core/` — do not pin versions manually:

```bash
cargo add serde --features derive
cargo add serde_json
cargo add walkdir
cargo add chrono --features serde
cargo add sysinfo
cargo add notify
cargo add rodio
cargo add sha2
cargo add regex
cargo add dirs
```