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 format_change_reformats() {
1962        let mut manifest = Manifest::new();
1963        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1964        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1965        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1966        assert_eq!(
1967            plan.actions,
1968            vec![Action::Reformat {
1969                clip: clip("a"),
1970                path: "a.mp3".to_string(),
1971                from_path: "a.flac".to_string(),
1972                from: AudioFormat::Flac,
1973                to: AudioFormat::Mp3,
1974            }]
1975        );
1976    }
1977
1978    #[test]
1979    fn format_change_takes_precedence_over_rename_and_retag() {
1980        // Format, path, and metadata all changed at once: a single reformat
1981        // replaces the file, so no separate rename or retag is emitted.
1982        let mut manifest = Manifest::new();
1983        manifest.insert(
1984            "a",
1985            entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1986        );
1987        let d = vec![desired(
1988            "a",
1989            "new/a.mp3",
1990            AudioFormat::Mp3,
1991            "new",
1992            "new-art",
1993        )];
1994        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1995        assert_eq!(plan.reformats(), 1);
1996        assert_eq!(plan.renames(), 0);
1997        assert_eq!(plan.retags(), 0);
1998    }
1999
2000    // ── SYNC-10: zero-length / missing local file ───────────────────
2001
2002    #[test]
2003    fn zero_length_file_downloads_even_when_hashes_match() {
2004        let mut manifest = Manifest::new();
2005        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2006        let local: HashMap<String, LocalFile> = [(
2007            "a".to_string(),
2008            LocalFile {
2009                exists: true,
2010                size: 0,
2011            },
2012        )]
2013        .into_iter()
2014        .collect();
2015        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2016        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2017        assert_eq!(plan.downloads(), 1);
2018        assert_eq!(plan.skips(), 0);
2019    }
2020
2021    #[test]
2022    fn missing_file_downloads_even_when_hashes_match() {
2023        let mut manifest = Manifest::new();
2024        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2025        let local: HashMap<String, LocalFile> = [(
2026            "a".to_string(),
2027            LocalFile {
2028                exists: false,
2029                size: 0,
2030            },
2031        )]
2032        .into_iter()
2033        .collect();
2034        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2035        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2036        assert_eq!(plan.downloads(), 1);
2037    }
2038
2039    #[test]
2040    fn absent_local_probe_treated_as_missing() {
2041        // A manifest clip with no probe entry is conservatively re-downloaded.
2042        let mut manifest = Manifest::new();
2043        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2044        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2045        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2046        assert_eq!(plan.downloads(), 1);
2047    }
2048
2049    #[test]
2050    fn missing_file_download_wins_over_format_difference() {
2051        // A missing file is re-downloaded directly in the desired format rather
2052        // than reformatted from a file that is not there.
2053        let mut manifest = Manifest::new();
2054        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2055        let local: HashMap<String, LocalFile> = [(
2056            "a".to_string(),
2057            LocalFile {
2058                exists: false,
2059                size: 0,
2060            },
2061        )]
2062        .into_iter()
2063        .collect();
2064        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2065        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2066        assert_eq!(plan.downloads(), 1);
2067        assert_eq!(plan.reformats(), 0);
2068    }
2069
2070    // ── SYNC-12: trashed and private ────────────────────────────────
2071
2072    #[test]
2073    fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2074        // A trashed clip is complete and carries no excluded type or task, so it
2075        // passes `is_downloadable` (downloadability never screens on trashed).
2076        // A full run still schedules its deletion, proving the two concerns stay
2077        // decoupled: the download filter does not suppress the delete signal.
2078        let mut trashed = clip("a");
2079        trashed.status = "complete".to_string();
2080        trashed.is_trashed = true;
2081        assert!(crate::is_downloadable(&trashed));
2082
2083        let mut manifest = Manifest::new();
2084        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2085        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2086        d.clip = trashed;
2087        d.trashed = true;
2088        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2089        assert_eq!(
2090            plan.actions,
2091            vec![Action::Delete {
2092                path: "a.flac".to_string(),
2093                clip_id: "a".to_string(),
2094            }]
2095        );
2096    }
2097
2098    #[test]
2099    fn trashed_clip_deletes_local_file() {
2100        let mut manifest = Manifest::new();
2101        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2102        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2103        d.trashed = true;
2104        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2105        assert_eq!(
2106            plan.actions,
2107            vec![Action::Delete {
2108                path: "a.flac".to_string(),
2109                clip_id: "a".to_string(),
2110            }]
2111        );
2112    }
2113
2114    #[test]
2115    fn trashed_clip_not_in_manifest_skips() {
2116        // Nothing on disk to remove, so trashing is a no-op.
2117        let manifest = Manifest::new();
2118        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2119        d.trashed = true;
2120        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2121        assert_eq!(
2122            plan.actions,
2123            vec![Action::Skip {
2124                clip_id: "a".to_string()
2125            }]
2126        );
2127    }
2128
2129    #[test]
2130    fn private_clip_is_kept() {
2131        let mut manifest = Manifest::new();
2132        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2133        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2134        d.private = true;
2135        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2136        assert_eq!(
2137            plan.actions,
2138            vec![Action::Skip {
2139                clip_id: "a".to_string()
2140            }]
2141        );
2142    }
2143
2144    #[test]
2145    fn private_beats_trashed_never_deletes() {
2146        // Safety first: a clip that is both trashed and private is kept.
2147        let mut manifest = Manifest::new();
2148        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2149        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2150        d.trashed = true;
2151        d.private = true;
2152        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2153        assert_eq!(plan.deletes(), 0);
2154        assert_eq!(plan.skips(), 1);
2155    }
2156
2157    #[test]
2158    fn copy_held_trashed_clip_is_not_deleted() {
2159        // SYNC-8: copy always wins, so a trashed clip still held by a copy
2160        // source is kept and synced rather than deleted.
2161        let mut manifest = Manifest::new();
2162        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2163        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2164        d.modes = vec![SourceMode::Copy];
2165        d.trashed = true;
2166        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2167        assert_eq!(plan.deletes(), 0);
2168        assert_eq!(
2169            plan.actions,
2170            vec![Action::Skip {
2171                clip_id: "a".to_string()
2172            }]
2173        );
2174    }
2175
2176    // ── Deletion pass: absent manifest entries ──────────────────────
2177
2178    #[test]
2179    fn absent_clip_deleted_when_all_mirrors_enumerated() {
2180        let mut manifest = Manifest::new();
2181        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2182        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2183        assert_eq!(
2184            plan.actions,
2185            vec![Action::Delete {
2186                path: "gone.flac".to_string(),
2187                clip_id: "gone".to_string(),
2188            }]
2189        );
2190    }
2191
2192    #[test]
2193    fn absent_clip_kept_when_any_mirror_not_enumerated() {
2194        let mut manifest = Manifest::new();
2195        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2196        let sources = vec![
2197            SourceStatus {
2198                mode: SourceMode::Mirror,
2199                fully_enumerated: true,
2200            },
2201            SourceStatus {
2202                mode: SourceMode::Mirror,
2203                fully_enumerated: false,
2204            },
2205        ];
2206        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2207        assert_eq!(plan.deletes(), 0);
2208        assert_eq!(
2209            plan.actions,
2210            vec![Action::Skip {
2211                clip_id: "gone".to_string()
2212            }]
2213        );
2214    }
2215
2216    #[test]
2217    fn empty_listing_cannot_cause_deletion() {
2218        // A failed or truncated listing presents as a not-fully-enumerated
2219        // mirror source: absence must never delete in that case.
2220        let mut manifest = Manifest::new();
2221        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2222        let sources = vec![SourceStatus {
2223            mode: SourceMode::Mirror,
2224            fully_enumerated: false,
2225        }];
2226        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2227        assert_eq!(plan.deletes(), 0);
2228        assert_eq!(plan.skips(), 1);
2229    }
2230
2231    #[test]
2232    fn no_mirror_sources_means_no_deletion() {
2233        // Copy-only or sourceless runs are additive: nothing is deleted.
2234        let mut manifest = Manifest::new();
2235        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2236        let copy_only = vec![SourceStatus {
2237            mode: SourceMode::Copy,
2238            fully_enumerated: true,
2239        }];
2240        assert_eq!(
2241            reconcile(&manifest, &[], &HashMap::new(), &copy_only).deletes(),
2242            0
2243        );
2244        assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2245    }
2246
2247    #[test]
2248    fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2249        let mut manifest = Manifest::new();
2250        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2251        let sources = vec![
2252            SourceStatus {
2253                mode: SourceMode::Copy,
2254                fully_enumerated: true,
2255            },
2256            SourceStatus {
2257                mode: SourceMode::Mirror,
2258                fully_enumerated: false,
2259            },
2260        ];
2261        assert_eq!(
2262            reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2263            0
2264        );
2265    }
2266
2267    #[test]
2268    fn playlist_authoritative_requires_all_conditions() {
2269        // All three conditions satisfied: authoritative.
2270        assert!(playlist_authoritative(true, false, false));
2271        // Incomplete page drain: not authoritative.
2272        assert!(!playlist_authoritative(false, false, false));
2273        // A member lost to the downloadable filter: not authoritative.
2274        assert!(!playlist_authoritative(true, true, false));
2275        // Narrowed with --limit/--since: not authoritative.
2276        assert!(!playlist_authoritative(true, false, true));
2277        // Multiple conditions: any failure disarms.
2278        assert!(!playlist_authoritative(false, true, true));
2279    }
2280
2281    #[test]
2282    fn area_fully_enumerated_applies_empty_mirror_guard() {
2283        // A non-empty Mirror that fully listed is authoritative.
2284        assert!(area_fully_enumerated(true, false, SourceMode::Mirror));
2285        // An empty Mirror is never authoritative (indistinguishable from a drop).
2286        assert!(!area_fully_enumerated(true, true, SourceMode::Mirror));
2287        // An empty Copy is still authoritative (it protects nothing).
2288        assert!(area_fully_enumerated(true, true, SourceMode::Copy));
2289        // A non-empty Copy is authoritative.
2290        assert!(area_fully_enumerated(true, false, SourceMode::Copy));
2291        // A non-authoritative (narrowed/incomplete) area is not enumerated regardless.
2292        assert!(!area_fully_enumerated(false, false, SourceMode::Mirror));
2293        assert!(!area_fully_enumerated(false, true, SourceMode::Copy));
2294    }
2295
2296    #[test]
2297    fn narrows_downloads_only_when_no_deletion_and_no_full_library() {
2298        // Neither deleting nor a full library: narrowing is allowed.
2299        assert!(narrows_downloads(false, false));
2300        // Armed deletion: narrowing must not occur (D2).
2301        assert!(!narrows_downloads(true, false));
2302        // Full library listed: narrowing regresses the index.
2303        assert!(!narrows_downloads(false, true));
2304        // Both: definitely no narrowing.
2305        assert!(!narrows_downloads(true, true));
2306    }
2307
2308    #[test]
2309    fn narrowing_never_coexists_with_deletion() {
2310        for can_delete in [false, true] {
2311            for lib_auth in [false, true] {
2312                assert!(
2313                    !(narrows_downloads(can_delete, lib_auth) && can_delete),
2314                    "truncate must imply !can_delete"
2315                );
2316            }
2317        }
2318    }
2319
2320    #[test]
2321    fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2322        // SYNC-8 falls out naturally: a copy-held clip is in the desired set,
2323        // so it is classified there (Skip) and never reaches the delete pass,
2324        // even while a sibling clip is being deleted.
2325        let mut manifest = Manifest::new();
2326        manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2327        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2328        let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2329        held.modes = vec![SourceMode::Copy];
2330        let local: HashMap<String, LocalFile> = [
2331            ("keep".to_string(), present(100)),
2332            ("gone".to_string(), present(100)),
2333        ]
2334        .into_iter()
2335        .collect();
2336        let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2337        assert!(plan.actions.contains(&Action::Skip {
2338            clip_id: "keep".to_string()
2339        }));
2340        assert!(plan.actions.contains(&Action::Delete {
2341            path: "gone.flac".to_string(),
2342            clip_id: "gone".to_string(),
2343        }));
2344        // The copy-held clip is never deleted.
2345        assert!(
2346            !plan
2347                .actions
2348                .iter()
2349                .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2350        );
2351    }
2352
2353    // ── Item 1: persisted preserve marker ───────────────────────────
2354
2355    #[test]
2356    fn orphan_with_preserve_marker_is_kept() {
2357        // A copy-held or private clip whose source was deselected is absent from
2358        // desired, but the persisted marker still protects it from deletion.
2359        let mut manifest = Manifest::new();
2360        manifest.insert(
2361            "gone",
2362            preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2363        );
2364        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2365        assert_eq!(plan.deletes(), 0);
2366        assert_eq!(
2367            plan.actions,
2368            vec![Action::Skip {
2369                clip_id: "gone".to_string()
2370            }]
2371        );
2372    }
2373
2374    #[test]
2375    fn trashed_clip_with_preserve_marker_is_kept() {
2376        // The marker also defends the trashed path: a preserved entry is never
2377        // deleted even when the clip is trashed and fully enumerated.
2378        let mut manifest = Manifest::new();
2379        manifest.insert(
2380            "a",
2381            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2382        );
2383        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2384        d.trashed = true;
2385        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2386        assert_eq!(plan.deletes(), 0);
2387        assert_eq!(plan.skips(), 1);
2388    }
2389
2390    // ── Item 2: unified, enumeration-gated delete guard ─────────────
2391
2392    #[test]
2393    fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2394        // The trashed path now obeys the same enumeration guard as orphans.
2395        let mut manifest = Manifest::new();
2396        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2397        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2398        d.trashed = true;
2399        let sources = vec![SourceStatus {
2400            mode: SourceMode::Mirror,
2401            fully_enumerated: false,
2402        }];
2403        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2404        assert_eq!(plan.deletes(), 0);
2405        assert_eq!(plan.skips(), 1);
2406    }
2407
2408    #[test]
2409    fn trashed_clip_kept_when_sources_empty() {
2410        // With no sources there is no authoritative listing, so even a trashed
2411        // clip is kept rather than deleted.
2412        let mut manifest = Manifest::new();
2413        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2414        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2415        d.trashed = true;
2416        let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2417        assert_eq!(plan.deletes(), 0);
2418        assert_eq!(plan.skips(), 1);
2419    }
2420
2421    #[test]
2422    fn failed_copy_listing_suppresses_orphan_deletion() {
2423        // A partial or failed copy listing is as unreliable as a mirror one and
2424        // must suppress deletes, even with a fully enumerated mirror present.
2425        let mut manifest = Manifest::new();
2426        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2427        let sources = vec![
2428            SourceStatus {
2429                mode: SourceMode::Mirror,
2430                fully_enumerated: true,
2431            },
2432            SourceStatus {
2433                mode: SourceMode::Copy,
2434                fully_enumerated: false,
2435            },
2436        ];
2437        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2438        assert_eq!(plan.deletes(), 0);
2439    }
2440
2441    #[test]
2442    fn failed_copy_listing_suppresses_trashed_deletion() {
2443        let mut manifest = Manifest::new();
2444        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2445        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2446        d.trashed = true;
2447        let sources = vec![
2448            SourceStatus {
2449                mode: SourceMode::Mirror,
2450                fully_enumerated: true,
2451            },
2452            SourceStatus {
2453                mode: SourceMode::Copy,
2454                fully_enumerated: false,
2455            },
2456        ];
2457        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2458        assert_eq!(plan.deletes(), 0);
2459        assert_eq!(plan.skips(), 1);
2460    }
2461
2462    #[test]
2463    fn empty_path_entry_never_deletes() {
2464        // A default or partially written manifest entry can have an empty path;
2465        // that must never become a Delete of the account root.
2466        let mut manifest = Manifest::new();
2467        manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2468        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2469        assert_eq!(plan.deletes(), 0);
2470        assert_eq!(
2471            plan.actions,
2472            vec![Action::Skip {
2473                clip_id: "gone".to_string()
2474            }]
2475        );
2476    }
2477
2478    // ── Item 3: path aliasing suppression ───────────────────────────
2479
2480    #[test]
2481    fn delete_suppressed_when_path_aliases_rename_target() {
2482        // Clip "a" renames into the path that absent clip "b" recorded; deleting
2483        // "b" would clobber the file "a" was just moved to, so it is suppressed.
2484        let mut manifest = Manifest::new();
2485        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2486        manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2487        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2488        let local: HashMap<String, LocalFile> = [
2489            ("a".to_string(), present(100)),
2490            ("b".to_string(), present(100)),
2491        ]
2492        .into_iter()
2493        .collect();
2494        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2495        assert!(plan.actions.contains(&Action::Rename {
2496            from: "old/a.flac".to_string(),
2497            to: "new/a.flac".to_string(),
2498        }));
2499        // No delete targets the renamed-to path.
2500        assert!(
2501            !plan
2502                .actions
2503                .iter()
2504                .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2505        );
2506        assert!(plan.actions.contains(&Action::Skip {
2507            clip_id: "b".to_string()
2508        }));
2509    }
2510
2511    #[test]
2512    fn delete_suppressed_when_path_aliases_download_target() {
2513        // A new clip downloads to the path an absent clip recorded.
2514        let mut manifest = Manifest::new();
2515        manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2516        let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2517        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2518        assert!(
2519            !plan
2520                .actions
2521                .iter()
2522                .any(|a| matches!(a, Action::Delete { .. }))
2523        );
2524        assert_eq!(plan.downloads(), 1);
2525    }
2526
2527    #[test]
2528    fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2529        // A sidecar delete must never clobber a file a rename just produced this
2530        // run. A DeleteArtifact whose path equals a Rename's `to` is downgraded
2531        // to a Skip, exactly as an audio Delete is. Built directly so the
2532        // collision is explicit and independent of how reconcile derives it.
2533        let mut actions = vec![
2534            Action::Rename {
2535                from: "old/song.flac".to_string(),
2536                to: "new/cover.jpg".to_string(),
2537            },
2538            Action::DeleteArtifact {
2539                kind: ArtifactKind::CoverJpg,
2540                path: "new/cover.jpg".to_string(),
2541                owner_id: "a".to_string(),
2542            },
2543        ];
2544        suppress_path_aliasing(&mut actions);
2545        // The colliding delete is gone; only its Skip downgrade remains.
2546        assert!(
2547            !actions
2548                .iter()
2549                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2550            "a sidecar delete must not alias a rename target"
2551        );
2552        assert!(actions.contains(&Action::Skip {
2553            clip_id: "a".to_string()
2554        }));
2555        // The rename target is untouched.
2556        assert!(actions.contains(&Action::Rename {
2557            from: "old/song.flac".to_string(),
2558            to: "new/cover.jpg".to_string(),
2559        }));
2560    }
2561
2562    #[test]
2563    fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2564        // The same guard covers every write class: a DeleteArtifact colliding
2565        // with another artifact's WriteArtifact path is downgraded too.
2566        let mut actions = vec![
2567            Action::WriteArtifact {
2568                kind: ArtifactKind::FolderJpg,
2569                path: "creator/album/folder.jpg".to_string(),
2570                source_url: "https://art/large.jpg".to_string(),
2571                hash: "h".to_string(),
2572                owner_id: "root".to_string(),
2573                content: None,
2574            },
2575            Action::DeleteArtifact {
2576                kind: ArtifactKind::FolderJpg,
2577                path: "creator/album/folder.jpg".to_string(),
2578                owner_id: "root-old".to_string(),
2579            },
2580        ];
2581        suppress_path_aliasing(&mut actions);
2582        assert!(
2583            !actions
2584                .iter()
2585                .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2586        );
2587        assert!(actions.contains(&Action::Skip {
2588            clip_id: "root-old".to_string()
2589        }));
2590    }
2591
2592    // ── Item 5: aggregation of duplicate desired ids ────────────────
2593
2594    #[test]
2595    fn duplicate_trashed_does_not_defeat_copy_sibling() {
2596        // The same clip held by a copy source and reported trashed by a mirror:
2597        // copy wins, so it is kept, not deleted.
2598        let mut manifest = Manifest::new();
2599        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2600        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2601        copy_entry.modes = vec![SourceMode::Copy];
2602        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2603        trashed_entry.modes = vec![SourceMode::Mirror];
2604        trashed_entry.trashed = true;
2605        let plan = reconcile(
2606            &manifest,
2607            &[copy_entry, trashed_entry],
2608            &local_present("a"),
2609            &mirror_ok(),
2610        );
2611        assert_eq!(plan.deletes(), 0);
2612        assert_eq!(plan.skips(), 1);
2613    }
2614
2615    #[test]
2616    fn duplicate_trashed_does_not_defeat_private_sibling() {
2617        let mut manifest = Manifest::new();
2618        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2619        let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2620        private_entry.private = true;
2621        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2622        trashed_entry.trashed = true;
2623        let plan = reconcile(
2624            &manifest,
2625            &[private_entry, trashed_entry],
2626            &local_present("a"),
2627            &mirror_ok(),
2628        );
2629        assert_eq!(plan.deletes(), 0);
2630        assert_eq!(plan.skips(), 1);
2631    }
2632
2633    #[test]
2634    fn duplicate_trashed_deletes_only_when_all_trashed() {
2635        // Every duplicate trashed and unprotected: a single delete results.
2636        let mut manifest = Manifest::new();
2637        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2638        let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2639        first.trashed = true;
2640        let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2641        second.trashed = true;
2642        let plan = reconcile(
2643            &manifest,
2644            &[first, second],
2645            &local_present("a"),
2646            &mirror_ok(),
2647        );
2648        assert_eq!(plan.deletes(), 1);
2649    }
2650
2651    #[test]
2652    fn duplicate_desired_unions_modes() {
2653        // Mirror and copy entries for one id aggregate to a copy-held clip.
2654        let mut manifest = Manifest::new();
2655        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2656        let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2657        mirror_entry.modes = vec![SourceMode::Mirror];
2658        mirror_entry.trashed = true;
2659        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2660        copy_entry.modes = vec![SourceMode::Copy];
2661        let plan = reconcile(
2662            &manifest,
2663            &[mirror_entry, copy_entry],
2664            &local_present("a"),
2665            &mirror_ok(),
2666        );
2667        // Copy-held wins over the trashed mirror entry, so no delete.
2668        assert_eq!(plan.deletes(), 0);
2669    }
2670
2671    // ── Item 6: private is deletion-exempt only ─────────────────────
2672
2673    #[test]
2674    fn private_new_clip_downloads() {
2675        // Private no longer short-circuits to Skip: a missing private clip is
2676        // downloaded like any other.
2677        let manifest = Manifest::new();
2678        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2679        d.private = true;
2680        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2681        assert_eq!(plan.downloads(), 1);
2682    }
2683
2684    #[test]
2685    fn private_zero_length_file_redownloads() {
2686        let mut manifest = Manifest::new();
2687        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2688        let local: HashMap<String, LocalFile> = [(
2689            "a".to_string(),
2690            LocalFile {
2691                exists: true,
2692                size: 0,
2693            },
2694        )]
2695        .into_iter()
2696        .collect();
2697        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2698        d.private = true;
2699        let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2700        assert_eq!(plan.downloads(), 1);
2701    }
2702
2703    #[test]
2704    fn private_meta_change_retags() {
2705        let mut manifest = Manifest::new();
2706        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2707        let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2708        d.private = true;
2709        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2710        assert_eq!(plan.retags(), 1);
2711        assert_eq!(plan.deletes(), 0);
2712    }
2713
2714    #[test]
2715    fn absent_private_clip_protected_by_preserve_marker() {
2716        // Items 1 and 6 together: a private clip deselected from the run is
2717        // absent from desired, but its preserve marker keeps it across runs.
2718        let mut manifest = Manifest::new();
2719        manifest.insert(
2720            "a",
2721            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2722        );
2723        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2724        assert_eq!(plan.deletes(), 0);
2725        assert_eq!(plan.skips(), 1);
2726    }
2727
2728    // ── Determinism and robustness ──────────────────────────────────
2729
2730    #[test]
2731    fn output_is_deterministic_regardless_of_input_order() {
2732        let mut manifest = Manifest::new();
2733        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2734        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2735        manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2736        let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2737            .iter()
2738            .map(|id| (id.to_string(), present(100)))
2739            .collect();
2740
2741        let forward = vec![
2742            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2743            desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2744            desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2745        ];
2746        let mut reversed = forward.clone();
2747        reversed.reverse();
2748
2749        let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2750        let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2751        assert_eq!(p1.actions, p2.actions);
2752
2753        // And the order is clip-id sorted: a (skip), b (retag), c (download),
2754        // then absent z (delete).
2755        let ids: Vec<&str> = p1
2756            .actions
2757            .iter()
2758            .map(|a| match a {
2759                Action::Skip { clip_id } => clip_id.as_str(),
2760                Action::Retag { clip, .. } => clip.id.as_str(),
2761                Action::Download { clip, .. } => clip.id.as_str(),
2762                Action::Delete { clip_id, .. } => clip_id.as_str(),
2763                Action::Reformat { clip, .. } => clip.id.as_str(),
2764                Action::Rename { to, .. } => to.as_str(),
2765                Action::WriteArtifact { owner_id, .. }
2766                | Action::DeleteArtifact { owner_id, .. }
2767                | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2768                Action::WriteStem { clip_id, .. }
2769                | Action::DeleteStem { clip_id, .. }
2770                | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2771            })
2772            .collect();
2773        assert_eq!(ids, ["a", "b", "c", "z"]);
2774    }
2775
2776    #[test]
2777    fn empty_inputs_do_not_panic() {
2778        let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2779        assert!(plan.is_empty());
2780        assert_eq!(plan.len(), 0);
2781    }
2782
2783    #[test]
2784    fn empty_desired_with_full_manifest_deletes_all() {
2785        let mut manifest = Manifest::new();
2786        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2787        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2788        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2789        assert_eq!(plan.deletes(), 2);
2790    }
2791
2792    #[test]
2793    fn full_desired_with_empty_manifest_downloads_all() {
2794        let d = vec![
2795            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2796            desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2797        ];
2798        let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2799        assert_eq!(plan.downloads(), 2);
2800    }
2801
2802    #[test]
2803    fn plan_counts_sum_to_len() {
2804        let mut manifest = Manifest::new();
2805        manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2806        manifest.insert(
2807            "retag",
2808            entry("retag.flac", AudioFormat::Flac, "old", "art"),
2809        );
2810        manifest.insert(
2811            "reformat",
2812            entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2813        );
2814        manifest.insert(
2815            "rename",
2816            entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2817        );
2818        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2819        let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2820            .iter()
2821            .map(|id| (id.to_string(), present(100)))
2822            .collect();
2823        let d = vec![
2824            desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2825            desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2826            desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2827            desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2828            desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2829        ];
2830        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2831        let summed = plan.downloads()
2832            + plan.reformats()
2833            + plan.retags()
2834            + plan.renames()
2835            + plan.deletes()
2836            + plan.skips();
2837        assert_eq!(summed, plan.len());
2838        assert_eq!(plan.downloads(), 1);
2839        assert_eq!(plan.reformats(), 1);
2840        assert_eq!(plan.retags(), 1);
2841        assert_eq!(plan.renames(), 1);
2842        assert_eq!(plan.deletes(), 1);
2843        assert_eq!(plan.skips(), 1);
2844    }
2845
2846    // ── Phase 6: artifact reconcile ─────────────────────────────────
2847
2848    fn cover(path: &str, hash: &str) -> ArtifactState {
2849        ArtifactState {
2850            path: path.to_string(),
2851            hash: hash.to_string(),
2852        }
2853    }
2854
2855    fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2856        DesiredArtifact {
2857            kind,
2858            path: path.to_string(),
2859            source_url: url.to_string(),
2860            hash: hash.to_string(),
2861            content: None,
2862        }
2863    }
2864
2865    /// A generated text sidecar desired artifact carrying its body inline.
2866    fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2867        DesiredArtifact {
2868            kind,
2869            path: path.to_string(),
2870            source_url: String::new(),
2871            hash: content_hash(body),
2872            content: Some(body.to_string()),
2873        }
2874    }
2875
2876    // An unchanged FLAC clip (Skip audio) that desires the given artifacts.
2877    fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2878        Desired {
2879            artifacts: arts,
2880            ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2881        }
2882    }
2883
2884    // A manifest entry for an unchanged FLAC clip carrying a cover.jpg sidecar.
2885    fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2886        ManifestEntry {
2887            cover_jpg: Some(cover(cover_path, cover_hash)),
2888            ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2889        }
2890    }
2891
2892    fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2893        plan.actions
2894            .iter()
2895            .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2896            .collect()
2897    }
2898
2899    #[test]
2900    fn write_artifact_emitted_when_manifest_lacks_it() {
2901        // The clip's audio is unchanged (Skip), but the manifest has no cover.jpg
2902        // slot, so the desired sidecar is written.
2903        let mut manifest = Manifest::new();
2904        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2905        let d = vec![desired_arts(
2906            "a",
2907            vec![art(
2908                ArtifactKind::CoverJpg,
2909                "a/cover.jpg",
2910                "https://art/a",
2911                "h1",
2912            )],
2913        )];
2914        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2915        assert_eq!(plan.artifact_writes(), 1);
2916        assert_eq!(plan.artifact_deletes(), 0);
2917        assert_eq!(plan.skips(), 1);
2918        assert_eq!(
2919            write_artifacts(&plan)[0],
2920            &Action::WriteArtifact {
2921                kind: ArtifactKind::CoverJpg,
2922                path: "a/cover.jpg".to_string(),
2923                source_url: "https://art/a".to_string(),
2924                hash: "h1".to_string(),
2925                owner_id: "a".to_string(),
2926                content: None,
2927            }
2928        );
2929    }
2930
2931    #[test]
2932    fn write_artifact_emitted_when_hash_differs() {
2933        // The manifest already tracks a cover.jpg, but its stored hash differs
2934        // from the desired one, so it is rewritten (and never delete-reconciled).
2935        let mut manifest = Manifest::new();
2936        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2937        let d = vec![desired_arts(
2938            "a",
2939            vec![art(
2940                ArtifactKind::CoverJpg,
2941                "a/cover.jpg",
2942                "https://art/a",
2943                "new",
2944            )],
2945        )];
2946        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2947        assert_eq!(plan.artifact_writes(), 1);
2948        assert_eq!(plan.artifact_deletes(), 0);
2949        if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2950            assert_eq!(hash, "new");
2951        } else {
2952            panic!("expected a WriteArtifact");
2953        }
2954    }
2955
2956    #[test]
2957    fn write_artifact_skipped_when_hash_matches() {
2958        // Present with a matching hash: no write, no delete.
2959        let mut manifest = Manifest::new();
2960        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2961        let d = vec![desired_arts(
2962            "a",
2963            vec![art(
2964                ArtifactKind::CoverJpg,
2965                "a/cover.jpg",
2966                "https://art/a",
2967                "h1",
2968            )],
2969        )];
2970        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2971        assert_eq!(plan.artifact_writes(), 0);
2972        assert_eq!(plan.artifact_deletes(), 0);
2973        assert_eq!(
2974            plan.actions,
2975            vec![Action::Skip {
2976                clip_id: "a".to_string()
2977            }]
2978        );
2979    }
2980
2981    #[test]
2982    fn removed_kind_cover_is_kept_not_deleted() {
2983        // The clip is kept but no longer desires a cover.jpg (an empty/transient
2984        // art URL this run). Covers opt out of removed-kind deletion, so the
2985        // existing sidecar is KEPT: no DeleteArtifact, no write, just a Skip.
2986        // This is the empty-art-URL keep the P6 review deferred to P7.
2987        let mut manifest = Manifest::new();
2988        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2989        let d = vec![desired_arts("a", vec![])];
2990        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2991        assert_eq!(plan.artifact_deletes(), 0);
2992        assert_eq!(plan.artifact_writes(), 0);
2993        // The audio is untouched and the cover is preserved on disk.
2994        assert_eq!(plan.deletes(), 0);
2995        assert_eq!(
2996            plan.actions,
2997            vec![Action::Skip {
2998                clip_id: "a".to_string()
2999            }]
3000        );
3001        assert!(!plan.actions.iter().any(|a| matches!(
3002            a,
3003            Action::DeleteArtifact {
3004                kind: ArtifactKind::CoverJpg,
3005                ..
3006            }
3007        )));
3008    }
3009
3010    #[test]
3011    fn delete_artifact_never_on_incomplete_listing() {
3012        // Kept clips no longer desiring their covers keep them: covers opt out of
3013        // removed-kind deletion. An incomplete mirror is a further backstop that
3014        // forbids every delete (the B2 gate on the co-delete path). Either way, a
3015        // large manifest of stale sidecars is safe.
3016        let mut manifest = Manifest::new();
3017        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3018        manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
3019        let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
3020        let sources = vec![SourceStatus {
3021            mode: SourceMode::Mirror,
3022            fully_enumerated: false,
3023        }];
3024        let local: HashMap<String, LocalFile> = [
3025            ("a".to_string(), present(100)),
3026            ("b".to_string(), present(100)),
3027        ]
3028        .into_iter()
3029        .collect();
3030        let plan = reconcile(&manifest, &d, &local, &sources);
3031        assert_eq!(plan.artifact_deletes(), 0);
3032        assert_eq!(plan.deletes(), 0);
3033    }
3034
3035    #[test]
3036    fn delete_artifact_never_when_entry_preserved() {
3037        // A kept clip that stops desiring its cover keeps it (covers opt out of
3038        // removed-kind deletion); the preserve marker is a further backstop.
3039        let mut manifest = Manifest::new();
3040        let preserved = ManifestEntry {
3041            preserve: true,
3042            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3043        };
3044        manifest.insert("a", preserved);
3045        let d = vec![desired_arts("a", vec![])];
3046        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3047        assert_eq!(plan.artifact_deletes(), 0);
3048    }
3049
3050    #[test]
3051    fn co_delete_never_when_path_empty() {
3052        // The empty-path guard now matters on the co-delete path (covers opt out
3053        // of removed-kind deletion). An absent clip's audio is deleted, but its
3054        // sidecar with an empty path must never become a delete of the root.
3055        let mut manifest = Manifest::new();
3056        manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
3057        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3058        assert_eq!(plan.deletes(), 1);
3059        assert_eq!(plan.artifact_deletes(), 0);
3060    }
3061
3062    #[test]
3063    fn co_delete_absent_clip_deletes_audio_and_cover() {
3064        // A clip absent from desired is deleted; its cover.jpg is co-deleted
3065        // under the same gate.
3066        let mut manifest = Manifest::new();
3067        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3068        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3069        assert_eq!(plan.deletes(), 1);
3070        assert_eq!(plan.artifact_deletes(), 1);
3071        assert!(plan.actions.contains(&Action::Delete {
3072            path: "gone.flac".to_string(),
3073            clip_id: "gone".to_string(),
3074        }));
3075        assert!(plan.actions.contains(&Action::DeleteArtifact {
3076            kind: ArtifactKind::CoverJpg,
3077            path: "gone/cover.jpg".to_string(),
3078            owner_id: "gone".to_string(),
3079        }));
3080    }
3081
3082    #[test]
3083    fn co_delete_absent_clip_suppressed_when_not_enumerated() {
3084        // Neither audio nor sidecar is removed on an incomplete listing.
3085        let mut manifest = Manifest::new();
3086        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3087        let sources = vec![SourceStatus {
3088            mode: SourceMode::Mirror,
3089            fully_enumerated: false,
3090        }];
3091        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
3092        assert_eq!(plan.deletes(), 0);
3093        assert_eq!(plan.artifact_deletes(), 0);
3094    }
3095
3096    #[test]
3097    fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
3098        // A trashed clip present in desired: audio Delete plus cover co-delete.
3099        let mut manifest = Manifest::new();
3100        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3101        let mut d = desired_arts("a", vec![]);
3102        d.trashed = true;
3103        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3104        assert_eq!(plan.deletes(), 1);
3105        assert_eq!(plan.artifact_deletes(), 1);
3106    }
3107
3108    #[test]
3109    fn co_delete_trashed_suppressed_when_not_enumerated() {
3110        // The trashed co-delete obeys the same enumeration gate as the audio.
3111        let mut manifest = Manifest::new();
3112        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3113        let mut d = desired_arts("a", vec![]);
3114        d.trashed = true;
3115        let sources = vec![SourceStatus {
3116            mode: SourceMode::Mirror,
3117            fully_enumerated: false,
3118        }];
3119        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
3120        assert_eq!(plan.deletes(), 0);
3121        assert_eq!(plan.artifact_deletes(), 0);
3122        assert_eq!(plan.skips(), 1);
3123    }
3124
3125    #[test]
3126    fn co_delete_trashed_suppressed_when_preserved() {
3127        // A preserved, trashed clip keeps both audio and sidecar.
3128        let mut manifest = Manifest::new();
3129        let preserved = ManifestEntry {
3130            preserve: true,
3131            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3132        };
3133        manifest.insert("a", preserved);
3134        let mut d = desired_arts("a", vec![]);
3135        d.trashed = true;
3136        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3137        assert_eq!(plan.deletes(), 0);
3138        assert_eq!(plan.artifact_deletes(), 0);
3139    }
3140
3141    // ── Issue #15: per-song text sidecars ───────────────────────────
3142
3143    #[test]
3144    fn details_sidecar_written_with_inline_content_when_slot_absent() {
3145        // The audio is unchanged (Skip) but no details slot exists, so the
3146        // generated sidecar is written and carries its body inline.
3147        let mut manifest = Manifest::new();
3148        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3149        let d = vec![desired_arts(
3150            "a",
3151            vec![text_art(
3152                ArtifactKind::DetailsTxt,
3153                "a.details.txt",
3154                "Title: A\n",
3155            )],
3156        )];
3157        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3158        assert_eq!(plan.artifact_writes(), 1);
3159        assert_eq!(plan.artifact_deletes(), 0);
3160        assert_eq!(
3161            write_artifacts(&plan)[0],
3162            &Action::WriteArtifact {
3163                kind: ArtifactKind::DetailsTxt,
3164                path: "a.details.txt".to_string(),
3165                source_url: String::new(),
3166                hash: content_hash("Title: A\n"),
3167                owner_id: "a".to_string(),
3168                content: Some("Title: A\n".to_string()),
3169            }
3170        );
3171    }
3172
3173    #[test]
3174    fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3175        // The audio is unchanged (Skip) but no lrc slot exists, so the generated
3176        // sidecar is written and carries its body inline. This is the guard that
3177        // the type system cannot provide: dropping Lrc from is_per_clip_kind
3178        // would silently never write the file, and only this test would catch it.
3179        let mut manifest = Manifest::new();
3180        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3181        let body = "[re:rs-suno]\nla la\n";
3182        let d = vec![desired_arts(
3183            "a",
3184            vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3185        )];
3186        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3187        assert_eq!(plan.artifact_writes(), 1);
3188        assert_eq!(plan.artifact_deletes(), 0);
3189        assert_eq!(
3190            write_artifacts(&plan)[0],
3191            &Action::WriteArtifact {
3192                kind: ArtifactKind::Lrc,
3193                path: "a.lrc".to_string(),
3194                source_url: String::new(),
3195                hash: content_hash(body),
3196                owner_id: "a".to_string(),
3197                content: Some(body.to_string()),
3198            }
3199        );
3200    }
3201
3202    #[test]
3203    fn text_sidecars_skipped_when_hash_and_path_match() {
3204        // Present with a matching content hash and path: no write, no delete.
3205        let mut manifest = Manifest::new();
3206        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3207        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3208        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3209        manifest.insert("a", e);
3210        let d = vec![desired_arts(
3211            "a",
3212            vec![
3213                text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3214                text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3215            ],
3216        )];
3217        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3218        assert_eq!(plan.artifact_writes(), 0);
3219        assert_eq!(plan.artifact_deletes(), 0);
3220    }
3221
3222    #[test]
3223    fn details_rewritten_when_content_hash_differs() {
3224        // A title change alters the details body, so its content hash drifts and
3225        // the sidecar is rewritten even though the audio is otherwise unchanged.
3226        let mut manifest = Manifest::new();
3227        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3228        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3229        manifest.insert("a", e);
3230        let d = vec![desired_arts(
3231            "a",
3232            vec![text_art(
3233                ArtifactKind::DetailsTxt,
3234                "a.details.txt",
3235                "Title: New\n",
3236            )],
3237        )];
3238        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3239        assert_eq!(plan.artifact_writes(), 1);
3240        assert_eq!(plan.artifact_deletes(), 0);
3241    }
3242
3243    #[test]
3244    fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3245        // The per-sidecar content hash keys on the rendered lyrics independently
3246        // of the audio's stored meta_hash, so editing the sidecar body rewrites
3247        // the file with no audio retag even when the meta_hash slot is unchanged.
3248        let mut manifest = Manifest::new();
3249        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3250        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3251        manifest.insert("a", e);
3252        let d = vec![desired_arts(
3253            "a",
3254            vec![text_art(
3255                ArtifactKind::LyricsTxt,
3256                "a.lyrics.txt",
3257                "new words\n",
3258            )],
3259        )];
3260        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3261        // The audio meta_hash matches ("m"), so only the sidecar rewrites.
3262        assert_eq!(plan.artifact_writes(), 1);
3263        assert_eq!(plan.retags(), 0);
3264    }
3265
3266    #[test]
3267    fn text_sidecar_relocated_when_path_differs() {
3268        // The audio moved (rename), so the tracked details path drifts and the
3269        // sidecar is rewritten at the new path even though the content matches.
3270        let mut manifest = Manifest::new();
3271        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3272        e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3273        manifest.insert("a", e);
3274        let d = vec![desired_arts(
3275            "a",
3276            vec![text_art(
3277                ArtifactKind::DetailsTxt,
3278                "new/a.details.txt",
3279                "Title: A\n",
3280            )],
3281        )];
3282        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3283        assert_eq!(plan.artifact_writes(), 1);
3284        if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3285            assert_eq!(path, "new/a.details.txt");
3286        } else {
3287            panic!("expected a WriteArtifact");
3288        }
3289    }
3290
3291    #[test]
3292    fn fetched_sidecar_path_drift_emits_move() {
3293        // #141: a fetched cover whose bytes are unchanged but whose path drifted
3294        // (a retitle) is relocated with a rename rather than re-fetched.
3295        let mut manifest = Manifest::new();
3296        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3297        e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3298        manifest.insert("a", e);
3299        let d = vec![desired_arts(
3300            "a",
3301            vec![art(
3302                ArtifactKind::CoverJpg,
3303                "new/cover.jpg",
3304                "https://art/large.jpg",
3305                "arthash",
3306            )],
3307        )];
3308        let local: HashMap<String, LocalFile> = [
3309            ("a".to_string(), present(100)),
3310            ("old/cover.jpg".to_string(), present(50)),
3311        ]
3312        .into_iter()
3313        .collect();
3314        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3315        assert_eq!(plan.artifact_moves(), 1);
3316        assert_eq!(plan.artifact_writes(), 0);
3317        assert!(plan.actions.contains(&Action::MoveArtifact {
3318            kind: ArtifactKind::CoverJpg,
3319            from: "old/cover.jpg".to_string(),
3320            to: "new/cover.jpg".to_string(),
3321            source_url: "https://art/large.jpg".to_string(),
3322            hash: "arthash".to_string(),
3323            owner_id: "a".to_string(),
3324        }));
3325    }
3326
3327    #[test]
3328    fn sidecar_hash_drift_emits_write_not_move() {
3329        // Different bytes must re-fetch, even when the path also drifted.
3330        let mut manifest = Manifest::new();
3331        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3332        e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3333        manifest.insert("a", e);
3334        let d = vec![desired_arts(
3335            "a",
3336            vec![art(
3337                ArtifactKind::CoverJpg,
3338                "new/cover.jpg",
3339                "https://art/large.jpg",
3340                "newhash",
3341            )],
3342        )];
3343        let local: HashMap<String, LocalFile> = [
3344            ("a".to_string(), present(100)),
3345            ("old/cover.jpg".to_string(), present(50)),
3346        ]
3347        .into_iter()
3348        .collect();
3349        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3350        assert_eq!(plan.artifact_moves(), 0);
3351        assert_eq!(plan.artifact_writes(), 1);
3352    }
3353
3354    #[test]
3355    fn inline_sidecar_path_drift_stays_a_write() {
3356        // Inline-content kinds (text) rewrite from the in-hand bytes, so a move
3357        // buys nothing: a path drift stays a WriteArtifact even at an equal hash.
3358        let mut manifest = Manifest::new();
3359        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3360        e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3361        manifest.insert("a", e);
3362        let d = vec![desired_arts(
3363            "a",
3364            vec![text_art(
3365                ArtifactKind::LyricsTxt,
3366                "new/a.lyrics.txt",
3367                "words\n",
3368            )],
3369        )];
3370        let local: HashMap<String, LocalFile> = [
3371            ("a".to_string(), present(100)),
3372            ("old/a.lyrics.txt".to_string(), present(50)),
3373        ]
3374        .into_iter()
3375        .collect();
3376        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3377        assert_eq!(plan.artifact_moves(), 0);
3378        assert_eq!(plan.artifact_writes(), 1);
3379    }
3380
3381    #[test]
3382    fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3383        // Same bytes and a path drift, but the old file is gone: fetch fresh at
3384        // the new path (a self-heal), never emit a move that cannot rename.
3385        let mut manifest = Manifest::new();
3386        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3387        e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3388        manifest.insert("a", e);
3389        let d = vec![desired_arts(
3390            "a",
3391            vec![art(
3392                ArtifactKind::CoverJpg,
3393                "new/cover.jpg",
3394                "https://art/large.jpg",
3395                "arthash",
3396            )],
3397        )];
3398        let local: HashMap<String, LocalFile> = [
3399            ("a".to_string(), present(100)),
3400            (
3401                "old/cover.jpg".to_string(),
3402                LocalFile {
3403                    exists: false,
3404                    size: 0,
3405                },
3406            ),
3407        ]
3408        .into_iter()
3409        .collect();
3410        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3411        assert_eq!(plan.artifact_moves(), 0);
3412        assert_eq!(plan.artifact_writes(), 1);
3413    }
3414
3415    #[test]
3416    fn move_target_suppresses_a_colliding_delete() {
3417        // A MoveArtifact to a path another manifest entry is having deleted must
3418        // downgrade that delete, so relocation never clobbers the relocated file.
3419        let mut manifest = Manifest::new();
3420        let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3421        a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3422        manifest.insert("a", a);
3423        // b holds a cover at the path a is moving TO; b's cover is a removed kind
3424        // this run (feature toggled), so it would be delete-reconciled.
3425        let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3426        b.details_txt = Some(cover("new/cover.jpg", "bh"));
3427        manifest.insert("b", b);
3428        let d = vec![
3429            desired_arts(
3430                "a",
3431                vec![art(
3432                    ArtifactKind::CoverJpg,
3433                    "new/cover.jpg",
3434                    "https://art/large.jpg",
3435                    "arthash",
3436                )],
3437            ),
3438            desired_arts("b", vec![]),
3439        ];
3440        let local: HashMap<String, LocalFile> = [
3441            ("a".to_string(), present(100)),
3442            ("b".to_string(), present(100)),
3443            ("old/cover.jpg".to_string(), present(50)),
3444            ("new/cover.jpg".to_string(), present(50)),
3445        ]
3446        .into_iter()
3447        .collect();
3448        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3449        assert_eq!(plan.artifact_moves(), 1);
3450        // The colliding delete of new/cover.jpg is suppressed.
3451        assert!(!plan.actions.iter().any(|a| matches!(
3452            a,
3453            Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3454        )));
3455    }
3456
3457    #[test]
3458    fn stem_path_drift_emits_move() {
3459        // #141: a stem whose path drifts at an equal hash is relocated with a
3460        // rename rather than re-rendered or re-fetched.
3461        let mut manifest = Manifest::new();
3462        manifest.insert(
3463            "a",
3464            entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3465        );
3466        let d = vec![stem_desired(
3467            "a",
3468            Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3469        )];
3470        let local: HashMap<String, LocalFile> = [
3471            ("a".to_string(), present(100)),
3472            ("old.stems/voc.mp3".to_string(), present(50)),
3473        ]
3474        .into_iter()
3475        .collect();
3476        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3477        assert_eq!(plan.stem_moves(), 1);
3478        assert_eq!(plan.stem_writes(), 0);
3479        assert!(plan.actions.contains(&Action::MoveStem {
3480            clip_id: "a".to_string(),
3481            key: "voc".to_string(),
3482            stem_id: "voc".to_string(),
3483            from: "old.stems/voc.mp3".to_string(),
3484            to: "new.stems/voc.mp3".to_string(),
3485            source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3486            format: StemFormat::Mp3,
3487            hash: "h1".to_string(),
3488        }));
3489    }
3490
3491    #[test]
3492    fn details_removed_kind_is_deleted_when_feature_off() {
3493        // DetailsTxt is total, so an absent desired can only mean the feature is
3494        // off: the stale sidecar is delete-reconciled through the shared gate.
3495        let mut manifest = Manifest::new();
3496        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3497        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3498        manifest.insert("a", e);
3499        let d = vec![desired_arts("a", vec![])];
3500        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3501        assert_eq!(plan.artifact_deletes(), 1);
3502        assert!(plan.actions.contains(&Action::DeleteArtifact {
3503            kind: ArtifactKind::DetailsTxt,
3504            path: "a.details.txt".to_string(),
3505            owner_id: "a".to_string(),
3506        }));
3507    }
3508
3509    #[test]
3510    fn lyrics_removed_kind_is_kept_not_deleted() {
3511        // LyricsTxt is partial (absent could be feature-off OR a transient empty
3512        // lyrics read), so it opts out of removed-kind deletion cover-style: the
3513        // existing file is KEPT when no lyrics sidecar is desired this run.
3514        let mut manifest = Manifest::new();
3515        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3516        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3517        manifest.insert("a", e);
3518        let d = vec![desired_arts("a", vec![])];
3519        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3520        assert_eq!(plan.artifact_deletes(), 0);
3521        assert_eq!(plan.deletes(), 0);
3522    }
3523
3524    #[test]
3525    fn lrc_removed_kind_is_kept_not_deleted() {
3526        // Lrc is partial like LyricsTxt, so it opts out of removed-kind deletion:
3527        // an existing `.lrc` is KEPT when no lrc sidecar is desired this run.
3528        let mut manifest = Manifest::new();
3529        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3530        e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3531        manifest.insert("a", e);
3532        let d = vec![desired_arts("a", vec![])];
3533        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3534        assert_eq!(plan.artifact_deletes(), 0);
3535        assert_eq!(plan.deletes(), 0);
3536    }
3537
3538    #[test]
3539    fn video_mp4_removed_kind_is_kept_not_deleted() {
3540        // VideoMp4 opts out of removed-kind deletion like a cover: a large binary
3541        // is never deleted merely because the video feature is off this run (or
3542        // the URL was transiently absent). Only a co-delete removes it.
3543        let mut manifest = Manifest::new();
3544        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3545        e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3546        manifest.insert("a", e);
3547        let d = vec![desired_arts("a", vec![])];
3548        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3549        assert_eq!(plan.artifact_deletes(), 0);
3550        assert_eq!(plan.deletes(), 0);
3551    }
3552
3553    #[test]
3554    fn video_mp4_written_when_manifest_lacks_it() {
3555        // A desired VideoMp4 with no manifest slot is written as a fetched binary
3556        // (no inline content), proving the new kind flows through per-clip planning.
3557        let mut manifest = Manifest::new();
3558        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3559        let d = vec![desired_arts(
3560            "a",
3561            vec![art(
3562                ArtifactKind::VideoMp4,
3563                "a/song.mp4",
3564                "https://cdn/a/video.mp4",
3565                "vid-hash",
3566            )],
3567        )];
3568        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3569        assert_eq!(plan.artifact_writes(), 1);
3570        assert_eq!(
3571            write_artifacts(&plan)[0],
3572            &Action::WriteArtifact {
3573                kind: ArtifactKind::VideoMp4,
3574                path: "a/song.mp4".to_string(),
3575                source_url: "https://cdn/a/video.mp4".to_string(),
3576                hash: "vid-hash".to_string(),
3577                owner_id: "a".to_string(),
3578                content: None,
3579            }
3580        );
3581    }
3582
3583    #[test]
3584    fn details_removed_kind_not_deleted_on_incomplete_listing() {
3585        // The removed-kind delete still obeys the enumeration gate: an incomplete
3586        // mirror forbids removing the stale details sidecar.
3587        let mut manifest = Manifest::new();
3588        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3589        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3590        manifest.insert("a", e);
3591        let d = vec![desired_arts("a", vec![])];
3592        let sources = vec![SourceStatus {
3593            mode: SourceMode::Mirror,
3594            fully_enumerated: false,
3595        }];
3596        let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3597        assert_eq!(plan.artifact_deletes(), 0);
3598    }
3599
3600    #[test]
3601    fn details_removed_kind_not_deleted_when_preserved() {
3602        // A preserved (private/copy-held) clip keeps its stale details sidecar
3603        // even when the feature is off this run.
3604        let mut manifest = Manifest::new();
3605        let mut e = ManifestEntry {
3606            preserve: true,
3607            ..entry("a.flac", AudioFormat::Flac, "m", "art")
3608        };
3609        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3610        manifest.insert("a", e);
3611        let d = vec![desired_arts("a", vec![])];
3612        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3613        assert_eq!(plan.artifact_deletes(), 0);
3614    }
3615
3616    #[test]
3617    fn co_delete_orphan_removes_every_text_sidecar() {
3618        // An orphaned clip's audio is deleted; ALL its per-clip sidecars must be
3619        // co-deleted. This fails if `manifest_artifacts` misses a kind, which
3620        // would strand the file. Guards the single most important #15 wiring.
3621        let mut manifest = Manifest::new();
3622        let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3623        e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3624        e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3625        e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3626        e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3627        e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3628        manifest.insert("gone", e);
3629        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3630        assert_eq!(plan.deletes(), 1);
3631        assert_eq!(plan.artifact_deletes(), 5);
3632        for (kind, path) in [
3633            (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3634            (ArtifactKind::DetailsTxt, "gone.details.txt"),
3635            (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3636            (ArtifactKind::Lrc, "gone.lrc"),
3637            (ArtifactKind::VideoMp4, "gone/song.mp4"),
3638        ] {
3639            assert!(
3640                plan.actions.contains(&Action::DeleteArtifact {
3641                    kind,
3642                    path: path.to_string(),
3643                    owner_id: "gone".to_string(),
3644                }),
3645                "missing co-delete for {kind:?}"
3646            );
3647        }
3648    }
3649
3650    #[test]
3651    fn co_delete_trashed_removes_every_text_sidecar() {
3652        // The same co-delete completeness holds on the trashed path.
3653        let mut manifest = Manifest::new();
3654        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3655        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3656        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3657        manifest.insert("a", e);
3658        let mut d = desired_arts("a", vec![]);
3659        d.trashed = true;
3660        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3661        assert_eq!(plan.deletes(), 1);
3662        assert_eq!(plan.artifact_deletes(), 2);
3663    }
3664
3665    #[test]
3666    fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3667        // Clip "a" writes a cover to the very path clip "b"'s stale cover holds;
3668        // deleting it would clobber the freshly written file, so it is dropped.
3669        let mut manifest = Manifest::new();
3670        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3671        manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3672        // "a" writes a new CoverJpg to the shared path; "b" is absent (its cover
3673        // would be co-deleted from the same path).
3674        let d = vec![desired_arts(
3675            "a",
3676            vec![art(
3677                ArtifactKind::CoverJpg,
3678                "shared/cover.jpg",
3679                "https://art/a",
3680                "h2",
3681            )],
3682        )];
3683        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3684        assert_eq!(plan.artifact_writes(), 1);
3685        // The colliding DeleteArtifact is suppressed.
3686        assert!(!plan.actions.iter().any(
3687            |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3688        ));
3689        // The audio for "b" is still deleted (different path), just not its cover.
3690        assert!(plan.actions.contains(&Action::Delete {
3691            path: "b.flac".to_string(),
3692            clip_id: "b".to_string(),
3693        }));
3694    }
3695
3696    #[test]
3697    fn suppress_downgrades_delete_artifact_colliding_with_download() {
3698        // A fresh clip downloads audio to the path an absent clip's cover holds.
3699        let mut manifest = Manifest::new();
3700        manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3701        let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3702        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3703        assert_eq!(plan.downloads(), 1);
3704        assert!(
3705            !plan
3706                .actions
3707                .iter()
3708                .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3709        );
3710    }
3711
3712    #[test]
3713    fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3714        // SYNC-8/9/10/12 matrix invariance: the audio actions and plan.deletes()
3715        // are identical with and without artifacts attached. One absent clip is
3716        // deleted, one desired clip is kept (Skip), one trashed clip is deleted.
3717        let build = |with_art: bool| {
3718            let mut manifest = Manifest::new();
3719            manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3720            manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3721            manifest.insert(
3722                "trash",
3723                entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3724            );
3725            let keep = if with_art {
3726                desired_arts(
3727                    "keep",
3728                    vec![art(
3729                        ArtifactKind::CoverJpg,
3730                        "keep/cover.jpg",
3731                        "https://art/keep",
3732                        "h1",
3733                    )],
3734                )
3735            } else {
3736                desired_arts("keep", vec![])
3737            };
3738            let mut trash = desired_arts("trash", vec![]);
3739            trash.trashed = true;
3740            let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3741                .iter()
3742                .map(|id| (id.to_string(), present(100)))
3743                .collect();
3744            reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3745        };
3746
3747        let with = build(true);
3748        let without = build(false);
3749
3750        // The audio decisions are identical regardless of artifacts.
3751        let audio = |plan: &Plan| -> Vec<Action> {
3752            plan.actions
3753                .iter()
3754                .filter(|a| {
3755                    !matches!(
3756                        a,
3757                        Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3758                    )
3759                })
3760                .cloned()
3761                .collect()
3762        };
3763        assert_eq!(audio(&with), audio(&without));
3764        assert_eq!(with.deletes(), without.deletes());
3765        // gone + trash audio deletes, unaffected by the artifacts.
3766        assert_eq!(with.deletes(), 2);
3767        // The `with` run additionally reconciles sidecars: gone + trash covers
3768        // co-deleted, and keep's cover matches so it is neither written nor
3769        // deleted.
3770        assert_eq!(with.artifact_deletes(), 2);
3771        assert_eq!(with.artifact_writes(), 0);
3772    }
3773
3774    // ── Phase 6 review fixes: protection, path-drift, kind guard ─────
3775
3776    #[test]
3777    fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3778        // Covers opt out of removed-kind deletion, so a kept clip keeps its cover
3779        // regardless of protection. This case additionally proves protection is
3780        // honoured: a private clip and a copy-held clip each keep a removed-kind
3781        // cover even though the persisted entry is NOT preserve-marked and the
3782        // mirror is fully enumerated.
3783        let mut manifest = Manifest::new();
3784        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3785        assert!(!manifest.get("a").unwrap().preserve);
3786
3787        // Private this run.
3788        let private = Desired {
3789            private: true,
3790            ..desired_arts("a", vec![])
3791        };
3792        let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3793        assert_eq!(plan.artifact_deletes(), 0);
3794
3795        // Copy-held this run (modes contains Copy).
3796        let copy_held = Desired {
3797            modes: vec![SourceMode::Copy],
3798            ..desired_arts("a", vec![])
3799        };
3800        let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3801        assert_eq!(plan.artifact_deletes(), 0);
3802    }
3803
3804    #[test]
3805    fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3806        // The audio moved (new album/name) so the sidecar belongs at a new path;
3807        // the bytes are unchanged (same hash) but a rewrite at the new path is
3808        // still required. Reconcile emits no DeleteArtifact for the old path: the
3809        // executor's WriteArtifact relocates the sidecar (writes new, removes the
3810        // old copy), so the plan stays a single write.
3811        let mut manifest = Manifest::new();
3812        manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3813        let d = vec![desired_arts(
3814            "a",
3815            vec![art(
3816                ArtifactKind::CoverJpg,
3817                "new/cover.jpg",
3818                "https://art/a",
3819                "h1",
3820            )],
3821        )];
3822        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3823        assert_eq!(plan.artifact_writes(), 1);
3824        assert_eq!(plan.artifact_deletes(), 0);
3825        if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3826            assert_eq!(path, "new/cover.jpg");
3827        } else {
3828            panic!("expected a WriteArtifact");
3829        }
3830    }
3831
3832    #[test]
3833    fn needs_write_drift_applies_hash_path_and_probe_rules() {
3834        let local: HashMap<String, LocalFile> = [
3835            ("ok".to_string(), present(10)),
3836            ("missing".to_string(), LocalFile::default()),
3837            ("empty".to_string(), present(0)),
3838        ]
3839        .into_iter()
3840        .collect();
3841
3842        assert!(needs_write_drift(None, "h1", "ok", &local));
3843        assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
3844        assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
3845        assert!(needs_write_drift(
3846            Some(("h1", "missing")),
3847            "h1",
3848            "missing",
3849            &local
3850        ));
3851        assert!(needs_write_drift(
3852            Some(("h1", "empty")),
3853            "h1",
3854            "empty",
3855            &local
3856        ));
3857        assert!(!needs_write_drift(
3858            Some(("h1", "unprobed")),
3859            "h1",
3860            "unprobed",
3861            &local
3862        ));
3863    }
3864
3865    #[test]
3866    fn per_clip_reconcile_ignores_album_and_library_kinds() {
3867        // Album/library kinds must never be written per clip (they have no
3868        // per-song manifest slot, so they would be rewritten every run). A
3869        // CoverJpg alongside them is still handled.
3870        let mut manifest = Manifest::new();
3871        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3872        let d = vec![desired_arts(
3873            "a",
3874            vec![
3875                art(
3876                    ArtifactKind::FolderJpg,
3877                    "a/folder.jpg",
3878                    "https://art/folder",
3879                    "hf",
3880                ),
3881                art(
3882                    ArtifactKind::Playlist,
3883                    "a/list.m3u",
3884                    "https://art/list",
3885                    "hp",
3886                ),
3887                art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3888            ],
3889        )];
3890        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3891        assert_eq!(plan.artifact_writes(), 1);
3892        let paths: Vec<&str> = plan
3893            .actions
3894            .iter()
3895            .filter_map(|a| match a {
3896                Action::WriteArtifact { path, .. } => Some(path.as_str()),
3897                _ => None,
3898            })
3899            .collect();
3900        assert_eq!(paths, vec!["a/cover.jpg"]);
3901    }
3902
3903    #[test]
3904    fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3905        let mut manifest = Manifest::new();
3906        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3907        let d = vec![desired_arts(
3908            "a",
3909            vec![art(
3910                ArtifactKind::FolderWebp,
3911                "a/folder.webp",
3912                "https://art/folder",
3913                "hf",
3914            )],
3915        )];
3916        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3917        assert_eq!(plan.artifact_writes(), 0);
3918        assert_eq!(plan.artifact_deletes(), 0);
3919    }
3920
3921    // ── Self-heal: missing-on-disk sidecar / folder-art / playlist ──
3922
3923    /// A local probe map that marks `path` as missing (exists=false).
3924    fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3925        let mut m = local_present(audio_id);
3926        m.insert(missing_path.to_owned(), LocalFile::default());
3927        m
3928    }
3929
3930    /// A local probe map that marks `path` as present (exists=true, size>0).
3931    fn local_with_present_artifact(
3932        audio_id: &str,
3933        artifact_path: &str,
3934    ) -> HashMap<String, LocalFile> {
3935        let mut m = local_present(audio_id);
3936        m.insert(artifact_path.to_owned(), present(50));
3937        m
3938    }
3939
3940    #[test]
3941    fn sidecar_missing_on_disk_forces_rewrite() {
3942        // Manifest and desired agree on hash+path, but the file is absent on
3943        // disk: the probe forces needs_write = true and a WriteArtifact is
3944        // emitted to self-heal it.
3945        let mut manifest = Manifest::new();
3946        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3947        let d = vec![desired_arts(
3948            "a",
3949            vec![art(
3950                ArtifactKind::CoverJpg,
3951                "a/cover.jpg",
3952                "https://art/a",
3953                "h1",
3954            )],
3955        )];
3956        let local = local_with_missing("a", "a/cover.jpg");
3957        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3958        assert_eq!(
3959            plan.artifact_writes(),
3960            1,
3961            "missing sidecar must be rewritten"
3962        );
3963        assert_eq!(plan.artifact_deletes(), 0);
3964    }
3965
3966    #[test]
3967    fn sidecar_present_on_disk_with_matching_hash_no_churn() {
3968        // Same manifest / desired / hash — but the file IS present. No write.
3969        let mut manifest = Manifest::new();
3970        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3971        let d = vec![desired_arts(
3972            "a",
3973            vec![art(
3974                ArtifactKind::CoverJpg,
3975                "a/cover.jpg",
3976                "https://art/a",
3977                "h1",
3978            )],
3979        )];
3980        let local = local_with_present_artifact("a", "a/cover.jpg");
3981        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3982        assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
3983        assert_eq!(plan.artifact_deletes(), 0);
3984    }
3985
3986    #[test]
3987    fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
3988        // When the artifact path is not in the local map (probe unavailable),
3989        // the engine falls back to hash/path comparison only. A matching entry
3990        // must NOT trigger a write, and must NOT trigger a delete.
3991        let mut manifest = Manifest::new();
3992        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3993        let d = vec![desired_arts(
3994            "a",
3995            vec![art(
3996                ArtifactKind::CoverJpg,
3997                "a/cover.jpg",
3998                "https://art/a",
3999                "h1",
4000            )],
4001        )];
4002        // local only has the audio entry; cover path is unprobeable.
4003        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4004        assert_eq!(
4005            plan.artifact_writes(),
4006            0,
4007            "no write when probe unavailable and hash matches"
4008        );
4009        assert_eq!(
4010            plan.artifact_deletes(),
4011            0,
4012            "missing probe must never trigger a delete"
4013        );
4014    }
4015
4016    #[test]
4017    fn folder_art_missing_on_disk_forces_rewrite() {
4018        // The album store records a matching folder.jpg, but the file is absent:
4019        // the probe must force a WriteArtifact.
4020        let members = vec![album_member(
4021            album_clip("a", 1, "t0", "art-a", ""),
4022            "root",
4023            "c/al/a.flac",
4024        )];
4025        let desired = album_desired(&members, false, false);
4026        let mut albums = BTreeMap::new();
4027        albums.insert(
4028            "root".to_string(),
4029            AlbumArt {
4030                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4031                folder_webp: None,
4032                folder_mp4: None,
4033            },
4034        );
4035        let mut local: HashMap<String, LocalFile> = HashMap::new();
4036        local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
4037        let actions = plan_album_artifacts(&desired, &albums, true, &local);
4038        assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
4039        assert!(matches!(
4040            &actions[0],
4041            Action::WriteArtifact {
4042                kind: ArtifactKind::FolderJpg,
4043                ..
4044            }
4045        ));
4046    }
4047
4048    #[test]
4049    fn folder_art_present_on_disk_no_churn() {
4050        // Matching hash+path and the file is present: no write.
4051        let members = vec![album_member(
4052            album_clip("a", 1, "t0", "art-a", ""),
4053            "root",
4054            "c/al/a.flac",
4055        )];
4056        let desired = album_desired(&members, false, false);
4057        let mut albums = BTreeMap::new();
4058        albums.insert(
4059            "root".to_string(),
4060            AlbumArt {
4061                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4062                folder_webp: None,
4063                folder_mp4: None,
4064            },
4065        );
4066        let mut local: HashMap<String, LocalFile> = HashMap::new();
4067        local.insert("c/al/folder.jpg".to_owned(), present(5000));
4068        let actions = plan_album_artifacts(&desired, &albums, true, &local);
4069        assert!(
4070            actions.is_empty(),
4071            "present folder art with matching hash must not churn"
4072        );
4073    }
4074
4075    #[test]
4076    fn playlist_missing_on_disk_forces_rewrite() {
4077        // The playlist store records a matching entry, but the file is absent:
4078        // the probe must force a WriteArtifact.
4079        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4080        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4081        let mut local: HashMap<String, LocalFile> = HashMap::new();
4082        local.insert("Mix.m3u8".to_owned(), LocalFile::default());
4083        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4084        assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
4085        assert!(matches!(
4086            &actions[0],
4087            Action::WriteArtifact {
4088                kind: ArtifactKind::Playlist,
4089                ..
4090            }
4091        ));
4092    }
4093
4094    #[test]
4095    fn playlist_present_on_disk_no_churn() {
4096        // Matching hash+path and the file is present: no write.
4097        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4098        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4099        let mut local: HashMap<String, LocalFile> = HashMap::new();
4100        local.insert("Mix.m3u8".to_owned(), present(200));
4101        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4102        assert!(
4103            actions.is_empty(),
4104            "present playlist with matching hash must not churn"
4105        );
4106    }
4107
4108    // ── Phase 8: folder art (album-scoped) ──────────────────────────
4109
4110    fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
4111        Clip {
4112            id: id.to_string(),
4113            title: "Song".to_string(),
4114            image_large_url: image.to_string(),
4115            video_cover_url: video.to_string(),
4116            play_count,
4117            created_at: created_at.to_string(),
4118            ..Default::default()
4119        }
4120    }
4121
4122    fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4123        let mut lineage = LineageContext::own_root(&clip);
4124        lineage.root_id = root_id.to_string();
4125        Desired {
4126            clip,
4127            lineage,
4128            path: path.to_string(),
4129            format: AudioFormat::Flac,
4130            meta_hash: "m".to_string(),
4131            art_hash: "a".to_string(),
4132            modes: vec![SourceMode::Mirror],
4133            trashed: false,
4134            private: false,
4135            artifacts: Vec::new(),
4136            stems: None,
4137        }
4138    }
4139
4140    fn stored(path: &str, hash: &str) -> ArtifactState {
4141        ArtifactState {
4142            path: path.to_string(),
4143            hash: hash.to_string(),
4144        }
4145    }
4146
4147    #[test]
4148    fn folder_jpg_source_is_most_played() {
4149        let members = vec![
4150            album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4151            album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4152            album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4153        ];
4154        let albums = album_desired(&members, false, false);
4155        assert_eq!(albums.len(), 1);
4156        let jpg = albums[0].folder_jpg.as_ref().unwrap();
4157        // "b" has the highest play_count, so its art content hash wins.
4158        assert_eq!(jpg.hash, art_url_hash("art-b"));
4159        assert_eq!(jpg.source_url, "art-b");
4160        assert_eq!(jpg.path, "c/al/folder.jpg");
4161        assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4162    }
4163
4164    #[test]
4165    fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4166        // Equal play_count: earliest created_at wins.
4167        let by_time = vec![
4168            album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4169            album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4170            album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4171        ];
4172        let jpg = album_desired(&by_time, false, false)[0]
4173            .folder_jpg
4174            .clone()
4175            .unwrap();
4176        assert_eq!(jpg.source_url, "art-y");
4177
4178        // Equal play_count and created_at: lexicographically smallest id wins.
4179        let by_id = vec![
4180            album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4181            album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4182        ];
4183        let jpg = album_desired(&by_id, false, false)[0]
4184            .folder_jpg
4185            .clone()
4186            .unwrap();
4187        assert_eq!(jpg.source_url, "art-g");
4188    }
4189
4190    #[test]
4191    fn folder_webp_source_is_first_created_animated() {
4192        let members = vec![
4193            album_member(
4194                album_clip("a", 9, "t2", "art-a", "vid-a"),
4195                "root",
4196                "c/al/a.flac",
4197            ),
4198            album_member(
4199                album_clip("b", 1, "t0", "art-b", "vid-b"),
4200                "root",
4201                "c/al/b.flac",
4202            ),
4203            album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4204        ];
4205        let webp = album_desired(&members, true, false)[0]
4206            .folder_webp
4207            .clone()
4208            .unwrap();
4209        // "b" is earliest-created with an animated source, regardless of plays.
4210        assert_eq!(webp.source_url, "vid-b");
4211        assert_eq!(webp.hash, art_url_hash("vid-b"));
4212        assert_eq!(webp.path, "c/al/cover.webp");
4213        assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4214    }
4215
4216    #[test]
4217    fn animated_covers_off_yields_no_folder_webp() {
4218        let members = vec![album_member(
4219            album_clip("a", 1, "t0", "art-a", "vid-a"),
4220            "root",
4221            "c/al/a.flac",
4222        )];
4223        let off = album_desired(&members, false, false);
4224        assert!(off[0].folder_webp.is_none());
4225        let on = album_desired(&members, true, false);
4226        assert!(on[0].folder_webp.is_some());
4227    }
4228
4229    #[test]
4230    fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4231        let members = vec![
4232            album_member(
4233                album_clip("a", 9, "t2", "art-a", "vid-a"),
4234                "root",
4235                "c/al/a.flac",
4236            ),
4237            album_member(
4238                album_clip("b", 1, "t0", "art-b", "vid-b"),
4239                "root",
4240                "c/al/b.flac",
4241            ),
4242        ];
4243        // `both`: cover.webp (transcoded) and cover.mp4 (raw) come from the SAME
4244        // earliest-created animated variant, so they describe one animation. The
4245        // raw cover keeps the `video_cover_url` unchanged and hashes on the URL.
4246        let album = album_desired(&members, true, true).remove(0);
4247        let webp = album.folder_webp.unwrap();
4248        let mp4 = album.folder_mp4.unwrap();
4249        assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4250        assert_eq!(mp4.path, "c/al/cover.mp4");
4251        assert_eq!(mp4.source_url, "vid-b");
4252        assert_eq!(mp4.hash, art_url_hash("vid-b"));
4253        assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4254    }
4255
4256    #[test]
4257    fn raw_cover_and_webp_are_independent_toggles() {
4258        let members = vec![album_member(
4259            album_clip("a", 1, "t0", "art-a", "vid-a"),
4260            "root",
4261            "c/al/a.flac",
4262        )];
4263        // webp-only keeps the transcode but no raw mp4.
4264        let webp_only = album_desired(&members, true, false).remove(0);
4265        assert!(webp_only.folder_webp.is_some());
4266        assert!(webp_only.folder_mp4.is_none());
4267        // mp4-only keeps the raw source but no transcode.
4268        let mp4_only = album_desired(&members, false, true).remove(0);
4269        assert!(mp4_only.folder_webp.is_none());
4270        assert!(mp4_only.folder_mp4.is_some());
4271    }
4272
4273    #[test]
4274    fn raw_cover_needs_an_animated_source() {
4275        // No variant carries a video_cover_url, so there is nothing to keep.
4276        let members = vec![album_member(
4277            album_clip("a", 3, "t0", "art-a", ""),
4278            "root",
4279            "c/al/a.flac",
4280        )];
4281        let album = album_desired(&members, true, true).remove(0);
4282        assert!(album.folder_mp4.is_none());
4283        assert!(album.folder_webp.is_none());
4284    }
4285
4286    #[test]
4287    fn album_with_no_art_yields_no_folder_jpg() {
4288        let members = vec![album_member(
4289            album_clip("a", 3, "t0", "", ""),
4290            "root",
4291            "c/al/a.flac",
4292        )];
4293        let albums = album_desired(&members, true, false);
4294        assert!(albums[0].folder_jpg.is_none());
4295        assert!(albums[0].folder_webp.is_none());
4296    }
4297
4298    #[test]
4299    fn album_desired_groups_by_root_id() {
4300        let members = vec![
4301            album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4302            album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4303            album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4304        ];
4305        let albums = album_desired(&members, false, false);
4306        assert_eq!(albums.len(), 2);
4307        assert_eq!(albums[0].root_id, "r1");
4308        assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4309        assert_eq!(
4310            albums[0].folder_jpg.as_ref().unwrap().path,
4311            "c/al1/folder.jpg"
4312        );
4313        assert_eq!(albums[1].root_id, "r2");
4314        assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4315        assert_eq!(
4316            albums[1].folder_jpg.as_ref().unwrap().path,
4317            "c/al2/folder.jpg"
4318        );
4319    }
4320
4321    #[test]
4322    fn plan_writes_folder_art_when_store_empty() {
4323        let members = vec![album_member(
4324            album_clip("a", 1, "t0", "art-a", "vid-a"),
4325            "root",
4326            "c/al/a.flac",
4327        )];
4328        let desired = album_desired(&members, true, false);
4329        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4330        assert_eq!(
4331            actions,
4332            vec![
4333                Action::WriteArtifact {
4334                    kind: ArtifactKind::FolderJpg,
4335                    path: "c/al/folder.jpg".to_string(),
4336                    source_url: "art-a".to_string(),
4337                    hash: art_url_hash("art-a"),
4338                    owner_id: "root".to_string(),
4339                    content: None,
4340                },
4341                Action::WriteArtifact {
4342                    kind: ArtifactKind::FolderWebp,
4343                    path: "c/al/cover.webp".to_string(),
4344                    source_url: "vid-a".to_string(),
4345                    hash: art_url_hash("vid-a"),
4346                    owner_id: "root".to_string(),
4347                    content: None,
4348                },
4349            ]
4350        );
4351    }
4352
4353    #[test]
4354    fn plan_skips_when_hash_and_path_match() {
4355        let members = vec![album_member(
4356            album_clip("a", 1, "t0", "art-a", ""),
4357            "root",
4358            "c/al/a.flac",
4359        )];
4360        let desired = album_desired(&members, false, false);
4361        let mut albums = BTreeMap::new();
4362        albums.insert(
4363            "root".to_string(),
4364            AlbumArt {
4365                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4366                folder_webp: None,
4367                folder_mp4: None,
4368            },
4369        );
4370        assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4371    }
4372
4373    #[test]
4374    fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4375        let members = vec![album_member(
4376            album_clip("a", 1, "t0", "art-a", ""),
4377            "root",
4378            "c/al/a.flac",
4379        )];
4380        let desired = album_desired(&members, false, false);
4381        let mut albums = BTreeMap::new();
4382        albums.insert(
4383            "root".to_string(),
4384            AlbumArt {
4385                folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4386                folder_webp: None,
4387                folder_mp4: None,
4388            },
4389        );
4390        let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4391        assert_eq!(actions.len(), 1);
4392        assert!(matches!(
4393            &actions[0],
4394            Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4395        ));
4396    }
4397
4398    #[test]
4399    fn h1_most_played_flip_to_same_art_writes_nothing() {
4400        // Two variants sharing identical art. Run 1: "a" is most-played.
4401        let run1 = vec![
4402            album_member(
4403                album_clip("a", 9, "t0", "same-art", ""),
4404                "root",
4405                "c/al/a.flac",
4406            ),
4407            album_member(
4408                album_clip("b", 1, "t1", "same-art", ""),
4409                "root",
4410                "c/al/b.flac",
4411            ),
4412        ];
4413        let desired1 = album_desired(&run1, false, false);
4414        let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4415        assert_eq!(write1.len(), 1);
4416
4417        // Persist the winner's state as the executor would.
4418        let mut albums = BTreeMap::new();
4419        if let Action::WriteArtifact {
4420            path,
4421            hash,
4422            owner_id,
4423            ..
4424        } = &write1[0]
4425        {
4426            albums.insert(
4427                owner_id.clone(),
4428                AlbumArt {
4429                    folder_jpg: Some(stored(path, hash)),
4430                    folder_webp: None,
4431                    folder_mp4: None,
4432                },
4433            );
4434        }
4435
4436        // Run 2: "b" overtakes "a" on plays, but the art content is identical.
4437        let run2 = vec![
4438            album_member(
4439                album_clip("a", 1, "t0", "same-art", ""),
4440                "root",
4441                "c/al/a.flac",
4442            ),
4443            album_member(
4444                album_clip("b", 9, "t1", "same-art", ""),
4445                "root",
4446                "c/al/b.flac",
4447            ),
4448        ];
4449        let desired2 = album_desired(&run2, false, false);
4450        // The winner flipped, but the chosen art content hash did not: no churn.
4451        assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4452    }
4453
4454    #[test]
4455    fn h1_flip_to_different_art_writes_exactly_one() {
4456        let mut albums = BTreeMap::new();
4457        albums.insert(
4458            "root".to_string(),
4459            AlbumArt {
4460                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4461                folder_webp: None,
4462                folder_mp4: None,
4463            },
4464        );
4465        // The new most-played variant carries genuinely different art.
4466        let members = vec![
4467            album_member(
4468                album_clip("a", 1, "t0", "old-art", ""),
4469                "root",
4470                "c/al/a.flac",
4471            ),
4472            album_member(
4473                album_clip("b", 9, "t1", "new-art", ""),
4474                "root",
4475                "c/al/b.flac",
4476            ),
4477        ];
4478        let desired = album_desired(&members, false, false);
4479        let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4480        assert_eq!(actions.len(), 1);
4481        assert!(matches!(
4482            &actions[0],
4483            Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4484        ));
4485    }
4486
4487    #[test]
4488    fn one_write_per_album_regardless_of_clip_count() {
4489        let members: Vec<Desired> = (0..200)
4490            .map(|i| {
4491                album_member(
4492                    album_clip(
4493                        &format!("clip-{i:03}"),
4494                        i as u64,
4495                        &format!("t{i:03}"),
4496                        &format!("art-{i:03}"),
4497                        &format!("vid-{i:03}"),
4498                    ),
4499                    "root",
4500                    &format!("c/al/clip-{i:03}.flac"),
4501                )
4502            })
4503            .collect();
4504        let desired = album_desired(&members, true, false);
4505        assert_eq!(desired.len(), 1);
4506        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4507        // Exactly one folder.jpg and one cover.webp for the whole 200-clip album.
4508        assert_eq!(actions.len(), 2);
4509        assert_eq!(
4510            actions
4511                .iter()
4512                .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4513                .count(),
4514            2
4515        );
4516    }
4517
4518    #[test]
4519    fn emptied_album_deletes_only_when_can_delete() {
4520        let mut albums = BTreeMap::new();
4521        albums.insert(
4522            "root".to_string(),
4523            AlbumArt {
4524                folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4525                folder_webp: Some(stored("c/al/cover.webp", "hw")),
4526                folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4527            },
4528        );
4529        // No album desires this root any more (it emptied out this run).
4530        let desired: Vec<AlbumDesired> = Vec::new();
4531
4532        // Gated off: an incomplete/unsafe listing removes nothing.
4533        assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4534
4535        // Gated on: every stored kind is removed, sorted by kind.
4536        let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4537        assert_eq!(
4538            actions,
4539            vec![
4540                Action::DeleteArtifact {
4541                    kind: ArtifactKind::FolderJpg,
4542                    path: "c/al/folder.jpg".to_string(),
4543                    owner_id: "root".to_string(),
4544                },
4545                Action::DeleteArtifact {
4546                    kind: ArtifactKind::FolderWebp,
4547                    path: "c/al/cover.webp".to_string(),
4548                    owner_id: "root".to_string(),
4549                },
4550                Action::DeleteArtifact {
4551                    kind: ArtifactKind::FolderMp4,
4552                    path: "c/al/cover.mp4".to_string(),
4553                    owner_id: "root".to_string(),
4554                },
4555            ]
4556        );
4557    }
4558
4559    #[test]
4560    fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4561        let mut albums = BTreeMap::new();
4562        albums.insert(
4563            "root".to_string(),
4564            AlbumArt {
4565                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4566                folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4567                folder_mp4: None,
4568            },
4569        );
4570        // The album is still present with the same folder.jpg, but animated
4571        // covers are now off, so the webp source has disappeared.
4572        let members = vec![album_member(
4573            album_clip("a", 1, "t0", "art-a", "vid-a"),
4574            "root",
4575            "c/al/a.flac",
4576        )];
4577        let desired = album_desired(&members, false, false);
4578
4579        assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4580
4581        let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4582        assert_eq!(
4583            actions,
4584            vec![Action::DeleteArtifact {
4585                kind: ArtifactKind::FolderWebp,
4586                path: "c/al/cover.webp".to_string(),
4587                owner_id: "root".to_string(),
4588            }]
4589        );
4590    }
4591
4592    #[test]
4593    fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4594        let mut albums = BTreeMap::new();
4595        albums.insert(
4596            "root".to_string(),
4597            AlbumArt {
4598                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4599                folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4600                folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4601            },
4602        );
4603        // The album stays and animated covers stay on, but raw cover retention
4604        // is now off, so only the raw `cover.mp4` is no longer desired.
4605        let members = vec![album_member(
4606            album_clip("a", 1, "t0", "art-a", "vid-a"),
4607            "root",
4608            "c/al/a.flac",
4609        )];
4610        let desired = album_desired(&members, true, false);
4611
4612        // Gated off: nothing removed on an unsafe listing.
4613        assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4614
4615        // Gated on: only the raw cover goes; folder.jpg and cover.webp stay.
4616        let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4617        assert_eq!(
4618            actions,
4619            vec![Action::DeleteArtifact {
4620                kind: ArtifactKind::FolderMp4,
4621                path: "c/al/cover.mp4".to_string(),
4622                owner_id: "root".to_string(),
4623            }]
4624        );
4625    }
4626
4627    #[test]
4628    fn plan_album_artifacts_is_deterministically_ordered() {
4629        let members = vec![
4630            album_member(
4631                album_clip("a", 1, "t0", "art-a", "vid-a"),
4632                "r2",
4633                "c/al2/a.flac",
4634            ),
4635            album_member(
4636                album_clip("b", 1, "t0", "art-b", "vid-b"),
4637                "r1",
4638                "c/al1/b.flac",
4639            ),
4640        ];
4641        let desired = album_desired(&members, true, true);
4642        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4643        let keys: Vec<(&str, ArtifactKind)> = actions
4644            .iter()
4645            .map(|a| match a {
4646                Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4647                _ => unreachable!(),
4648            })
4649            .collect();
4650        assert_eq!(
4651            keys,
4652            vec![
4653                ("r1", ArtifactKind::FolderJpg),
4654                ("r1", ArtifactKind::FolderWebp),
4655                ("r1", ArtifactKind::FolderMp4),
4656                ("r2", ArtifactKind::FolderJpg),
4657                ("r2", ArtifactKind::FolderWebp),
4658                ("r2", ArtifactKind::FolderMp4),
4659            ]
4660        );
4661    }
4662
4663    // ── Phase 9: playlist artifacts ─────────────────────────────────
4664
4665    fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4666        PlaylistDesired {
4667            id: id.to_owned(),
4668            name: name.to_owned(),
4669            path: path.to_owned(),
4670            content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4671            hash: hash.to_owned(),
4672        }
4673    }
4674
4675    fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4676        PlaylistState {
4677            name: name.to_owned(),
4678            path: path.to_owned(),
4679            hash: hash.to_owned(),
4680        }
4681    }
4682
4683    fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4684        entries
4685            .iter()
4686            .map(|(id, state)| ((*id).to_owned(), state.clone()))
4687            .collect()
4688    }
4689
4690    #[test]
4691    fn playlist_write_emitted_for_a_new_playlist() {
4692        let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4693        let actions =
4694            plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4695        assert_eq!(
4696            actions,
4697            vec![Action::WriteArtifact {
4698                kind: ArtifactKind::Playlist,
4699                path: "Road Trip.m3u8".to_owned(),
4700                source_url: String::new(),
4701                hash: "h1".to_owned(),
4702                owner_id: "pl1".to_owned(),
4703                content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4704            }]
4705        );
4706    }
4707
4708    #[test]
4709    fn playlist_write_emitted_when_hash_changes() {
4710        // Same id and path, different content hash (a member's title, an order
4711        // flip, a new path) — the m3u8 is rewritten (B1).
4712        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4713        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4714        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4715        assert_eq!(actions.len(), 1);
4716        assert!(matches!(
4717            &actions[0],
4718            Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4719        ));
4720    }
4721
4722    #[test]
4723    fn playlist_unchanged_is_idempotent() {
4724        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4725        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4726        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4727        assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4728    }
4729
4730    #[test]
4731    fn playlist_rename_writes_new_and_deletes_old_path() {
4732        // The playlist was renamed on Suno, so its sanitised path changed: write
4733        // the new file and delete the old one, both under the full delete gate.
4734        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4735        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4736        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4737        assert_eq!(
4738            actions,
4739            vec![
4740                Action::WriteArtifact {
4741                    kind: ArtifactKind::Playlist,
4742                    path: "Summer.m3u8".to_owned(),
4743                    source_url: String::new(),
4744                    hash: "h2".to_owned(),
4745                    owner_id: "pl1".to_owned(),
4746                    content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4747                },
4748                Action::DeleteArtifact {
4749                    kind: ArtifactKind::Playlist,
4750                    path: "Spring.m3u8".to_owned(),
4751                    owner_id: "pl1".to_owned(),
4752                },
4753            ]
4754        );
4755    }
4756
4757    #[test]
4758    fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4759        // A rename still writes the new file, but the OLD-path cleanup is a
4760        // delete and is gated: no can_delete means no removal (B2).
4761        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4762        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4763        let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4764        assert_eq!(actions.len(), 1);
4765        assert!(matches!(
4766            &actions[0],
4767            Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4768        ));
4769        assert!(
4770            !actions
4771                .iter()
4772                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4773            "old path must not be deleted when deletes are disallowed"
4774        );
4775    }
4776
4777    #[test]
4778    fn playlist_stale_removed_only_under_full_gate() {
4779        // A stored playlist absent from desired is stale. It is deleted only when
4780        // BOTH can_delete and list_fully_enumerated hold.
4781        let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4782
4783        let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4784        assert_eq!(
4785            deleted,
4786            vec![Action::DeleteArtifact {
4787                kind: ArtifactKind::Playlist,
4788                path: "Gone.m3u8".to_owned(),
4789                owner_id: "gone".to_owned(),
4790            }]
4791        );
4792
4793        // Any gate off → no delete.
4794        assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4795        assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4796        assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4797    }
4798
4799    #[test]
4800    fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4801        // B2 BLOCKER: when the /api/playlist/me listing fails, the caller passes
4802        // an empty desired and list_fully_enumerated=false. Even with a
4803        // non-empty store and can_delete, NOTHING is planned — every existing
4804        // .m3u8 is left untouched.
4805        let stored = pl_store(&[
4806            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4807            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4808        ]);
4809        let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4810        assert!(
4811            actions.is_empty(),
4812            "a failed playlist listing must plan zero actions, got {actions:?}"
4813        );
4814    }
4815
4816    #[test]
4817    fn b2_empty_list_deletes_only_when_fully_enumerated() {
4818        // An empty desired that contradicts a non-empty store is a genuine
4819        // wipe ONLY when the listing was fully enumerated (and can_delete). That
4820        // path IS a mass delete — the CLI cap/confirmation then guards it — but
4821        // an unreliable listing (not fully enumerated) plans nothing here (B2).
4822        let stored = pl_store(&[
4823            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4824            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4825        ]);
4826
4827        // Not fully enumerated: zero deletes (the safety valve).
4828        assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4829
4830        // Fully enumerated and allowed: both are deleted (the caller's cap
4831        // catches this mass removal).
4832        let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4833        assert_eq!(
4834            wiped
4835                .iter()
4836                .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4837                .count(),
4838            2
4839        );
4840    }
4841
4842    #[test]
4843    fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4844        // A playlist whose member fetch failed is excluded upstream from BOTH
4845        // desired and the stored map handed here, so it is neither rewritten nor
4846        // treated as stale: its .m3u8 survives while a sibling reconciles.
4847        // `pl_ok` reconciles; `pl_fail` is simply absent from both maps.
4848        let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4849        let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4850        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4851        // Only the healthy playlist is rewritten; nothing references pl_fail.
4852        assert_eq!(actions.len(), 1);
4853        assert!(matches!(
4854            &actions[0],
4855            Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4856        ));
4857        assert!(
4858            !actions.iter().any(|a| match a {
4859                Action::WriteArtifact { owner_id, .. }
4860                | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4861                _ => false,
4862            }),
4863            "a protected (failed-member) playlist must have no action"
4864        );
4865    }
4866
4867    #[test]
4868    fn playlist_rename_collision_downgrades_the_delete() {
4869        // pl1 renames Old -> Shared.m3u8; pl2 already renders Shared.m3u8 this
4870        // run. The delete of pl1's old path is fine, but a delete must never
4871        // alias a write target, so if the OLD path equals another write target
4872        // it is downgraded. Here we force the collision: pl1's old path is the
4873        // very path pl2 writes.
4874        let desired = vec![
4875            pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4876            pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4877        ];
4878        let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4879        let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4880        // No DeleteArtifact survives against a path some write produces.
4881        let write_paths: BTreeSet<&str> = actions
4882            .iter()
4883            .filter_map(|a| match a {
4884                Action::WriteArtifact { path, .. } => Some(path.as_str()),
4885                _ => None,
4886            })
4887            .collect();
4888        for a in &actions {
4889            if let Action::DeleteArtifact { path, .. } = a {
4890                assert!(
4891                    !write_paths.contains(path.as_str()),
4892                    "a playlist delete aliases a write target: {path}"
4893                );
4894            }
4895        }
4896    }
4897
4898    // ── Keyed stem reconcile ────────────────────────────────────────
4899
4900    fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4901        DesiredStem {
4902            key: key.to_string(),
4903            stem_id: key.to_string(),
4904            path: path.to_string(),
4905            source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4906            format: StemFormat::Mp3,
4907            hash: hash.to_string(),
4908        }
4909    }
4910
4911    /// A kept FLAC clip that desires the given (possibly `None`) stem set.
4912    fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4913        Desired {
4914            stems,
4915            ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4916        }
4917    }
4918
4919    /// A manifest entry for a kept clip carrying the given tracked stems.
4920    fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4921        let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4922        for (key, path, hash) in stems {
4923            e.stems.insert(
4924                key.to_string(),
4925                ArtifactState {
4926                    path: path.to_string(),
4927                    hash: hash.to_string(),
4928                },
4929            );
4930        }
4931        e
4932    }
4933
4934    fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4935        plan.actions
4936            .iter()
4937            .filter_map(|a| match a {
4938                Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4939                _ => None,
4940            })
4941            .collect()
4942    }
4943
4944    fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4945        plan.actions
4946            .iter()
4947            .filter_map(|a| match a {
4948                Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4949                _ => None,
4950            })
4951            .collect()
4952    }
4953
4954    #[test]
4955    fn stems_none_keeps_every_existing_stem() {
4956        // An indeterminate listing (feature off, has_stem false, or a
4957        // paged-error) surfaces as `None`: no stem is written or deleted.
4958        let mut manifest = Manifest::new();
4959        manifest.insert(
4960            "a",
4961            entry_with_stems(
4962                "a",
4963                &[
4964                    ("voc", "a.stems/voc.mp3", "h1"),
4965                    ("drm", "a.stems/drm.mp3", "h2"),
4966                ],
4967            ),
4968        );
4969        let d = vec![stem_desired("a", None)];
4970        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4971        assert_eq!(plan.stem_writes(), 0);
4972        assert_eq!(plan.stem_deletes(), 0);
4973    }
4974
4975    #[test]
4976    fn stems_authoritative_writes_missing_stems() {
4977        let mut manifest = Manifest::new();
4978        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4979        let d = vec![stem_desired(
4980            "a",
4981            Some(vec![
4982                dstem("voc", "a.stems/voc.mp3", "h1"),
4983                dstem("drm", "a.stems/drm.mp3", "h2"),
4984            ]),
4985        )];
4986        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4987        assert_eq!(
4988            stem_writes(&plan),
4989            vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4990        );
4991        assert_eq!(plan.stem_deletes(), 0);
4992    }
4993
4994    #[test]
4995    fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4996        let mut manifest = Manifest::new();
4997        // voc unchanged, drm hash drift, bas path drift (song moved).
4998        manifest.insert(
4999            "a",
5000            entry_with_stems(
5001                "a",
5002                &[
5003                    ("voc", "a.stems/voc.mp3", "h1"),
5004                    ("drm", "a.stems/drm.mp3", "h2"),
5005                    ("bas", "old.stems/bas.mp3", "h3"),
5006                ],
5007            ),
5008        );
5009        let d = vec![stem_desired(
5010            "a",
5011            Some(vec![
5012                dstem("voc", "a.stems/voc.mp3", "h1"),     // unchanged
5013                dstem("drm", "a.stems/drm.mp3", "h2-new"), // hash drift
5014                dstem("bas", "a.stems/bas.mp3", "h3"),     // path drift
5015            ]),
5016        )];
5017        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5018        assert_eq!(
5019            stem_writes(&plan),
5020            vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
5021        );
5022        assert_eq!(plan.stem_deletes(), 0);
5023    }
5024
5025    #[test]
5026    fn stems_authoritative_removes_a_stem_absent_from_the_set() {
5027        // drm is gone from the authoritative listing, so it is delete-reconciled
5028        // through the shared gate; voc (still present) is untouched.
5029        let mut manifest = Manifest::new();
5030        manifest.insert(
5031            "a",
5032            entry_with_stems(
5033                "a",
5034                &[
5035                    ("voc", "a.stems/voc.mp3", "h1"),
5036                    ("drm", "a.stems/drm.mp3", "h2"),
5037                ],
5038            ),
5039        );
5040        let d = vec![stem_desired(
5041            "a",
5042            Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5043        )];
5044        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5045        assert_eq!(plan.stem_writes(), 0);
5046        assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
5047    }
5048
5049    #[test]
5050    fn stems_removal_needs_deletion_allowed() {
5051        // The same authoritative-omission case, but deletion is not allowed this
5052        // run (no fully-enumerated mirror). The stem is KEPT, never deleted.
5053        let mut manifest = Manifest::new();
5054        manifest.insert(
5055            "a",
5056            entry_with_stems(
5057                "a",
5058                &[
5059                    ("voc", "a.stems/voc.mp3", "h1"),
5060                    ("drm", "a.stems/drm.mp3", "h2"),
5061                ],
5062            ),
5063        );
5064        let d = vec![stem_desired(
5065            "a",
5066            Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5067        )];
5068
5069        let incomplete = vec![SourceStatus {
5070            mode: SourceMode::Mirror,
5071            fully_enumerated: false,
5072        }];
5073        assert_eq!(
5074            reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
5075            0
5076        );
5077
5078        let copy_only = vec![SourceStatus {
5079            mode: SourceMode::Copy,
5080            fully_enumerated: true,
5081        }];
5082        assert_eq!(
5083            reconcile(&manifest, &d, &local_present("a"), &copy_only).stem_deletes(),
5084            0
5085        );
5086    }
5087
5088    #[test]
5089    fn stems_removal_skipped_for_preserved_or_protected_clip() {
5090        let mut manifest = Manifest::new();
5091        let mut e = entry_with_stems(
5092            "a",
5093            &[
5094                ("voc", "a.stems/voc.mp3", "h1"),
5095                ("drm", "a.stems/drm.mp3", "h2"),
5096            ],
5097        );
5098        e.preserve = true;
5099        manifest.insert("a", e);
5100        let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
5101
5102        // preserve marker wins: no stem delete.
5103        let d = vec![stem_desired("a", authoritative.clone())];
5104        assert_eq!(
5105            reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
5106            0
5107        );
5108
5109        // A copy-held clip this run also keeps all stems (protected_now).
5110        let mut manifest2 = Manifest::new();
5111        manifest2.insert(
5112            "a",
5113            entry_with_stems(
5114                "a",
5115                &[
5116                    ("voc", "a.stems/voc.mp3", "h1"),
5117                    ("drm", "a.stems/drm.mp3", "h2"),
5118                ],
5119            ),
5120        );
5121        let held = Desired {
5122            modes: vec![SourceMode::Mirror, SourceMode::Copy],
5123            stems: authoritative,
5124            ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5125        };
5126        assert_eq!(
5127            reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5128            0
5129        );
5130    }
5131
5132    #[test]
5133    fn stems_are_co_deleted_when_the_song_is_trashed() {
5134        // A trashed clip's audio is deleted; its stems must be co-deleted so the
5135        // `.stems` folder is not orphaned (no stranding).
5136        let mut manifest = Manifest::new();
5137        manifest.insert(
5138            "a",
5139            entry_with_stems(
5140                "a",
5141                &[
5142                    ("voc", "a.stems/voc.mp3", "h1"),
5143                    ("drm", "a.stems/drm.mp3", "h2"),
5144                ],
5145            ),
5146        );
5147        let trashed = Desired {
5148            trashed: true,
5149            ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5150        };
5151        let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5152        assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5153        let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5154        deleted.sort_unstable();
5155        assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5156    }
5157
5158    #[test]
5159    fn stems_are_co_deleted_for_an_absent_clip() {
5160        let mut manifest = Manifest::new();
5161        manifest.insert(
5162            "a",
5163            entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5164        );
5165        // Desired is empty: clip "a" left every source and is deleted.
5166        let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5167        assert_eq!(plan.deletes(), 1);
5168        assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5169    }
5170
5171    #[test]
5172    fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5173        // SYNC-9: an unreliable listing deletes nothing, stems included.
5174        let mut manifest = Manifest::new();
5175        manifest.insert(
5176            "a",
5177            entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5178        );
5179        let incomplete = vec![SourceStatus {
5180            mode: SourceMode::Mirror,
5181            fully_enumerated: false,
5182        }];
5183        let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5184        assert_eq!(plan.deletes(), 0);
5185        assert_eq!(plan.stem_deletes(), 0);
5186    }
5187
5188    #[test]
5189    fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5190        // A prior stem at a path is being removed, while a different stem is
5191        // written to that same path this run (a re-key at a stable path). The
5192        // delete must be downgraded so it can never clobber the fresh write.
5193        let mut manifest = Manifest::new();
5194        manifest.insert(
5195            "a",
5196            entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5197        );
5198        let d = vec![stem_desired(
5199            "a",
5200            Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5201        )];
5202        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5203        // The new stem is written to the shared path; the old key's delete of the
5204        // same path is suppressed (no DeleteStem survives for that path).
5205        assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5206        assert!(
5207            !plan.actions.iter().any(|a| matches!(
5208                a,
5209                Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5210            )),
5211            "a stem delete must never alias a stem write target"
5212        );
5213    }
5214}
5215
5216/// Property-based tests that lock the delete guard against random inputs.
5217///
5218/// These complement the deterministic unit tests above. The generators are
5219/// bounded (a small clip-id space, short paths and hashes) so the cases stay
5220/// cheap and CI stays stable, and failure persistence is disabled so a run
5221/// never leaves regression files behind.
5222///
5223/// The generators are fully random: `trashed`, `private`, source `modes`, and
5224/// the persisted `preserve` marker are all exercised, and the desired list may
5225/// hold duplicate ids so aggregation is covered too. The invariants below are
5226/// written to hold for every such input, so the trashed delete path is no
5227/// longer a special case hidden from the property tests.
5228#[cfg(test)]
5229mod proptests {
5230    use super::*;
5231    use proptest::collection::{btree_map, hash_map, vec};
5232    use proptest::prelude::*;
5233    use std::collections::BTreeSet;
5234
5235    type DesiredFields = (
5236        String,
5237        AudioFormat,
5238        String,
5239        String,
5240        Vec<SourceMode>,
5241        bool,
5242        bool,
5243    );
5244
5245    fn audio_format() -> impl Strategy<Value = AudioFormat> {
5246        prop_oneof![
5247            Just(AudioFormat::Mp3),
5248            Just(AudioFormat::Flac),
5249            Just(AudioFormat::Wav),
5250        ]
5251    }
5252
5253    fn source_mode() -> impl Strategy<Value = SourceMode> {
5254        prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5255    }
5256
5257    // A small id space forces overlap between the manifest and the desired set,
5258    // so deletes, renames, retags, and downloads all get exercised.
5259    fn clip_id() -> impl Strategy<Value = String> {
5260        (0u8..8).prop_map(|n| format!("c{n}"))
5261    }
5262
5263    fn small_path() -> impl Strategy<Value = String> {
5264        (0u8..6).prop_map(|n| format!("path{n}"))
5265    }
5266
5267    // The manifest entry path is the source of every `Delete.path`, so it must
5268    // occasionally be empty for INV9 to actually exercise the empty-path guard.
5269    fn manifest_path() -> impl Strategy<Value = String> {
5270        prop_oneof![
5271            1 => Just(String::new()),
5272            6 => small_path(),
5273        ]
5274    }
5275
5276    fn small_hash() -> impl Strategy<Value = String> {
5277        (0u8..4).prop_map(|n| format!("h{n}"))
5278    }
5279
5280    fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5281        (
5282            manifest_path(),
5283            audio_format(),
5284            small_hash(),
5285            small_hash(),
5286            0u64..4,
5287            any::<bool>(),
5288        )
5289            .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5290                ManifestEntry {
5291                    path,
5292                    format,
5293                    meta_hash,
5294                    art_hash,
5295                    size,
5296                    preserve,
5297                    ..Default::default()
5298                }
5299            })
5300    }
5301
5302    fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5303        btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5304    }
5305
5306    fn local_file() -> impl Strategy<Value = LocalFile> {
5307        (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5308    }
5309
5310    fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5311        hash_map(clip_id(), local_file(), 0..8)
5312    }
5313
5314    fn source_status() -> impl Strategy<Value = SourceStatus> {
5315        (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5316            mode,
5317            fully_enumerated,
5318        })
5319    }
5320
5321    fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5322        vec(source_status(), 0..5)
5323    }
5324
5325    fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5326        vec(
5327            any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5328                mode: SourceMode::Copy,
5329                fully_enumerated,
5330            }),
5331            1..5,
5332        )
5333    }
5334
5335    fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5336        (
5337            small_path(),
5338            audio_format(),
5339            small_hash(),
5340            small_hash(),
5341            vec(source_mode(), 1..3),
5342            any::<bool>(),
5343            any::<bool>(),
5344        )
5345    }
5346
5347    fn build_desired(id: String, fields: DesiredFields) -> Desired {
5348        let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5349        let clip = Clip {
5350            id,
5351            title: "t".to_string(),
5352            ..Default::default()
5353        };
5354        Desired {
5355            lineage: LineageContext::own_root(&clip),
5356            clip,
5357            path,
5358            format,
5359            meta_hash,
5360            art_hash,
5361            modes,
5362            trashed,
5363            private,
5364            artifacts: Vec::new(),
5365            stems: None,
5366        }
5367    }
5368
5369    // A desired list over the shared id space that may hold duplicate ids, so
5370    // aggregation and the trashed/private/copy folds are all exercised.
5371    fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5372        vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5373            items
5374                .into_iter()
5375                .map(|(id, fields)| build_desired(id, fields))
5376                .collect()
5377        })
5378    }
5379
5380    fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5381        desired.iter().map(|d| d.clip.id.as_str()).collect()
5382    }
5383
5384    // Ids protected from deletion: any duplicate that is private or copy-held
5385    // protects the whole id, mirroring the aggregation's union semantics.
5386    fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5387        desired
5388            .iter()
5389            .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5390            .map(|d| d.clip.id.as_str())
5391            .collect()
5392    }
5393
5394    // Ids with at least one non-trashed duplicate: the trashed fold is an
5395    // intersection, so one live duplicate keeps the clip.
5396    fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5397        desired
5398            .iter()
5399            .filter(|d| !d.trashed)
5400            .map(|d| d.clip.id.as_str())
5401            .collect()
5402    }
5403
5404    fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5405        plan.actions
5406            .iter()
5407            .filter_map(|a| match a {
5408                Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5409                _ => None,
5410            })
5411            .collect()
5412    }
5413
5414    fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5415        plan.actions
5416            .iter()
5417            .filter_map(|a| match a {
5418                Action::Download { path, .. } | Action::Reformat { path, .. } => {
5419                    Some(path.as_str())
5420                }
5421                Action::Rename { to, .. } => Some(to.as_str()),
5422                _ => None,
5423            })
5424            .collect()
5425    }
5426
5427    proptest! {
5428        #![proptest_config(ProptestConfig {
5429            cases: 256,
5430            failure_persistence: None,
5431            ..ProptestConfig::default()
5432        })]
5433
5434        // INVARIANT 1: a desired clip is deleted only when every one of its
5435        // duplicates is trashed; one live (non-trashed) duplicate keeps it.
5436        #[test]
5437        fn inv1_desired_clip_deleted_only_when_fully_trashed(
5438            manifest in manifest_strategy(),
5439            desired in desired_strategy(),
5440            local in local_strategy(),
5441            sources in sources_strategy(),
5442        ) {
5443            let plan = reconcile(&manifest, &desired, &local, &sources);
5444            let present = desired_ids(&desired);
5445            let live = non_trashed_ids(&desired);
5446            for id in delete_clip_ids(&plan) {
5447                prop_assert!(
5448                    !(present.contains(id) && live.contains(id)),
5449                    "deleted a desired clip with a non-trashed duplicate: {id}"
5450                );
5451            }
5452        }
5453
5454        // INVARIANT 2: a single not-fully-enumerated mirror source (truncated,
5455        // partial, empty, or failed listing) suppresses every deletion, trashed
5456        // clips included.
5457        #[test]
5458        fn inv2_no_delete_when_any_mirror_unenumerated(
5459            manifest in manifest_strategy(),
5460            desired in desired_strategy(),
5461            local in local_strategy(),
5462            mut sources in sources_strategy(),
5463        ) {
5464            sources.push(SourceStatus {
5465                mode: SourceMode::Mirror,
5466                fully_enumerated: false,
5467            });
5468            let plan = reconcile(&manifest, &desired, &local, &sources);
5469            prop_assert_eq!(plan.deletes(), 0);
5470        }
5471
5472        // INVARIANT 3: a copy-only run is additive and never deletes.
5473        #[test]
5474        fn inv3_all_copy_sources_means_no_deletes(
5475            manifest in manifest_strategy(),
5476            desired in desired_strategy(),
5477            local in local_strategy(),
5478            sources in copy_sources_strategy(),
5479        ) {
5480            let plan = reconcile(&manifest, &desired, &local, &sources);
5481            prop_assert_eq!(plan.deletes(), 0);
5482        }
5483
5484        // INVARIANT 4: identical inputs always yield an identical plan, and the
5485        // plan does not depend on the order of the desired or source lists.
5486        #[test]
5487        fn inv4_plan_is_deterministic(
5488            manifest in manifest_strategy(),
5489            desired in desired_strategy(),
5490            local in local_strategy(),
5491            sources in sources_strategy(),
5492        ) {
5493            let plan = reconcile(&manifest, &desired, &local, &sources);
5494
5495            let again = reconcile(&manifest, &desired, &local, &sources);
5496            prop_assert_eq!(&plan, &again);
5497
5498            let mut desired_rev = desired.clone();
5499            desired_rev.reverse();
5500            let mut sources_rev = sources.clone();
5501            sources_rev.reverse();
5502            let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5503            prop_assert_eq!(&plan, &shuffled);
5504        }
5505
5506        // INVARIANT 5: every Delete names a clip that exists in the manifest.
5507        #[test]
5508        fn inv5_every_delete_is_in_the_manifest(
5509            manifest in manifest_strategy(),
5510            desired in desired_strategy(),
5511            local in local_strategy(),
5512            sources in sources_strategy(),
5513        ) {
5514            let plan = reconcile(&manifest, &desired, &local, &sources);
5515            for id in delete_clip_ids(&plan) {
5516                prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5517            }
5518        }
5519
5520        // INVARIANT 6: never delete a copy-held or private clip, whether that
5521        // protection is in the current selection or persisted on the manifest.
5522        #[test]
5523        fn inv6_never_deletes_protected_clip(
5524            manifest in manifest_strategy(),
5525            desired in desired_strategy(),
5526            local in local_strategy(),
5527            sources in sources_strategy(),
5528        ) {
5529            let plan = reconcile(&manifest, &desired, &local, &sources);
5530            let protected = protected_ids(&desired);
5531            for id in delete_clip_ids(&plan) {
5532                prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5533                let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5534                prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5535            }
5536        }
5537
5538        // INVARIANT 7: every Delete requires deletion to be allowed for the run,
5539        // so the trashed path is no longer an exception to the enumeration guard.
5540        #[test]
5541        fn inv7_no_delete_unless_deletion_allowed(
5542            manifest in manifest_strategy(),
5543            desired in desired_strategy(),
5544            local in local_strategy(),
5545            sources in sources_strategy(),
5546        ) {
5547            let plan = reconcile(&manifest, &desired, &local, &sources);
5548            if !deletion_allowed(&sources) {
5549                prop_assert_eq!(plan.deletes(), 0);
5550            }
5551        }
5552
5553        // INVARIANT 8: at most one Delete per clip id.
5554        #[test]
5555        fn inv8_at_most_one_delete_per_clip(
5556            manifest in manifest_strategy(),
5557            desired in desired_strategy(),
5558            local in local_strategy(),
5559            sources in sources_strategy(),
5560        ) {
5561            let plan = reconcile(&manifest, &desired, &local, &sources);
5562            let ids = delete_clip_ids(&plan);
5563            let unique: BTreeSet<&str> = ids.iter().copied().collect();
5564            prop_assert_eq!(ids.len(), unique.len());
5565        }
5566
5567        // INVARIANT 9: no Delete carries an empty path.
5568        #[test]
5569        fn inv9_no_delete_with_empty_path(
5570            manifest in manifest_strategy(),
5571            desired in desired_strategy(),
5572            local in local_strategy(),
5573            sources in sources_strategy(),
5574        ) {
5575            let plan = reconcile(&manifest, &desired, &local, &sources);
5576            for action in &plan.actions {
5577                if let Action::Delete { path, .. } = action {
5578                    prop_assert!(!path.is_empty(), "delete with an empty path");
5579                }
5580            }
5581        }
5582
5583        // INVARIANT 10: no Delete path equals a file another action writes this
5584        // run, so a deletion can never clobber a just-written file.
5585        #[test]
5586        fn inv10_no_delete_aliases_a_write_target(
5587            manifest in manifest_strategy(),
5588            desired in desired_strategy(),
5589            local in local_strategy(),
5590            sources in sources_strategy(),
5591        ) {
5592            let plan = reconcile(&manifest, &desired, &local, &sources);
5593            let targets = write_target_paths(&plan);
5594            for action in &plan.actions {
5595                if let Action::Delete { path, .. } = action {
5596                    prop_assert!(
5597                        !targets.contains(path.as_str()),
5598                        "delete path {path} aliases a write target"
5599                    );
5600                }
5601            }
5602        }
5603    }
5604}