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