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