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