Skip to main content

suno_core/
reconcile.rs

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