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