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