Skip to main content

suno_core/
reconcile.rs

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