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