Skip to main content

suno_core/
reconcile.rs

1//! The pure reconcile engine: it decides what to download, retag, rename,
2//! reformat, and delete.
3//!
4//! This is the highest-risk module in the project. It is intentionally pure:
5//! no IO, no clock, no network. The caller supplies every input (the prior
6//! [`Manifest`], the desired selection, the on-disk probe for each manifest
7//! path, and the per-source enumeration status) and [`reconcile`] returns a
8//! [`Plan`] that the CLI executes later. The plan is itself the dry-run
9//! recording, so there is never an `if dry_run` branch.
10//!
11//! Deletion safety is paramount. The guards encoded here are:
12//!
13//! - SYNC-8: a clip held by any `Copy` source is never deleted; copy and
14//!   archive always win. This holds both for the clip's current selection
15//!   (`Desired::modes`) and across runs through the persisted
16//!   [`ManifestEntry::preserve`] marker, so a copy-held or private clip whose
17//!   source is later deselected, or whose copy listing fails, is still kept.
18//! - SYNC-9: never delete on an empty, failed, partial, or truncated listing.
19//!   Deletion is allowed only when every selected source (mirror and copy) was
20//!   fully enumerated, and only when at least one mirror source was selected.
21//! - SYNC-10: a manifest path that is missing or zero length on disk is treated
22//!   as missing and re-downloaded, even when its hashes still match.
23//! - SYNC-12: a clip trashed in Suno is removed from the source and its local
24//!   file is deleted under the same enumeration guard; a private or copy-held
25//!   clip is kept.
26//!
27//! Every `Delete`, whether for a trashed clip or an absent orphan, flows through
28//! one guard ([`delete_action`]): a manifest entry must exist with a non-empty,
29//! non-preserved path, deletion must be allowed for the run, and the clip must
30//! not be copy-held or private in the current selection. A final pass suppresses
31//! any `Delete` whose path collides with a file another action writes this run.
32
33use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36
37use crate::config::AudioFormat;
38use crate::graph::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43
44/// The class of an external sidecar artifact a clip (or album/library) owns.
45///
46/// The reconcile engine keeps a single pair of artifact actions
47/// ([`Action::WriteArtifact`] / [`Action::DeleteArtifact`]) rather than one
48/// variant per class; the `kind` distinguishes them so the executor and the
49/// manifest can route each to the right slot. `VideoMp4` is deferred and
50/// intentionally absent. Per-clip classes ([`CoverJpg`](ArtifactKind::CoverJpg)
51/// and [`CoverWebp`](ArtifactKind::CoverWebp)) map to a manifest entry field;
52/// the album/library classes are reconciled by later phases and have no per-clip
53/// manifest slot yet.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub enum ArtifactKind {
56    /// The per-song external cover, sourced from `image_large_url`.
57    CoverJpg,
58    /// The per-song animated cover, derived from `video_cover_url`.
59    CoverWebp,
60    /// The album folder's static cover (album-scoped, later phase).
61    FolderJpg,
62    /// The album folder's animated cover (album-scoped, later phase).
63    FolderWebp,
64    /// A library-root `.m3u8` playlist (library-scoped, later phase).
65    Playlist,
66}
67
68/// How a selected source treats its clips: mirror with deletion, or additive copy.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum SourceMode {
72    /// Mirror the source, deleting local files that leave it (rclone `sync`).
73    Mirror,
74    /// Copy additively; never delete (rclone `copy`).
75    Copy,
76}
77
78/// One desired clip in the current selection.
79///
80/// The caller has already deduped per account and resolved naming and format,
81/// so each entry is the authoritative target state for one clip. `modes` lists
82/// every selected source that currently holds the clip, so a clip can be held
83/// by a `Mirror` and a `Copy` source at once.
84#[derive(Debug, Clone, PartialEq)]
85pub struct Desired {
86    /// The clip itself, carried so actions can be executed without a re-fetch.
87    pub clip: Clip,
88    /// The clip's resolved lineage, carried so the executor tags with the same
89    /// root/parent/album that drove naming and the change hash.
90    pub lineage: LineageContext,
91    /// Resolved relative target path for the file.
92    pub path: String,
93    /// Resolved target format.
94    pub format: AudioFormat,
95    /// Hash of the clip's tag-bearing metadata.
96    pub meta_hash: String,
97    /// Hash of the clip's cover art.
98    pub art_hash: String,
99    /// Every selected source that currently holds this clip.
100    pub modes: Vec<SourceMode>,
101    /// True when the clip is trashed in Suno (removed from the source).
102    pub trashed: bool,
103    /// True when the clip is private; private clips are always kept.
104    pub private: bool,
105    /// The clip's desired external artifacts (cover.jpg, cover.webp, ...).
106    ///
107    /// This is the authoritative target set of sidecars for the clip: an
108    /// artifact present here is written when missing or changed, and a manifest
109    /// artifact absent here is a removed kind and reconciled for deletion. It
110    /// defaults to empty; later phases populate it (P7 covers per-song art), so
111    /// for now every production caller passes an empty vec and only tests set it.
112    pub artifacts: Vec<DesiredArtifact>,
113}
114
115/// One desired external artifact for a clip.
116///
117/// Carries where the sidecar should live, where to fetch it, and the content or
118/// source change hash that drives rewrite detection against the manifest.
119#[derive(Debug, Clone, PartialEq)]
120pub struct DesiredArtifact {
121    /// Which artifact class this is.
122    pub kind: ArtifactKind,
123    /// Resolved relative target path for the sidecar.
124    pub path: String,
125    /// The URL the sidecar's bytes are fetched from.
126    pub source_url: String,
127    /// Content/source change hash; a change from the manifest triggers a write.
128    pub hash: String,
129}
130
131/// The desired folder-art target for one album (one stable root id).
132///
133/// Folder art is album-scoped, so it is reconciled against the album store
134/// ([`AlbumArt`]) rather than the per-clip manifest. Each present kind carries a
135/// [`DesiredArtifact`] whose `hash` is the *content* hash of the chosen art, not
136/// the source clip id: a most-played flip that yields the same art content is a
137/// no-op (HARDENING H1). A `None` kind means the album desires no art of that
138/// kind this run (no art-bearing clip, no animated source, or the feature is
139/// off), which delete-reconciles any stored art of that kind under the shared
140/// deletion gate.
141#[derive(Debug, Clone, PartialEq)]
142pub struct AlbumDesired {
143    /// The album's stable key: the resolved root ancestor id (HARDENING H2).
144    pub root_id: String,
145    /// The desired static `folder.jpg`, from the most-played art-bearing variant.
146    pub folder_jpg: Option<DesiredArtifact>,
147    /// The desired animated `cover.webp`, from the first-created animated variant.
148    pub folder_webp: Option<DesiredArtifact>,
149}
150
151/// The desired `.m3u8` target for one playlist (a Suno playlist, or the
152/// synthetic liked feed).
153///
154/// A playlist's body is *generated* from this run's rendered audio paths, not
155/// fetched, so it is reconciled by a single content [`hash`](Self::hash) over
156/// the full rendered text (HARDENING B1: the name, member order, and every
157/// member's path/title/duration feed it). The rendered body is carried inline
158/// in [`content`](Self::content) so the executor writes it without a network
159/// round-trip. [`path`](Self::path) is `<sanitised name>.m3u8` at the library
160/// root, tracked so a rename removes the stale file.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct PlaylistDesired {
163    /// The playlist's stable key: its Suno id (the synthetic `"liked"` id for
164    /// the liked feed).
165    pub id: String,
166    /// The playlist's display name, as shown on Suno.
167    pub name: String,
168    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
169    pub path: String,
170    /// The fully rendered `.m3u8` body, written inline (no fetch).
171    pub content: String,
172    /// The content hash over `content`, driving rewrite detection.
173    pub hash: String,
174}
175
176/// The caller's on-disk probe of one manifest path.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub struct LocalFile {
179    /// Whether the file exists on disk.
180    pub exists: bool,
181    /// Size of the file in bytes (zero when absent).
182    pub size: u64,
183}
184
185/// Per-source enumeration status for one selected source.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct SourceStatus {
188    /// The source's mode.
189    pub mode: SourceMode,
190    /// Whether this source was completely and successfully enumerated.
191    pub fully_enumerated: bool,
192}
193
194/// One executable step in a [`Plan`].
195#[derive(Debug, Clone, PartialEq)]
196pub enum Action {
197    /// Download the clip to `path` in `format` (new, missing, or zero length).
198    Download {
199        clip: Clip,
200        lineage: LineageContext,
201        path: String,
202        format: AudioFormat,
203    },
204    /// Render the clip to `path` in `to`, replacing the prior `from` rendering.
205    ///
206    /// A format change always changes the file extension, so the prior file at
207    /// `from_path` is a different path that must be removed once the new file is
208    /// written; carrying it keeps the plan a full account of disk mutations.
209    Reformat {
210        clip: Clip,
211        path: String,
212        from_path: String,
213        from: AudioFormat,
214        to: AudioFormat,
215    },
216    /// Re-tag the existing file at `path` to match current metadata or art.
217    Retag {
218        clip: Clip,
219        lineage: LineageContext,
220        path: String,
221    },
222    /// Move the file from one relative path to another.
223    Rename { from: String, to: String },
224    /// Delete the local file for a clip that has left every mirror source.
225    Delete { path: String, clip_id: String },
226    /// Take no action for a clip; recorded so the plan is a full account.
227    Skip { clip_id: String },
228    /// Write (or rewrite) an external sidecar artifact for its owning clip.
229    ///
230    /// Emitted when the manifest lacks the artifact or its stored hash differs
231    /// from `hash`. A write is additive and never gated by deletion safety.
232    ///
233    /// `content` carries an inline body for *generated* artifacts (playlists):
234    /// when `Some`, the executor writes those exact bytes atomically and skips
235    /// the network entirely; when `None`, it fetches (and transcodes) from
236    /// `source_url` as before. A fetched artifact leaves `source_url` set and
237    /// `content` `None`; a generated one leaves `source_url` empty and `content`
238    /// `Some`.
239    WriteArtifact {
240        kind: ArtifactKind,
241        path: String,
242        source_url: String,
243        hash: String,
244        owner_id: String,
245        content: Option<String>,
246    },
247    /// Delete an external sidecar artifact (a removed kind, or a co-deleted
248    /// sidecar of a clip whose audio is being deleted).
249    ///
250    /// Only ever emitted through [`delete_artifact_action`], which shares the
251    /// audio `can_delete` gate and the owning entry's `preserve` marker, so a
252    /// sidecar is never removed on an incomplete listing or for a preserved clip.
253    DeleteArtifact {
254        kind: ArtifactKind,
255        path: String,
256        owner_id: String,
257    },
258}
259
260/// The reconcile output: an ordered, deterministic list of actions.
261///
262/// The plan is the dry-run recording. The convenience counts let the CLI
263/// summarise a run without re-walking the action list by hand.
264#[derive(Debug, Clone, Default, PartialEq)]
265pub struct Plan {
266    /// The actions, in stable order.
267    pub actions: Vec<Action>,
268}
269
270impl Plan {
271    /// Total number of actions.
272    pub fn len(&self) -> usize {
273        self.actions.len()
274    }
275
276    /// True when there are no actions.
277    pub fn is_empty(&self) -> bool {
278        self.actions.is_empty()
279    }
280
281    /// Number of [`Action::Download`] actions.
282    pub fn downloads(&self) -> usize {
283        self.count(|a| matches!(a, Action::Download { .. }))
284    }
285
286    /// Number of [`Action::Reformat`] actions.
287    pub fn reformats(&self) -> usize {
288        self.count(|a| matches!(a, Action::Reformat { .. }))
289    }
290
291    /// Number of [`Action::Retag`] actions.
292    pub fn retags(&self) -> usize {
293        self.count(|a| matches!(a, Action::Retag { .. }))
294    }
295
296    /// Number of [`Action::Rename`] actions.
297    pub fn renames(&self) -> usize {
298        self.count(|a| matches!(a, Action::Rename { .. }))
299    }
300
301    /// Number of [`Action::Delete`] actions.
302    pub fn deletes(&self) -> usize {
303        self.count(|a| matches!(a, Action::Delete { .. }))
304    }
305
306    /// Number of [`Action::Skip`] actions.
307    pub fn skips(&self) -> usize {
308        self.count(|a| matches!(a, Action::Skip { .. }))
309    }
310
311    /// Number of [`Action::WriteArtifact`] actions.
312    pub fn artifact_writes(&self) -> usize {
313        self.count(|a| matches!(a, Action::WriteArtifact { .. }))
314    }
315
316    /// Number of [`Action::DeleteArtifact`] actions.
317    pub fn artifact_deletes(&self) -> usize {
318        self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
319    }
320
321    fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
322        self.actions.iter().filter(|a| pred(a)).count()
323    }
324}
325
326/// Decide the plan for one reconcile run.
327///
328/// `local` maps a clip id to the probe of that clip's manifest path; entries
329/// are expected for clips present in `manifest`. `sources` lists every selected
330/// source with its enumeration status, which gates every deletion this run.
331///
332/// Duplicate `desired` entries for one clip id (the same clip held by a mirror
333/// and a copy source, say) are aggregated first: the result is private if any
334/// is, copy-held if any is, and trashed only if all are, so a stray trashed
335/// duplicate can never defeat a sibling's protection.
336///
337/// The output order is stable: desired clips are processed in clip-id order,
338/// then absent manifest entries in clip-id order. No output depends on hash-map
339/// iteration order.
340pub fn reconcile(
341    manifest: &Manifest,
342    desired: &[Desired],
343    local: &HashMap<String, LocalFile>,
344    sources: &[SourceStatus],
345) -> Plan {
346    let mut actions: Vec<Action> = Vec::new();
347
348    // Aggregate duplicate ids, then process in clip-id order for determinism.
349    let desired = aggregate_desired(desired);
350    let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
351
352    let can_delete = deletion_allowed(sources);
353
354    for d in &desired {
355        // Decide the audio action(s) first (unchanged), then reconcile the
356        // clip's artifacts alongside. A clip whose audio is being deleted this
357        // run has its sidecars co-deleted under the same gate; otherwise its
358        // desired artifacts are written and any removed kind reconciled.
359        let before = actions.len();
360        plan_desired(d, manifest, local, can_delete, &mut actions);
361        let audio_deleted = actions[before..]
362            .iter()
363            .any(|a| matches!(a, Action::Delete { .. }));
364        if audio_deleted {
365            co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
366        } else {
367            plan_clip_artifacts(d, manifest, can_delete, &mut actions);
368        }
369    }
370
371    // Absent manifest entries, processed in clip-id order (BTreeMap is sorted).
372    for (clip_id, _entry) in manifest.iter() {
373        if desired_ids.contains(clip_id.as_str()) {
374            continue;
375        }
376        match delete_action(clip_id, manifest, can_delete) {
377            Some(action) => {
378                actions.push(action);
379                // Co-delete the absent clip's sidecars under the same gate.
380                co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
381            }
382            // SYNC-9 / preserve / empty-path: absence is unreliable or the entry
383            // is protected, so keep the file rather than delete it.
384            None => actions.push(Action::Skip {
385                clip_id: clip_id.clone(),
386            }),
387        }
388    }
389
390    suppress_path_aliasing(&mut actions);
391    Plan { actions }
392}
393
394/// Whether clips may be deleted this run.
395///
396/// SYNC-9: deletion requires at least one selected `Mirror` source and every
397/// selected source (mirror and copy alike) fully enumerated. A failed or partial
398/// copy listing is just as unreliable as a mirror one, so it suppresses deletes
399/// too. With no mirror source there is no authoritative listing to delete
400/// against, and copy-only runs are additive.
401///
402/// This is the single deletion verdict for the run; the CLI threads the same
403/// value into [`plan_album_artifacts`] so folder-art deletes share it.
404pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
405    let mut saw_mirror = false;
406    for status in sources {
407        if !status.fully_enumerated {
408            return false;
409        }
410        if status.mode == SourceMode::Mirror {
411            saw_mirror = true;
412        }
413    }
414    saw_mirror
415}
416
417/// The single gate every `Delete` passes through.
418///
419/// Returns a [`Action::Delete`] only when deletion is allowed for the run, a
420/// manifest entry exists for the clip, its path is non-empty, and the entry is
421/// not preserve-marked. A `None` result means the caller must keep the file.
422fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
423    if !can_delete {
424        return None;
425    }
426    let entry = manifest.get(clip_id)?;
427    if entry.path.is_empty() || entry.preserve {
428        return None;
429    }
430    Some(Action::Delete {
431        path: entry.path.clone(),
432        clip_id: clip_id.to_string(),
433    })
434}
435
436/// The single gate every `DeleteArtifact` passes through.
437///
438/// This is the artifact analogue of [`delete_action`] and deliberately shares
439/// the audio deletion safety: it returns a [`Action::DeleteArtifact`] only when
440/// deletion is allowed for the run (`can_delete`, the same
441/// [`deletion_allowed`] verdict), the owning manifest entry exists, the sidecar
442/// `path` is non-empty (so an empty path can never delete the account root), and
443/// the owning entry is not `preserve`-marked (a preserved clip's artifacts are
444/// preserved too). A `None` result means the caller must keep the sidecar.
445fn delete_artifact_action(
446    owner_id: &str,
447    kind: ArtifactKind,
448    path: &str,
449    manifest: &Manifest,
450    can_delete: bool,
451) -> Option<Action> {
452    if !can_delete {
453        return None;
454    }
455    let entry = manifest.get(owner_id)?;
456    if path.is_empty() || entry.preserve {
457        return None;
458    }
459    Some(Action::DeleteArtifact {
460        kind,
461        path: path.to_string(),
462        owner_id: owner_id.to_string(),
463    })
464}
465
466/// Whether an artifact kind is a per-song sidecar reconciled per clip.
467///
468/// Only cover art lives on the manifest entry today; album/library classes
469/// (folder art, playlists) are owned by later phases and reconciled elsewhere,
470/// so per-clip planning ignores them.
471fn is_per_clip_kind(kind: ArtifactKind) -> bool {
472    matches!(kind, ArtifactKind::CoverJpg | ArtifactKind::CoverWebp)
473}
474
475/// Whether a no-longer-desired ("removed kind") artifact may be delete-reconciled
476/// while its owning clip's audio is kept this run.
477///
478/// Cover art deliberately opts out: a clip's art or video-preview URL can be
479/// transiently absent for a run (the feed omits it, or a fetch fails), and the
480/// desired set then simply lacks that cover. Treating that absence as a removal
481/// and deleting the on-disk sidecar would churn a perfectly good cover, so an
482/// empty/transient URL must KEEP the existing file. A cover is therefore removed
483/// only by [`co_delete_artifacts`], when the owning clip leaves every mirror
484/// source and its audio is deleted (a fully gated path). The removed-kind
485/// mechanism is kept intact for any future sidecar kind that genuinely wants it.
486fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
487    match kind {
488        ArtifactKind::CoverJpg | ArtifactKind::CoverWebp => false,
489        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => true,
490    }
491}
492
493/// The manifest slot for a per-clip artifact kind, if that kind is stored on the
494/// entry. Album/library classes have no per-clip slot yet, so they map to
495/// `None`; the match stays generic so later phases can add slots without
496/// touching callers.
497fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
498    match kind {
499        ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
500        ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
501        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
502    }
503}
504
505/// The per-clip artifacts an entry currently records, paired with their kind, in
506/// a stable order. Only the per-song sidecars live on the entry today.
507fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
508    let mut out = Vec::new();
509    if let Some(state) = &entry.cover_jpg {
510        out.push((ArtifactKind::CoverJpg, state));
511    }
512    if let Some(state) = &entry.cover_webp {
513        out.push((ArtifactKind::CoverWebp, state));
514    }
515    out
516}
517
518/// Set (or clear) the manifest slot for a per-clip artifact kind.
519///
520/// The executor calls this after a [`Action::WriteArtifact`] (with the new
521/// state) or a [`Action::DeleteArtifact`] (with `None`), so the kind-to-field
522/// mapping lives in exactly one place. Album/library classes have no per-clip
523/// slot yet and are no-ops.
524pub(crate) fn set_manifest_artifact(
525    entry: &mut ManifestEntry,
526    kind: ArtifactKind,
527    state: Option<ArtifactState>,
528) {
529    match kind {
530        ArtifactKind::CoverJpg => entry.cover_jpg = state,
531        ArtifactKind::CoverWebp => entry.cover_webp = state,
532        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
533    }
534}
535
536/// Reconcile the artifacts of a clip whose audio is kept this run.
537///
538/// Writes each desired per-clip artifact that the manifest lacks, whose stored
539/// hash drifts, or whose stored path drifts (the audio moved). Delete-reconciles
540/// each manifest artifact whose kind is no longer desired (a removed kind)
541/// through the shared [`delete_artifact_action`] gate, unless the clip is
542/// protected this run, and unless the kind opts out of removed-kind deletion
543/// ([`removed_kind_delete_eligible`]) — cover art does, so a transient empty URL
544/// keeps its sidecar rather than deleting it.
545fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
546    let owner_id = d.clip.id.as_str();
547    let entry = manifest.get(owner_id);
548
549    for artifact in &d.artifacts {
550        // Per-clip reconcile owns only the per-song sidecars (cover.jpg/.webp).
551        // Album/library classes (folder art, playlists) belong to later phases;
552        // ignore them here so they are not rewritten every run.
553        if !is_per_clip_kind(artifact.kind) {
554            continue;
555        }
556        // A write is needed when the manifest lacks the sidecar, its bytes drift
557        // (hash), or the clip moved so the sidecar belongs at a new path (audio
558        // renamed to a new album/name). On a move the executor's WriteArtifact
559        // relocates the sidecar: it writes the new path, then removes the copy
560        // left at the previously tracked path (see `Ctx::write_artifact`).
561        // Self-healing a sidecar that is missing on disk despite a matching
562        // manifest record is deferred beyond P7 (it needs a local-artifact
563        // presence probe, as audio has).
564        let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
565            None => true,
566            Some(state) => state.hash != artifact.hash || state.path != artifact.path,
567        };
568        if needs_write {
569            out.push(Action::WriteArtifact {
570                kind: artifact.kind,
571                path: artifact.path.clone(),
572                source_url: artifact.source_url.clone(),
573                hash: artifact.hash.clone(),
574                owner_id: owner_id.to_string(),
575                content: None,
576            });
577        }
578    }
579
580    // A clip protected THIS run (private or copy-held) keeps its sidecars even
581    // when a kind is no longer desired, regardless of the persisted preserve
582    // marker (which may still be false on the run that first protects the clip).
583    // Preserve wins, so no removed-kind delete is emitted for it.
584    let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
585    if !protected_now && let Some(entry) = entry {
586        let desired_kinds: BTreeSet<ArtifactKind> = d
587            .artifacts
588            .iter()
589            .filter(|a| is_per_clip_kind(a.kind))
590            .map(|a| a.kind)
591            .collect();
592        for (kind, state) in manifest_artifacts(entry) {
593            // Cover kinds opt out of removed-kind deletion (see
594            // `removed_kind_delete_eligible`): an absent desired cover means an
595            // empty/transient URL, which must KEEP the on-disk sidecar, never
596            // delete it. Only a co-delete (audio gone) removes a cover. The loop
597            // and gate stay in place for any future kind that opts back in.
598            if removed_kind_delete_eligible(kind)
599                && !desired_kinds.contains(&kind)
600                && let Some(action) =
601                    delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
602            {
603                out.push(action);
604            }
605        }
606    }
607}
608
609/// Co-delete every sidecar of a clip whose audio is being deleted this run.
610///
611/// Each removal flows through the shared [`delete_artifact_action`] gate, so a
612/// sidecar is co-deleted only when the audio delete itself was allowed; on an
613/// incomplete listing or a preserved entry nothing is emitted.
614fn co_delete_artifacts(
615    owner_id: &str,
616    manifest: &Manifest,
617    can_delete: bool,
618    out: &mut Vec<Action>,
619) {
620    let Some(entry) = manifest.get(owner_id) else {
621        return;
622    };
623    for (kind, state) in manifest_artifacts(entry) {
624        if let Some(action) =
625            delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
626        {
627            out.push(action);
628        }
629    }
630}
631
632/// Collapse duplicate desired entries for one clip id into a single record.
633///
634/// Safety folds are order-independent: `private` and copy-held are unions, and
635/// `trashed` is an intersection. The non-safety fields (clip, path, format,
636/// hashes) are taken from a deterministic representative so the result never
637/// depends on input order.
638fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
639    let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
640    for d in desired {
641        match by_id.get_mut(d.clip.id.as_str()) {
642            None => {
643                by_id.insert(d.clip.id.as_str(), d.clone());
644            }
645            Some(acc) => {
646                let take = rep_key(d) < rep_key(acc);
647                acc.private = acc.private || d.private;
648                acc.trashed = acc.trashed && d.trashed;
649                for mode in &d.modes {
650                    if !acc.modes.contains(mode) {
651                        acc.modes.push(*mode);
652                    }
653                }
654                if take {
655                    acc.clip = d.clip.clone();
656                    acc.path = d.path.clone();
657                    acc.format = d.format;
658                    acc.meta_hash = d.meta_hash.clone();
659                    acc.art_hash = d.art_hash.clone();
660                    acc.artifacts = d.artifacts.clone();
661                }
662            }
663        }
664    }
665    let mut out: Vec<Desired> = by_id.into_values().collect();
666    for d in &mut out {
667        // Normalise modes to a canonical order so aggregation is deterministic.
668        let has_mirror = d.modes.contains(&SourceMode::Mirror);
669        let has_copy = d.modes.contains(&SourceMode::Copy);
670        d.modes.clear();
671        if has_mirror {
672            d.modes.push(SourceMode::Mirror);
673        }
674        if has_copy {
675            d.modes.push(SourceMode::Copy);
676        }
677    }
678    out
679}
680
681/// A deterministic, order-independent sort key for choosing the representative
682/// non-safety fields when aggregating duplicate desired entries.
683fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
684    let format = match d.format {
685        AudioFormat::Mp3 => 0,
686        AudioFormat::Flac => 1,
687        AudioFormat::Wav => 2,
688    };
689    (
690        d.path.as_str(),
691        d.meta_hash.as_str(),
692        d.art_hash.as_str(),
693        format,
694    )
695}
696
697/// Downgrade any delete whose path is also written by a `Download`,
698/// `Reformat`, `Rename`, or `WriteArtifact` this run, so a deletion can never
699/// clobber a file the same plan just produced. This covers both the audio
700/// [`Action::Delete`] and every artifact [`Action::DeleteArtifact`] class.
701fn suppress_path_aliasing(actions: &mut [Action]) {
702    let targets: BTreeSet<String> = actions
703        .iter()
704        .filter_map(|a| match a {
705            Action::Download { path, .. }
706            | Action::Reformat { path, .. }
707            | Action::WriteArtifact { path, .. } => Some(path.clone()),
708            Action::Rename { to, .. } => Some(to.clone()),
709            _ => None,
710        })
711        .collect();
712    for a in actions.iter_mut() {
713        if let Action::Delete { path, clip_id } = a
714            && targets.contains(path.as_str())
715        {
716            *a = Action::Skip {
717                clip_id: clip_id.clone(),
718            };
719        }
720        if let Action::DeleteArtifact { path, owner_id, .. } = a
721            && targets.contains(path.as_str())
722        {
723            *a = Action::Skip {
724                clip_id: owner_id.clone(),
725            };
726        }
727    }
728}
729
730/// Append the action(s) for one desired clip.
731fn plan_desired(
732    d: &Desired,
733    manifest: &Manifest,
734    local: &HashMap<String, LocalFile>,
735    can_delete: bool,
736    out: &mut Vec<Action>,
737) {
738    let clip_id = d.clip.id.as_str();
739    let copy_held = d.modes.contains(&SourceMode::Copy);
740
741    // SYNC-12: a trashed clip is removed from the source, so its local file is
742    // deleted, but only when neither private nor copy-held (protection beats
743    // removal) and only through the shared delete guard. If the guard refuses
744    // (deletion not allowed, no entry, empty path, or preserve-marked), keep the
745    // file rather than fall through to a re-download of a clip that is gone.
746    if d.trashed && !d.private && !copy_held {
747        match delete_action(clip_id, manifest, can_delete) {
748            Some(action) => out.push(action),
749            None => out.push(Action::Skip {
750                clip_id: clip_id.to_string(),
751            }),
752        }
753        return;
754    }
755
756    let Some(entry) = manifest.get(clip_id) else {
757        // Not in the manifest: a fresh download.
758        out.push(Action::Download {
759            clip: d.clip.clone(),
760            lineage: d.lineage.clone(),
761            path: d.path.clone(),
762            format: d.format,
763        });
764        return;
765    };
766
767    // SYNC-10: a missing or zero-length file is treated as missing and
768    // re-downloaded, even when the hashes still match.
769    let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
770    if missing {
771        out.push(Action::Download {
772            clip: d.clip.clone(),
773            lineage: d.lineage.clone(),
774            path: d.path.clone(),
775            format: d.format,
776        });
777        return;
778    }
779
780    if d.format != entry.format {
781        // Replace via re-encode; never pre-delete the existing file. The old
782        // file lives at a different extension, so carry it for cleanup.
783        out.push(Action::Reformat {
784            clip: d.clip.clone(),
785            path: d.path.clone(),
786            from_path: entry.path.clone(),
787            from: entry.format,
788            to: d.format,
789        });
790        return;
791    }
792
793    if d.path != entry.path {
794        out.push(Action::Rename {
795            from: entry.path.clone(),
796            to: d.path.clone(),
797        });
798        // A rename still needs a retag when the metadata or art drifted.
799        if meta_or_art_changed(d, entry) {
800            out.push(Action::Retag {
801                clip: d.clip.clone(),
802                lineage: d.lineage.clone(),
803                path: d.path.clone(),
804            });
805        }
806        return;
807    }
808
809    if meta_or_art_changed(d, entry) {
810        out.push(Action::Retag {
811            clip: d.clip.clone(),
812            lineage: d.lineage.clone(),
813            path: entry.path.clone(),
814        });
815        return;
816    }
817
818    out.push(Action::Skip {
819        clip_id: clip_id.to_string(),
820    });
821}
822
823/// Whether the desired metadata or art hash differs from the manifest entry.
824fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
825    d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
826}
827
828// ── Folder art (album-scoped) ───────────────────────────────────────────────
829
830/// Derive the desired folder art for every album in `desired`, grouped by the
831/// stable root id (HARDENING H2).
832///
833/// This is pure: it groups the selected clips by their resolved `root_id`, then
834/// per album chooses the folder-art sources deterministically:
835///
836/// - `folder.jpg` comes from the MOST-PLAYED art-bearing variant; ties break to
837///   the EARLIEST `created_at`, then the lexicographically smallest id. Its hash
838///   is the chosen art's content hash ([`art_hash`]), so a most-played flip to a
839///   variant sharing the same art is a no-op downstream (H1).
840/// - `cover.webp` (only when `animated_covers` is set) comes from the
841///   EARLIEST-created variant with a non-empty `video_cover_url`; ties break to
842///   the smallest id. `None` when no variant has an animated source.
843///
844/// The album folder is the common parent of the album's clips' audio paths (they
845/// share `{creator}/{album}/`); `folder.jpg` lands at `{album_dir}/folder.jpg`
846/// and the animated cover at `{album_dir}/cover.webp`.
847pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
848    let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
849    for d in desired {
850        groups
851            .entry(d.lineage.root_id.as_str())
852            .or_default()
853            .push(d);
854    }
855
856    groups
857        .into_iter()
858        .map(|(root_id, members)| {
859            let album_dir = album_dir_of(&members);
860            let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
861                kind: ArtifactKind::FolderJpg,
862                path: album_child(&album_dir, "folder.jpg"),
863                source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
864                hash: art_hash(&source.clip),
865            });
866            let folder_webp = animated_covers
867                .then(|| folder_webp_source(&members))
868                .flatten()
869                .map(|source| DesiredArtifact {
870                    kind: ArtifactKind::FolderWebp,
871                    path: album_child(&album_dir, "cover.webp"),
872                    source_url: source.clip.video_cover_url.clone(),
873                    hash: art_url_hash(&source.clip.video_cover_url),
874                });
875            AlbumDesired {
876                root_id: root_id.to_owned(),
877                folder_jpg,
878                folder_webp,
879            }
880        })
881        .collect()
882}
883
884/// The album folder: the common parent of the members' audio paths.
885///
886/// The album's clips share `{creator}/{album}/`, so any member's parent is the
887/// album dir; the smallest is taken so a stray differing path stays deterministic.
888fn album_dir_of(members: &[&Desired]) -> String {
889    members
890        .iter()
891        .map(|d| parent_dir(&d.path))
892        .min()
893        .unwrap_or("")
894        .to_owned()
895}
896
897/// The most-played art-bearing variant: the `folder.jpg` source.
898///
899/// Filtered to variants that carry selectable art, then the winner MAXIMISES
900/// `play_count`, breaking ties to the EARLIEST `created_at` and then the
901/// lexicographically smallest id, so selection is fully deterministic.
902fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
903    members
904        .iter()
905        .copied()
906        .filter(|d| {
907            d.clip
908                .selected_image_url()
909                .is_some_and(|url| !url.is_empty())
910        })
911        .min_by(|a, b| {
912            b.clip
913                .play_count
914                .cmp(&a.clip.play_count)
915                .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
916                .then_with(|| a.clip.id.cmp(&b.clip.id))
917        })
918}
919
920/// The first-created animated variant: the `cover.webp` source.
921///
922/// Filtered to variants with a non-empty `video_cover_url`, then the winner is
923/// the EARLIEST `created_at`, tie-broken by the smallest id for determinism.
924fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
925    members
926        .iter()
927        .copied()
928        .filter(|d| !d.clip.video_cover_url.is_empty())
929        .min_by(|a, b| {
930            a.clip
931                .created_at
932                .cmp(&b.clip.created_at)
933                .then_with(|| a.clip.id.cmp(&b.clip.id))
934        })
935}
936
937/// The parent directory of a forward-slash relative path, or `""` at the root.
938fn parent_dir(path: &str) -> &str {
939    match path.rsplit_once('/') {
940        Some((dir, _)) => dir,
941        None => "",
942    }
943}
944
945/// Join an album dir and a file name with a forward slash, tolerating an empty
946/// dir (a path at the account root).
947fn album_child(album_dir: &str, name: &str) -> String {
948    if album_dir.is_empty() {
949        name.to_owned()
950    } else {
951        format!("{album_dir}/{name}")
952    }
953}
954
955/// Plan the folder-art writes and deletes for this run's albums.
956///
957/// Writes are keyed on the CHOSEN ART CONTENT HASH (and the target path), never
958/// the source clip id: for each present desired kind, a [`Action::WriteArtifact`]
959/// is emitted only when the album store lacks that kind, its stored hash differs,
960/// or its stored path differs. When both hash and path match, nothing is written,
961/// so a most-played flip that resolves to the same art content is a no-op
962/// (HARDENING H1). Exactly one write can be emitted per album per kind.
963///
964/// Deletes cover any stored album/kind no longer desired — the album emptied (no
965/// selected clips root there this run) or the kind's source disappeared (no
966/// art-bearing or animated variant). Each is emitted only when `can_delete` (the
967/// shared [`deletion_allowed`] verdict), so folder art is never removed on an
968/// empty, failed, partial, or truncated listing. Folder art has no preserve
969/// concept; the `can_delete` gate is the guard.
970///
971/// The output is deterministic: actions are sorted by `(root_id, kind)`, and a
972/// given `(root_id, kind)` yields at most one action (a write or a delete).
973pub fn plan_album_artifacts(
974    desired: &[AlbumDesired],
975    albums: &BTreeMap<String, AlbumArt>,
976    can_delete: bool,
977) -> Vec<Action> {
978    let mut actions: Vec<Action> = Vec::new();
979    let by_root: BTreeMap<&str, &AlbumDesired> =
980        desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
981
982    for d in desired {
983        let stored = albums.get(&d.root_id);
984        for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
985            .into_iter()
986            .flatten()
987        {
988            let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
989                None => true,
990                Some(state) => state.hash != artifact.hash || state.path != artifact.path,
991            };
992            if needs_write {
993                actions.push(Action::WriteArtifact {
994                    kind: artifact.kind,
995                    path: artifact.path.clone(),
996                    source_url: artifact.source_url.clone(),
997                    hash: artifact.hash.clone(),
998                    owner_id: d.root_id.clone(),
999                    content: None,
1000                });
1001            }
1002        }
1003    }
1004
1005    // Deletes are fully gated: nothing is removed on an unreliable listing.
1006    if can_delete {
1007        for (root_id, art) in albums {
1008            for (kind, state) in album_artifacts(art) {
1009                let desired_here = by_root
1010                    .get(root_id.as_str())
1011                    .is_some_and(|d| album_desires_kind(d, kind));
1012                if !desired_here && !state.path.is_empty() {
1013                    actions.push(Action::DeleteArtifact {
1014                        kind,
1015                        path: state.path.clone(),
1016                        owner_id: root_id.clone(),
1017                    });
1018                }
1019            }
1020        }
1021    }
1022
1023    actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1024    actions
1025}
1026
1027/// The folder-art artifacts an album currently stores, paired with their kind,
1028/// in a stable order.
1029fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1030    let mut out = Vec::new();
1031    if let Some(state) = &art.folder_jpg {
1032        out.push((ArtifactKind::FolderJpg, state));
1033    }
1034    if let Some(state) = &art.folder_webp {
1035        out.push((ArtifactKind::FolderWebp, state));
1036    }
1037    out
1038}
1039
1040/// Whether an [`AlbumDesired`] desires the given folder-art kind this run.
1041fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1042    match kind {
1043        ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1044        ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1045        ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => false,
1046    }
1047}
1048
1049/// The `(root_id, kind)` sort key for a folder-art action, for deterministic order.
1050fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1051    match action {
1052        Action::WriteArtifact { owner_id, kind, .. }
1053        | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1054        _ => ("", ArtifactKind::CoverJpg),
1055    }
1056}
1057
1058/// Plan the `.m3u8` writes and deletes for this run's playlists.
1059///
1060/// # Writes
1061///
1062/// For each desired playlist a single [`Action::WriteArtifact`] of kind
1063/// [`Playlist`](ArtifactKind::Playlist) is emitted (carrying the rendered body
1064/// inline in `content`) when the store lacks the playlist, its stored hash
1065/// differs, or its stored path differs. The hash is taken over the full rendered
1066/// text, so a name, order, path, title, or duration change all trigger a rewrite
1067/// (HARDENING B1); an unchanged playlist writes nothing (idempotent).
1068///
1069/// A **rename** (the same id whose sanitised name, and so path, changed) writes
1070/// the new file and, gated exactly like a stale delete (`can_delete &&
1071/// list_fully_enumerated`), also deletes the old stored path so the previous
1072/// `<oldname>.m3u8` does not linger.
1073///
1074/// # Deletes (HARDENING B2 — paramount)
1075///
1076/// A stored playlist absent from `desired` is stale (removed on Suno) and its
1077/// file is deleted **only** when `can_delete` AND `list_fully_enumerated`. The
1078/// second gate is the playlist-specific safety valve: `list_fully_enumerated`
1079/// is `true` only when the `/api/playlist/me` listing succeeded and was fully
1080/// paginated. If that listing **failed or was not fully enumerated**, the caller
1081/// passes `list_fully_enumerated = false` (and an empty `desired`), so this
1082/// function emits **zero deletes and zero writes** and every existing `.m3u8` is
1083/// left untouched. A failed *member* fetch for one playlist is handled upstream
1084/// by excluding that id from BOTH `desired` and `stored`, so it is never treated
1085/// as stale here.
1086///
1087/// The output is deterministic (sorted by `(owner_id, kind)`) and self-suppresses
1088/// path aliasing, so a rename to a name another playlist also renders this run
1089/// downgrades the colliding delete rather than removing a just-written file.
1090pub fn plan_playlist_artifacts(
1091    desired: &[PlaylistDesired],
1092    stored: &BTreeMap<String, PlaylistState>,
1093    can_delete: bool,
1094    list_fully_enumerated: bool,
1095) -> Vec<Action> {
1096    let mut actions: Vec<Action> = Vec::new();
1097    let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1098    // Deletes (stale removals and rename cleanups) are gated on BOTH the shared
1099    // deletion verdict and a fully-enumerated playlist listing (B2).
1100    let deletes_allowed = can_delete && list_fully_enumerated;
1101
1102    for d in desired {
1103        let stored_here = stored.get(&d.id);
1104        let needs_write = match stored_here {
1105            None => true,
1106            Some(state) => state.hash != d.hash || state.path != d.path,
1107        };
1108        if needs_write {
1109            actions.push(Action::WriteArtifact {
1110                kind: ArtifactKind::Playlist,
1111                path: d.path.clone(),
1112                source_url: String::new(),
1113                hash: d.hash.clone(),
1114                owner_id: d.id.clone(),
1115                content: Some(d.content.clone()),
1116            });
1117        }
1118        // A rename changed the path: remove the old file, under the delete gate.
1119        if deletes_allowed
1120            && let Some(state) = stored_here
1121            && !state.path.is_empty()
1122            && state.path != d.path
1123        {
1124            actions.push(Action::DeleteArtifact {
1125                kind: ArtifactKind::Playlist,
1126                path: state.path.clone(),
1127                owner_id: d.id.clone(),
1128            });
1129        }
1130    }
1131
1132    // Stale playlists (removed on Suno) are deleted only under the full gate, so
1133    // a failed or partial listing never removes an existing `.m3u8` (B2).
1134    if deletes_allowed {
1135        for (id, state) in stored {
1136            if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1137                actions.push(Action::DeleteArtifact {
1138                    kind: ArtifactKind::Playlist,
1139                    path: state.path.clone(),
1140                    owner_id: id.clone(),
1141                });
1142            }
1143        }
1144    }
1145
1146    actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1147    // A rename to a name another playlist also renders this run must not delete
1148    // the file that write just produced; downgrade any such colliding delete.
1149    suppress_path_aliasing(&mut actions);
1150    actions
1151}
1152
1153/// The `(owner_id, is_delete)` sort key for a playlist action, so writes and
1154/// deletes for one id stay adjacent and order is deterministic.
1155fn playlist_action_key(action: &Action) -> (&str, u8) {
1156    match action {
1157        Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1158        Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1159        Action::Skip { clip_id } => (clip_id.as_str(), 2),
1160        _ => ("", 3),
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    fn clip(id: &str) -> Clip {
1169        Clip {
1170            id: id.to_string(),
1171            title: "Song".to_string(),
1172            ..Default::default()
1173        }
1174    }
1175
1176    fn lineage(id: &str) -> LineageContext {
1177        LineageContext::own_root(&clip(id))
1178    }
1179
1180    fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1181        ManifestEntry {
1182            path: path.to_string(),
1183            format,
1184            meta_hash: meta.to_string(),
1185            art_hash: art.to_string(),
1186            size: 100,
1187            preserve: false,
1188            ..Default::default()
1189        }
1190    }
1191
1192    fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1193        ManifestEntry {
1194            preserve: true,
1195            ..entry(path, format, meta, art)
1196        }
1197    }
1198
1199    fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1200        Desired {
1201            clip: clip(id),
1202            lineage: lineage(id),
1203            path: path.to_string(),
1204            format,
1205            meta_hash: meta.to_string(),
1206            art_hash: art.to_string(),
1207            modes: vec![SourceMode::Mirror],
1208            trashed: false,
1209            private: false,
1210            artifacts: Vec::new(),
1211        }
1212    }
1213
1214    fn present(size: u64) -> LocalFile {
1215        LocalFile { exists: true, size }
1216    }
1217
1218    fn local_present(id: &str) -> HashMap<String, LocalFile> {
1219        [(id.to_string(), present(100))].into_iter().collect()
1220    }
1221
1222    fn mirror_ok() -> Vec<SourceStatus> {
1223        vec![SourceStatus {
1224            mode: SourceMode::Mirror,
1225            fully_enumerated: true,
1226        }]
1227    }
1228
1229    // ── Per-clip classification ─────────────────────────────────────
1230
1231    #[test]
1232    fn not_in_manifest_downloads() {
1233        let manifest = Manifest::new();
1234        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1235        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1236        assert_eq!(
1237            plan.actions,
1238            vec![Action::Download {
1239                clip: clip("a"),
1240                lineage: lineage("a"),
1241                path: "a.flac".to_string(),
1242                format: AudioFormat::Flac,
1243            }]
1244        );
1245    }
1246
1247    #[test]
1248    fn unchanged_clip_skips() {
1249        let mut manifest = Manifest::new();
1250        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1251        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1252        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1253        assert_eq!(
1254            plan.actions,
1255            vec![Action::Skip {
1256                clip_id: "a".to_string()
1257            }]
1258        );
1259    }
1260
1261    #[test]
1262    fn meta_change_retags_in_place() {
1263        let mut manifest = Manifest::new();
1264        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1265        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1266        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1267        assert_eq!(
1268            plan.actions,
1269            vec![Action::Retag {
1270                clip: clip("a"),
1271                lineage: lineage("a"),
1272                path: "a.flac".to_string(),
1273            }]
1274        );
1275    }
1276
1277    #[test]
1278    fn art_change_retags_in_place() {
1279        let mut manifest = Manifest::new();
1280        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1281        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1282        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1283        assert_eq!(
1284            plan.actions,
1285            vec![Action::Retag {
1286                clip: clip("a"),
1287                lineage: lineage("a"),
1288                path: "a.flac".to_string(),
1289            }]
1290        );
1291    }
1292
1293    #[test]
1294    fn rename_when_path_changes() {
1295        let mut manifest = Manifest::new();
1296        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1297        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1298        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1299        assert_eq!(
1300            plan.actions,
1301            vec![Action::Rename {
1302                from: "old/a.flac".to_string(),
1303                to: "new/a.flac".to_string(),
1304            }]
1305        );
1306    }
1307
1308    #[test]
1309    fn rename_with_meta_change_also_retags() {
1310        let mut manifest = Manifest::new();
1311        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1312        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1313        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1314        assert_eq!(
1315            plan.actions,
1316            vec![
1317                Action::Rename {
1318                    from: "old/a.flac".to_string(),
1319                    to: "new/a.flac".to_string(),
1320                },
1321                Action::Retag {
1322                    clip: clip("a"),
1323                    lineage: lineage("a"),
1324                    path: "new/a.flac".to_string(),
1325                },
1326            ]
1327        );
1328    }
1329
1330    #[test]
1331    fn rename_without_meta_change_does_not_retag() {
1332        let mut manifest = Manifest::new();
1333        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1334        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1335        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1336        assert_eq!(plan.renames(), 1);
1337        assert_eq!(plan.retags(), 0);
1338    }
1339
1340    #[test]
1341    fn format_change_reformats() {
1342        let mut manifest = Manifest::new();
1343        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1344        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1345        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1346        assert_eq!(
1347            plan.actions,
1348            vec![Action::Reformat {
1349                clip: clip("a"),
1350                path: "a.mp3".to_string(),
1351                from_path: "a.flac".to_string(),
1352                from: AudioFormat::Flac,
1353                to: AudioFormat::Mp3,
1354            }]
1355        );
1356    }
1357
1358    #[test]
1359    fn format_change_takes_precedence_over_rename_and_retag() {
1360        // Format, path, and metadata all changed at once: a single reformat
1361        // replaces the file, so no separate rename or retag is emitted.
1362        let mut manifest = Manifest::new();
1363        manifest.insert(
1364            "a",
1365            entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1366        );
1367        let d = vec![desired(
1368            "a",
1369            "new/a.mp3",
1370            AudioFormat::Mp3,
1371            "new",
1372            "new-art",
1373        )];
1374        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1375        assert_eq!(plan.reformats(), 1);
1376        assert_eq!(plan.renames(), 0);
1377        assert_eq!(plan.retags(), 0);
1378    }
1379
1380    // ── SYNC-10: zero-length / missing local file ───────────────────
1381
1382    #[test]
1383    fn zero_length_file_downloads_even_when_hashes_match() {
1384        let mut manifest = Manifest::new();
1385        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1386        let local: HashMap<String, LocalFile> = [(
1387            "a".to_string(),
1388            LocalFile {
1389                exists: true,
1390                size: 0,
1391            },
1392        )]
1393        .into_iter()
1394        .collect();
1395        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1396        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1397        assert_eq!(plan.downloads(), 1);
1398        assert_eq!(plan.skips(), 0);
1399    }
1400
1401    #[test]
1402    fn missing_file_downloads_even_when_hashes_match() {
1403        let mut manifest = Manifest::new();
1404        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1405        let local: HashMap<String, LocalFile> = [(
1406            "a".to_string(),
1407            LocalFile {
1408                exists: false,
1409                size: 0,
1410            },
1411        )]
1412        .into_iter()
1413        .collect();
1414        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1415        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1416        assert_eq!(plan.downloads(), 1);
1417    }
1418
1419    #[test]
1420    fn absent_local_probe_treated_as_missing() {
1421        // A manifest clip with no probe entry is conservatively re-downloaded.
1422        let mut manifest = Manifest::new();
1423        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1424        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1425        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1426        assert_eq!(plan.downloads(), 1);
1427    }
1428
1429    #[test]
1430    fn missing_file_download_wins_over_format_difference() {
1431        // A missing file is re-downloaded directly in the desired format rather
1432        // than reformatted from a file that is not there.
1433        let mut manifest = Manifest::new();
1434        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1435        let local: HashMap<String, LocalFile> = [(
1436            "a".to_string(),
1437            LocalFile {
1438                exists: false,
1439                size: 0,
1440            },
1441        )]
1442        .into_iter()
1443        .collect();
1444        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1445        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1446        assert_eq!(plan.downloads(), 1);
1447        assert_eq!(plan.reformats(), 0);
1448    }
1449
1450    // ── SYNC-12: trashed and private ────────────────────────────────
1451
1452    #[test]
1453    fn trashed_clip_deletes_local_file() {
1454        let mut manifest = Manifest::new();
1455        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1456        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1457        d.trashed = true;
1458        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1459        assert_eq!(
1460            plan.actions,
1461            vec![Action::Delete {
1462                path: "a.flac".to_string(),
1463                clip_id: "a".to_string(),
1464            }]
1465        );
1466    }
1467
1468    #[test]
1469    fn trashed_clip_not_in_manifest_skips() {
1470        // Nothing on disk to remove, so trashing is a no-op.
1471        let manifest = Manifest::new();
1472        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1473        d.trashed = true;
1474        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1475        assert_eq!(
1476            plan.actions,
1477            vec![Action::Skip {
1478                clip_id: "a".to_string()
1479            }]
1480        );
1481    }
1482
1483    #[test]
1484    fn private_clip_is_kept() {
1485        let mut manifest = Manifest::new();
1486        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1487        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1488        d.private = true;
1489        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1490        assert_eq!(
1491            plan.actions,
1492            vec![Action::Skip {
1493                clip_id: "a".to_string()
1494            }]
1495        );
1496    }
1497
1498    #[test]
1499    fn private_beats_trashed_never_deletes() {
1500        // Safety first: a clip that is both trashed and private is kept.
1501        let mut manifest = Manifest::new();
1502        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1503        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1504        d.trashed = true;
1505        d.private = true;
1506        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1507        assert_eq!(plan.deletes(), 0);
1508        assert_eq!(plan.skips(), 1);
1509    }
1510
1511    #[test]
1512    fn copy_held_trashed_clip_is_not_deleted() {
1513        // SYNC-8: copy always wins, so a trashed clip still held by a copy
1514        // source is kept and synced rather than deleted.
1515        let mut manifest = Manifest::new();
1516        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1517        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1518        d.modes = vec![SourceMode::Copy];
1519        d.trashed = true;
1520        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1521        assert_eq!(plan.deletes(), 0);
1522        assert_eq!(
1523            plan.actions,
1524            vec![Action::Skip {
1525                clip_id: "a".to_string()
1526            }]
1527        );
1528    }
1529
1530    // ── Deletion pass: absent manifest entries ──────────────────────
1531
1532    #[test]
1533    fn absent_clip_deleted_when_all_mirrors_enumerated() {
1534        let mut manifest = Manifest::new();
1535        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1536        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1537        assert_eq!(
1538            plan.actions,
1539            vec![Action::Delete {
1540                path: "gone.flac".to_string(),
1541                clip_id: "gone".to_string(),
1542            }]
1543        );
1544    }
1545
1546    #[test]
1547    fn absent_clip_kept_when_any_mirror_not_enumerated() {
1548        let mut manifest = Manifest::new();
1549        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1550        let sources = vec![
1551            SourceStatus {
1552                mode: SourceMode::Mirror,
1553                fully_enumerated: true,
1554            },
1555            SourceStatus {
1556                mode: SourceMode::Mirror,
1557                fully_enumerated: false,
1558            },
1559        ];
1560        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1561        assert_eq!(plan.deletes(), 0);
1562        assert_eq!(
1563            plan.actions,
1564            vec![Action::Skip {
1565                clip_id: "gone".to_string()
1566            }]
1567        );
1568    }
1569
1570    #[test]
1571    fn empty_listing_cannot_cause_deletion() {
1572        // A failed or truncated listing presents as a not-fully-enumerated
1573        // mirror source: absence must never delete in that case.
1574        let mut manifest = Manifest::new();
1575        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1576        let sources = vec![SourceStatus {
1577            mode: SourceMode::Mirror,
1578            fully_enumerated: false,
1579        }];
1580        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1581        assert_eq!(plan.deletes(), 0);
1582        assert_eq!(plan.skips(), 1);
1583    }
1584
1585    #[test]
1586    fn no_mirror_sources_means_no_deletion() {
1587        // Copy-only or sourceless runs are additive: nothing is deleted.
1588        let mut manifest = Manifest::new();
1589        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1590        let copy_only = vec![SourceStatus {
1591            mode: SourceMode::Copy,
1592            fully_enumerated: true,
1593        }];
1594        assert_eq!(
1595            reconcile(&manifest, &[], &HashMap::new(), &copy_only).deletes(),
1596            0
1597        );
1598        assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1599    }
1600
1601    #[test]
1602    fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1603        let mut manifest = Manifest::new();
1604        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1605        let sources = vec![
1606            SourceStatus {
1607                mode: SourceMode::Copy,
1608                fully_enumerated: true,
1609            },
1610            SourceStatus {
1611                mode: SourceMode::Mirror,
1612                fully_enumerated: false,
1613            },
1614        ];
1615        assert_eq!(
1616            reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1617            0
1618        );
1619    }
1620
1621    #[test]
1622    fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1623        // SYNC-8 falls out naturally: a copy-held clip is in the desired set,
1624        // so it is classified there (Skip) and never reaches the delete pass,
1625        // even while a sibling clip is being deleted.
1626        let mut manifest = Manifest::new();
1627        manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1628        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1629        let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1630        held.modes = vec![SourceMode::Copy];
1631        let local: HashMap<String, LocalFile> = [
1632            ("keep".to_string(), present(100)),
1633            ("gone".to_string(), present(100)),
1634        ]
1635        .into_iter()
1636        .collect();
1637        let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1638        assert!(plan.actions.contains(&Action::Skip {
1639            clip_id: "keep".to_string()
1640        }));
1641        assert!(plan.actions.contains(&Action::Delete {
1642            path: "gone.flac".to_string(),
1643            clip_id: "gone".to_string(),
1644        }));
1645        // The copy-held clip is never deleted.
1646        assert!(
1647            !plan
1648                .actions
1649                .iter()
1650                .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1651        );
1652    }
1653
1654    // ── Item 1: persisted preserve marker ───────────────────────────
1655
1656    #[test]
1657    fn orphan_with_preserve_marker_is_kept() {
1658        // A copy-held or private clip whose source was deselected is absent from
1659        // desired, but the persisted marker still protects it from deletion.
1660        let mut manifest = Manifest::new();
1661        manifest.insert(
1662            "gone",
1663            preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1664        );
1665        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1666        assert_eq!(plan.deletes(), 0);
1667        assert_eq!(
1668            plan.actions,
1669            vec![Action::Skip {
1670                clip_id: "gone".to_string()
1671            }]
1672        );
1673    }
1674
1675    #[test]
1676    fn trashed_clip_with_preserve_marker_is_kept() {
1677        // The marker also defends the trashed path: a preserved entry is never
1678        // deleted even when the clip is trashed and fully enumerated.
1679        let mut manifest = Manifest::new();
1680        manifest.insert(
1681            "a",
1682            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1683        );
1684        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1685        d.trashed = true;
1686        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1687        assert_eq!(plan.deletes(), 0);
1688        assert_eq!(plan.skips(), 1);
1689    }
1690
1691    // ── Item 2: unified, enumeration-gated delete guard ─────────────
1692
1693    #[test]
1694    fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1695        // The trashed path now obeys the same enumeration guard as orphans.
1696        let mut manifest = Manifest::new();
1697        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1698        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1699        d.trashed = true;
1700        let sources = vec![SourceStatus {
1701            mode: SourceMode::Mirror,
1702            fully_enumerated: false,
1703        }];
1704        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1705        assert_eq!(plan.deletes(), 0);
1706        assert_eq!(plan.skips(), 1);
1707    }
1708
1709    #[test]
1710    fn trashed_clip_kept_when_sources_empty() {
1711        // With no sources there is no authoritative listing, so even a trashed
1712        // clip is kept rather than deleted.
1713        let mut manifest = Manifest::new();
1714        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1715        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1716        d.trashed = true;
1717        let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1718        assert_eq!(plan.deletes(), 0);
1719        assert_eq!(plan.skips(), 1);
1720    }
1721
1722    #[test]
1723    fn failed_copy_listing_suppresses_orphan_deletion() {
1724        // A partial or failed copy listing is as unreliable as a mirror one and
1725        // must suppress deletes, even with a fully enumerated mirror present.
1726        let mut manifest = Manifest::new();
1727        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1728        let sources = vec![
1729            SourceStatus {
1730                mode: SourceMode::Mirror,
1731                fully_enumerated: true,
1732            },
1733            SourceStatus {
1734                mode: SourceMode::Copy,
1735                fully_enumerated: false,
1736            },
1737        ];
1738        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1739        assert_eq!(plan.deletes(), 0);
1740    }
1741
1742    #[test]
1743    fn failed_copy_listing_suppresses_trashed_deletion() {
1744        let mut manifest = Manifest::new();
1745        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1746        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1747        d.trashed = true;
1748        let sources = vec![
1749            SourceStatus {
1750                mode: SourceMode::Mirror,
1751                fully_enumerated: true,
1752            },
1753            SourceStatus {
1754                mode: SourceMode::Copy,
1755                fully_enumerated: false,
1756            },
1757        ];
1758        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1759        assert_eq!(plan.deletes(), 0);
1760        assert_eq!(plan.skips(), 1);
1761    }
1762
1763    #[test]
1764    fn empty_path_entry_never_deletes() {
1765        // A default or partially written manifest entry can have an empty path;
1766        // that must never become a Delete of the account root.
1767        let mut manifest = Manifest::new();
1768        manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1769        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1770        assert_eq!(plan.deletes(), 0);
1771        assert_eq!(
1772            plan.actions,
1773            vec![Action::Skip {
1774                clip_id: "gone".to_string()
1775            }]
1776        );
1777    }
1778
1779    // ── Item 3: path aliasing suppression ───────────────────────────
1780
1781    #[test]
1782    fn delete_suppressed_when_path_aliases_rename_target() {
1783        // Clip "a" renames into the path that absent clip "b" recorded; deleting
1784        // "b" would clobber the file "a" was just moved to, so it is suppressed.
1785        let mut manifest = Manifest::new();
1786        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1787        manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1788        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1789        let local: HashMap<String, LocalFile> = [
1790            ("a".to_string(), present(100)),
1791            ("b".to_string(), present(100)),
1792        ]
1793        .into_iter()
1794        .collect();
1795        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1796        assert!(plan.actions.contains(&Action::Rename {
1797            from: "old/a.flac".to_string(),
1798            to: "new/a.flac".to_string(),
1799        }));
1800        // No delete targets the renamed-to path.
1801        assert!(
1802            !plan
1803                .actions
1804                .iter()
1805                .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1806        );
1807        assert!(plan.actions.contains(&Action::Skip {
1808            clip_id: "b".to_string()
1809        }));
1810    }
1811
1812    #[test]
1813    fn delete_suppressed_when_path_aliases_download_target() {
1814        // A new clip downloads to the path an absent clip recorded.
1815        let mut manifest = Manifest::new();
1816        manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1817        let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1818        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1819        assert!(
1820            !plan
1821                .actions
1822                .iter()
1823                .any(|a| matches!(a, Action::Delete { .. }))
1824        );
1825        assert_eq!(plan.downloads(), 1);
1826    }
1827
1828    #[test]
1829    fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1830        // A sidecar delete must never clobber a file a rename just produced this
1831        // run. A DeleteArtifact whose path equals a Rename's `to` is downgraded
1832        // to a Skip, exactly as an audio Delete is. Built directly so the
1833        // collision is explicit and independent of how reconcile derives it.
1834        let mut actions = vec![
1835            Action::Rename {
1836                from: "old/song.flac".to_string(),
1837                to: "new/cover.jpg".to_string(),
1838            },
1839            Action::DeleteArtifact {
1840                kind: ArtifactKind::CoverJpg,
1841                path: "new/cover.jpg".to_string(),
1842                owner_id: "a".to_string(),
1843            },
1844        ];
1845        suppress_path_aliasing(&mut actions);
1846        // The colliding delete is gone; only its Skip downgrade remains.
1847        assert!(
1848            !actions
1849                .iter()
1850                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1851            "a sidecar delete must not alias a rename target"
1852        );
1853        assert!(actions.contains(&Action::Skip {
1854            clip_id: "a".to_string()
1855        }));
1856        // The rename target is untouched.
1857        assert!(actions.contains(&Action::Rename {
1858            from: "old/song.flac".to_string(),
1859            to: "new/cover.jpg".to_string(),
1860        }));
1861    }
1862
1863    #[test]
1864    fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1865        // The same guard covers every write class: a DeleteArtifact colliding
1866        // with another artifact's WriteArtifact path is downgraded too.
1867        let mut actions = vec![
1868            Action::WriteArtifact {
1869                kind: ArtifactKind::FolderJpg,
1870                path: "creator/album/folder.jpg".to_string(),
1871                source_url: "https://art/large.jpg".to_string(),
1872                hash: "h".to_string(),
1873                owner_id: "root".to_string(),
1874                content: None,
1875            },
1876            Action::DeleteArtifact {
1877                kind: ArtifactKind::FolderJpg,
1878                path: "creator/album/folder.jpg".to_string(),
1879                owner_id: "root-old".to_string(),
1880            },
1881        ];
1882        suppress_path_aliasing(&mut actions);
1883        assert!(
1884            !actions
1885                .iter()
1886                .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1887        );
1888        assert!(actions.contains(&Action::Skip {
1889            clip_id: "root-old".to_string()
1890        }));
1891    }
1892
1893    // ── Item 5: aggregation of duplicate desired ids ────────────────
1894
1895    #[test]
1896    fn duplicate_trashed_does_not_defeat_copy_sibling() {
1897        // The same clip held by a copy source and reported trashed by a mirror:
1898        // copy wins, so it is kept, not deleted.
1899        let mut manifest = Manifest::new();
1900        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1901        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1902        copy_entry.modes = vec![SourceMode::Copy];
1903        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1904        trashed_entry.modes = vec![SourceMode::Mirror];
1905        trashed_entry.trashed = true;
1906        let plan = reconcile(
1907            &manifest,
1908            &[copy_entry, trashed_entry],
1909            &local_present("a"),
1910            &mirror_ok(),
1911        );
1912        assert_eq!(plan.deletes(), 0);
1913        assert_eq!(plan.skips(), 1);
1914    }
1915
1916    #[test]
1917    fn duplicate_trashed_does_not_defeat_private_sibling() {
1918        let mut manifest = Manifest::new();
1919        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1920        let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1921        private_entry.private = true;
1922        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1923        trashed_entry.trashed = true;
1924        let plan = reconcile(
1925            &manifest,
1926            &[private_entry, trashed_entry],
1927            &local_present("a"),
1928            &mirror_ok(),
1929        );
1930        assert_eq!(plan.deletes(), 0);
1931        assert_eq!(plan.skips(), 1);
1932    }
1933
1934    #[test]
1935    fn duplicate_trashed_deletes_only_when_all_trashed() {
1936        // Every duplicate trashed and unprotected: a single delete results.
1937        let mut manifest = Manifest::new();
1938        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1939        let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1940        first.trashed = true;
1941        let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1942        second.trashed = true;
1943        let plan = reconcile(
1944            &manifest,
1945            &[first, second],
1946            &local_present("a"),
1947            &mirror_ok(),
1948        );
1949        assert_eq!(plan.deletes(), 1);
1950    }
1951
1952    #[test]
1953    fn duplicate_desired_unions_modes() {
1954        // Mirror and copy entries for one id aggregate to a copy-held clip.
1955        let mut manifest = Manifest::new();
1956        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1957        let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1958        mirror_entry.modes = vec![SourceMode::Mirror];
1959        mirror_entry.trashed = true;
1960        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1961        copy_entry.modes = vec![SourceMode::Copy];
1962        let plan = reconcile(
1963            &manifest,
1964            &[mirror_entry, copy_entry],
1965            &local_present("a"),
1966            &mirror_ok(),
1967        );
1968        // Copy-held wins over the trashed mirror entry, so no delete.
1969        assert_eq!(plan.deletes(), 0);
1970    }
1971
1972    // ── Item 6: private is deletion-exempt only ─────────────────────
1973
1974    #[test]
1975    fn private_new_clip_downloads() {
1976        // Private no longer short-circuits to Skip: a missing private clip is
1977        // downloaded like any other.
1978        let manifest = Manifest::new();
1979        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1980        d.private = true;
1981        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1982        assert_eq!(plan.downloads(), 1);
1983    }
1984
1985    #[test]
1986    fn private_zero_length_file_redownloads() {
1987        let mut manifest = Manifest::new();
1988        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1989        let local: HashMap<String, LocalFile> = [(
1990            "a".to_string(),
1991            LocalFile {
1992                exists: true,
1993                size: 0,
1994            },
1995        )]
1996        .into_iter()
1997        .collect();
1998        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1999        d.private = true;
2000        let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2001        assert_eq!(plan.downloads(), 1);
2002    }
2003
2004    #[test]
2005    fn private_meta_change_retags() {
2006        let mut manifest = Manifest::new();
2007        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2008        let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2009        d.private = true;
2010        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2011        assert_eq!(plan.retags(), 1);
2012        assert_eq!(plan.deletes(), 0);
2013    }
2014
2015    #[test]
2016    fn absent_private_clip_protected_by_preserve_marker() {
2017        // Items 1 and 6 together: a private clip deselected from the run is
2018        // absent from desired, but its preserve marker keeps it across runs.
2019        let mut manifest = Manifest::new();
2020        manifest.insert(
2021            "a",
2022            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2023        );
2024        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2025        assert_eq!(plan.deletes(), 0);
2026        assert_eq!(plan.skips(), 1);
2027    }
2028
2029    // ── Determinism and robustness ──────────────────────────────────
2030
2031    #[test]
2032    fn output_is_deterministic_regardless_of_input_order() {
2033        let mut manifest = Manifest::new();
2034        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2035        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2036        manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2037        let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2038            .iter()
2039            .map(|id| (id.to_string(), present(100)))
2040            .collect();
2041
2042        let forward = vec![
2043            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2044            desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2045            desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2046        ];
2047        let mut reversed = forward.clone();
2048        reversed.reverse();
2049
2050        let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2051        let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2052        assert_eq!(p1.actions, p2.actions);
2053
2054        // And the order is clip-id sorted: a (skip), b (retag), c (download),
2055        // then absent z (delete).
2056        let ids: Vec<&str> = p1
2057            .actions
2058            .iter()
2059            .map(|a| match a {
2060                Action::Skip { clip_id } => clip_id.as_str(),
2061                Action::Retag { clip, .. } => clip.id.as_str(),
2062                Action::Download { clip, .. } => clip.id.as_str(),
2063                Action::Delete { clip_id, .. } => clip_id.as_str(),
2064                Action::Reformat { clip, .. } => clip.id.as_str(),
2065                Action::Rename { to, .. } => to.as_str(),
2066                Action::WriteArtifact { owner_id, .. }
2067                | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2068            })
2069            .collect();
2070        assert_eq!(ids, ["a", "b", "c", "z"]);
2071    }
2072
2073    #[test]
2074    fn empty_inputs_do_not_panic() {
2075        let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2076        assert!(plan.is_empty());
2077        assert_eq!(plan.len(), 0);
2078    }
2079
2080    #[test]
2081    fn empty_desired_with_full_manifest_deletes_all() {
2082        let mut manifest = Manifest::new();
2083        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2084        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2085        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2086        assert_eq!(plan.deletes(), 2);
2087    }
2088
2089    #[test]
2090    fn full_desired_with_empty_manifest_downloads_all() {
2091        let d = vec![
2092            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2093            desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2094        ];
2095        let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2096        assert_eq!(plan.downloads(), 2);
2097    }
2098
2099    #[test]
2100    fn plan_counts_sum_to_len() {
2101        let mut manifest = Manifest::new();
2102        manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2103        manifest.insert(
2104            "retag",
2105            entry("retag.flac", AudioFormat::Flac, "old", "art"),
2106        );
2107        manifest.insert(
2108            "reformat",
2109            entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2110        );
2111        manifest.insert(
2112            "rename",
2113            entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2114        );
2115        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2116        let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2117            .iter()
2118            .map(|id| (id.to_string(), present(100)))
2119            .collect();
2120        let d = vec![
2121            desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2122            desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2123            desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2124            desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2125            desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2126        ];
2127        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2128        let summed = plan.downloads()
2129            + plan.reformats()
2130            + plan.retags()
2131            + plan.renames()
2132            + plan.deletes()
2133            + plan.skips();
2134        assert_eq!(summed, plan.len());
2135        assert_eq!(plan.downloads(), 1);
2136        assert_eq!(plan.reformats(), 1);
2137        assert_eq!(plan.retags(), 1);
2138        assert_eq!(plan.renames(), 1);
2139        assert_eq!(plan.deletes(), 1);
2140        assert_eq!(plan.skips(), 1);
2141    }
2142
2143    // ── Phase 6: artifact reconcile ─────────────────────────────────
2144
2145    fn cover(path: &str, hash: &str) -> ArtifactState {
2146        ArtifactState {
2147            path: path.to_string(),
2148            hash: hash.to_string(),
2149        }
2150    }
2151
2152    fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2153        DesiredArtifact {
2154            kind,
2155            path: path.to_string(),
2156            source_url: url.to_string(),
2157            hash: hash.to_string(),
2158        }
2159    }
2160
2161    // An unchanged FLAC clip (Skip audio) that desires the given artifacts.
2162    fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2163        Desired {
2164            artifacts: arts,
2165            ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2166        }
2167    }
2168
2169    // A manifest entry for an unchanged FLAC clip carrying a cover.jpg sidecar.
2170    fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2171        ManifestEntry {
2172            cover_jpg: Some(cover(cover_path, cover_hash)),
2173            ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2174        }
2175    }
2176
2177    fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2178        plan.actions
2179            .iter()
2180            .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2181            .collect()
2182    }
2183
2184    #[test]
2185    fn write_artifact_emitted_when_manifest_lacks_it() {
2186        // The clip's audio is unchanged (Skip), but the manifest has no cover.jpg
2187        // slot, so the desired sidecar is written.
2188        let mut manifest = Manifest::new();
2189        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2190        let d = vec![desired_arts(
2191            "a",
2192            vec![art(
2193                ArtifactKind::CoverJpg,
2194                "a/cover.jpg",
2195                "https://art/a",
2196                "h1",
2197            )],
2198        )];
2199        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2200        assert_eq!(plan.artifact_writes(), 1);
2201        assert_eq!(plan.artifact_deletes(), 0);
2202        assert_eq!(plan.skips(), 1);
2203        assert_eq!(
2204            write_artifacts(&plan)[0],
2205            &Action::WriteArtifact {
2206                kind: ArtifactKind::CoverJpg,
2207                path: "a/cover.jpg".to_string(),
2208                source_url: "https://art/a".to_string(),
2209                hash: "h1".to_string(),
2210                owner_id: "a".to_string(),
2211                content: None,
2212            }
2213        );
2214    }
2215
2216    #[test]
2217    fn write_artifact_emitted_when_hash_differs() {
2218        // The manifest already tracks a cover.jpg, but its stored hash differs
2219        // from the desired one, so it is rewritten (and never delete-reconciled).
2220        let mut manifest = Manifest::new();
2221        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2222        let d = vec![desired_arts(
2223            "a",
2224            vec![art(
2225                ArtifactKind::CoverJpg,
2226                "a/cover.jpg",
2227                "https://art/a",
2228                "new",
2229            )],
2230        )];
2231        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2232        assert_eq!(plan.artifact_writes(), 1);
2233        assert_eq!(plan.artifact_deletes(), 0);
2234        if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2235            assert_eq!(hash, "new");
2236        } else {
2237            panic!("expected a WriteArtifact");
2238        }
2239    }
2240
2241    #[test]
2242    fn write_artifact_skipped_when_hash_matches() {
2243        // Present with a matching hash: no write, no delete.
2244        let mut manifest = Manifest::new();
2245        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2246        let d = vec![desired_arts(
2247            "a",
2248            vec![art(
2249                ArtifactKind::CoverJpg,
2250                "a/cover.jpg",
2251                "https://art/a",
2252                "h1",
2253            )],
2254        )];
2255        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2256        assert_eq!(plan.artifact_writes(), 0);
2257        assert_eq!(plan.artifact_deletes(), 0);
2258        assert_eq!(
2259            plan.actions,
2260            vec![Action::Skip {
2261                clip_id: "a".to_string()
2262            }]
2263        );
2264    }
2265
2266    #[test]
2267    fn removed_kind_cover_is_kept_not_deleted() {
2268        // The clip is kept but no longer desires a cover.jpg (an empty/transient
2269        // art URL this run). Covers opt out of removed-kind deletion, so the
2270        // existing sidecar is KEPT: no DeleteArtifact, no write, just a Skip.
2271        // This is the empty-art-URL keep the P6 review deferred to P7.
2272        let mut manifest = Manifest::new();
2273        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2274        let d = vec![desired_arts("a", vec![])];
2275        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2276        assert_eq!(plan.artifact_deletes(), 0);
2277        assert_eq!(plan.artifact_writes(), 0);
2278        // The audio is untouched and the cover is preserved on disk.
2279        assert_eq!(plan.deletes(), 0);
2280        assert_eq!(
2281            plan.actions,
2282            vec![Action::Skip {
2283                clip_id: "a".to_string()
2284            }]
2285        );
2286        assert!(!plan.actions.iter().any(|a| matches!(
2287            a,
2288            Action::DeleteArtifact {
2289                kind: ArtifactKind::CoverJpg,
2290                ..
2291            }
2292        )));
2293    }
2294
2295    #[test]
2296    fn delete_artifact_never_on_incomplete_listing() {
2297        // Kept clips no longer desiring their covers keep them: covers opt out of
2298        // removed-kind deletion. An incomplete mirror is a further backstop that
2299        // forbids every delete (the B2 gate on the co-delete path). Either way, a
2300        // large manifest of stale sidecars is safe.
2301        let mut manifest = Manifest::new();
2302        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2303        manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2304        let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2305        let sources = vec![SourceStatus {
2306            mode: SourceMode::Mirror,
2307            fully_enumerated: false,
2308        }];
2309        let local: HashMap<String, LocalFile> = [
2310            ("a".to_string(), present(100)),
2311            ("b".to_string(), present(100)),
2312        ]
2313        .into_iter()
2314        .collect();
2315        let plan = reconcile(&manifest, &d, &local, &sources);
2316        assert_eq!(plan.artifact_deletes(), 0);
2317        assert_eq!(plan.deletes(), 0);
2318    }
2319
2320    #[test]
2321    fn delete_artifact_never_when_entry_preserved() {
2322        // A kept clip that stops desiring its cover keeps it (covers opt out of
2323        // removed-kind deletion); the preserve marker is a further backstop.
2324        let mut manifest = Manifest::new();
2325        let preserved = ManifestEntry {
2326            preserve: true,
2327            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2328        };
2329        manifest.insert("a", preserved);
2330        let d = vec![desired_arts("a", vec![])];
2331        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2332        assert_eq!(plan.artifact_deletes(), 0);
2333    }
2334
2335    #[test]
2336    fn co_delete_never_when_path_empty() {
2337        // The empty-path guard now matters on the co-delete path (covers opt out
2338        // of removed-kind deletion). An absent clip's audio is deleted, but its
2339        // sidecar with an empty path must never become a delete of the root.
2340        let mut manifest = Manifest::new();
2341        manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2342        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2343        assert_eq!(plan.deletes(), 1);
2344        assert_eq!(plan.artifact_deletes(), 0);
2345    }
2346
2347    #[test]
2348    fn co_delete_absent_clip_deletes_audio_and_cover() {
2349        // A clip absent from desired is deleted; its cover.jpg is co-deleted
2350        // under the same gate.
2351        let mut manifest = Manifest::new();
2352        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2353        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2354        assert_eq!(plan.deletes(), 1);
2355        assert_eq!(plan.artifact_deletes(), 1);
2356        assert!(plan.actions.contains(&Action::Delete {
2357            path: "gone.flac".to_string(),
2358            clip_id: "gone".to_string(),
2359        }));
2360        assert!(plan.actions.contains(&Action::DeleteArtifact {
2361            kind: ArtifactKind::CoverJpg,
2362            path: "gone/cover.jpg".to_string(),
2363            owner_id: "gone".to_string(),
2364        }));
2365    }
2366
2367    #[test]
2368    fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2369        // Neither audio nor sidecar is removed on an incomplete listing.
2370        let mut manifest = Manifest::new();
2371        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2372        let sources = vec![SourceStatus {
2373            mode: SourceMode::Mirror,
2374            fully_enumerated: false,
2375        }];
2376        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2377        assert_eq!(plan.deletes(), 0);
2378        assert_eq!(plan.artifact_deletes(), 0);
2379    }
2380
2381    #[test]
2382    fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2383        // A trashed clip present in desired: audio Delete plus cover co-delete.
2384        let mut manifest = Manifest::new();
2385        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2386        let mut d = desired_arts("a", vec![]);
2387        d.trashed = true;
2388        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2389        assert_eq!(plan.deletes(), 1);
2390        assert_eq!(plan.artifact_deletes(), 1);
2391    }
2392
2393    #[test]
2394    fn co_delete_trashed_suppressed_when_not_enumerated() {
2395        // The trashed co-delete obeys the same enumeration gate as the audio.
2396        let mut manifest = Manifest::new();
2397        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2398        let mut d = desired_arts("a", vec![]);
2399        d.trashed = true;
2400        let sources = vec![SourceStatus {
2401            mode: SourceMode::Mirror,
2402            fully_enumerated: false,
2403        }];
2404        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2405        assert_eq!(plan.deletes(), 0);
2406        assert_eq!(plan.artifact_deletes(), 0);
2407        assert_eq!(plan.skips(), 1);
2408    }
2409
2410    #[test]
2411    fn co_delete_trashed_suppressed_when_preserved() {
2412        // A preserved, trashed clip keeps both audio and sidecar.
2413        let mut manifest = Manifest::new();
2414        let preserved = ManifestEntry {
2415            preserve: true,
2416            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2417        };
2418        manifest.insert("a", preserved);
2419        let mut d = desired_arts("a", vec![]);
2420        d.trashed = true;
2421        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2422        assert_eq!(plan.deletes(), 0);
2423        assert_eq!(plan.artifact_deletes(), 0);
2424    }
2425
2426    #[test]
2427    fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2428        // Clip "a" writes a cover to the very path clip "b"'s stale cover holds;
2429        // deleting it would clobber the freshly written file, so it is dropped.
2430        let mut manifest = Manifest::new();
2431        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2432        manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2433        // "a" writes a new CoverJpg to the shared path; "b" is absent (its cover
2434        // would be co-deleted from the same path).
2435        let d = vec![desired_arts(
2436            "a",
2437            vec![art(
2438                ArtifactKind::CoverJpg,
2439                "shared/cover.jpg",
2440                "https://art/a",
2441                "h2",
2442            )],
2443        )];
2444        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2445        assert_eq!(plan.artifact_writes(), 1);
2446        // The colliding DeleteArtifact is suppressed.
2447        assert!(!plan.actions.iter().any(
2448            |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2449        ));
2450        // The audio for "b" is still deleted (different path), just not its cover.
2451        assert!(plan.actions.contains(&Action::Delete {
2452            path: "b.flac".to_string(),
2453            clip_id: "b".to_string(),
2454        }));
2455    }
2456
2457    #[test]
2458    fn suppress_downgrades_delete_artifact_colliding_with_download() {
2459        // A fresh clip downloads audio to the path an absent clip's cover holds.
2460        let mut manifest = Manifest::new();
2461        manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2462        let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2463        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2464        assert_eq!(plan.downloads(), 1);
2465        assert!(
2466            !plan
2467                .actions
2468                .iter()
2469                .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2470        );
2471    }
2472
2473    #[test]
2474    fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2475        // SYNC-8/9/10/12 matrix invariance: the audio actions and plan.deletes()
2476        // are identical with and without artifacts attached. One absent clip is
2477        // deleted, one desired clip is kept (Skip), one trashed clip is deleted.
2478        let build = |with_art: bool| {
2479            let mut manifest = Manifest::new();
2480            manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2481            manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2482            manifest.insert(
2483                "trash",
2484                entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2485            );
2486            let keep = if with_art {
2487                desired_arts(
2488                    "keep",
2489                    vec![art(
2490                        ArtifactKind::CoverJpg,
2491                        "keep/cover.jpg",
2492                        "https://art/keep",
2493                        "h1",
2494                    )],
2495                )
2496            } else {
2497                desired_arts("keep", vec![])
2498            };
2499            let mut trash = desired_arts("trash", vec![]);
2500            trash.trashed = true;
2501            let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2502                .iter()
2503                .map(|id| (id.to_string(), present(100)))
2504                .collect();
2505            reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2506        };
2507
2508        let with = build(true);
2509        let without = build(false);
2510
2511        // The audio decisions are identical regardless of artifacts.
2512        let audio = |plan: &Plan| -> Vec<Action> {
2513            plan.actions
2514                .iter()
2515                .filter(|a| {
2516                    !matches!(
2517                        a,
2518                        Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2519                    )
2520                })
2521                .cloned()
2522                .collect()
2523        };
2524        assert_eq!(audio(&with), audio(&without));
2525        assert_eq!(with.deletes(), without.deletes());
2526        // gone + trash audio deletes, unaffected by the artifacts.
2527        assert_eq!(with.deletes(), 2);
2528        // The `with` run additionally reconciles sidecars: gone + trash covers
2529        // co-deleted, and keep's cover matches so it is neither written nor
2530        // deleted.
2531        assert_eq!(with.artifact_deletes(), 2);
2532        assert_eq!(with.artifact_writes(), 0);
2533    }
2534
2535    // ── Phase 6 review fixes: protection, path-drift, kind guard ─────
2536
2537    #[test]
2538    fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2539        // Covers opt out of removed-kind deletion, so a kept clip keeps its cover
2540        // regardless of protection. This case additionally proves protection is
2541        // honoured: a private clip and a copy-held clip each keep a removed-kind
2542        // cover even though the persisted entry is NOT preserve-marked and the
2543        // mirror is fully enumerated.
2544        let mut manifest = Manifest::new();
2545        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2546        assert!(!manifest.get("a").unwrap().preserve);
2547
2548        // Private this run.
2549        let private = Desired {
2550            private: true,
2551            ..desired_arts("a", vec![])
2552        };
2553        let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2554        assert_eq!(plan.artifact_deletes(), 0);
2555
2556        // Copy-held this run (modes contains Copy).
2557        let copy_held = Desired {
2558            modes: vec![SourceMode::Copy],
2559            ..desired_arts("a", vec![])
2560        };
2561        let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2562        assert_eq!(plan.artifact_deletes(), 0);
2563    }
2564
2565    #[test]
2566    fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2567        // The audio moved (new album/name) so the sidecar belongs at a new path;
2568        // the bytes are unchanged (same hash) but a rewrite at the new path is
2569        // still required. Reconcile emits no DeleteArtifact for the old path: the
2570        // executor's WriteArtifact relocates the sidecar (writes new, removes the
2571        // old copy), so the plan stays a single write.
2572        let mut manifest = Manifest::new();
2573        manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2574        let d = vec![desired_arts(
2575            "a",
2576            vec![art(
2577                ArtifactKind::CoverJpg,
2578                "new/cover.jpg",
2579                "https://art/a",
2580                "h1",
2581            )],
2582        )];
2583        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2584        assert_eq!(plan.artifact_writes(), 1);
2585        assert_eq!(plan.artifact_deletes(), 0);
2586        if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2587            assert_eq!(path, "new/cover.jpg");
2588        } else {
2589            panic!("expected a WriteArtifact");
2590        }
2591    }
2592
2593    #[test]
2594    fn per_clip_reconcile_ignores_album_and_library_kinds() {
2595        // Album/library kinds must never be written per clip (they have no
2596        // per-song manifest slot, so they would be rewritten every run). A
2597        // CoverJpg alongside them is still handled.
2598        let mut manifest = Manifest::new();
2599        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2600        let d = vec![desired_arts(
2601            "a",
2602            vec![
2603                art(
2604                    ArtifactKind::FolderJpg,
2605                    "a/folder.jpg",
2606                    "https://art/folder",
2607                    "hf",
2608                ),
2609                art(
2610                    ArtifactKind::Playlist,
2611                    "a/list.m3u",
2612                    "https://art/list",
2613                    "hp",
2614                ),
2615                art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2616            ],
2617        )];
2618        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2619        assert_eq!(plan.artifact_writes(), 1);
2620        let paths: Vec<&str> = plan
2621            .actions
2622            .iter()
2623            .filter_map(|a| match a {
2624                Action::WriteArtifact { path, .. } => Some(path.as_str()),
2625                _ => None,
2626            })
2627            .collect();
2628        assert_eq!(paths, vec!["a/cover.jpg"]);
2629    }
2630
2631    #[test]
2632    fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
2633        let mut manifest = Manifest::new();
2634        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2635        let d = vec![desired_arts(
2636            "a",
2637            vec![art(
2638                ArtifactKind::FolderWebp,
2639                "a/folder.webp",
2640                "https://art/folder",
2641                "hf",
2642            )],
2643        )];
2644        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2645        assert_eq!(plan.artifact_writes(), 0);
2646        assert_eq!(plan.artifact_deletes(), 0);
2647    }
2648
2649    // ── Phase 8: folder art (album-scoped) ──────────────────────────
2650
2651    fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
2652        Clip {
2653            id: id.to_string(),
2654            title: "Song".to_string(),
2655            image_large_url: image.to_string(),
2656            video_cover_url: video.to_string(),
2657            play_count,
2658            created_at: created_at.to_string(),
2659            ..Default::default()
2660        }
2661    }
2662
2663    fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
2664        let mut lineage = LineageContext::own_root(&clip);
2665        lineage.root_id = root_id.to_string();
2666        Desired {
2667            clip,
2668            lineage,
2669            path: path.to_string(),
2670            format: AudioFormat::Flac,
2671            meta_hash: "m".to_string(),
2672            art_hash: "a".to_string(),
2673            modes: vec![SourceMode::Mirror],
2674            trashed: false,
2675            private: false,
2676            artifacts: Vec::new(),
2677        }
2678    }
2679
2680    fn stored(path: &str, hash: &str) -> ArtifactState {
2681        ArtifactState {
2682            path: path.to_string(),
2683            hash: hash.to_string(),
2684        }
2685    }
2686
2687    #[test]
2688    fn folder_jpg_source_is_most_played() {
2689        let members = vec![
2690            album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
2691            album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
2692            album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
2693        ];
2694        let albums = album_desired(&members, false);
2695        assert_eq!(albums.len(), 1);
2696        let jpg = albums[0].folder_jpg.as_ref().unwrap();
2697        // "b" has the highest play_count, so its art content hash wins.
2698        assert_eq!(jpg.hash, art_url_hash("art-b"));
2699        assert_eq!(jpg.source_url, "art-b");
2700        assert_eq!(jpg.path, "c/al/folder.jpg");
2701        assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
2702    }
2703
2704    #[test]
2705    fn folder_jpg_tie_breaks_earliest_then_lex_id() {
2706        // Equal play_count: earliest created_at wins.
2707        let by_time = vec![
2708            album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
2709            album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
2710            album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
2711        ];
2712        let jpg = album_desired(&by_time, false)[0]
2713            .folder_jpg
2714            .clone()
2715            .unwrap();
2716        assert_eq!(jpg.source_url, "art-y");
2717
2718        // Equal play_count and created_at: lexicographically smallest id wins.
2719        let by_id = vec![
2720            album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
2721            album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
2722        ];
2723        let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
2724        assert_eq!(jpg.source_url, "art-g");
2725    }
2726
2727    #[test]
2728    fn folder_webp_source_is_first_created_animated() {
2729        let members = vec![
2730            album_member(
2731                album_clip("a", 9, "t2", "art-a", "vid-a"),
2732                "root",
2733                "c/al/a.flac",
2734            ),
2735            album_member(
2736                album_clip("b", 1, "t0", "art-b", "vid-b"),
2737                "root",
2738                "c/al/b.flac",
2739            ),
2740            album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
2741        ];
2742        let webp = album_desired(&members, true)[0]
2743            .folder_webp
2744            .clone()
2745            .unwrap();
2746        // "b" is earliest-created with an animated source, regardless of plays.
2747        assert_eq!(webp.source_url, "vid-b");
2748        assert_eq!(webp.hash, art_url_hash("vid-b"));
2749        assert_eq!(webp.path, "c/al/cover.webp");
2750        assert_eq!(webp.kind, ArtifactKind::FolderWebp);
2751    }
2752
2753    #[test]
2754    fn animated_covers_off_yields_no_folder_webp() {
2755        let members = vec![album_member(
2756            album_clip("a", 1, "t0", "art-a", "vid-a"),
2757            "root",
2758            "c/al/a.flac",
2759        )];
2760        let off = album_desired(&members, false);
2761        assert!(off[0].folder_webp.is_none());
2762        let on = album_desired(&members, true);
2763        assert!(on[0].folder_webp.is_some());
2764    }
2765
2766    #[test]
2767    fn album_with_no_art_yields_no_folder_jpg() {
2768        let members = vec![album_member(
2769            album_clip("a", 3, "t0", "", ""),
2770            "root",
2771            "c/al/a.flac",
2772        )];
2773        let albums = album_desired(&members, true);
2774        assert!(albums[0].folder_jpg.is_none());
2775        assert!(albums[0].folder_webp.is_none());
2776    }
2777
2778    #[test]
2779    fn album_desired_groups_by_root_id() {
2780        let members = vec![
2781            album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
2782            album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
2783            album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
2784        ];
2785        let albums = album_desired(&members, false);
2786        assert_eq!(albums.len(), 2);
2787        assert_eq!(albums[0].root_id, "r1");
2788        assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
2789        assert_eq!(
2790            albums[0].folder_jpg.as_ref().unwrap().path,
2791            "c/al1/folder.jpg"
2792        );
2793        assert_eq!(albums[1].root_id, "r2");
2794        assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
2795        assert_eq!(
2796            albums[1].folder_jpg.as_ref().unwrap().path,
2797            "c/al2/folder.jpg"
2798        );
2799    }
2800
2801    #[test]
2802    fn plan_writes_folder_art_when_store_empty() {
2803        let members = vec![album_member(
2804            album_clip("a", 1, "t0", "art-a", "vid-a"),
2805            "root",
2806            "c/al/a.flac",
2807        )];
2808        let desired = album_desired(&members, true);
2809        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
2810        assert_eq!(
2811            actions,
2812            vec![
2813                Action::WriteArtifact {
2814                    kind: ArtifactKind::FolderJpg,
2815                    path: "c/al/folder.jpg".to_string(),
2816                    source_url: "art-a".to_string(),
2817                    hash: art_url_hash("art-a"),
2818                    owner_id: "root".to_string(),
2819                    content: None,
2820                },
2821                Action::WriteArtifact {
2822                    kind: ArtifactKind::FolderWebp,
2823                    path: "c/al/cover.webp".to_string(),
2824                    source_url: "vid-a".to_string(),
2825                    hash: art_url_hash("vid-a"),
2826                    owner_id: "root".to_string(),
2827                    content: None,
2828                },
2829            ]
2830        );
2831    }
2832
2833    #[test]
2834    fn plan_skips_when_hash_and_path_match() {
2835        let members = vec![album_member(
2836            album_clip("a", 1, "t0", "art-a", ""),
2837            "root",
2838            "c/al/a.flac",
2839        )];
2840        let desired = album_desired(&members, false);
2841        let mut albums = BTreeMap::new();
2842        albums.insert(
2843            "root".to_string(),
2844            AlbumArt {
2845                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
2846                folder_webp: None,
2847            },
2848        );
2849        assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
2850    }
2851
2852    #[test]
2853    fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
2854        let members = vec![album_member(
2855            album_clip("a", 1, "t0", "art-a", ""),
2856            "root",
2857            "c/al/a.flac",
2858        )];
2859        let desired = album_desired(&members, false);
2860        let mut albums = BTreeMap::new();
2861        albums.insert(
2862            "root".to_string(),
2863            AlbumArt {
2864                folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
2865                folder_webp: None,
2866            },
2867        );
2868        let actions = plan_album_artifacts(&desired, &albums, true);
2869        assert_eq!(actions.len(), 1);
2870        assert!(matches!(
2871            &actions[0],
2872            Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
2873        ));
2874    }
2875
2876    #[test]
2877    fn h1_most_played_flip_to_same_art_writes_nothing() {
2878        // Two variants sharing identical art. Run 1: "a" is most-played.
2879        let run1 = vec![
2880            album_member(
2881                album_clip("a", 9, "t0", "same-art", ""),
2882                "root",
2883                "c/al/a.flac",
2884            ),
2885            album_member(
2886                album_clip("b", 1, "t1", "same-art", ""),
2887                "root",
2888                "c/al/b.flac",
2889            ),
2890        ];
2891        let desired1 = album_desired(&run1, false);
2892        let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
2893        assert_eq!(write1.len(), 1);
2894
2895        // Persist the winner's state as the executor would.
2896        let mut albums = BTreeMap::new();
2897        if let Action::WriteArtifact {
2898            path,
2899            hash,
2900            owner_id,
2901            ..
2902        } = &write1[0]
2903        {
2904            albums.insert(
2905                owner_id.clone(),
2906                AlbumArt {
2907                    folder_jpg: Some(stored(path, hash)),
2908                    folder_webp: None,
2909                },
2910            );
2911        }
2912
2913        // Run 2: "b" overtakes "a" on plays, but the art content is identical.
2914        let run2 = vec![
2915            album_member(
2916                album_clip("a", 1, "t0", "same-art", ""),
2917                "root",
2918                "c/al/a.flac",
2919            ),
2920            album_member(
2921                album_clip("b", 9, "t1", "same-art", ""),
2922                "root",
2923                "c/al/b.flac",
2924            ),
2925        ];
2926        let desired2 = album_desired(&run2, false);
2927        // The winner flipped, but the chosen art content hash did not: no churn.
2928        assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
2929    }
2930
2931    #[test]
2932    fn h1_flip_to_different_art_writes_exactly_one() {
2933        let mut albums = BTreeMap::new();
2934        albums.insert(
2935            "root".to_string(),
2936            AlbumArt {
2937                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
2938                folder_webp: None,
2939            },
2940        );
2941        // The new most-played variant carries genuinely different art.
2942        let members = vec![
2943            album_member(
2944                album_clip("a", 1, "t0", "old-art", ""),
2945                "root",
2946                "c/al/a.flac",
2947            ),
2948            album_member(
2949                album_clip("b", 9, "t1", "new-art", ""),
2950                "root",
2951                "c/al/b.flac",
2952            ),
2953        ];
2954        let desired = album_desired(&members, false);
2955        let actions = plan_album_artifacts(&desired, &albums, true);
2956        assert_eq!(actions.len(), 1);
2957        assert!(matches!(
2958            &actions[0],
2959            Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
2960        ));
2961    }
2962
2963    #[test]
2964    fn one_write_per_album_regardless_of_clip_count() {
2965        let members: Vec<Desired> = (0..200)
2966            .map(|i| {
2967                album_member(
2968                    album_clip(
2969                        &format!("clip-{i:03}"),
2970                        i as u64,
2971                        &format!("t{i:03}"),
2972                        &format!("art-{i:03}"),
2973                        &format!("vid-{i:03}"),
2974                    ),
2975                    "root",
2976                    &format!("c/al/clip-{i:03}.flac"),
2977                )
2978            })
2979            .collect();
2980        let desired = album_desired(&members, true);
2981        assert_eq!(desired.len(), 1);
2982        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
2983        // Exactly one folder.jpg and one cover.webp for the whole 200-clip album.
2984        assert_eq!(actions.len(), 2);
2985        assert_eq!(
2986            actions
2987                .iter()
2988                .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2989                .count(),
2990            2
2991        );
2992    }
2993
2994    #[test]
2995    fn emptied_album_deletes_only_when_can_delete() {
2996        let mut albums = BTreeMap::new();
2997        albums.insert(
2998            "root".to_string(),
2999            AlbumArt {
3000                folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3001                folder_webp: Some(stored("c/al/cover.webp", "hw")),
3002            },
3003        );
3004        // No album desires this root any more (it emptied out this run).
3005        let desired: Vec<AlbumDesired> = Vec::new();
3006
3007        // Gated off: an incomplete/unsafe listing removes nothing.
3008        assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3009
3010        // Gated on: both stored kinds are removed, sorted by kind.
3011        let actions = plan_album_artifacts(&desired, &albums, true);
3012        assert_eq!(
3013            actions,
3014            vec![
3015                Action::DeleteArtifact {
3016                    kind: ArtifactKind::FolderJpg,
3017                    path: "c/al/folder.jpg".to_string(),
3018                    owner_id: "root".to_string(),
3019                },
3020                Action::DeleteArtifact {
3021                    kind: ArtifactKind::FolderWebp,
3022                    path: "c/al/cover.webp".to_string(),
3023                    owner_id: "root".to_string(),
3024                },
3025            ]
3026        );
3027    }
3028
3029    #[test]
3030    fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3031        let mut albums = BTreeMap::new();
3032        albums.insert(
3033            "root".to_string(),
3034            AlbumArt {
3035                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3036                folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3037            },
3038        );
3039        // The album is still present with the same folder.jpg, but animated
3040        // covers are now off, so the webp source has disappeared.
3041        let members = vec![album_member(
3042            album_clip("a", 1, "t0", "art-a", "vid-a"),
3043            "root",
3044            "c/al/a.flac",
3045        )];
3046        let desired = album_desired(&members, false);
3047
3048        assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3049
3050        let actions = plan_album_artifacts(&desired, &albums, true);
3051        assert_eq!(
3052            actions,
3053            vec![Action::DeleteArtifact {
3054                kind: ArtifactKind::FolderWebp,
3055                path: "c/al/cover.webp".to_string(),
3056                owner_id: "root".to_string(),
3057            }]
3058        );
3059    }
3060
3061    #[test]
3062    fn plan_album_artifacts_is_deterministically_ordered() {
3063        let members = vec![
3064            album_member(
3065                album_clip("a", 1, "t0", "art-a", "vid-a"),
3066                "r2",
3067                "c/al2/a.flac",
3068            ),
3069            album_member(
3070                album_clip("b", 1, "t0", "art-b", "vid-b"),
3071                "r1",
3072                "c/al1/b.flac",
3073            ),
3074        ];
3075        let desired = album_desired(&members, true);
3076        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3077        let keys: Vec<(&str, ArtifactKind)> = actions
3078            .iter()
3079            .map(|a| match a {
3080                Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3081                _ => unreachable!(),
3082            })
3083            .collect();
3084        assert_eq!(
3085            keys,
3086            vec![
3087                ("r1", ArtifactKind::FolderJpg),
3088                ("r1", ArtifactKind::FolderWebp),
3089                ("r2", ArtifactKind::FolderJpg),
3090                ("r2", ArtifactKind::FolderWebp),
3091            ]
3092        );
3093    }
3094
3095    // ── Phase 9: playlist artifacts ─────────────────────────────────
3096
3097    fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3098        PlaylistDesired {
3099            id: id.to_owned(),
3100            name: name.to_owned(),
3101            path: path.to_owned(),
3102            content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3103            hash: hash.to_owned(),
3104        }
3105    }
3106
3107    fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3108        PlaylistState {
3109            name: name.to_owned(),
3110            path: path.to_owned(),
3111            hash: hash.to_owned(),
3112        }
3113    }
3114
3115    fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3116        entries
3117            .iter()
3118            .map(|(id, state)| ((*id).to_owned(), state.clone()))
3119            .collect()
3120    }
3121
3122    #[test]
3123    fn playlist_write_emitted_for_a_new_playlist() {
3124        let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3125        let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3126        assert_eq!(
3127            actions,
3128            vec![Action::WriteArtifact {
3129                kind: ArtifactKind::Playlist,
3130                path: "Road Trip.m3u8".to_owned(),
3131                source_url: String::new(),
3132                hash: "h1".to_owned(),
3133                owner_id: "pl1".to_owned(),
3134                content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3135            }]
3136        );
3137    }
3138
3139    #[test]
3140    fn playlist_write_emitted_when_hash_changes() {
3141        // Same id and path, different content hash (a member's title, an order
3142        // flip, a new path) — the m3u8 is rewritten (B1).
3143        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3144        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3145        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3146        assert_eq!(actions.len(), 1);
3147        assert!(matches!(
3148            &actions[0],
3149            Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3150        ));
3151    }
3152
3153    #[test]
3154    fn playlist_unchanged_is_idempotent() {
3155        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3156        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3157        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3158        assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3159    }
3160
3161    #[test]
3162    fn playlist_rename_writes_new_and_deletes_old_path() {
3163        // The playlist was renamed on Suno, so its sanitised path changed: write
3164        // the new file and delete the old one, both under the full delete gate.
3165        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3166        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3167        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3168        assert_eq!(
3169            actions,
3170            vec![
3171                Action::WriteArtifact {
3172                    kind: ArtifactKind::Playlist,
3173                    path: "Summer.m3u8".to_owned(),
3174                    source_url: String::new(),
3175                    hash: "h2".to_owned(),
3176                    owner_id: "pl1".to_owned(),
3177                    content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3178                },
3179                Action::DeleteArtifact {
3180                    kind: ArtifactKind::Playlist,
3181                    path: "Spring.m3u8".to_owned(),
3182                    owner_id: "pl1".to_owned(),
3183                },
3184            ]
3185        );
3186    }
3187
3188    #[test]
3189    fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3190        // A rename still writes the new file, but the OLD-path cleanup is a
3191        // delete and is gated: no can_delete means no removal (B2).
3192        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3193        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3194        let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3195        assert_eq!(actions.len(), 1);
3196        assert!(matches!(
3197            &actions[0],
3198            Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3199        ));
3200        assert!(
3201            !actions
3202                .iter()
3203                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3204            "old path must not be deleted when deletes are disallowed"
3205        );
3206    }
3207
3208    #[test]
3209    fn playlist_stale_removed_only_under_full_gate() {
3210        // A stored playlist absent from desired is stale. It is deleted only when
3211        // BOTH can_delete and list_fully_enumerated hold.
3212        let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3213
3214        let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3215        assert_eq!(
3216            deleted,
3217            vec![Action::DeleteArtifact {
3218                kind: ArtifactKind::Playlist,
3219                path: "Gone.m3u8".to_owned(),
3220                owner_id: "gone".to_owned(),
3221            }]
3222        );
3223
3224        // Any gate off → no delete.
3225        assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3226        assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3227        assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3228    }
3229
3230    #[test]
3231    fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3232        // B2 BLOCKER: when the /api/playlist/me listing fails, the caller passes
3233        // an empty desired and list_fully_enumerated=false. Even with a
3234        // non-empty store and can_delete, NOTHING is planned — every existing
3235        // .m3u8 is left untouched.
3236        let stored = pl_store(&[
3237            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3238            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3239        ]);
3240        let actions = plan_playlist_artifacts(&[], &stored, true, false);
3241        assert!(
3242            actions.is_empty(),
3243            "a failed playlist listing must plan zero actions, got {actions:?}"
3244        );
3245    }
3246
3247    #[test]
3248    fn b2_empty_list_deletes_only_when_fully_enumerated() {
3249        // An empty desired that contradicts a non-empty store is a genuine
3250        // wipe ONLY when the listing was fully enumerated (and can_delete). That
3251        // path IS a mass delete — the CLI cap/confirmation then guards it — but
3252        // an unreliable listing (not fully enumerated) plans nothing here (B2).
3253        let stored = pl_store(&[
3254            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3255            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3256        ]);
3257
3258        // Not fully enumerated: zero deletes (the safety valve).
3259        assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3260
3261        // Fully enumerated and allowed: both are deleted (the caller's cap
3262        // catches this mass removal).
3263        let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3264        assert_eq!(
3265            wiped
3266                .iter()
3267                .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3268                .count(),
3269            2
3270        );
3271    }
3272
3273    #[test]
3274    fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3275        // A playlist whose member fetch failed is excluded upstream from BOTH
3276        // desired and the stored map handed here, so it is neither rewritten nor
3277        // treated as stale: its .m3u8 survives while a sibling reconciles.
3278        // `pl_ok` reconciles; `pl_fail` is simply absent from both maps.
3279        let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3280        let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3281        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3282        // Only the healthy playlist is rewritten; nothing references pl_fail.
3283        assert_eq!(actions.len(), 1);
3284        assert!(matches!(
3285            &actions[0],
3286            Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3287        ));
3288        assert!(
3289            !actions.iter().any(|a| match a {
3290                Action::WriteArtifact { owner_id, .. }
3291                | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3292                _ => false,
3293            }),
3294            "a protected (failed-member) playlist must have no action"
3295        );
3296    }
3297
3298    #[test]
3299    fn playlist_rename_collision_downgrades_the_delete() {
3300        // pl1 renames Old -> Shared.m3u8; pl2 already renders Shared.m3u8 this
3301        // run. The delete of pl1's old path is fine, but a delete must never
3302        // alias a write target, so if the OLD path equals another write target
3303        // it is downgraded. Here we force the collision: pl1's old path is the
3304        // very path pl2 writes.
3305        let desired = vec![
3306            pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3307            pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3308        ];
3309        let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3310        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3311        // No DeleteArtifact survives against a path some write produces.
3312        let write_paths: BTreeSet<&str> = actions
3313            .iter()
3314            .filter_map(|a| match a {
3315                Action::WriteArtifact { path, .. } => Some(path.as_str()),
3316                _ => None,
3317            })
3318            .collect();
3319        for a in &actions {
3320            if let Action::DeleteArtifact { path, .. } = a {
3321                assert!(
3322                    !write_paths.contains(path.as_str()),
3323                    "a playlist delete aliases a write target: {path}"
3324                );
3325            }
3326        }
3327    }
3328}
3329
3330/// Property-based tests that lock the delete guard against random inputs.
3331///
3332/// These complement the deterministic unit tests above. The generators are
3333/// bounded (a small clip-id space, short paths and hashes) so the cases stay
3334/// cheap and CI stays stable, and failure persistence is disabled so a run
3335/// never leaves regression files behind.
3336///
3337/// The generators are fully random: `trashed`, `private`, source `modes`, and
3338/// the persisted `preserve` marker are all exercised, and the desired list may
3339/// hold duplicate ids so aggregation is covered too. The invariants below are
3340/// written to hold for every such input, so the trashed delete path is no
3341/// longer a special case hidden from the property tests.
3342#[cfg(test)]
3343mod proptests {
3344    use super::*;
3345    use proptest::collection::{btree_map, hash_map, vec};
3346    use proptest::prelude::*;
3347    use std::collections::BTreeSet;
3348
3349    type DesiredFields = (
3350        String,
3351        AudioFormat,
3352        String,
3353        String,
3354        Vec<SourceMode>,
3355        bool,
3356        bool,
3357    );
3358
3359    fn audio_format() -> impl Strategy<Value = AudioFormat> {
3360        prop_oneof![
3361            Just(AudioFormat::Mp3),
3362            Just(AudioFormat::Flac),
3363            Just(AudioFormat::Wav),
3364        ]
3365    }
3366
3367    fn source_mode() -> impl Strategy<Value = SourceMode> {
3368        prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3369    }
3370
3371    // A small id space forces overlap between the manifest and the desired set,
3372    // so deletes, renames, retags, and downloads all get exercised.
3373    fn clip_id() -> impl Strategy<Value = String> {
3374        (0u8..8).prop_map(|n| format!("c{n}"))
3375    }
3376
3377    fn small_path() -> impl Strategy<Value = String> {
3378        (0u8..6).prop_map(|n| format!("path{n}"))
3379    }
3380
3381    // The manifest entry path is the source of every `Delete.path`, so it must
3382    // occasionally be empty for INV9 to actually exercise the empty-path guard.
3383    fn manifest_path() -> impl Strategy<Value = String> {
3384        prop_oneof![
3385            1 => Just(String::new()),
3386            6 => small_path(),
3387        ]
3388    }
3389
3390    fn small_hash() -> impl Strategy<Value = String> {
3391        (0u8..4).prop_map(|n| format!("h{n}"))
3392    }
3393
3394    fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3395        (
3396            manifest_path(),
3397            audio_format(),
3398            small_hash(),
3399            small_hash(),
3400            0u64..4,
3401            any::<bool>(),
3402        )
3403            .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3404                ManifestEntry {
3405                    path,
3406                    format,
3407                    meta_hash,
3408                    art_hash,
3409                    size,
3410                    preserve,
3411                    ..Default::default()
3412                }
3413            })
3414    }
3415
3416    fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3417        btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3418    }
3419
3420    fn local_file() -> impl Strategy<Value = LocalFile> {
3421        (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3422    }
3423
3424    fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3425        hash_map(clip_id(), local_file(), 0..8)
3426    }
3427
3428    fn source_status() -> impl Strategy<Value = SourceStatus> {
3429        (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3430            mode,
3431            fully_enumerated,
3432        })
3433    }
3434
3435    fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3436        vec(source_status(), 0..5)
3437    }
3438
3439    fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3440        vec(
3441            any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3442                mode: SourceMode::Copy,
3443                fully_enumerated,
3444            }),
3445            1..5,
3446        )
3447    }
3448
3449    fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3450        (
3451            small_path(),
3452            audio_format(),
3453            small_hash(),
3454            small_hash(),
3455            vec(source_mode(), 1..3),
3456            any::<bool>(),
3457            any::<bool>(),
3458        )
3459    }
3460
3461    fn build_desired(id: String, fields: DesiredFields) -> Desired {
3462        let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3463        let clip = Clip {
3464            id,
3465            title: "t".to_string(),
3466            ..Default::default()
3467        };
3468        Desired {
3469            lineage: LineageContext::own_root(&clip),
3470            clip,
3471            path,
3472            format,
3473            meta_hash,
3474            art_hash,
3475            modes,
3476            trashed,
3477            private,
3478            artifacts: Vec::new(),
3479        }
3480    }
3481
3482    // A desired list over the shared id space that may hold duplicate ids, so
3483    // aggregation and the trashed/private/copy folds are all exercised.
3484    fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3485        vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3486            items
3487                .into_iter()
3488                .map(|(id, fields)| build_desired(id, fields))
3489                .collect()
3490        })
3491    }
3492
3493    fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3494        desired.iter().map(|d| d.clip.id.as_str()).collect()
3495    }
3496
3497    // Ids protected from deletion: any duplicate that is private or copy-held
3498    // protects the whole id, mirroring the aggregation's union semantics.
3499    fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3500        desired
3501            .iter()
3502            .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3503            .map(|d| d.clip.id.as_str())
3504            .collect()
3505    }
3506
3507    // Ids with at least one non-trashed duplicate: the trashed fold is an
3508    // intersection, so one live duplicate keeps the clip.
3509    fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3510        desired
3511            .iter()
3512            .filter(|d| !d.trashed)
3513            .map(|d| d.clip.id.as_str())
3514            .collect()
3515    }
3516
3517    fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3518        plan.actions
3519            .iter()
3520            .filter_map(|a| match a {
3521                Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3522                _ => None,
3523            })
3524            .collect()
3525    }
3526
3527    fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3528        plan.actions
3529            .iter()
3530            .filter_map(|a| match a {
3531                Action::Download { path, .. } | Action::Reformat { path, .. } => {
3532                    Some(path.as_str())
3533                }
3534                Action::Rename { to, .. } => Some(to.as_str()),
3535                _ => None,
3536            })
3537            .collect()
3538    }
3539
3540    proptest! {
3541        #![proptest_config(ProptestConfig {
3542            cases: 256,
3543            failure_persistence: None,
3544            ..ProptestConfig::default()
3545        })]
3546
3547        // INVARIANT 1: a desired clip is deleted only when every one of its
3548        // duplicates is trashed; one live (non-trashed) duplicate keeps it.
3549        #[test]
3550        fn inv1_desired_clip_deleted_only_when_fully_trashed(
3551            manifest in manifest_strategy(),
3552            desired in desired_strategy(),
3553            local in local_strategy(),
3554            sources in sources_strategy(),
3555        ) {
3556            let plan = reconcile(&manifest, &desired, &local, &sources);
3557            let present = desired_ids(&desired);
3558            let live = non_trashed_ids(&desired);
3559            for id in delete_clip_ids(&plan) {
3560                prop_assert!(
3561                    !(present.contains(id) && live.contains(id)),
3562                    "deleted a desired clip with a non-trashed duplicate: {id}"
3563                );
3564            }
3565        }
3566
3567        // INVARIANT 2: a single not-fully-enumerated mirror source (truncated,
3568        // partial, empty, or failed listing) suppresses every deletion, trashed
3569        // clips included.
3570        #[test]
3571        fn inv2_no_delete_when_any_mirror_unenumerated(
3572            manifest in manifest_strategy(),
3573            desired in desired_strategy(),
3574            local in local_strategy(),
3575            mut sources in sources_strategy(),
3576        ) {
3577            sources.push(SourceStatus {
3578                mode: SourceMode::Mirror,
3579                fully_enumerated: false,
3580            });
3581            let plan = reconcile(&manifest, &desired, &local, &sources);
3582            prop_assert_eq!(plan.deletes(), 0);
3583        }
3584
3585        // INVARIANT 3: a copy-only run is additive and never deletes.
3586        #[test]
3587        fn inv3_all_copy_sources_means_no_deletes(
3588            manifest in manifest_strategy(),
3589            desired in desired_strategy(),
3590            local in local_strategy(),
3591            sources in copy_sources_strategy(),
3592        ) {
3593            let plan = reconcile(&manifest, &desired, &local, &sources);
3594            prop_assert_eq!(plan.deletes(), 0);
3595        }
3596
3597        // INVARIANT 4: identical inputs always yield an identical plan, and the
3598        // plan does not depend on the order of the desired or source lists.
3599        #[test]
3600        fn inv4_plan_is_deterministic(
3601            manifest in manifest_strategy(),
3602            desired in desired_strategy(),
3603            local in local_strategy(),
3604            sources in sources_strategy(),
3605        ) {
3606            let plan = reconcile(&manifest, &desired, &local, &sources);
3607
3608            let again = reconcile(&manifest, &desired, &local, &sources);
3609            prop_assert_eq!(&plan, &again);
3610
3611            let mut desired_rev = desired.clone();
3612            desired_rev.reverse();
3613            let mut sources_rev = sources.clone();
3614            sources_rev.reverse();
3615            let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3616            prop_assert_eq!(&plan, &shuffled);
3617        }
3618
3619        // INVARIANT 5: every Delete names a clip that exists in the manifest.
3620        #[test]
3621        fn inv5_every_delete_is_in_the_manifest(
3622            manifest in manifest_strategy(),
3623            desired in desired_strategy(),
3624            local in local_strategy(),
3625            sources in sources_strategy(),
3626        ) {
3627            let plan = reconcile(&manifest, &desired, &local, &sources);
3628            for id in delete_clip_ids(&plan) {
3629                prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
3630            }
3631        }
3632
3633        // INVARIANT 6: never delete a copy-held or private clip, whether that
3634        // protection is in the current selection or persisted on the manifest.
3635        #[test]
3636        fn inv6_never_deletes_protected_clip(
3637            manifest in manifest_strategy(),
3638            desired in desired_strategy(),
3639            local in local_strategy(),
3640            sources in sources_strategy(),
3641        ) {
3642            let plan = reconcile(&manifest, &desired, &local, &sources);
3643            let protected = protected_ids(&desired);
3644            for id in delete_clip_ids(&plan) {
3645                prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
3646                let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
3647                prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
3648            }
3649        }
3650
3651        // INVARIANT 7: every Delete requires deletion to be allowed for the run,
3652        // so the trashed path is no longer an exception to the enumeration guard.
3653        #[test]
3654        fn inv7_no_delete_unless_deletion_allowed(
3655            manifest in manifest_strategy(),
3656            desired in desired_strategy(),
3657            local in local_strategy(),
3658            sources in sources_strategy(),
3659        ) {
3660            let plan = reconcile(&manifest, &desired, &local, &sources);
3661            if !deletion_allowed(&sources) {
3662                prop_assert_eq!(plan.deletes(), 0);
3663            }
3664        }
3665
3666        // INVARIANT 8: at most one Delete per clip id.
3667        #[test]
3668        fn inv8_at_most_one_delete_per_clip(
3669            manifest in manifest_strategy(),
3670            desired in desired_strategy(),
3671            local in local_strategy(),
3672            sources in sources_strategy(),
3673        ) {
3674            let plan = reconcile(&manifest, &desired, &local, &sources);
3675            let ids = delete_clip_ids(&plan);
3676            let unique: BTreeSet<&str> = ids.iter().copied().collect();
3677            prop_assert_eq!(ids.len(), unique.len());
3678        }
3679
3680        // INVARIANT 9: no Delete carries an empty path.
3681        #[test]
3682        fn inv9_no_delete_with_empty_path(
3683            manifest in manifest_strategy(),
3684            desired in desired_strategy(),
3685            local in local_strategy(),
3686            sources in sources_strategy(),
3687        ) {
3688            let plan = reconcile(&manifest, &desired, &local, &sources);
3689            for action in &plan.actions {
3690                if let Action::Delete { path, .. } = action {
3691                    prop_assert!(!path.is_empty(), "delete with an empty path");
3692                }
3693            }
3694        }
3695
3696        // INVARIANT 10: no Delete path equals a file another action writes this
3697        // run, so a deletion can never clobber a just-written file.
3698        #[test]
3699        fn inv10_no_delete_aliases_a_write_target(
3700            manifest in manifest_strategy(),
3701            desired in desired_strategy(),
3702            local in local_strategy(),
3703            sources in sources_strategy(),
3704        ) {
3705            let plan = reconcile(&manifest, &desired, &local, &sources);
3706            let targets = write_target_paths(&plan);
3707            for action in &plan.actions {
3708                if let Action::Delete { path, .. } = action {
3709                    prop_assert!(
3710                        !targets.contains(path.as_str()),
3711                        "delete path {path} aliases a write target"
3712                    );
3713                }
3714            }
3715        }
3716    }
3717}