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