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