Skip to content

feat(sync): playlist mapping, incremental state and opt-in auto-sync (tr-bau) - #180

Open
jm2 wants to merge 1 commit into
polecat/tr-t3ifrom
polecat/tr-bau
Open

feat(sync): playlist mapping, incremental state and opt-in auto-sync (tr-bau)#180
jm2 wants to merge 1 commit into
polecat/tr-t3ifrom
polecat/tr-bau

Conversation

@jm2

@jm2 jm2 commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

docs/task.md:1042-1044. Issue #8.
Playlist mapping, incremental state, conflict resolution, and opted-in auto-sync with safe attach/detach recovery.
Depends on both the transfer planner and MTP discovery.

Implementation notes

Implemented: device-sync module with playlist mapping (PlaylistMap/PlaylistPair), incremental per-track state (IncrementalSyncState), per-playlist opt-in policy (SyncPolicy), read-only planner (SyncPlanner), write-side executor (SyncExecutor), and attach/detach recovery (SyncSessionGuard/AttachDetachRecovery). 37 sync unit tests added; full suite 1805 tests pass; strict clippy + cargo fmt --check clean in debug and release. Branch polecat/tr-bau pushed (1 commit, 2054 insertions) based on origin/polecat/tr-t3i because both prereq branches (tr-0na #175, tr-t3i #178) are open PRs not yet on main.

Refinery handoff

  • Issue: tr-bau (task, P3)
  • Source branch: polecat/tr-bau
  • Target: polecat/tr-t3i
  • Rebased on polecat/tr-t3i via Gastown Refinery.

…(tr-bau)

Issue #8 / P3.2 require playlist mapping, incremental state, conflict
resolution, and explicitly opted-in auto-sync with safe attach/detach
recovery. This change introduces the device-sync module that owns that
work end-to-end, on top of the existing transfer planner (tr-0na) and
MTP discovery (tr-t3i):

- `src/device/sync/policy.rs`: per-playlist sync policy. Default is
  disabled; opt-in via `SyncPolicy::enable` with a relative destination
  under the device root. Conflict resolution covers host-wins,
  device-wins, skip, and fail. Delete-missing is off by default so a
  host track that disappears never silently removes a device track.

- `src/device/sync/mapping.rs`: `PlaylistMap` + `PlaylistPair` pairs
  host playlist ids with device-side identifiers and the destination
  root. The pair is the anchor that lets incremental sync recognise
  what changed on either side. Invariants: no two pairs share a host
  id, no two pairs share a device-side id, every destination is a
  valid relative path.

- `src/device/sync/state.rs`: per-track `IncrementalSyncState`. Status
  is `Pending`, `Synced` (with fingerprint, device-relative path, and
  last-synced instant), `Modified` (fingerprint changed since last
  sync), or `Missing` (host track gone). The planner reads this to
  decide new/modified/unchanged/removed; the executor updates it after
  every successful stage.

- `src/device/sync/planner.rs`: read-only half. Accepts host tracks
  with their current fingerprints, compares against recorded state,
  and emits `SyncDelta`s grouped by playlist pair. Validates request
  shape, host-id consistency between pairs and track sets, and
  non-empty fingerprints. The planner never decides a conflict
  outcome — that is the executor's job, keeping the planner testable
  without an authority.

- `src/device/sync/executor.rs`: write-side. Walks the planner's
  deltas, emits `SyncStage`s in a stable order (OpenSession,
  BrowseStorage, FetchTrack, RemoveTrack), and updates the recorded
  state. Honourable-failure path: without a policy or with
  delete-missing disabled, removals are skipped and counted as
  skipped conflicts in the run summary.

- `src/device/sync/recovery.rs`: attach/detach detection. The
  `SyncSessionGuard` is the runtime handle the executor checks
  between stages. The `AttachDetachRecovery::verdict` consumes the
  guard's event stream and produces a verdict (Completed / Detached
  with last-completed stage and next-stage hint / Failed) so a later
  re-attach can resume from the right place. The recovery never
  mutates recorded state — the executor does that only after a
  successful stage.

