Skip to main content

suno_core/
reconcile.rs

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