Skip to main content

suno_core/
reconcile.rs

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