The module exposes its types at the sync module root so a future sync
UX layer can compose them. Auto-sync is the explicit path: every policy
starts unlinked, every map is empty, every state is empty. No code in
this change can silently write a host playlist to a device without a
user action.

Tests: 37 unit tests in the new module cover policy validation,
mapping invariants, planner delta classification, executor stage
ordering, recovery verdict logic, and host-playlist-id hygiene.
`cargo test --all-targets` passes 1791 + 14 = 1805 tests. Strict
clippy is clean in debug and release. `cargo fmt --check` is clean.

Prereqs: tr-0na (mounted-filesystem transfer planner/executor) and
tr-t3i (MTP discovery + bounded browsing/transfer). This branch is
based on `origin/polecat/tr-t3i` rather than `origin/main` because
both prereq branches are open PRs (refs #175 and #178) and their
code has not yet landed on main.

Validation: cargo fmt --check, cargo clippy --all-targets -D warnings,
cargo clippy --release -D warnings, cargo test --all-targets.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f5440493-f65c-4055-b066-21094b20091b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch polecat/tr-bau

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @jm2's task in 2m 49s —— View job


Claude's review of PR #180

Tasks

  • Gather context (PR body, diff stat, changed files)
  • Read all changed/added files in src/device/sync/
  • Review correctness (logic, error handling, concurrency)
  • Review security (input validation, attach/detach safety)
  • Review performance (allocations, I/O)
  • Review testing coverage
  • Review documentation
  • Post final review feedback

Summary

This is a well-documented, cleanly factored module (mapping/state/policy/planner/executor/recovery separation is sensible, and path validation in policy.rs::validate_relative_path correctly rejects absolute paths and .. components). However, I found one critical correctness bug and one significant scope gap that I posted as inline comments — both are masked by a real test-coverage gap, so despite "37 sync unit tests, full suite passing" the core write path is effectively untested.

Findings (posted inline)

  1. executor.rs:217-247 (apply_written) — records the wrong fingerprint and a fabricated device path. SyncDelta never carries the host track's new fingerprint or its resolved device-relative path for New/Modified deltas, so the executor falls back to the old recorded fingerprint (empty string for brand-new tracks) and invents a path from track_id + ".bin". For a genuinely new track this trips the debug_assert!(!fingerprint.is_empty()) invariant in state.rs (panics in debug builds) and in release builds persists a bogus empty fingerprint + wrong path, which will make the track look permanently "modified" and break later removal/resume logic. No existing test exercises this because every executor test passes an empty track list into build_inputs(...), so apply_written is never invoked with a real New/Modified delta.

  2. executor.rs:140-176 — conflict resolution is documented but never implemented. The PR description and module docs describe host-wins/device-wins/skip/fail conflict handling as a core feature, but SyncExecutor::run() never reads policy.conflict_strategy() — every Modified delta is unconditionally written (host-wins), so DeviceWins/Skip/Fail are dead states. There's also no mechanism anywhere that detects a device-side edit, so DeviceWins would have nothing to act on even if wired up.

  3. planner.rs:172-175destination_relative is computed/validated then discarded, which is the root cause of finding USB device support (drag and drop) #1: the correct path is calculated once here and thrown away instead of being attached to the SyncDelta.

  4. executor.rs:178-191 — dead code. A loop that iterates self.map.iter() and does nothing (let _ = pair;) before the real update loop just below it. Looks like a leftover first draft; safe to delete.

Other observations (not inline)

  • SyncPlan::expected_write_bytes (planner.rs:100,274) is documented as "used for capacity budgeting" but is hardcoded to 0 — there's no track-size input anywhere in HostTrackEntry to compute it from, so the field is currently dead weight. Worth either wiring it up or dropping the doc claim until it's implemented.
  • Using debug_assert! in state.rs::record_synced to enforce a documented invariant ("Synced always has a non-empty fingerprint") is fragile — it silently does nothing in release builds, which is exactly the build where finding USB device support (drag and drop) #1's empty-fingerprint bug would go unnoticed. Consider returning a Result or making the invariant a type-level guarantee instead.
  • Good practices worth calling out: PlaylistPair/PlaylistMap construction consistently validates paths and rejects ambiguous device-side ids that look like paths; SyncPolicy defaults to disabled with opt-in required, matching the PR's stated "opt-in only" design goal; the attach/detach guard (recovery.rs) has a clean, minimal event-based design that's easy to reason about.

I did not run cargo test/cargo clippy myself in this session (read-only review); the PR description states the full suite and strict clippy pass, but note that finding #1 shows passing tests don't currently exercise the new-track write path.

Branch: polecat/tr-bau

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 medium · 2 minor

Alerts:
⚠ 4 issues (≤ 0 issues of at least minor severity)

Results:
4 new issues

Category Results
Complexity 2 medium
2 minor

View in Codacy

🟢 Metrics 165 complexity · 24 duplication

Metric Results
Complexity 165
Duplication 24

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

Comment on lines +217 to +247
fn apply_written(&mut self, delta: &SyncDelta) {
if matches!(delta.kind, SyncDeltaKind::Removed { .. }) {
return;
}
let host = &delta.host;
let track_id = delta.track_id.clone();
let destination_root = match self.map.get_by_host(host) {
Some(pair) => pair.destination_root().to_path_buf(),
None => return,
};
let recorded_fingerprint = self
.state_by_pair
.get(host)
.and_then(|state| state.status(&track_id))
.and_then(|status| status.fingerprint())
.unwrap_or("")
.to_string();
let device_path = destination_root
.join(format!("{track_id}.bin"))
.to_string_lossy()
.into_owned();
let Some(state) = self.state_by_pair.get_mut(host) else {
return;
};
state.record_synced(
track_id,
recorded_fingerprint,
device_path,
self.now_seconds,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: apply_written records the wrong fingerprint and a fabricated device path.

SyncDelta (planner.rs) never carries the host track's current fingerprint or its intended device-relative path for New/Modified/Unchanged — only Removed carries device_relative_path. So here:

let recorded_fingerprint = self
    .state_by_pair
    .get(host)
    .and_then(|state| state.status(&track_id))
    .and_then(|status| status.fingerprint())
    .unwrap_or("")
    .to_string();

this reads the old/previously-recorded fingerprint (or "" for a brand-new track), not the new fingerprint the planner compared against (HostTrackEntry::fingerprint in planner.rs). For a New track this passes fingerprint = "" into state.record_synced(...), which trips the documented invariant in state.rs:

debug_assert!(!fingerprint.is_empty(), "fingerprint must be non-empty");

i.e. this will panic in any debug build the first time a genuinely new track is synced end-to-end, and in release builds it silently persists an empty fingerprint as Synced, which breaks the "unchanged" comparison on the next planning pass (every subsequent run will treat the track as Modified forever, since the stored fingerprint "" never equals the real one).

Separately, the device path is fabricated here:

let device_path = destination_root
    .join(format!("{track_id}.bin"))
    .to_string_lossy()
    .into_owned();

This discards the actual planned relative path (entry.device_relative_path from HostTrackEntry, validated as destination_relative in planner.rs:172-175 and then thrown away) and invents a new one keyed only on track_id with a hardcoded .bin extension — unrelated to the real file (e.g. .flac/.mp3). The recorded device_relative_path in TrackSyncStatus::Synced will not match where the transfer executor actually writes the file, which will corrupt later Removed deltas (wrong path to delete) and defeats the "resume from recorded state" goal this module exists for.

Root cause: SyncDelta needs to carry the new fingerprint and the resolved device-relative path (already computed once in the planner) so the executor doesn't have to reconstruct or guess them.

No test exercises this path — every executor test in this file calls build_inputs(vec![], ...) (empty track list), so apply_written is never invoked with a New/Modified delta, which is why 37 passing tests didn't catch this.

Comment on lines +172 to +175
let destination_relative =
pair.destination_root().join(&entry.device_relative_path);
validate_relative_path(&destination_relative, "destination")
.map_err(|source| PlannerError::Policy { source })?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destination_relative is computed and validated here but never stored on the emitted SyncDelta (see SyncDelta/SyncDeltaKind above — only the Removed variant carries a path). The executor (executor.rs::apply_written) ends up fabricating its own path from track_id instead of reusing this validated value — see the inline comment on executor.rs.

Comment on lines +140 to +176
}
match &delta.kind {
SyncDeltaKind::New | SyncDeltaKind::Modified | SyncDeltaKind::Unchanged => {
// Unchanged tracks produce no work but still need a
// stage entry so the recovery can quote the last
// completed stage.
let stage = SyncStage::FetchTrack {
track_id: delta.track_id.clone(),
};
self.guard.record_stage_completed(stage);
if matches!(delta.kind, SyncDeltaKind::New | SyncDeltaKind::Modified) {
summary.written = summary.written.saturating_add(1);
self.apply_written(&delta);
}
}
SyncDeltaKind::Removed { .. } => {
let host = delta.host.clone();
let stage = SyncStage::RemoveTrack {
track_id: delta.track_id.clone(),
};
let Some(policy) = self.policies.get(&host) else {
// Without a policy, removals are skipped.
self.guard.record_stage_completed(stage);
summary.skipped_conflicts = summary.skipped_conflicts.saturating_add(1);
continue;
};
if !policy.deletes_missing() {
self.guard.record_stage_completed(stage);
summary.skipped_conflicts = summary.skipped_conflicts.saturating_add(1);
continue;
}
self.guard.record_stage_completed(stage);
summary.removed = summary.removed.saturating_add(1);
self.apply_removed(&delta);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflict resolution is documented but never implemented. The module docs (mod.rs, policy.rs) describe host-wins/device-wins/skip/fail conflict handling as a headline feature of this PR ("conflict resolution covers the three cases..."), and planner.rs states "the executor uses the policy's ConflictResolution to pick the outcome" for a Modified delta. But run() never calls policy.conflict_strategy() anywhere — every Modified delta is unconditionally written via apply_written (host-wins behavior), regardless of what ConflictResolution the policy holds. DeviceWins, Skip, and Fail are unreachable dead states.

This is acknowledged implicitly by the marker function at the bottom of this file:

// `SyncConflictResolution` is exposed at the module root and re-exported
// here so a future method that consults it does not need a separate
// import. It is currently unreferenced; the warning suppression keeps the
// unused-import check quiet.
#[allow(dead_code)]
fn _conflict_strategy_marker(_: SyncConflictResolution) {}

Given the PR description explicitly calls out "conflict resolution" as implemented, this looks like a real functional gap rather than a deferred TODO — worth flagging before merge, or the PR description/module docs should be adjusted to say conflict resolution is not yet wired into the executor.

Related: there's also no mechanism anywhere in this module that detects a device-side edit (the "device edited since last sync, host unchanged" case from policy.rs's doc comment) — the planner only ever compares the host's current fingerprint against the last-recorded-synced fingerprint, so DeviceWins would have nothing to act on even if it were wired up.

Comment on lines +178 to +191
for (host, pair) in self.map.iter() {
if !self
.policies
.get(host)
.map(|p| p.is_enabled())
.unwrap_or(false)
{
continue;
}
// We need a mutable borrow of the map to record the sync
// instant. Take a clone of the host id and look it up
// again.
let _ = pair;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: this loop does nothing (let _ = pair; is the only effect of the branch that isn't continue) — it's superseded by the actual mutation loop directly below (lines 192-204). Looks like a first attempt that was left in place after being reworked into the second loop. Worth deleting for clarity.

@codacy-production codacy-production Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

The PR introduces a device synchronization module but is currently not up to standards. While the architectural foundations are present, several critical logic flaws prevent the module from functioning as intended. Most notably, the run method in the executor consumes the instance without returning the updated state, meaning all synchronization progress is lost upon completion. Additionally, the incremental sync mechanism is fundamentally broken because it records stale fingerprints rather than updated ones, which will cause redundant transfers in every subsequent run.

Several acceptance criteria are unaddressed: conflict resolution strategies (DeviceWins, Skip, Fail) are ignored in favor of a hardcoded HostWins approach, and capacity budgeting is non-functional. The executor also disregards the file paths calculated by the planner, which may lead to inconsistencies between the host's intent and the device's filesystem. These issues, paired with high cyclomatic complexity in the planner and executor, must be resolved before merging.

About this PR

  • The synchronization state (IncrementalSyncState and PlaylistMap) is held internally by the SyncExecutor, but the 'run' method consumes 'self' and only returns a summary. This means the mutated state is dropped and lost after execution, preventing subsequent incremental syncs from benefiting from the work performed.
  • The system defines conflict resolution strategies (HostWins, DeviceWins, etc.) in the schema, but the current implementation treats all conflicts as HostWins. This is a significant gap in the required acceptance criteria.

Test suggestions

  • Planner correctly identifies New, Modified, and Unchanged tracks based on fingerprint comparison.
  • Planner detects tracks removed from the host that exist in the device's recorded state.
  • Executor honors the 'delete_missing' policy for removed tracks.
  • Executor stops immediately and records a detach event if the session guard is marked detached.
  • Recovery module produces a correct verdict (Completed/Detached/Failed) based on the event stream.
  • Executor respects the conflict resolution strategy (e.g., DeviceWins or Skip) for modified tracks.
  • Planner calculates total expected bytes for the write plan for capacity budgeting.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Executor respects the conflict resolution strategy (e.g., DeviceWins or Skip) for modified tracks.
2. Planner calculates total expected bytes for the write plan for capacity budgeting.

TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback

Some(pair) => pair.destination_root().to_path_buf(),
None => return,
};
let recorded_fingerprint = self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

The apply_written method currently retrieves the recorded_fingerprint from the existing state rather than the new fingerprint computed by the planner. This breaks incremental sync because subsequent runs will always detect a mismatch. SyncDelta should be updated to carry the new fingerprint and the relative path from the planner.

/// Run the plan. The returned summary reports what happened; the
/// caller reads the session guard through the recovery API to
/// decide whether to retry.
pub fn run(mut self) -> SyncRunSummary {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

This method manages the entire lifecycle of a sync run and has high cyclomatic complexity. Crucially, it consumes 'self' but only returns a summary, meaning updated incremental state and playlist mappings are lost upon completion. This should be refactored to either return the updated state or operate on a mutable reference, and the logic should be split into helper methods (e.g., 'execute_deltas' and 'finalize_sync_instants') to improve maintainability.

See Issue in Codacy
See Issue in Codacy
See Complexity in Codacy


Ok(SyncPlan {
deltas,
expected_write_bytes: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

expected_write_bytes is hardcoded to 0, preventing capacity budgeting. The planner should calculate this by summing the sizes of the tracks marked for transfer (New or Modified).

.unwrap_or("")
.to_string();
let device_path = destination_root
.join(format!("{track_id}.bin"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

The executor hardcodes a '.bin' extension and ignores the relative path calculated by the planner. This contradicts the planner's logic and ignores the host's preferred naming scheme or media types.

/// track. Pairs without a matching host track set or recorded state
/// are reported as a removed delta for every previously-synced track.
#[allow(clippy::unused_self)]
pub fn plan(&self, request: &SyncRequest) -> Result<SyncPlan, PlannerError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: This method handles too many responsibilities, including request validation, track-by-track comparison, and removal detection. Refactor by extracting the track delta logic (lines 164-234) and removal detection (lines 238-269) into private helper methods.

See Issue in Codacy
See Issue in Codacy
See Complexity in Codacy

Comment on lines +178 to +191
for (host, pair) in self.map.iter() {
if !self
.policies
.get(host)
.map(|p| p.is_enabled())
.unwrap_or(false)
{
continue;
}
// We need a mutable borrow of the map to record the sync
// instant. Take a clone of the host id and look it up
// again.
let _ = pair;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Nitpick: Redundant loop detected; this block performs no operations and should be removed.

Comment on lines +172 to +175
let destination_relative =
pair.destination_root().join(&entry.device_relative_path);
validate_relative_path(&destination_relative, "destination")
.map_err(|source| PlannerError::Policy { source })?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destination_relative is computed and validated here but never stored on the emitted SyncDelta (see SyncDelta/SyncDeltaKind above, only the Removed variant carries a path). The executor (executor.rs::apply_written) ends up fabricating its own path from track_id instead of reusing this validated value — see the inline comment on executor.rs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant