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