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;
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. `VideoMp4` is deferred and
50/// intentionally absent. Per-clip classes ([`CoverJpg`](ArtifactKind::CoverJpg),
51/// [`CoverWebp`](ArtifactKind::CoverWebp), [`DetailsTxt`](ArtifactKind::DetailsTxt),
52/// [`LyricsTxt`](ArtifactKind::LyricsTxt), and [`Lrc`](ArtifactKind::Lrc)) 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 album folder's static cover (album-scoped, later phase).
68    FolderJpg,
69    /// The album folder's animated cover (album-scoped, later phase).
70    FolderWebp,
71    /// A library-root `.m3u8` playlist (library-scoped, later phase).
72    Playlist,
73}
74
75/// How a selected source treats its clips: mirror with deletion, or additive copy.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum SourceMode {
79    /// Mirror the source, deleting local files that leave it (rclone `sync`).
80    Mirror,
81    /// Copy additively; never delete (rclone `copy`).
82    Copy,
83}
84
85/// One desired clip in the current selection.
86///
87/// The caller has already deduped per account and resolved naming and format,
88/// so each entry is the authoritative target state for one clip. `modes` lists
89/// every selected source that currently holds the clip, so a clip can be held
90/// by a `Mirror` and a `Copy` source at once.
91#[derive(Debug, Clone, PartialEq)]
92pub struct Desired {
93    /// The clip itself, carried so actions can be executed without a re-fetch.
94    pub clip: Clip,
95    /// The clip's resolved lineage, carried so the executor tags with the same
96    /// root/parent/album that drove naming and the change hash.
97    pub lineage: LineageContext,
98    /// Resolved relative target path for the file.
99    pub path: String,
100    /// Resolved target format.
101    pub format: AudioFormat,
102    /// Hash of the clip's tag-bearing metadata.
103    pub meta_hash: String,
104    /// Hash of the clip's cover art.
105    pub art_hash: String,
106    /// Every selected source that currently holds this clip.
107    pub modes: Vec<SourceMode>,
108    /// True when the clip is trashed in Suno (removed from the source).
109    pub trashed: bool,
110    /// True when the clip is private; private clips are always kept.
111    pub private: bool,
112    /// The clip's desired external artifacts (cover.jpg, cover.webp, ...).
113    ///
114    /// This is the authoritative target set of sidecars for the clip: an
115    /// artifact present here is written when missing or changed, and a manifest
116    /// artifact absent here is a removed kind and reconciled for deletion. It
117    /// defaults to empty; later phases populate it (P7 covers per-song art), so
118    /// for now every production caller passes an empty vec and only tests set it.
119    pub artifacts: Vec<DesiredArtifact>,
120}
121
122/// One desired external artifact for a clip.
123///
124/// Carries where the sidecar should live, where to fetch it, and the content or
125/// source change hash that drives rewrite detection against the manifest.
126#[derive(Debug, Clone, PartialEq)]
127pub struct DesiredArtifact {
128    /// Which artifact class this is.
129    pub kind: ArtifactKind,
130    /// Resolved relative target path for the sidecar.
131    pub path: String,
132    /// The URL the sidecar's bytes are fetched from. Empty for a generated
133    /// artifact that carries its body inline via `content`.
134    pub source_url: String,
135    /// Content/source change hash; a change from the manifest triggers a write.
136    pub hash: String,
137    /// Inline body for a *generated* artifact (the text sidecars). When `Some`,
138    /// the executor writes these exact bytes and never touches the network;
139    /// fetched artifacts (covers) leave it `None`.
140    pub content: Option<String>,
141}
142
143/// The desired folder-art target for one album (one stable root id).
144///
145/// Folder art is album-scoped, so it is reconciled against the album store
146/// ([`AlbumArt`]) rather than the per-clip manifest. Each present kind carries a
147/// [`DesiredArtifact`] whose `hash` is the *content* hash of the chosen art, not
148/// the source clip id: a most-played flip that yields the same art content is a
149/// no-op (HARDENING H1). A `None` kind means the album desires no art of that
150/// kind this run (no art-bearing clip, no animated source, or the feature is
151/// off), which delete-reconciles any stored art of that kind under the shared
152/// deletion gate.
153#[derive(Debug, Clone, PartialEq)]
154pub struct AlbumDesired {
155    /// The album's stable key: the resolved root ancestor id (HARDENING H2).
156    pub root_id: String,
157    /// The desired static `folder.jpg`, from the most-played art-bearing variant.
158    pub folder_jpg: Option<DesiredArtifact>,
159    /// The desired animated `cover.webp`, from the first-created animated variant.
160    pub folder_webp: Option<DesiredArtifact>,
161}
162
163/// The desired `.m3u8` target for one playlist (a Suno playlist, or the
164/// synthetic liked feed).
165///
166/// A playlist's body is *generated* from this run's rendered audio paths, not
167/// fetched, so it is reconciled by a single content [`hash`](Self::hash) over
168/// the full rendered text (HARDENING B1: the name, member order, and every
169/// member's path/title/duration feed it). The rendered body is carried inline
170/// in [`content`](Self::content) so the executor writes it without a network
171/// round-trip. [`path`](Self::path) is `<sanitised name>.m3u8` at the library
172/// root, tracked so a rename removes the stale file.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PlaylistDesired {
175    /// The playlist's stable key: its Suno id (the synthetic `"liked"` id for
176    /// the liked feed).
177    pub id: String,
178    /// The playlist's display name, as shown on Suno.
179    pub name: String,
180    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
181    pub path: String,
182    /// The fully rendered `.m3u8` body, written inline (no fetch).
183    pub content: String,
184    /// The content hash over `content`, driving rewrite detection.
185    pub hash: String,
186}
187
188/// The caller's on-disk probe of one manifest path.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
190pub struct LocalFile {
191    /// Whether the file exists on disk.
192    pub exists: bool,
193    /// Size of the file in bytes (zero when absent).
194    pub size: u64,
195}
196
197/// Per-source enumeration status for one selected source.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct SourceStatus {
200    /// The source's mode.
201    pub mode: SourceMode,
202    /// Whether this source was completely and successfully enumerated.
203    pub fully_enumerated: bool,
204}
205
206/// One executable step in a [`Plan`].
207#[derive(Debug, Clone, PartialEq)]
208pub enum Action {
209    /// Download the clip to `path` in `format` (new, missing, or zero length).
210    Download {
211        clip: Clip,
212        lineage: LineageContext,
213        path: String,
214        format: AudioFormat,
215    },
216    /// Render the clip to `path` in `to`, replacing the prior `from` rendering.
217    ///
218    /// A format change always changes the file extension, so the prior file at
219    /// `from_path` is a different path that must be removed once the new file is
220    /// written; carrying it keeps the plan a full account of disk mutations.
221    Reformat {
222        clip: Clip,
223        path: String,
224        from_path: String,
225        from: AudioFormat,
226        to: AudioFormat,
227    },
228    /// Re-tag the existing file at `path` to match current metadata or art.
229    Retag {
230        clip: Clip,
231        lineage: LineageContext,
232        path: String,
233    },
234    /// Move the file from one relative path to another.
235    Rename { from: String, to: String },
236    /// Delete the local file for a clip that has left every mirror source.
237    Delete { path: String, clip_id: String },
238    /// Take no action for a clip; recorded so the plan is a full account.
239    Skip { clip_id: String },
240    /// Write (or rewrite) an external sidecar artifact for its owning clip.
241    ///
242    /// Emitted when the manifest lacks the artifact or its stored hash differs
243    /// from `hash`. A write is additive and never gated by deletion safety.
244    ///
245    /// `content` carries an inline body for *generated* artifacts (playlists):
246    /// when `Some`, the executor writes those exact bytes atomically and skips
247    /// the network entirely; when `None`, it fetches (and transcodes) from
248    /// `source_url` as before. A fetched artifact leaves `source_url` set and
249    /// `content` `None`; a generated one leaves `source_url` empty and `content`
250    /// `Some`.
251    WriteArtifact {
252        kind: ArtifactKind,
253        path: String,
254        source_url: String,
255        hash: String,
256        owner_id: String,
257        content: Option<String>,
258    },
259    /// Delete an external sidecar artifact (a removed kind, or a co-deleted
260    /// sidecar of a clip whose audio is being deleted).
261    ///
262    /// Only ever emitted through [`delete_artifact_action`], which shares the
263    /// audio `can_delete` gate and the owning entry's `preserve` marker, so a
264    /// sidecar is never removed on an incomplete listing or for a preserved clip.
265    DeleteArtifact {
266        kind: ArtifactKind,
267        path: String,
268        owner_id: String,
269    },
270}
271
272/// The reconcile output: an ordered, deterministic list of actions.
273///
274/// The plan is the dry-run recording. The convenience counts let the CLI
275/// summarise a run without re-walking the action list by hand.
276#[derive(Debug, Clone, Default, PartialEq)]
277pub struct Plan {
278    /// The actions, in stable order.
279    pub actions: Vec<Action>,
280}
281
282impl Plan {
283    /// Total number of actions.
284    pub fn len(&self) -> usize {
285        self.actions.len()
286    }
287
288    /// True when there are no actions.
289    pub fn is_empty(&self) -> bool {
290        self.actions.is_empty()
291    }
292
293    /// Number of [`Action::Download`] actions.
294    pub fn downloads(&self) -> usize {
295        self.count(|a| matches!(a, Action::Download { .. }))
296    }
297
298    /// Number of [`Action::Reformat`] actions.
299    pub fn reformats(&self) -> usize {
300        self.count(|a| matches!(a, Action::Reformat { .. }))
301    }
302
303    /// Number of [`Action::Retag`] actions.
304    pub fn retags(&self) -> usize {
305        self.count(|a| matches!(a, Action::Retag { .. }))
306    }
307
308    /// Number of [`Action::Rename`] actions.
309    pub fn renames(&self) -> usize {
310        self.count(|a| matches!(a, Action::Rename { .. }))
311    }
312
313    /// Number of [`Action::Delete`] actions.
314    pub fn deletes(&self) -> usize {
315        self.count(|a| matches!(a, Action::Delete { .. }))
316    }
317
318    /// Number of [`Action::Skip`] actions.
319    pub fn skips(&self) -> usize {
320        self.count(|a| matches!(a, Action::Skip { .. }))
321    }
322
323    /// Number of [`Action::WriteArtifact`] actions.
324    pub fn artifact_writes(&self) -> usize {
325        self.count(|a| matches!(a, Action::WriteArtifact { .. }))
326    }
327
328    /// Number of [`Action::DeleteArtifact`] actions.
329    pub fn artifact_deletes(&self) -> usize {
330        self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
331    }
332
333    fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
334        self.actions.iter().filter(|a| pred(a)).count()
335    }
336}
337
338/// Decide the plan for one reconcile run.
339///
340/// `local` maps a clip id to the probe of that clip's manifest path; entries
341/// are expected for clips present in `manifest`. `sources` lists every selected
342/// source with its enumeration status, which gates every deletion this run.
343///
344/// Duplicate `desired` entries for one clip id (the same clip held by a mirror
345/// and a copy source, say) are aggregated first: the result is private if any
346/// is, copy-held if any is, and trashed only if all are, so a stray trashed
347/// duplicate can never defeat a sibling's protection.
348///
349/// The output order is stable: desired clips are processed in clip-id order,
350/// then absent manifest entries in clip-id order. No output depends on hash-map
351/// iteration order.
352pub fn reconcile(
353    manifest: &Manifest,
354    desired: &[Desired],
355    local: &HashMap<String, LocalFile>,
356    sources: &[SourceStatus],
357) -> Plan {
358    let mut actions: Vec<Action> = Vec::new();
359
360    // Aggregate duplicate ids, then process in clip-id order for determinism.
361    let desired = aggregate_desired(desired);
362    let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
363
364    let can_delete = deletion_allowed(sources);
365
366    for d in &desired {
367        // Decide the audio action(s) first (unchanged), then reconcile the
368        // clip's artifacts alongside. A clip whose audio is being deleted this
369        // run has its sidecars co-deleted under the same gate; otherwise its
370        // desired artifacts are written and any removed kind reconciled.
371        let before = actions.len();
372        plan_desired(d, manifest, local, can_delete, &mut actions);
373        let audio_deleted = actions[before..]
374            .iter()
375            .any(|a| matches!(a, Action::Delete { .. }));
376        if audio_deleted {
377            co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
378        } else {
379            plan_clip_artifacts(d, manifest, can_delete, &mut actions);
380        }
381    }
382
383    // Absent manifest entries, processed in clip-id order (BTreeMap is sorted).
384    for (clip_id, _entry) in manifest.iter() {
385        if desired_ids.contains(clip_id.as_str()) {
386            continue;
387        }
388        match delete_action(clip_id, manifest, can_delete) {
389            Some(action) => {
390                actions.push(action);
391                // Co-delete the absent clip's sidecars under the same gate.
392                co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
393            }
394            // SYNC-9 / preserve / empty-path: absence is unreliable or the entry
395            // is protected, so keep the file rather than delete it.
396            None => actions.push(Action::Skip {
397                clip_id: clip_id.clone(),
398            }),
399        }
400    }
401
402    suppress_path_aliasing(&mut actions);
403    Plan { actions }
404}
405
406/// Whether clips may be deleted this run.
407///
408/// SYNC-9: deletion requires at least one selected `Mirror` source and every
409/// selected source (mirror and copy alike) fully enumerated. A failed or partial
410/// copy listing is just as unreliable as a mirror one, so it suppresses deletes
411/// too. With no mirror source there is no authoritative listing to delete
412/// against, and copy-only runs are additive.
413///
414/// This is the single deletion verdict for the run; the CLI threads the same
415/// value into [`plan_album_artifacts`] so folder-art deletes share it.
416pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
417    let mut saw_mirror = false;
418    for status in sources {
419        if !status.fully_enumerated {
420            return false;
421        }
422        if status.mode == SourceMode::Mirror {
423            saw_mirror = true;
424        }
425    }
426    saw_mirror
427}
428
429/// The single gate every `Delete` passes through.
430///
431/// Returns a [`Action::Delete`] only when deletion is allowed for the run, a
432/// manifest entry exists for the clip, its path is non-empty, and the entry is
433/// not preserve-marked. A `None` result means the caller must keep the file.
434fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
435    if !can_delete {
436        return None;
437    }
438    let entry = manifest.get(clip_id)?;
439    if entry.path.is_empty() || entry.preserve {
440        return None;
441    }
442    Some(Action::Delete {
443        path: entry.path.clone(),
444        clip_id: clip_id.to_string(),
445    })
446}
447
448/// The single gate every `DeleteArtifact` passes through.
449///
450/// This is the artifact analogue of [`delete_action`] and deliberately shares
451/// the audio deletion safety: it returns a [`Action::DeleteArtifact`] only when
452/// deletion is allowed for the run (`can_delete`, the same
453/// [`deletion_allowed`] verdict), the owning manifest entry exists, the sidecar
454/// `path` is non-empty (so an empty path can never delete the account root), and
455/// the owning entry is not `preserve`-marked (a preserved clip's artifacts are
456/// preserved too). A `None` result means the caller must keep the sidecar.
457fn delete_artifact_action(
458    owner_id: &str,
459    kind: ArtifactKind,
460    path: &str,
461    manifest: &Manifest,
462    can_delete: bool,
463) -> Option<Action> {
464    if !can_delete {
465        return None;
466    }
467    let entry = manifest.get(owner_id)?;
468    if path.is_empty() || entry.preserve {
469        return None;
470    }
471    Some(Action::DeleteArtifact {
472        kind,
473        path: path.to_string(),
474        owner_id: owner_id.to_string(),
475    })
476}
477
478/// Whether an artifact kind is a per-song sidecar reconciled per clip.
479///
480/// Only cover art lives on the manifest entry today; album/library classes
481/// (folder art, playlists) are owned by later phases and reconciled elsewhere,
482/// so per-clip planning ignores them.
483fn is_per_clip_kind(kind: ArtifactKind) -> bool {
484    matches!(
485        kind,
486        ArtifactKind::CoverJpg
487            | ArtifactKind::CoverWebp
488            | ArtifactKind::DetailsTxt
489            | ArtifactKind::LyricsTxt
490            | ArtifactKind::Lrc
491    )
492}
493
494/// Whether a no-longer-desired ("removed kind") artifact may be delete-reconciled
495/// while its owning clip's audio is kept this run.
496///
497/// Cover art deliberately opts out: a clip's art or video-preview URL can be
498/// transiently absent for a run (the feed omits it, or a fetch fails), and the
499/// desired set then simply lacks that cover. Treating that absence as a removal
500/// and deleting the on-disk sidecar would churn a perfectly good cover, so an
501/// empty/transient URL must KEEP the existing file. A cover is therefore removed
502/// only by [`co_delete_artifacts`], when the owning clip leaves every mirror
503/// source and its audio is deleted (a fully gated path). The removed-kind
504/// mechanism is kept intact for any future sidecar kind that genuinely wants it.
505///
506/// The text sidecars split on totality. [`render_clip_details`](crate::render_clip_details)
507/// is TOTAL (always renders), so a desired `DetailsTxt` is absent only when the
508/// feature is off — an unambiguous removal that is safe to delete through the
509/// shared gate. [`render_clip_lyrics`](crate::render_clip_lyrics) is PARTIAL
510/// (`None` on empty lyrics), so an absent `LyricsTxt` is ambiguous (feature off
511/// OR a transient empty-lyrics read); it opts out cover-style, so turning the
512/// lyrics feature off leaves existing `.lyrics.txt` files in place. The untimed
513/// [`Lrc`](ArtifactKind::Lrc) sidecar is partial the same way and opts out too.
514fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
515    match kind {
516        ArtifactKind::CoverJpg
517        | ArtifactKind::CoverWebp
518        | ArtifactKind::LyricsTxt
519        | ArtifactKind::Lrc => false,
520        ArtifactKind::DetailsTxt
521        | ArtifactKind::FolderJpg
522        | ArtifactKind::FolderWebp
523        | ArtifactKind::Playlist => true,
524    }
525}
526
527/// The manifest slot for a per-clip artifact kind, if that kind is stored on the
528/// entry. Album/library classes have no per-clip slot yet, so they map to
529/// `None`; the match stays generic so later phases can add slots without
530/// touching callers.
531fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
532    match kind {
533        ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
534        ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
535        ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
536        ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
537        ArtifactKind::Lrc => entry.lrc.as_ref(),
538        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
539    }
540}
541
542/// The per-clip artifacts an entry currently records, paired with their kind, in
543/// a stable order. Only the per-song sidecars live on the entry today.
544fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
545    let mut out = Vec::new();
546    if let Some(state) = &entry.cover_jpg {
547        out.push((ArtifactKind::CoverJpg, state));
548    }
549    if let Some(state) = &entry.cover_webp {
550        out.push((ArtifactKind::CoverWebp, state));
551    }
552    if let Some(state) = &entry.details_txt {
553        out.push((ArtifactKind::DetailsTxt, state));
554    }
555    if let Some(state) = &entry.lyrics_txt {
556        out.push((ArtifactKind::LyricsTxt, state));
557    }
558    if let Some(state) = &entry.lrc {
559        out.push((ArtifactKind::Lrc, state));
560    }
561    out
562}
563
564/// Set (or clear) the manifest slot for a per-clip artifact kind.
565///
566/// The executor calls this after a [`Action::WriteArtifact`] (with the new
567/// state) or a [`Action::DeleteArtifact`] (with `None`), so the kind-to-field
568/// mapping lives in exactly one place. Album/library classes have no per-clip
569/// slot yet and are no-ops.
570pub(crate) fn set_manifest_artifact(
571    entry: &mut ManifestEntry,
572    kind: ArtifactKind,
573    state: Option<ArtifactState>,
574) {
575    match kind {
576        ArtifactKind::CoverJpg => entry.cover_jpg = state,
577        ArtifactKind::CoverWebp => entry.cover_webp = state,
578        ArtifactKind::DetailsTxt => entry.details_txt = state,
579        ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
580        ArtifactKind::Lrc => entry.lrc = state,
581        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
582    }
583}
584
585/// Reconcile the artifacts of a clip whose audio is kept this run.
586///
587/// Writes each desired per-clip artifact that the manifest lacks, whose stored
588/// hash drifts, or whose stored path drifts (the audio moved). Delete-reconciles
589/// each manifest artifact whose kind is no longer desired (a removed kind)
590/// through the shared [`delete_artifact_action`] gate, unless the clip is
591/// protected this run, and unless the kind opts out of removed-kind deletion
592/// ([`removed_kind_delete_eligible`]) — cover art does, so a transient empty URL
593/// keeps its sidecar rather than deleting it.
594fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
595    let owner_id = d.clip.id.as_str();
596    let entry = manifest.get(owner_id);
597
598    for artifact in &d.artifacts {
599        // Per-clip reconcile owns only the per-song sidecars (cover.jpg/.webp).
600        // Album/library classes (folder art, playlists) belong to later phases;
601        // ignore them here so they are not rewritten every run.
602        if !is_per_clip_kind(artifact.kind) {
603            continue;
604        }
605        // A write is needed when the manifest lacks the sidecar, its bytes drift
606        // (hash), or the clip moved so the sidecar belongs at a new path (audio
607        // renamed to a new album/name). On a move the executor's WriteArtifact
608        // relocates the sidecar: it writes the new path, then removes the copy
609        // left at the previously tracked path (see `Ctx::write_artifact`).
610        // Self-healing a sidecar that is missing on disk despite a matching
611        // manifest record is deferred beyond P7 (it needs a local-artifact
612        // presence probe, as audio has).
613        let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
614            None => true,
615            Some(state) => state.hash != artifact.hash || state.path != artifact.path,
616        };
617        if needs_write {
618            out.push(Action::WriteArtifact {
619                kind: artifact.kind,
620                path: artifact.path.clone(),
621                source_url: artifact.source_url.clone(),
622                hash: artifact.hash.clone(),
623                owner_id: owner_id.to_string(),
624                content: artifact.content.clone(),
625            });
626        }
627    }
628
629    // A clip protected THIS run (private or copy-held) keeps its sidecars even
630    // when a kind is no longer desired, regardless of the persisted preserve
631    // marker (which may still be false on the run that first protects the clip).
632    // Preserve wins, so no removed-kind delete is emitted for it.
633    let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
634    if !protected_now && let Some(entry) = entry {
635        let desired_kinds: BTreeSet<ArtifactKind> = d
636            .artifacts
637            .iter()
638            .filter(|a| is_per_clip_kind(a.kind))
639            .map(|a| a.kind)
640            .collect();
641        for (kind, state) in manifest_artifacts(entry) {
642            // Cover kinds opt out of removed-kind deletion (see
643            // `removed_kind_delete_eligible`): an absent desired cover means an
644            // empty/transient URL, which must KEEP the on-disk sidecar, never
645            // delete it. Only a co-delete (audio gone) removes a cover. The loop
646            // and gate stay in place for any future kind that opts back in.
647            if removed_kind_delete_eligible(kind)
648                && !desired_kinds.contains(&kind)
649                && let Some(action) =
650                    delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
651            {
652                out.push(action);
653            }
654        }
655    }
656}
657
658/// Co-delete every sidecar of a clip whose audio is being deleted this run.
659///
660/// Each removal flows through the shared [`delete_artifact_action`] gate, so a
661/// sidecar is co-deleted only when the audio delete itself was allowed; on an
662/// incomplete listing or a preserved entry nothing is emitted.
663fn co_delete_artifacts(
664    owner_id: &str,
665    manifest: &Manifest,
666    can_delete: bool,
667    out: &mut Vec<Action>,
668) {
669    let Some(entry) = manifest.get(owner_id) else {
670        return;
671    };
672    for (kind, state) in manifest_artifacts(entry) {
673        if let Some(action) =
674            delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
675        {
676            out.push(action);
677        }
678    }
679}
680
681/// Collapse duplicate desired entries for one clip id into a single record.
682///
683/// Safety folds are order-independent: `private` and copy-held are unions, and
684/// `trashed` is an intersection. The non-safety fields (clip, path, format,
685/// hashes) are taken from a deterministic representative so the result never
686/// depends on input order.
687fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
688    let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
689    for d in desired {
690        match by_id.get_mut(d.clip.id.as_str()) {
691            None => {
692                by_id.insert(d.clip.id.as_str(), d.clone());
693            }
694            Some(acc) => {
695                let take = rep_key(d) < rep_key(acc);
696                acc.private = acc.private || d.private;
697                acc.trashed = acc.trashed && d.trashed;
698                for mode in &d.modes {
699                    if !acc.modes.contains(mode) {
700                        acc.modes.push(*mode);
701                    }
702                }
703                if take {
704                    acc.clip = d.clip.clone();
705                    acc.path = d.path.clone();
706                    acc.format = d.format;
707                    acc.meta_hash = d.meta_hash.clone();
708                    acc.art_hash = d.art_hash.clone();
709                    acc.artifacts = d.artifacts.clone();
710                }
711            }
712        }
713    }
714    let mut out: Vec<Desired> = by_id.into_values().collect();
715    for d in &mut out {
716        // Normalise modes to a canonical order so aggregation is deterministic.
717        let has_mirror = d.modes.contains(&SourceMode::Mirror);
718        let has_copy = d.modes.contains(&SourceMode::Copy);
719        d.modes.clear();
720        if has_mirror {
721            d.modes.push(SourceMode::Mirror);
722        }
723        if has_copy {
724            d.modes.push(SourceMode::Copy);
725        }
726    }
727    out
728}
729
730/// A deterministic, order-independent sort key for choosing the representative
731/// non-safety fields when aggregating duplicate desired entries.
732fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
733    let format = match d.format {
734        AudioFormat::Mp3 => 0,
735        AudioFormat::Flac => 1,
736        AudioFormat::Wav => 2,
737    };
738    (
739        d.path.as_str(),
740        d.meta_hash.as_str(),
741        d.art_hash.as_str(),
742        format,
743    )
744}
745
746/// Downgrade any delete whose path is also written by a `Download`,
747/// `Reformat`, `Rename`, or `WriteArtifact` this run, so a deletion can never
748/// clobber a file the same plan just produced. This covers both the audio
749/// [`Action::Delete`] and every artifact [`Action::DeleteArtifact`] class.
750fn suppress_path_aliasing(actions: &mut [Action]) {
751    let targets: BTreeSet<String> = actions
752        .iter()
753        .filter_map(|a| match a {
754            Action::Download { path, .. }
755            | Action::Reformat { path, .. }
756            | Action::WriteArtifact { path, .. } => Some(path.clone()),
757            Action::Rename { to, .. } => Some(to.clone()),
758            _ => None,
759        })
760        .collect();
761    for a in actions.iter_mut() {
762        if let Action::Delete { path, clip_id } = a
763            && targets.contains(path.as_str())
764        {
765            *a = Action::Skip {
766                clip_id: clip_id.clone(),
767            };
768        }
769        if let Action::DeleteArtifact { path, owner_id, .. } = a
770            && targets.contains(path.as_str())
771        {
772            *a = Action::Skip {
773                clip_id: owner_id.clone(),
774            };
775        }
776    }
777}
778
779/// Append the action(s) for one desired clip.
780fn plan_desired(
781    d: &Desired,
782    manifest: &Manifest,
783    local: &HashMap<String, LocalFile>,
784    can_delete: bool,
785    out: &mut Vec<Action>,
786) {
787    let clip_id = d.clip.id.as_str();
788    let copy_held = d.modes.contains(&SourceMode::Copy);
789
790    // SYNC-12: a trashed clip is removed from the source, so its local file is
791    // deleted, but only when neither private nor copy-held (protection beats
792    // removal) and only through the shared delete guard. If the guard refuses
793    // (deletion not allowed, no entry, empty path, or preserve-marked), keep the
794    // file rather than fall through to a re-download of a clip that is gone.
795    if d.trashed && !d.private && !copy_held {
796        match delete_action(clip_id, manifest, can_delete) {
797            Some(action) => out.push(action),
798            None => out.push(Action::Skip {
799                clip_id: clip_id.to_string(),
800            }),
801        }
802        return;
803    }
804
805    let Some(entry) = manifest.get(clip_id) else {
806        // Not in the manifest: a fresh download.
807        out.push(Action::Download {
808            clip: d.clip.clone(),
809            lineage: d.lineage.clone(),
810            path: d.path.clone(),
811            format: d.format,
812        });
813        return;
814    };
815
816    // SYNC-10: a missing or zero-length file is treated as missing and
817    // re-downloaded, even when the hashes still match.
818    let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
819    if missing {
820        out.push(Action::Download {
821            clip: d.clip.clone(),
822            lineage: d.lineage.clone(),
823            path: d.path.clone(),
824            format: d.format,
825        });
826        return;
827    }
828
829    if d.format != entry.format {
830        // Replace via re-encode; never pre-delete the existing file. The old
831        // file lives at a different extension, so carry it for cleanup.
832        out.push(Action::Reformat {
833            clip: d.clip.clone(),
834            path: d.path.clone(),
835            from_path: entry.path.clone(),
836            from: entry.format,
837            to: d.format,
838        });
839        return;
840    }
841
842    if d.path != entry.path {
843        out.push(Action::Rename {
844            from: entry.path.clone(),
845            to: d.path.clone(),
846        });
847        // A rename still needs a retag when the metadata or art drifted.
848        if meta_or_art_changed(d, entry) {
849            out.push(Action::Retag {
850                clip: d.clip.clone(),
851                lineage: d.lineage.clone(),
852                path: d.path.clone(),
853            });
854        }
855        return;
856    }
857
858    if meta_or_art_changed(d, entry) {
859        out.push(Action::Retag {
860            clip: d.clip.clone(),
861            lineage: d.lineage.clone(),
862            path: entry.path.clone(),
863        });
864        return;
865    }
866
867    out.push(Action::Skip {
868        clip_id: clip_id.to_string(),
869    });
870}
871
872/// Whether the desired metadata or art hash differs from the manifest entry.
873fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
874    d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
875}
876
877// ── Folder art (album-scoped) ───────────────────────────────────────────────
878
879/// Derive the desired folder art for every album in `desired`, grouped by the
880/// stable root id (HARDENING H2).
881///
882/// This is pure: it groups the selected clips by their resolved `root_id`, then
883/// per album chooses the folder-art sources deterministically:
884///
885/// - `folder.jpg` comes from the MOST-PLAYED art-bearing variant; ties break to
886///   the EARLIEST `created_at`, then the lexicographically smallest id. Its hash
887///   is the chosen art's content hash ([`art_hash`]), so a most-played flip to a
888///   variant sharing the same art is a no-op downstream (H1).
889/// - `cover.webp` (only when `animated_covers` is set) comes from the
890///   EARLIEST-created variant with a non-empty `video_cover_url`; ties break to
891///   the smallest id. `None` when no variant has an animated source.
892///
893/// The album folder is the common parent of the album's clips' audio paths (they
894/// share `{creator}/{album}/`); `folder.jpg` lands at `{album_dir}/folder.jpg`
895/// and the animated cover at `{album_dir}/cover.webp`.
896pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
897    let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
898    for d in desired {
899        groups
900            .entry(d.lineage.root_id.as_str())
901            .or_default()
902            .push(d);
903    }
904
905    groups
906        .into_iter()
907        .map(|(root_id, members)| {
908            let album_dir = album_dir_of(&members);
909            let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
910                kind: ArtifactKind::FolderJpg,
911                path: album_child(&album_dir, "folder.jpg"),
912                source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
913                hash: art_hash(&source.clip),
914                content: None,
915            });
916            let folder_webp = animated_covers
917                .then(|| folder_webp_source(&members))
918                .flatten()
919                .map(|source| DesiredArtifact {
920                    kind: ArtifactKind::FolderWebp,
921                    path: album_child(&album_dir, "cover.webp"),
922                    source_url: source.clip.video_cover_url.clone(),
923                    hash: art_url_hash(&source.clip.video_cover_url),
924                    content: None,
925                });
926            AlbumDesired {
927                root_id: root_id.to_owned(),
928                folder_jpg,
929                folder_webp,
930            }
931        })
932        .collect()
933}
934
935/// The album folder: the common parent of the members' audio paths.
936///
937/// The album's clips share `{creator}/{album}/`, so any member's parent is the
938/// album dir; the smallest is taken so a stray differing path stays deterministic.
939fn album_dir_of(members: &[&Desired]) -> String {
940    members
941        .iter()
942        .map(|d| parent_dir(&d.path))
943        .min()
944        .unwrap_or("")
945        .to_owned()
946}
947
948/// The most-played art-bearing variant: the `folder.jpg` source.
949///
950/// Filtered to variants that carry selectable art, then the winner MAXIMISES
951/// `play_count`, breaking ties to the EARLIEST `created_at` and then the
952/// lexicographically smallest id, so selection is fully deterministic.
953fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
954    members
955        .iter()
956        .copied()
957        .filter(|d| {
958            d.clip
959                .selected_image_url()
960                .is_some_and(|url| !url.is_empty())
961        })
962        .min_by(|a, b| {
963            b.clip
964                .play_count
965                .cmp(&a.clip.play_count)
966                .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
967                .then_with(|| a.clip.id.cmp(&b.clip.id))
968        })
969}
970
971/// The first-created animated variant: the `cover.webp` source.
972///
973/// Filtered to variants with a non-empty `video_cover_url`, then the winner is
974/// the EARLIEST `created_at`, tie-broken by the smallest id for determinism.
975fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
976    members
977        .iter()
978        .copied()
979        .filter(|d| !d.clip.video_cover_url.is_empty())
980        .min_by(|a, b| {
981            a.clip
982                .created_at
983                .cmp(&b.clip.created_at)
984                .then_with(|| a.clip.id.cmp(&b.clip.id))
985        })
986}
987
988/// The parent directory of a forward-slash relative path, or `""` at the root.
989fn parent_dir(path: &str) -> &str {
990    match path.rsplit_once('/') {
991        Some((dir, _)) => dir,
992        None => "",
993    }
994}
995
996/// Join an album dir and a file name with a forward slash, tolerating an empty
997/// dir (a path at the account root).
998fn album_child(album_dir: &str, name: &str) -> String {
999    if album_dir.is_empty() {
1000        name.to_owned()
1001    } else {
1002        format!("{album_dir}/{name}")
1003    }
1004}
1005
1006/// Plan the folder-art writes and deletes for this run's albums.
1007///
1008/// Writes are keyed on the CHOSEN ART CONTENT HASH (and the target path), never
1009/// the source clip id: for each present desired kind, a [`Action::WriteArtifact`]
1010/// is emitted only when the album store lacks that kind, its stored hash differs,
1011/// or its stored path differs. When both hash and path match, nothing is written,
1012/// so a most-played flip that resolves to the same art content is a no-op
1013/// (HARDENING H1). Exactly one write can be emitted per album per kind.
1014///
1015/// Deletes cover any stored album/kind no longer desired — the album emptied (no
1016/// selected clips root there this run) or the kind's source disappeared (no
1017/// art-bearing or animated variant). Each is emitted only when `can_delete` (the
1018/// shared [`deletion_allowed`] verdict), so folder art is never removed on an
1019/// empty, failed, partial, or truncated listing. Folder art has no preserve
1020/// concept; the `can_delete` gate is the guard.
1021///
1022/// The output is deterministic: actions are sorted by `(root_id, kind)`, and a
1023/// given `(root_id, kind)` yields at most one action (a write or a delete).
1024pub fn plan_album_artifacts(
1025    desired: &[AlbumDesired],
1026    albums: &BTreeMap<String, AlbumArt>,
1027    can_delete: bool,
1028) -> Vec<Action> {
1029    let mut actions: Vec<Action> = Vec::new();
1030    let by_root: BTreeMap<&str, &AlbumDesired> =
1031        desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1032
1033    for d in desired {
1034        let stored = albums.get(&d.root_id);
1035        for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
1036            .into_iter()
1037            .flatten()
1038        {
1039            let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1040                None => true,
1041                Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1042            };
1043            if needs_write {
1044                actions.push(Action::WriteArtifact {
1045                    kind: artifact.kind,
1046                    path: artifact.path.clone(),
1047                    source_url: artifact.source_url.clone(),
1048                    hash: artifact.hash.clone(),
1049                    owner_id: d.root_id.clone(),
1050                    content: None,
1051                });
1052            }
1053        }
1054    }
1055
1056    // Deletes are fully gated: nothing is removed on an unreliable listing.
1057    if can_delete {
1058        for (root_id, art) in albums {
1059            for (kind, state) in album_artifacts(art) {
1060                let desired_here = by_root
1061                    .get(root_id.as_str())
1062                    .is_some_and(|d| album_desires_kind(d, kind));
1063                if !desired_here && !state.path.is_empty() {
1064                    actions.push(Action::DeleteArtifact {
1065                        kind,
1066                        path: state.path.clone(),
1067                        owner_id: root_id.clone(),
1068                    });
1069                }
1070            }
1071        }
1072    }
1073
1074    actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1075    actions
1076}
1077
1078/// The folder-art artifacts an album currently stores, paired with their kind,
1079/// in a stable order.
1080fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1081    let mut out = Vec::new();
1082    if let Some(state) = &art.folder_jpg {
1083        out.push((ArtifactKind::FolderJpg, state));
1084    }
1085    if let Some(state) = &art.folder_webp {
1086        out.push((ArtifactKind::FolderWebp, state));
1087    }
1088    out
1089}
1090
1091/// Whether an [`AlbumDesired`] desires the given folder-art kind this run.
1092fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1093    match kind {
1094        ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1095        ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1096        ArtifactKind::CoverJpg
1097        | ArtifactKind::CoverWebp
1098        | ArtifactKind::DetailsTxt
1099        | ArtifactKind::LyricsTxt
1100        | ArtifactKind::Lrc
1101        | ArtifactKind::Playlist => false,
1102    }
1103}
1104
1105/// The `(root_id, kind)` sort key for a folder-art action, for deterministic order.
1106fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1107    match action {
1108        Action::WriteArtifact { owner_id, kind, .. }
1109        | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1110        _ => ("", ArtifactKind::CoverJpg),
1111    }
1112}
1113
1114/// Plan the `.m3u8` writes and deletes for this run's playlists.
1115///
1116/// # Writes
1117///
1118/// For each desired playlist a single [`Action::WriteArtifact`] of kind
1119/// [`Playlist`](ArtifactKind::Playlist) is emitted (carrying the rendered body
1120/// inline in `content`) when the store lacks the playlist, its stored hash
1121/// differs, or its stored path differs. The hash is taken over the full rendered
1122/// text, so a name, order, path, title, or duration change all trigger a rewrite
1123/// (HARDENING B1); an unchanged playlist writes nothing (idempotent).
1124///
1125/// A **rename** (the same id whose sanitised name, and so path, changed) writes
1126/// the new file and, gated exactly like a stale delete (`can_delete &&
1127/// list_fully_enumerated`), also deletes the old stored path so the previous
1128/// `<oldname>.m3u8` does not linger.
1129///
1130/// # Deletes (HARDENING B2 — paramount)
1131///
1132/// A stored playlist absent from `desired` is stale (removed on Suno) and its
1133/// file is deleted **only** when `can_delete` AND `list_fully_enumerated`. The
1134/// second gate is the playlist-specific safety valve: `list_fully_enumerated`
1135/// is `true` only when the `/api/playlist/me` listing succeeded and was fully
1136/// paginated. If that listing **failed or was not fully enumerated**, the caller
1137/// passes `list_fully_enumerated = false` (and an empty `desired`), so this
1138/// function emits **zero deletes and zero writes** and every existing `.m3u8` is
1139/// left untouched. A failed *member* fetch for one playlist is handled upstream
1140/// by excluding that id from BOTH `desired` and `stored`, so it is never treated
1141/// as stale here.
1142///
1143/// The output is deterministic (sorted by `(owner_id, kind)`) and self-suppresses
1144/// path aliasing, so a rename to a name another playlist also renders this run
1145/// downgrades the colliding delete rather than removing a just-written file.
1146pub fn plan_playlist_artifacts(
1147    desired: &[PlaylistDesired],
1148    stored: &BTreeMap<String, PlaylistState>,
1149    can_delete: bool,
1150    list_fully_enumerated: bool,
1151) -> Vec<Action> {
1152    let mut actions: Vec<Action> = Vec::new();
1153    let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1154    // Deletes (stale removals and rename cleanups) are gated on BOTH the shared
1155    // deletion verdict and a fully-enumerated playlist listing (B2).
1156    let deletes_allowed = can_delete && list_fully_enumerated;
1157
1158    for d in desired {
1159        let stored_here = stored.get(&d.id);
1160        let needs_write = match stored_here {
1161            None => true,
1162            Some(state) => state.hash != d.hash || state.path != d.path,
1163        };
1164        if needs_write {
1165            actions.push(Action::WriteArtifact {
1166                kind: ArtifactKind::Playlist,
1167                path: d.path.clone(),
1168                source_url: String::new(),
1169                hash: d.hash.clone(),
1170                owner_id: d.id.clone(),
1171                content: Some(d.content.clone()),
1172            });
1173        }
1174        // A rename changed the path: remove the old file, under the delete gate.
1175        if deletes_allowed
1176            && let Some(state) = stored_here
1177            && !state.path.is_empty()
1178            && state.path != d.path
1179        {
1180            actions.push(Action::DeleteArtifact {
1181                kind: ArtifactKind::Playlist,
1182                path: state.path.clone(),
1183                owner_id: d.id.clone(),
1184            });
1185        }
1186    }
1187
1188    // Stale playlists (removed on Suno) are deleted only under the full gate, so
1189    // a failed or partial listing never removes an existing `.m3u8` (B2).
1190    if deletes_allowed {
1191        for (id, state) in stored {
1192            if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1193                actions.push(Action::DeleteArtifact {
1194                    kind: ArtifactKind::Playlist,
1195                    path: state.path.clone(),
1196                    owner_id: id.clone(),
1197                });
1198            }
1199        }
1200    }
1201
1202    actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1203    // A rename to a name another playlist also renders this run must not delete
1204    // the file that write just produced; downgrade any such colliding delete.
1205    suppress_path_aliasing(&mut actions);
1206    actions
1207}
1208
1209/// The `(owner_id, is_delete)` sort key for a playlist action, so writes and
1210/// deletes for one id stay adjacent and order is deterministic.
1211fn playlist_action_key(action: &Action) -> (&str, u8) {
1212    match action {
1213        Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1214        Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1215        Action::Skip { clip_id } => (clip_id.as_str(), 2),
1216        _ => ("", 3),
1217    }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use crate::hash::content_hash;
1224
1225    fn clip(id: &str) -> Clip {
1226        Clip {
1227            id: id.to_string(),
1228            title: "Song".to_string(),
1229            ..Default::default()
1230        }
1231    }
1232
1233    fn lineage(id: &str) -> LineageContext {
1234        LineageContext::own_root(&clip(id))
1235    }
1236
1237    fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1238        ManifestEntry {
1239            path: path.to_string(),
1240            format,
1241            meta_hash: meta.to_string(),
1242            art_hash: art.to_string(),
1243            size: 100,
1244            preserve: false,
1245            ..Default::default()
1246        }
1247    }
1248
1249    fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1250        ManifestEntry {
1251            preserve: true,
1252            ..entry(path, format, meta, art)
1253        }
1254    }
1255
1256    fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1257        Desired {
1258            clip: clip(id),
1259            lineage: lineage(id),
1260            path: path.to_string(),
1261            format,
1262            meta_hash: meta.to_string(),
1263            art_hash: art.to_string(),
1264            modes: vec![SourceMode::Mirror],
1265            trashed: false,
1266            private: false,
1267            artifacts: Vec::new(),
1268        }
1269    }
1270
1271    fn present(size: u64) -> LocalFile {
1272        LocalFile { exists: true, size }
1273    }
1274
1275    fn local_present(id: &str) -> HashMap<String, LocalFile> {
1276        [(id.to_string(), present(100))].into_iter().collect()
1277    }
1278
1279    fn mirror_ok() -> Vec<SourceStatus> {
1280        vec![SourceStatus {
1281            mode: SourceMode::Mirror,
1282            fully_enumerated: true,
1283        }]
1284    }
1285
1286    // ── Per-clip classification ─────────────────────────────────────
1287
1288    #[test]
1289    fn not_in_manifest_downloads() {
1290        let manifest = Manifest::new();
1291        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1292        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1293        assert_eq!(
1294            plan.actions,
1295            vec![Action::Download {
1296                clip: clip("a"),
1297                lineage: lineage("a"),
1298                path: "a.flac".to_string(),
1299                format: AudioFormat::Flac,
1300            }]
1301        );
1302    }
1303
1304    #[test]
1305    fn unchanged_clip_skips() {
1306        let mut manifest = Manifest::new();
1307        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1308        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1309        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1310        assert_eq!(
1311            plan.actions,
1312            vec![Action::Skip {
1313                clip_id: "a".to_string()
1314            }]
1315        );
1316    }
1317
1318    #[test]
1319    fn meta_change_retags_in_place() {
1320        let mut manifest = Manifest::new();
1321        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1322        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1323        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1324        assert_eq!(
1325            plan.actions,
1326            vec![Action::Retag {
1327                clip: clip("a"),
1328                lineage: lineage("a"),
1329                path: "a.flac".to_string(),
1330            }]
1331        );
1332    }
1333
1334    #[test]
1335    fn art_change_retags_in_place() {
1336        let mut manifest = Manifest::new();
1337        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1338        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1339        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1340        assert_eq!(
1341            plan.actions,
1342            vec![Action::Retag {
1343                clip: clip("a"),
1344                lineage: lineage("a"),
1345                path: "a.flac".to_string(),
1346            }]
1347        );
1348    }
1349
1350    #[test]
1351    fn rename_when_path_changes() {
1352        let mut manifest = Manifest::new();
1353        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1354        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1355        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1356        assert_eq!(
1357            plan.actions,
1358            vec![Action::Rename {
1359                from: "old/a.flac".to_string(),
1360                to: "new/a.flac".to_string(),
1361            }]
1362        );
1363    }
1364
1365    #[test]
1366    fn rename_with_meta_change_also_retags() {
1367        let mut manifest = Manifest::new();
1368        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1369        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1370        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1371        assert_eq!(
1372            plan.actions,
1373            vec![
1374                Action::Rename {
1375                    from: "old/a.flac".to_string(),
1376                    to: "new/a.flac".to_string(),
1377                },
1378                Action::Retag {
1379                    clip: clip("a"),
1380                    lineage: lineage("a"),
1381                    path: "new/a.flac".to_string(),
1382                },
1383            ]
1384        );
1385    }
1386
1387    #[test]
1388    fn rename_without_meta_change_does_not_retag() {
1389        let mut manifest = Manifest::new();
1390        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1391        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1392        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1393        assert_eq!(plan.renames(), 1);
1394        assert_eq!(plan.retags(), 0);
1395    }
1396
1397    #[test]
1398    fn format_change_reformats() {
1399        let mut manifest = Manifest::new();
1400        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1401        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1402        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1403        assert_eq!(
1404            plan.actions,
1405            vec![Action::Reformat {
1406                clip: clip("a"),
1407                path: "a.mp3".to_string(),
1408                from_path: "a.flac".to_string(),
1409                from: AudioFormat::Flac,
1410                to: AudioFormat::Mp3,
1411            }]
1412        );
1413    }
1414
1415    #[test]
1416    fn format_change_takes_precedence_over_rename_and_retag() {
1417        // Format, path, and metadata all changed at once: a single reformat
1418        // replaces the file, so no separate rename or retag is emitted.
1419        let mut manifest = Manifest::new();
1420        manifest.insert(
1421            "a",
1422            entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1423        );
1424        let d = vec![desired(
1425            "a",
1426            "new/a.mp3",
1427            AudioFormat::Mp3,
1428            "new",
1429            "new-art",
1430        )];
1431        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1432        assert_eq!(plan.reformats(), 1);
1433        assert_eq!(plan.renames(), 0);
1434        assert_eq!(plan.retags(), 0);
1435    }
1436
1437    // ── SYNC-10: zero-length / missing local file ───────────────────
1438
1439    #[test]
1440    fn zero_length_file_downloads_even_when_hashes_match() {
1441        let mut manifest = Manifest::new();
1442        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1443        let local: HashMap<String, LocalFile> = [(
1444            "a".to_string(),
1445            LocalFile {
1446                exists: true,
1447                size: 0,
1448            },
1449        )]
1450        .into_iter()
1451        .collect();
1452        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1453        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1454        assert_eq!(plan.downloads(), 1);
1455        assert_eq!(plan.skips(), 0);
1456    }
1457
1458    #[test]
1459    fn missing_file_downloads_even_when_hashes_match() {
1460        let mut manifest = Manifest::new();
1461        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1462        let local: HashMap<String, LocalFile> = [(
1463            "a".to_string(),
1464            LocalFile {
1465                exists: false,
1466                size: 0,
1467            },
1468        )]
1469        .into_iter()
1470        .collect();
1471        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1472        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1473        assert_eq!(plan.downloads(), 1);
1474    }
1475
1476    #[test]
1477    fn absent_local_probe_treated_as_missing() {
1478        // A manifest clip with no probe entry is conservatively re-downloaded.
1479        let mut manifest = Manifest::new();
1480        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1481        let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1482        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1483        assert_eq!(plan.downloads(), 1);
1484    }
1485
1486    #[test]
1487    fn missing_file_download_wins_over_format_difference() {
1488        // A missing file is re-downloaded directly in the desired format rather
1489        // than reformatted from a file that is not there.
1490        let mut manifest = Manifest::new();
1491        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1492        let local: HashMap<String, LocalFile> = [(
1493            "a".to_string(),
1494            LocalFile {
1495                exists: false,
1496                size: 0,
1497            },
1498        )]
1499        .into_iter()
1500        .collect();
1501        let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1502        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1503        assert_eq!(plan.downloads(), 1);
1504        assert_eq!(plan.reformats(), 0);
1505    }
1506
1507    // ── SYNC-12: trashed and private ────────────────────────────────
1508
1509    #[test]
1510    fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1511        // A trashed clip is complete and carries no excluded type or task, so it
1512        // passes `is_downloadable` (downloadability never screens on trashed).
1513        // A full run still schedules its deletion, proving the two concerns stay
1514        // decoupled: the download filter does not suppress the delete signal.
1515        let mut trashed = clip("a");
1516        trashed.status = "complete".to_string();
1517        trashed.is_trashed = true;
1518        assert!(crate::is_downloadable(&trashed));
1519
1520        let mut manifest = Manifest::new();
1521        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1522        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1523        d.clip = trashed;
1524        d.trashed = true;
1525        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1526        assert_eq!(
1527            plan.actions,
1528            vec![Action::Delete {
1529                path: "a.flac".to_string(),
1530                clip_id: "a".to_string(),
1531            }]
1532        );
1533    }
1534
1535    #[test]
1536    fn trashed_clip_deletes_local_file() {
1537        let mut manifest = Manifest::new();
1538        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1539        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1540        d.trashed = true;
1541        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1542        assert_eq!(
1543            plan.actions,
1544            vec![Action::Delete {
1545                path: "a.flac".to_string(),
1546                clip_id: "a".to_string(),
1547            }]
1548        );
1549    }
1550
1551    #[test]
1552    fn trashed_clip_not_in_manifest_skips() {
1553        // Nothing on disk to remove, so trashing is a no-op.
1554        let manifest = Manifest::new();
1555        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1556        d.trashed = true;
1557        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1558        assert_eq!(
1559            plan.actions,
1560            vec![Action::Skip {
1561                clip_id: "a".to_string()
1562            }]
1563        );
1564    }
1565
1566    #[test]
1567    fn private_clip_is_kept() {
1568        let mut manifest = Manifest::new();
1569        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1570        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1571        d.private = true;
1572        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1573        assert_eq!(
1574            plan.actions,
1575            vec![Action::Skip {
1576                clip_id: "a".to_string()
1577            }]
1578        );
1579    }
1580
1581    #[test]
1582    fn private_beats_trashed_never_deletes() {
1583        // Safety first: a clip that is both trashed and private is kept.
1584        let mut manifest = Manifest::new();
1585        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1586        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1587        d.trashed = true;
1588        d.private = true;
1589        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1590        assert_eq!(plan.deletes(), 0);
1591        assert_eq!(plan.skips(), 1);
1592    }
1593
1594    #[test]
1595    fn copy_held_trashed_clip_is_not_deleted() {
1596        // SYNC-8: copy always wins, so a trashed clip still held by a copy
1597        // source is kept and synced rather than deleted.
1598        let mut manifest = Manifest::new();
1599        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1600        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1601        d.modes = vec![SourceMode::Copy];
1602        d.trashed = true;
1603        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1604        assert_eq!(plan.deletes(), 0);
1605        assert_eq!(
1606            plan.actions,
1607            vec![Action::Skip {
1608                clip_id: "a".to_string()
1609            }]
1610        );
1611    }
1612
1613    // ── Deletion pass: absent manifest entries ──────────────────────
1614
1615    #[test]
1616    fn absent_clip_deleted_when_all_mirrors_enumerated() {
1617        let mut manifest = Manifest::new();
1618        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1619        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1620        assert_eq!(
1621            plan.actions,
1622            vec![Action::Delete {
1623                path: "gone.flac".to_string(),
1624                clip_id: "gone".to_string(),
1625            }]
1626        );
1627    }
1628
1629    #[test]
1630    fn absent_clip_kept_when_any_mirror_not_enumerated() {
1631        let mut manifest = Manifest::new();
1632        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1633        let sources = vec![
1634            SourceStatus {
1635                mode: SourceMode::Mirror,
1636                fully_enumerated: true,
1637            },
1638            SourceStatus {
1639                mode: SourceMode::Mirror,
1640                fully_enumerated: false,
1641            },
1642        ];
1643        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1644        assert_eq!(plan.deletes(), 0);
1645        assert_eq!(
1646            plan.actions,
1647            vec![Action::Skip {
1648                clip_id: "gone".to_string()
1649            }]
1650        );
1651    }
1652
1653    #[test]
1654    fn empty_listing_cannot_cause_deletion() {
1655        // A failed or truncated listing presents as a not-fully-enumerated
1656        // mirror source: absence must never delete in that case.
1657        let mut manifest = Manifest::new();
1658        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1659        let sources = vec![SourceStatus {
1660            mode: SourceMode::Mirror,
1661            fully_enumerated: false,
1662        }];
1663        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1664        assert_eq!(plan.deletes(), 0);
1665        assert_eq!(plan.skips(), 1);
1666    }
1667
1668    #[test]
1669    fn no_mirror_sources_means_no_deletion() {
1670        // Copy-only or sourceless runs are additive: nothing is deleted.
1671        let mut manifest = Manifest::new();
1672        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1673        let copy_only = vec![SourceStatus {
1674            mode: SourceMode::Copy,
1675            fully_enumerated: true,
1676        }];
1677        assert_eq!(
1678            reconcile(&manifest, &[], &HashMap::new(), &copy_only).deletes(),
1679            0
1680        );
1681        assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1682    }
1683
1684    #[test]
1685    fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1686        let mut manifest = Manifest::new();
1687        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1688        let sources = vec![
1689            SourceStatus {
1690                mode: SourceMode::Copy,
1691                fully_enumerated: true,
1692            },
1693            SourceStatus {
1694                mode: SourceMode::Mirror,
1695                fully_enumerated: false,
1696            },
1697        ];
1698        assert_eq!(
1699            reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1700            0
1701        );
1702    }
1703
1704    #[test]
1705    fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1706        // SYNC-8 falls out naturally: a copy-held clip is in the desired set,
1707        // so it is classified there (Skip) and never reaches the delete pass,
1708        // even while a sibling clip is being deleted.
1709        let mut manifest = Manifest::new();
1710        manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1711        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1712        let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1713        held.modes = vec![SourceMode::Copy];
1714        let local: HashMap<String, LocalFile> = [
1715            ("keep".to_string(), present(100)),
1716            ("gone".to_string(), present(100)),
1717        ]
1718        .into_iter()
1719        .collect();
1720        let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1721        assert!(plan.actions.contains(&Action::Skip {
1722            clip_id: "keep".to_string()
1723        }));
1724        assert!(plan.actions.contains(&Action::Delete {
1725            path: "gone.flac".to_string(),
1726            clip_id: "gone".to_string(),
1727        }));
1728        // The copy-held clip is never deleted.
1729        assert!(
1730            !plan
1731                .actions
1732                .iter()
1733                .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1734        );
1735    }
1736
1737    // ── Item 1: persisted preserve marker ───────────────────────────
1738
1739    #[test]
1740    fn orphan_with_preserve_marker_is_kept() {
1741        // A copy-held or private clip whose source was deselected is absent from
1742        // desired, but the persisted marker still protects it from deletion.
1743        let mut manifest = Manifest::new();
1744        manifest.insert(
1745            "gone",
1746            preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1747        );
1748        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1749        assert_eq!(plan.deletes(), 0);
1750        assert_eq!(
1751            plan.actions,
1752            vec![Action::Skip {
1753                clip_id: "gone".to_string()
1754            }]
1755        );
1756    }
1757
1758    #[test]
1759    fn trashed_clip_with_preserve_marker_is_kept() {
1760        // The marker also defends the trashed path: a preserved entry is never
1761        // deleted even when the clip is trashed and fully enumerated.
1762        let mut manifest = Manifest::new();
1763        manifest.insert(
1764            "a",
1765            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1766        );
1767        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1768        d.trashed = true;
1769        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1770        assert_eq!(plan.deletes(), 0);
1771        assert_eq!(plan.skips(), 1);
1772    }
1773
1774    // ── Item 2: unified, enumeration-gated delete guard ─────────────
1775
1776    #[test]
1777    fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1778        // The trashed path now obeys the same enumeration guard as orphans.
1779        let mut manifest = Manifest::new();
1780        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1781        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1782        d.trashed = true;
1783        let sources = vec![SourceStatus {
1784            mode: SourceMode::Mirror,
1785            fully_enumerated: false,
1786        }];
1787        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1788        assert_eq!(plan.deletes(), 0);
1789        assert_eq!(plan.skips(), 1);
1790    }
1791
1792    #[test]
1793    fn trashed_clip_kept_when_sources_empty() {
1794        // With no sources there is no authoritative listing, so even a trashed
1795        // clip is kept rather than deleted.
1796        let mut manifest = Manifest::new();
1797        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1798        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1799        d.trashed = true;
1800        let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1801        assert_eq!(plan.deletes(), 0);
1802        assert_eq!(plan.skips(), 1);
1803    }
1804
1805    #[test]
1806    fn failed_copy_listing_suppresses_orphan_deletion() {
1807        // A partial or failed copy listing is as unreliable as a mirror one and
1808        // must suppress deletes, even with a fully enumerated mirror present.
1809        let mut manifest = Manifest::new();
1810        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1811        let sources = vec![
1812            SourceStatus {
1813                mode: SourceMode::Mirror,
1814                fully_enumerated: true,
1815            },
1816            SourceStatus {
1817                mode: SourceMode::Copy,
1818                fully_enumerated: false,
1819            },
1820        ];
1821        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1822        assert_eq!(plan.deletes(), 0);
1823    }
1824
1825    #[test]
1826    fn failed_copy_listing_suppresses_trashed_deletion() {
1827        let mut manifest = Manifest::new();
1828        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1829        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1830        d.trashed = true;
1831        let sources = vec![
1832            SourceStatus {
1833                mode: SourceMode::Mirror,
1834                fully_enumerated: true,
1835            },
1836            SourceStatus {
1837                mode: SourceMode::Copy,
1838                fully_enumerated: false,
1839            },
1840        ];
1841        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1842        assert_eq!(plan.deletes(), 0);
1843        assert_eq!(plan.skips(), 1);
1844    }
1845
1846    #[test]
1847    fn empty_path_entry_never_deletes() {
1848        // A default or partially written manifest entry can have an empty path;
1849        // that must never become a Delete of the account root.
1850        let mut manifest = Manifest::new();
1851        manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1852        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1853        assert_eq!(plan.deletes(), 0);
1854        assert_eq!(
1855            plan.actions,
1856            vec![Action::Skip {
1857                clip_id: "gone".to_string()
1858            }]
1859        );
1860    }
1861
1862    // ── Item 3: path aliasing suppression ───────────────────────────
1863
1864    #[test]
1865    fn delete_suppressed_when_path_aliases_rename_target() {
1866        // Clip "a" renames into the path that absent clip "b" recorded; deleting
1867        // "b" would clobber the file "a" was just moved to, so it is suppressed.
1868        let mut manifest = Manifest::new();
1869        manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1870        manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1871        let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1872        let local: HashMap<String, LocalFile> = [
1873            ("a".to_string(), present(100)),
1874            ("b".to_string(), present(100)),
1875        ]
1876        .into_iter()
1877        .collect();
1878        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1879        assert!(plan.actions.contains(&Action::Rename {
1880            from: "old/a.flac".to_string(),
1881            to: "new/a.flac".to_string(),
1882        }));
1883        // No delete targets the renamed-to path.
1884        assert!(
1885            !plan
1886                .actions
1887                .iter()
1888                .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1889        );
1890        assert!(plan.actions.contains(&Action::Skip {
1891            clip_id: "b".to_string()
1892        }));
1893    }
1894
1895    #[test]
1896    fn delete_suppressed_when_path_aliases_download_target() {
1897        // A new clip downloads to the path an absent clip recorded.
1898        let mut manifest = Manifest::new();
1899        manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1900        let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1901        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1902        assert!(
1903            !plan
1904                .actions
1905                .iter()
1906                .any(|a| matches!(a, Action::Delete { .. }))
1907        );
1908        assert_eq!(plan.downloads(), 1);
1909    }
1910
1911    #[test]
1912    fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1913        // A sidecar delete must never clobber a file a rename just produced this
1914        // run. A DeleteArtifact whose path equals a Rename's `to` is downgraded
1915        // to a Skip, exactly as an audio Delete is. Built directly so the
1916        // collision is explicit and independent of how reconcile derives it.
1917        let mut actions = vec![
1918            Action::Rename {
1919                from: "old/song.flac".to_string(),
1920                to: "new/cover.jpg".to_string(),
1921            },
1922            Action::DeleteArtifact {
1923                kind: ArtifactKind::CoverJpg,
1924                path: "new/cover.jpg".to_string(),
1925                owner_id: "a".to_string(),
1926            },
1927        ];
1928        suppress_path_aliasing(&mut actions);
1929        // The colliding delete is gone; only its Skip downgrade remains.
1930        assert!(
1931            !actions
1932                .iter()
1933                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1934            "a sidecar delete must not alias a rename target"
1935        );
1936        assert!(actions.contains(&Action::Skip {
1937            clip_id: "a".to_string()
1938        }));
1939        // The rename target is untouched.
1940        assert!(actions.contains(&Action::Rename {
1941            from: "old/song.flac".to_string(),
1942            to: "new/cover.jpg".to_string(),
1943        }));
1944    }
1945
1946    #[test]
1947    fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1948        // The same guard covers every write class: a DeleteArtifact colliding
1949        // with another artifact's WriteArtifact path is downgraded too.
1950        let mut actions = vec![
1951            Action::WriteArtifact {
1952                kind: ArtifactKind::FolderJpg,
1953                path: "creator/album/folder.jpg".to_string(),
1954                source_url: "https://art/large.jpg".to_string(),
1955                hash: "h".to_string(),
1956                owner_id: "root".to_string(),
1957                content: None,
1958            },
1959            Action::DeleteArtifact {
1960                kind: ArtifactKind::FolderJpg,
1961                path: "creator/album/folder.jpg".to_string(),
1962                owner_id: "root-old".to_string(),
1963            },
1964        ];
1965        suppress_path_aliasing(&mut actions);
1966        assert!(
1967            !actions
1968                .iter()
1969                .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1970        );
1971        assert!(actions.contains(&Action::Skip {
1972            clip_id: "root-old".to_string()
1973        }));
1974    }
1975
1976    // ── Item 5: aggregation of duplicate desired ids ────────────────
1977
1978    #[test]
1979    fn duplicate_trashed_does_not_defeat_copy_sibling() {
1980        // The same clip held by a copy source and reported trashed by a mirror:
1981        // copy wins, so it is kept, not deleted.
1982        let mut manifest = Manifest::new();
1983        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1984        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1985        copy_entry.modes = vec![SourceMode::Copy];
1986        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1987        trashed_entry.modes = vec![SourceMode::Mirror];
1988        trashed_entry.trashed = true;
1989        let plan = reconcile(
1990            &manifest,
1991            &[copy_entry, trashed_entry],
1992            &local_present("a"),
1993            &mirror_ok(),
1994        );
1995        assert_eq!(plan.deletes(), 0);
1996        assert_eq!(plan.skips(), 1);
1997    }
1998
1999    #[test]
2000    fn duplicate_trashed_does_not_defeat_private_sibling() {
2001        let mut manifest = Manifest::new();
2002        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2003        let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2004        private_entry.private = true;
2005        let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2006        trashed_entry.trashed = true;
2007        let plan = reconcile(
2008            &manifest,
2009            &[private_entry, trashed_entry],
2010            &local_present("a"),
2011            &mirror_ok(),
2012        );
2013        assert_eq!(plan.deletes(), 0);
2014        assert_eq!(plan.skips(), 1);
2015    }
2016
2017    #[test]
2018    fn duplicate_trashed_deletes_only_when_all_trashed() {
2019        // Every duplicate trashed and unprotected: a single delete results.
2020        let mut manifest = Manifest::new();
2021        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2022        let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2023        first.trashed = true;
2024        let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2025        second.trashed = true;
2026        let plan = reconcile(
2027            &manifest,
2028            &[first, second],
2029            &local_present("a"),
2030            &mirror_ok(),
2031        );
2032        assert_eq!(plan.deletes(), 1);
2033    }
2034
2035    #[test]
2036    fn duplicate_desired_unions_modes() {
2037        // Mirror and copy entries for one id aggregate to a copy-held clip.
2038        let mut manifest = Manifest::new();
2039        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2040        let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2041        mirror_entry.modes = vec![SourceMode::Mirror];
2042        mirror_entry.trashed = true;
2043        let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2044        copy_entry.modes = vec![SourceMode::Copy];
2045        let plan = reconcile(
2046            &manifest,
2047            &[mirror_entry, copy_entry],
2048            &local_present("a"),
2049            &mirror_ok(),
2050        );
2051        // Copy-held wins over the trashed mirror entry, so no delete.
2052        assert_eq!(plan.deletes(), 0);
2053    }
2054
2055    // ── Item 6: private is deletion-exempt only ─────────────────────
2056
2057    #[test]
2058    fn private_new_clip_downloads() {
2059        // Private no longer short-circuits to Skip: a missing private clip is
2060        // downloaded like any other.
2061        let manifest = Manifest::new();
2062        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2063        d.private = true;
2064        let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2065        assert_eq!(plan.downloads(), 1);
2066    }
2067
2068    #[test]
2069    fn private_zero_length_file_redownloads() {
2070        let mut manifest = Manifest::new();
2071        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2072        let local: HashMap<String, LocalFile> = [(
2073            "a".to_string(),
2074            LocalFile {
2075                exists: true,
2076                size: 0,
2077            },
2078        )]
2079        .into_iter()
2080        .collect();
2081        let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2082        d.private = true;
2083        let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2084        assert_eq!(plan.downloads(), 1);
2085    }
2086
2087    #[test]
2088    fn private_meta_change_retags() {
2089        let mut manifest = Manifest::new();
2090        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2091        let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2092        d.private = true;
2093        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2094        assert_eq!(plan.retags(), 1);
2095        assert_eq!(plan.deletes(), 0);
2096    }
2097
2098    #[test]
2099    fn absent_private_clip_protected_by_preserve_marker() {
2100        // Items 1 and 6 together: a private clip deselected from the run is
2101        // absent from desired, but its preserve marker keeps it across runs.
2102        let mut manifest = Manifest::new();
2103        manifest.insert(
2104            "a",
2105            preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2106        );
2107        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2108        assert_eq!(plan.deletes(), 0);
2109        assert_eq!(plan.skips(), 1);
2110    }
2111
2112    // ── Determinism and robustness ──────────────────────────────────
2113
2114    #[test]
2115    fn output_is_deterministic_regardless_of_input_order() {
2116        let mut manifest = Manifest::new();
2117        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2118        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2119        manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2120        let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2121            .iter()
2122            .map(|id| (id.to_string(), present(100)))
2123            .collect();
2124
2125        let forward = vec![
2126            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2127            desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2128            desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2129        ];
2130        let mut reversed = forward.clone();
2131        reversed.reverse();
2132
2133        let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2134        let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2135        assert_eq!(p1.actions, p2.actions);
2136
2137        // And the order is clip-id sorted: a (skip), b (retag), c (download),
2138        // then absent z (delete).
2139        let ids: Vec<&str> = p1
2140            .actions
2141            .iter()
2142            .map(|a| match a {
2143                Action::Skip { clip_id } => clip_id.as_str(),
2144                Action::Retag { clip, .. } => clip.id.as_str(),
2145                Action::Download { clip, .. } => clip.id.as_str(),
2146                Action::Delete { clip_id, .. } => clip_id.as_str(),
2147                Action::Reformat { clip, .. } => clip.id.as_str(),
2148                Action::Rename { to, .. } => to.as_str(),
2149                Action::WriteArtifact { owner_id, .. }
2150                | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2151            })
2152            .collect();
2153        assert_eq!(ids, ["a", "b", "c", "z"]);
2154    }
2155
2156    #[test]
2157    fn empty_inputs_do_not_panic() {
2158        let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2159        assert!(plan.is_empty());
2160        assert_eq!(plan.len(), 0);
2161    }
2162
2163    #[test]
2164    fn empty_desired_with_full_manifest_deletes_all() {
2165        let mut manifest = Manifest::new();
2166        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2167        manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2168        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2169        assert_eq!(plan.deletes(), 2);
2170    }
2171
2172    #[test]
2173    fn full_desired_with_empty_manifest_downloads_all() {
2174        let d = vec![
2175            desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2176            desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2177        ];
2178        let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2179        assert_eq!(plan.downloads(), 2);
2180    }
2181
2182    #[test]
2183    fn plan_counts_sum_to_len() {
2184        let mut manifest = Manifest::new();
2185        manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2186        manifest.insert(
2187            "retag",
2188            entry("retag.flac", AudioFormat::Flac, "old", "art"),
2189        );
2190        manifest.insert(
2191            "reformat",
2192            entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2193        );
2194        manifest.insert(
2195            "rename",
2196            entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2197        );
2198        manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2199        let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2200            .iter()
2201            .map(|id| (id.to_string(), present(100)))
2202            .collect();
2203        let d = vec![
2204            desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2205            desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2206            desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2207            desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2208            desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2209        ];
2210        let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2211        let summed = plan.downloads()
2212            + plan.reformats()
2213            + plan.retags()
2214            + plan.renames()
2215            + plan.deletes()
2216            + plan.skips();
2217        assert_eq!(summed, plan.len());
2218        assert_eq!(plan.downloads(), 1);
2219        assert_eq!(plan.reformats(), 1);
2220        assert_eq!(plan.retags(), 1);
2221        assert_eq!(plan.renames(), 1);
2222        assert_eq!(plan.deletes(), 1);
2223        assert_eq!(plan.skips(), 1);
2224    }
2225
2226    // ── Phase 6: artifact reconcile ─────────────────────────────────
2227
2228    fn cover(path: &str, hash: &str) -> ArtifactState {
2229        ArtifactState {
2230            path: path.to_string(),
2231            hash: hash.to_string(),
2232        }
2233    }
2234
2235    fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2236        DesiredArtifact {
2237            kind,
2238            path: path.to_string(),
2239            source_url: url.to_string(),
2240            hash: hash.to_string(),
2241            content: None,
2242        }
2243    }
2244
2245    /// A generated text sidecar desired artifact carrying its body inline.
2246    fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2247        DesiredArtifact {
2248            kind,
2249            path: path.to_string(),
2250            source_url: String::new(),
2251            hash: content_hash(body),
2252            content: Some(body.to_string()),
2253        }
2254    }
2255
2256    // An unchanged FLAC clip (Skip audio) that desires the given artifacts.
2257    fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2258        Desired {
2259            artifacts: arts,
2260            ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2261        }
2262    }
2263
2264    // A manifest entry for an unchanged FLAC clip carrying a cover.jpg sidecar.
2265    fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2266        ManifestEntry {
2267            cover_jpg: Some(cover(cover_path, cover_hash)),
2268            ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2269        }
2270    }
2271
2272    fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2273        plan.actions
2274            .iter()
2275            .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2276            .collect()
2277    }
2278
2279    #[test]
2280    fn write_artifact_emitted_when_manifest_lacks_it() {
2281        // The clip's audio is unchanged (Skip), but the manifest has no cover.jpg
2282        // slot, so the desired sidecar is written.
2283        let mut manifest = Manifest::new();
2284        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2285        let d = vec![desired_arts(
2286            "a",
2287            vec![art(
2288                ArtifactKind::CoverJpg,
2289                "a/cover.jpg",
2290                "https://art/a",
2291                "h1",
2292            )],
2293        )];
2294        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2295        assert_eq!(plan.artifact_writes(), 1);
2296        assert_eq!(plan.artifact_deletes(), 0);
2297        assert_eq!(plan.skips(), 1);
2298        assert_eq!(
2299            write_artifacts(&plan)[0],
2300            &Action::WriteArtifact {
2301                kind: ArtifactKind::CoverJpg,
2302                path: "a/cover.jpg".to_string(),
2303                source_url: "https://art/a".to_string(),
2304                hash: "h1".to_string(),
2305                owner_id: "a".to_string(),
2306                content: None,
2307            }
2308        );
2309    }
2310
2311    #[test]
2312    fn write_artifact_emitted_when_hash_differs() {
2313        // The manifest already tracks a cover.jpg, but its stored hash differs
2314        // from the desired one, so it is rewritten (and never delete-reconciled).
2315        let mut manifest = Manifest::new();
2316        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2317        let d = vec![desired_arts(
2318            "a",
2319            vec![art(
2320                ArtifactKind::CoverJpg,
2321                "a/cover.jpg",
2322                "https://art/a",
2323                "new",
2324            )],
2325        )];
2326        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2327        assert_eq!(plan.artifact_writes(), 1);
2328        assert_eq!(plan.artifact_deletes(), 0);
2329        if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2330            assert_eq!(hash, "new");
2331        } else {
2332            panic!("expected a WriteArtifact");
2333        }
2334    }
2335
2336    #[test]
2337    fn write_artifact_skipped_when_hash_matches() {
2338        // Present with a matching hash: no write, no delete.
2339        let mut manifest = Manifest::new();
2340        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2341        let d = vec![desired_arts(
2342            "a",
2343            vec![art(
2344                ArtifactKind::CoverJpg,
2345                "a/cover.jpg",
2346                "https://art/a",
2347                "h1",
2348            )],
2349        )];
2350        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2351        assert_eq!(plan.artifact_writes(), 0);
2352        assert_eq!(plan.artifact_deletes(), 0);
2353        assert_eq!(
2354            plan.actions,
2355            vec![Action::Skip {
2356                clip_id: "a".to_string()
2357            }]
2358        );
2359    }
2360
2361    #[test]
2362    fn removed_kind_cover_is_kept_not_deleted() {
2363        // The clip is kept but no longer desires a cover.jpg (an empty/transient
2364        // art URL this run). Covers opt out of removed-kind deletion, so the
2365        // existing sidecar is KEPT: no DeleteArtifact, no write, just a Skip.
2366        // This is the empty-art-URL keep the P6 review deferred to P7.
2367        let mut manifest = Manifest::new();
2368        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2369        let d = vec![desired_arts("a", vec![])];
2370        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2371        assert_eq!(plan.artifact_deletes(), 0);
2372        assert_eq!(plan.artifact_writes(), 0);
2373        // The audio is untouched and the cover is preserved on disk.
2374        assert_eq!(plan.deletes(), 0);
2375        assert_eq!(
2376            plan.actions,
2377            vec![Action::Skip {
2378                clip_id: "a".to_string()
2379            }]
2380        );
2381        assert!(!plan.actions.iter().any(|a| matches!(
2382            a,
2383            Action::DeleteArtifact {
2384                kind: ArtifactKind::CoverJpg,
2385                ..
2386            }
2387        )));
2388    }
2389
2390    #[test]
2391    fn delete_artifact_never_on_incomplete_listing() {
2392        // Kept clips no longer desiring their covers keep them: covers opt out of
2393        // removed-kind deletion. An incomplete mirror is a further backstop that
2394        // forbids every delete (the B2 gate on the co-delete path). Either way, a
2395        // large manifest of stale sidecars is safe.
2396        let mut manifest = Manifest::new();
2397        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2398        manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2399        let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2400        let sources = vec![SourceStatus {
2401            mode: SourceMode::Mirror,
2402            fully_enumerated: false,
2403        }];
2404        let local: HashMap<String, LocalFile> = [
2405            ("a".to_string(), present(100)),
2406            ("b".to_string(), present(100)),
2407        ]
2408        .into_iter()
2409        .collect();
2410        let plan = reconcile(&manifest, &d, &local, &sources);
2411        assert_eq!(plan.artifact_deletes(), 0);
2412        assert_eq!(plan.deletes(), 0);
2413    }
2414
2415    #[test]
2416    fn delete_artifact_never_when_entry_preserved() {
2417        // A kept clip that stops desiring its cover keeps it (covers opt out of
2418        // removed-kind deletion); the preserve marker is a further backstop.
2419        let mut manifest = Manifest::new();
2420        let preserved = ManifestEntry {
2421            preserve: true,
2422            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2423        };
2424        manifest.insert("a", preserved);
2425        let d = vec![desired_arts("a", vec![])];
2426        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2427        assert_eq!(plan.artifact_deletes(), 0);
2428    }
2429
2430    #[test]
2431    fn co_delete_never_when_path_empty() {
2432        // The empty-path guard now matters on the co-delete path (covers opt out
2433        // of removed-kind deletion). An absent clip's audio is deleted, but its
2434        // sidecar with an empty path must never become a delete of the root.
2435        let mut manifest = Manifest::new();
2436        manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2437        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2438        assert_eq!(plan.deletes(), 1);
2439        assert_eq!(plan.artifact_deletes(), 0);
2440    }
2441
2442    #[test]
2443    fn co_delete_absent_clip_deletes_audio_and_cover() {
2444        // A clip absent from desired is deleted; its cover.jpg is co-deleted
2445        // under the same gate.
2446        let mut manifest = Manifest::new();
2447        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2448        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2449        assert_eq!(plan.deletes(), 1);
2450        assert_eq!(plan.artifact_deletes(), 1);
2451        assert!(plan.actions.contains(&Action::Delete {
2452            path: "gone.flac".to_string(),
2453            clip_id: "gone".to_string(),
2454        }));
2455        assert!(plan.actions.contains(&Action::DeleteArtifact {
2456            kind: ArtifactKind::CoverJpg,
2457            path: "gone/cover.jpg".to_string(),
2458            owner_id: "gone".to_string(),
2459        }));
2460    }
2461
2462    #[test]
2463    fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2464        // Neither audio nor sidecar is removed on an incomplete listing.
2465        let mut manifest = Manifest::new();
2466        manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2467        let sources = vec![SourceStatus {
2468            mode: SourceMode::Mirror,
2469            fully_enumerated: false,
2470        }];
2471        let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2472        assert_eq!(plan.deletes(), 0);
2473        assert_eq!(plan.artifact_deletes(), 0);
2474    }
2475
2476    #[test]
2477    fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2478        // A trashed clip present in desired: audio Delete plus cover co-delete.
2479        let mut manifest = Manifest::new();
2480        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2481        let mut d = desired_arts("a", vec![]);
2482        d.trashed = true;
2483        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2484        assert_eq!(plan.deletes(), 1);
2485        assert_eq!(plan.artifact_deletes(), 1);
2486    }
2487
2488    #[test]
2489    fn co_delete_trashed_suppressed_when_not_enumerated() {
2490        // The trashed co-delete obeys the same enumeration gate as the audio.
2491        let mut manifest = Manifest::new();
2492        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2493        let mut d = desired_arts("a", vec![]);
2494        d.trashed = true;
2495        let sources = vec![SourceStatus {
2496            mode: SourceMode::Mirror,
2497            fully_enumerated: false,
2498        }];
2499        let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2500        assert_eq!(plan.deletes(), 0);
2501        assert_eq!(plan.artifact_deletes(), 0);
2502        assert_eq!(plan.skips(), 1);
2503    }
2504
2505    #[test]
2506    fn co_delete_trashed_suppressed_when_preserved() {
2507        // A preserved, trashed clip keeps both audio and sidecar.
2508        let mut manifest = Manifest::new();
2509        let preserved = ManifestEntry {
2510            preserve: true,
2511            ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2512        };
2513        manifest.insert("a", preserved);
2514        let mut d = desired_arts("a", vec![]);
2515        d.trashed = true;
2516        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2517        assert_eq!(plan.deletes(), 0);
2518        assert_eq!(plan.artifact_deletes(), 0);
2519    }
2520
2521    // ── Issue #15: per-song text sidecars ───────────────────────────
2522
2523    #[test]
2524    fn details_sidecar_written_with_inline_content_when_slot_absent() {
2525        // The audio is unchanged (Skip) but no details slot exists, so the
2526        // generated sidecar is written and carries its body inline.
2527        let mut manifest = Manifest::new();
2528        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2529        let d = vec![desired_arts(
2530            "a",
2531            vec![text_art(
2532                ArtifactKind::DetailsTxt,
2533                "a.details.txt",
2534                "Title: A\n",
2535            )],
2536        )];
2537        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2538        assert_eq!(plan.artifact_writes(), 1);
2539        assert_eq!(plan.artifact_deletes(), 0);
2540        assert_eq!(
2541            write_artifacts(&plan)[0],
2542            &Action::WriteArtifact {
2543                kind: ArtifactKind::DetailsTxt,
2544                path: "a.details.txt".to_string(),
2545                source_url: String::new(),
2546                hash: content_hash("Title: A\n"),
2547                owner_id: "a".to_string(),
2548                content: Some("Title: A\n".to_string()),
2549            }
2550        );
2551    }
2552
2553    #[test]
2554    fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2555        // The audio is unchanged (Skip) but no lrc slot exists, so the generated
2556        // sidecar is written and carries its body inline. This is the guard that
2557        // the type system cannot provide: dropping Lrc from is_per_clip_kind
2558        // would silently never write the file, and only this test would catch it.
2559        let mut manifest = Manifest::new();
2560        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2561        let body = "[re:rs-suno]\nla la\n";
2562        let d = vec![desired_arts(
2563            "a",
2564            vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2565        )];
2566        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2567        assert_eq!(plan.artifact_writes(), 1);
2568        assert_eq!(plan.artifact_deletes(), 0);
2569        assert_eq!(
2570            write_artifacts(&plan)[0],
2571            &Action::WriteArtifact {
2572                kind: ArtifactKind::Lrc,
2573                path: "a.lrc".to_string(),
2574                source_url: String::new(),
2575                hash: content_hash(body),
2576                owner_id: "a".to_string(),
2577                content: Some(body.to_string()),
2578            }
2579        );
2580    }
2581
2582    #[test]
2583    fn text_sidecars_skipped_when_hash_and_path_match() {
2584        // Present with a matching content hash and path: no write, no delete.
2585        let mut manifest = Manifest::new();
2586        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2587        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2588        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2589        manifest.insert("a", e);
2590        let d = vec![desired_arts(
2591            "a",
2592            vec![
2593                text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2594                text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2595            ],
2596        )];
2597        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2598        assert_eq!(plan.artifact_writes(), 0);
2599        assert_eq!(plan.artifact_deletes(), 0);
2600    }
2601
2602    #[test]
2603    fn details_rewritten_when_content_hash_differs() {
2604        // A title change alters the details body, so its content hash drifts and
2605        // the sidecar is rewritten even though the audio is otherwise unchanged.
2606        let mut manifest = Manifest::new();
2607        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2608        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2609        manifest.insert("a", e);
2610        let d = vec![desired_arts(
2611            "a",
2612            vec![text_art(
2613                ArtifactKind::DetailsTxt,
2614                "a.details.txt",
2615                "Title: New\n",
2616            )],
2617        )];
2618        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2619        assert_eq!(plan.artifact_writes(), 1);
2620        assert_eq!(plan.artifact_deletes(), 0);
2621    }
2622
2623    #[test]
2624    fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2625        // The per-sidecar content hash keys on the rendered lyrics independently
2626        // of the audio's stored meta_hash, so editing the sidecar body rewrites
2627        // the file with no audio retag even when the meta_hash slot is unchanged.
2628        let mut manifest = Manifest::new();
2629        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2630        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2631        manifest.insert("a", e);
2632        let d = vec![desired_arts(
2633            "a",
2634            vec![text_art(
2635                ArtifactKind::LyricsTxt,
2636                "a.lyrics.txt",
2637                "new words\n",
2638            )],
2639        )];
2640        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2641        // The audio meta_hash matches ("m"), so only the sidecar rewrites.
2642        assert_eq!(plan.artifact_writes(), 1);
2643        assert_eq!(plan.retags(), 0);
2644    }
2645
2646    #[test]
2647    fn text_sidecar_relocated_when_path_differs() {
2648        // The audio moved (rename), so the tracked details path drifts and the
2649        // sidecar is rewritten at the new path even though the content matches.
2650        let mut manifest = Manifest::new();
2651        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2652        e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2653        manifest.insert("a", e);
2654        let d = vec![desired_arts(
2655            "a",
2656            vec![text_art(
2657                ArtifactKind::DetailsTxt,
2658                "new/a.details.txt",
2659                "Title: A\n",
2660            )],
2661        )];
2662        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2663        assert_eq!(plan.artifact_writes(), 1);
2664        if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2665            assert_eq!(path, "new/a.details.txt");
2666        } else {
2667            panic!("expected a WriteArtifact");
2668        }
2669    }
2670
2671    #[test]
2672    fn details_removed_kind_is_deleted_when_feature_off() {
2673        // DetailsTxt is total, so an absent desired can only mean the feature is
2674        // off: the stale sidecar is delete-reconciled through the shared gate.
2675        let mut manifest = Manifest::new();
2676        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2677        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2678        manifest.insert("a", e);
2679        let d = vec![desired_arts("a", vec![])];
2680        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2681        assert_eq!(plan.artifact_deletes(), 1);
2682        assert!(plan.actions.contains(&Action::DeleteArtifact {
2683            kind: ArtifactKind::DetailsTxt,
2684            path: "a.details.txt".to_string(),
2685            owner_id: "a".to_string(),
2686        }));
2687    }
2688
2689    #[test]
2690    fn lyrics_removed_kind_is_kept_not_deleted() {
2691        // LyricsTxt is partial (absent could be feature-off OR a transient empty
2692        // lyrics read), so it opts out of removed-kind deletion cover-style: the
2693        // existing file is KEPT when no lyrics sidecar is desired this run.
2694        let mut manifest = Manifest::new();
2695        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2696        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2697        manifest.insert("a", e);
2698        let d = vec![desired_arts("a", vec![])];
2699        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2700        assert_eq!(plan.artifact_deletes(), 0);
2701        assert_eq!(plan.deletes(), 0);
2702    }
2703
2704    #[test]
2705    fn lrc_removed_kind_is_kept_not_deleted() {
2706        // Lrc is partial like LyricsTxt, so it opts out of removed-kind deletion:
2707        // an existing `.lrc` is KEPT when no lrc sidecar is desired this run.
2708        let mut manifest = Manifest::new();
2709        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2710        e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2711        manifest.insert("a", e);
2712        let d = vec![desired_arts("a", vec![])];
2713        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2714        assert_eq!(plan.artifact_deletes(), 0);
2715        assert_eq!(plan.deletes(), 0);
2716    }
2717
2718    #[test]
2719    fn details_removed_kind_not_deleted_on_incomplete_listing() {
2720        // The removed-kind delete still obeys the enumeration gate: an incomplete
2721        // mirror forbids removing the stale details sidecar.
2722        let mut manifest = Manifest::new();
2723        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2724        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2725        manifest.insert("a", e);
2726        let d = vec![desired_arts("a", vec![])];
2727        let sources = vec![SourceStatus {
2728            mode: SourceMode::Mirror,
2729            fully_enumerated: false,
2730        }];
2731        let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2732        assert_eq!(plan.artifact_deletes(), 0);
2733    }
2734
2735    #[test]
2736    fn details_removed_kind_not_deleted_when_preserved() {
2737        // A preserved (private/copy-held) clip keeps its stale details sidecar
2738        // even when the feature is off this run.
2739        let mut manifest = Manifest::new();
2740        let mut e = ManifestEntry {
2741            preserve: true,
2742            ..entry("a.flac", AudioFormat::Flac, "m", "art")
2743        };
2744        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2745        manifest.insert("a", e);
2746        let d = vec![desired_arts("a", vec![])];
2747        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2748        assert_eq!(plan.artifact_deletes(), 0);
2749    }
2750
2751    #[test]
2752    fn co_delete_orphan_removes_every_text_sidecar() {
2753        // An orphaned clip's audio is deleted; ALL its per-clip sidecars must be
2754        // co-deleted. This fails if `manifest_artifacts` misses a kind, which
2755        // would strand the file. Guards the single most important #15 wiring.
2756        let mut manifest = Manifest::new();
2757        let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2758        e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2759        e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2760        e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2761        e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2762        manifest.insert("gone", e);
2763        let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2764        assert_eq!(plan.deletes(), 1);
2765        assert_eq!(plan.artifact_deletes(), 4);
2766        for (kind, path) in [
2767            (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2768            (ArtifactKind::DetailsTxt, "gone.details.txt"),
2769            (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2770            (ArtifactKind::Lrc, "gone.lrc"),
2771        ] {
2772            assert!(
2773                plan.actions.contains(&Action::DeleteArtifact {
2774                    kind,
2775                    path: path.to_string(),
2776                    owner_id: "gone".to_string(),
2777                }),
2778                "missing co-delete for {kind:?}"
2779            );
2780        }
2781    }
2782
2783    #[test]
2784    fn co_delete_trashed_removes_every_text_sidecar() {
2785        // The same co-delete completeness holds on the trashed path.
2786        let mut manifest = Manifest::new();
2787        let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2788        e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2789        e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2790        manifest.insert("a", e);
2791        let mut d = desired_arts("a", vec![]);
2792        d.trashed = true;
2793        let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2794        assert_eq!(plan.deletes(), 1);
2795        assert_eq!(plan.artifact_deletes(), 2);
2796    }
2797
2798    #[test]
2799    fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2800        // Clip "a" writes a cover to the very path clip "b"'s stale cover holds;
2801        // deleting it would clobber the freshly written file, so it is dropped.
2802        let mut manifest = Manifest::new();
2803        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2804        manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2805        // "a" writes a new CoverJpg to the shared path; "b" is absent (its cover
2806        // would be co-deleted from the same path).
2807        let d = vec![desired_arts(
2808            "a",
2809            vec![art(
2810                ArtifactKind::CoverJpg,
2811                "shared/cover.jpg",
2812                "https://art/a",
2813                "h2",
2814            )],
2815        )];
2816        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2817        assert_eq!(plan.artifact_writes(), 1);
2818        // The colliding DeleteArtifact is suppressed.
2819        assert!(!plan.actions.iter().any(
2820            |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2821        ));
2822        // The audio for "b" is still deleted (different path), just not its cover.
2823        assert!(plan.actions.contains(&Action::Delete {
2824            path: "b.flac".to_string(),
2825            clip_id: "b".to_string(),
2826        }));
2827    }
2828
2829    #[test]
2830    fn suppress_downgrades_delete_artifact_colliding_with_download() {
2831        // A fresh clip downloads audio to the path an absent clip's cover holds.
2832        let mut manifest = Manifest::new();
2833        manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2834        let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2835        let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2836        assert_eq!(plan.downloads(), 1);
2837        assert!(
2838            !plan
2839                .actions
2840                .iter()
2841                .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2842        );
2843    }
2844
2845    #[test]
2846    fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2847        // SYNC-8/9/10/12 matrix invariance: the audio actions and plan.deletes()
2848        // are identical with and without artifacts attached. One absent clip is
2849        // deleted, one desired clip is kept (Skip), one trashed clip is deleted.
2850        let build = |with_art: bool| {
2851            let mut manifest = Manifest::new();
2852            manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2853            manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2854            manifest.insert(
2855                "trash",
2856                entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2857            );
2858            let keep = if with_art {
2859                desired_arts(
2860                    "keep",
2861                    vec![art(
2862                        ArtifactKind::CoverJpg,
2863                        "keep/cover.jpg",
2864                        "https://art/keep",
2865                        "h1",
2866                    )],
2867                )
2868            } else {
2869                desired_arts("keep", vec![])
2870            };
2871            let mut trash = desired_arts("trash", vec![]);
2872            trash.trashed = true;
2873            let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2874                .iter()
2875                .map(|id| (id.to_string(), present(100)))
2876                .collect();
2877            reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2878        };
2879
2880        let with = build(true);
2881        let without = build(false);
2882
2883        // The audio decisions are identical regardless of artifacts.
2884        let audio = |plan: &Plan| -> Vec<Action> {
2885            plan.actions
2886                .iter()
2887                .filter(|a| {
2888                    !matches!(
2889                        a,
2890                        Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2891                    )
2892                })
2893                .cloned()
2894                .collect()
2895        };
2896        assert_eq!(audio(&with), audio(&without));
2897        assert_eq!(with.deletes(), without.deletes());
2898        // gone + trash audio deletes, unaffected by the artifacts.
2899        assert_eq!(with.deletes(), 2);
2900        // The `with` run additionally reconciles sidecars: gone + trash covers
2901        // co-deleted, and keep's cover matches so it is neither written nor
2902        // deleted.
2903        assert_eq!(with.artifact_deletes(), 2);
2904        assert_eq!(with.artifact_writes(), 0);
2905    }
2906
2907    // ── Phase 6 review fixes: protection, path-drift, kind guard ─────
2908
2909    #[test]
2910    fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2911        // Covers opt out of removed-kind deletion, so a kept clip keeps its cover
2912        // regardless of protection. This case additionally proves protection is
2913        // honoured: a private clip and a copy-held clip each keep a removed-kind
2914        // cover even though the persisted entry is NOT preserve-marked and the
2915        // mirror is fully enumerated.
2916        let mut manifest = Manifest::new();
2917        manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2918        assert!(!manifest.get("a").unwrap().preserve);
2919
2920        // Private this run.
2921        let private = Desired {
2922            private: true,
2923            ..desired_arts("a", vec![])
2924        };
2925        let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2926        assert_eq!(plan.artifact_deletes(), 0);
2927
2928        // Copy-held this run (modes contains Copy).
2929        let copy_held = Desired {
2930            modes: vec![SourceMode::Copy],
2931            ..desired_arts("a", vec![])
2932        };
2933        let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2934        assert_eq!(plan.artifact_deletes(), 0);
2935    }
2936
2937    #[test]
2938    fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2939        // The audio moved (new album/name) so the sidecar belongs at a new path;
2940        // the bytes are unchanged (same hash) but a rewrite at the new path is
2941        // still required. Reconcile emits no DeleteArtifact for the old path: the
2942        // executor's WriteArtifact relocates the sidecar (writes new, removes the
2943        // old copy), so the plan stays a single write.
2944        let mut manifest = Manifest::new();
2945        manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2946        let d = vec![desired_arts(
2947            "a",
2948            vec![art(
2949                ArtifactKind::CoverJpg,
2950                "new/cover.jpg",
2951                "https://art/a",
2952                "h1",
2953            )],
2954        )];
2955        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2956        assert_eq!(plan.artifact_writes(), 1);
2957        assert_eq!(plan.artifact_deletes(), 0);
2958        if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2959            assert_eq!(path, "new/cover.jpg");
2960        } else {
2961            panic!("expected a WriteArtifact");
2962        }
2963    }
2964
2965    #[test]
2966    fn per_clip_reconcile_ignores_album_and_library_kinds() {
2967        // Album/library kinds must never be written per clip (they have no
2968        // per-song manifest slot, so they would be rewritten every run). A
2969        // CoverJpg alongside them is still handled.
2970        let mut manifest = Manifest::new();
2971        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2972        let d = vec![desired_arts(
2973            "a",
2974            vec![
2975                art(
2976                    ArtifactKind::FolderJpg,
2977                    "a/folder.jpg",
2978                    "https://art/folder",
2979                    "hf",
2980                ),
2981                art(
2982                    ArtifactKind::Playlist,
2983                    "a/list.m3u",
2984                    "https://art/list",
2985                    "hp",
2986                ),
2987                art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2988            ],
2989        )];
2990        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2991        assert_eq!(plan.artifact_writes(), 1);
2992        let paths: Vec<&str> = plan
2993            .actions
2994            .iter()
2995            .filter_map(|a| match a {
2996                Action::WriteArtifact { path, .. } => Some(path.as_str()),
2997                _ => None,
2998            })
2999            .collect();
3000        assert_eq!(paths, vec!["a/cover.jpg"]);
3001    }
3002
3003    #[test]
3004    fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3005        let mut manifest = Manifest::new();
3006        manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3007        let d = vec![desired_arts(
3008            "a",
3009            vec![art(
3010                ArtifactKind::FolderWebp,
3011                "a/folder.webp",
3012                "https://art/folder",
3013                "hf",
3014            )],
3015        )];
3016        let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3017        assert_eq!(plan.artifact_writes(), 0);
3018        assert_eq!(plan.artifact_deletes(), 0);
3019    }
3020
3021    // ── Phase 8: folder art (album-scoped) ──────────────────────────
3022
3023    fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3024        Clip {
3025            id: id.to_string(),
3026            title: "Song".to_string(),
3027            image_large_url: image.to_string(),
3028            video_cover_url: video.to_string(),
3029            play_count,
3030            created_at: created_at.to_string(),
3031            ..Default::default()
3032        }
3033    }
3034
3035    fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3036        let mut lineage = LineageContext::own_root(&clip);
3037        lineage.root_id = root_id.to_string();
3038        Desired {
3039            clip,
3040            lineage,
3041            path: path.to_string(),
3042            format: AudioFormat::Flac,
3043            meta_hash: "m".to_string(),
3044            art_hash: "a".to_string(),
3045            modes: vec![SourceMode::Mirror],
3046            trashed: false,
3047            private: false,
3048            artifacts: Vec::new(),
3049        }
3050    }
3051
3052    fn stored(path: &str, hash: &str) -> ArtifactState {
3053        ArtifactState {
3054            path: path.to_string(),
3055            hash: hash.to_string(),
3056        }
3057    }
3058
3059    #[test]
3060    fn folder_jpg_source_is_most_played() {
3061        let members = vec![
3062            album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3063            album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3064            album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3065        ];
3066        let albums = album_desired(&members, false);
3067        assert_eq!(albums.len(), 1);
3068        let jpg = albums[0].folder_jpg.as_ref().unwrap();
3069        // "b" has the highest play_count, so its art content hash wins.
3070        assert_eq!(jpg.hash, art_url_hash("art-b"));
3071        assert_eq!(jpg.source_url, "art-b");
3072        assert_eq!(jpg.path, "c/al/folder.jpg");
3073        assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3074    }
3075
3076    #[test]
3077    fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3078        // Equal play_count: earliest created_at wins.
3079        let by_time = vec![
3080            album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3081            album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3082            album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3083        ];
3084        let jpg = album_desired(&by_time, false)[0]
3085            .folder_jpg
3086            .clone()
3087            .unwrap();
3088        assert_eq!(jpg.source_url, "art-y");
3089
3090        // Equal play_count and created_at: lexicographically smallest id wins.
3091        let by_id = vec![
3092            album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3093            album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3094        ];
3095        let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3096        assert_eq!(jpg.source_url, "art-g");
3097    }
3098
3099    #[test]
3100    fn folder_webp_source_is_first_created_animated() {
3101        let members = vec![
3102            album_member(
3103                album_clip("a", 9, "t2", "art-a", "vid-a"),
3104                "root",
3105                "c/al/a.flac",
3106            ),
3107            album_member(
3108                album_clip("b", 1, "t0", "art-b", "vid-b"),
3109                "root",
3110                "c/al/b.flac",
3111            ),
3112            album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3113        ];
3114        let webp = album_desired(&members, true)[0]
3115            .folder_webp
3116            .clone()
3117            .unwrap();
3118        // "b" is earliest-created with an animated source, regardless of plays.
3119        assert_eq!(webp.source_url, "vid-b");
3120        assert_eq!(webp.hash, art_url_hash("vid-b"));
3121        assert_eq!(webp.path, "c/al/cover.webp");
3122        assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3123    }
3124
3125    #[test]
3126    fn animated_covers_off_yields_no_folder_webp() {
3127        let members = vec![album_member(
3128            album_clip("a", 1, "t0", "art-a", "vid-a"),
3129            "root",
3130            "c/al/a.flac",
3131        )];
3132        let off = album_desired(&members, false);
3133        assert!(off[0].folder_webp.is_none());
3134        let on = album_desired(&members, true);
3135        assert!(on[0].folder_webp.is_some());
3136    }
3137
3138    #[test]
3139    fn album_with_no_art_yields_no_folder_jpg() {
3140        let members = vec![album_member(
3141            album_clip("a", 3, "t0", "", ""),
3142            "root",
3143            "c/al/a.flac",
3144        )];
3145        let albums = album_desired(&members, true);
3146        assert!(albums[0].folder_jpg.is_none());
3147        assert!(albums[0].folder_webp.is_none());
3148    }
3149
3150    #[test]
3151    fn album_desired_groups_by_root_id() {
3152        let members = vec![
3153            album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3154            album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3155            album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3156        ];
3157        let albums = album_desired(&members, false);
3158        assert_eq!(albums.len(), 2);
3159        assert_eq!(albums[0].root_id, "r1");
3160        assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3161        assert_eq!(
3162            albums[0].folder_jpg.as_ref().unwrap().path,
3163            "c/al1/folder.jpg"
3164        );
3165        assert_eq!(albums[1].root_id, "r2");
3166        assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3167        assert_eq!(
3168            albums[1].folder_jpg.as_ref().unwrap().path,
3169            "c/al2/folder.jpg"
3170        );
3171    }
3172
3173    #[test]
3174    fn plan_writes_folder_art_when_store_empty() {
3175        let members = vec![album_member(
3176            album_clip("a", 1, "t0", "art-a", "vid-a"),
3177            "root",
3178            "c/al/a.flac",
3179        )];
3180        let desired = album_desired(&members, true);
3181        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3182        assert_eq!(
3183            actions,
3184            vec![
3185                Action::WriteArtifact {
3186                    kind: ArtifactKind::FolderJpg,
3187                    path: "c/al/folder.jpg".to_string(),
3188                    source_url: "art-a".to_string(),
3189                    hash: art_url_hash("art-a"),
3190                    owner_id: "root".to_string(),
3191                    content: None,
3192                },
3193                Action::WriteArtifact {
3194                    kind: ArtifactKind::FolderWebp,
3195                    path: "c/al/cover.webp".to_string(),
3196                    source_url: "vid-a".to_string(),
3197                    hash: art_url_hash("vid-a"),
3198                    owner_id: "root".to_string(),
3199                    content: None,
3200                },
3201            ]
3202        );
3203    }
3204
3205    #[test]
3206    fn plan_skips_when_hash_and_path_match() {
3207        let members = vec![album_member(
3208            album_clip("a", 1, "t0", "art-a", ""),
3209            "root",
3210            "c/al/a.flac",
3211        )];
3212        let desired = album_desired(&members, false);
3213        let mut albums = BTreeMap::new();
3214        albums.insert(
3215            "root".to_string(),
3216            AlbumArt {
3217                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3218                folder_webp: None,
3219            },
3220        );
3221        assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3222    }
3223
3224    #[test]
3225    fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3226        let members = vec![album_member(
3227            album_clip("a", 1, "t0", "art-a", ""),
3228            "root",
3229            "c/al/a.flac",
3230        )];
3231        let desired = album_desired(&members, false);
3232        let mut albums = BTreeMap::new();
3233        albums.insert(
3234            "root".to_string(),
3235            AlbumArt {
3236                folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3237                folder_webp: None,
3238            },
3239        );
3240        let actions = plan_album_artifacts(&desired, &albums, true);
3241        assert_eq!(actions.len(), 1);
3242        assert!(matches!(
3243            &actions[0],
3244            Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3245        ));
3246    }
3247
3248    #[test]
3249    fn h1_most_played_flip_to_same_art_writes_nothing() {
3250        // Two variants sharing identical art. Run 1: "a" is most-played.
3251        let run1 = vec![
3252            album_member(
3253                album_clip("a", 9, "t0", "same-art", ""),
3254                "root",
3255                "c/al/a.flac",
3256            ),
3257            album_member(
3258                album_clip("b", 1, "t1", "same-art", ""),
3259                "root",
3260                "c/al/b.flac",
3261            ),
3262        ];
3263        let desired1 = album_desired(&run1, false);
3264        let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3265        assert_eq!(write1.len(), 1);
3266
3267        // Persist the winner's state as the executor would.
3268        let mut albums = BTreeMap::new();
3269        if let Action::WriteArtifact {
3270            path,
3271            hash,
3272            owner_id,
3273            ..
3274        } = &write1[0]
3275        {
3276            albums.insert(
3277                owner_id.clone(),
3278                AlbumArt {
3279                    folder_jpg: Some(stored(path, hash)),
3280                    folder_webp: None,
3281                },
3282            );
3283        }
3284
3285        // Run 2: "b" overtakes "a" on plays, but the art content is identical.
3286        let run2 = vec![
3287            album_member(
3288                album_clip("a", 1, "t0", "same-art", ""),
3289                "root",
3290                "c/al/a.flac",
3291            ),
3292            album_member(
3293                album_clip("b", 9, "t1", "same-art", ""),
3294                "root",
3295                "c/al/b.flac",
3296            ),
3297        ];
3298        let desired2 = album_desired(&run2, false);
3299        // The winner flipped, but the chosen art content hash did not: no churn.
3300        assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3301    }
3302
3303    #[test]
3304    fn h1_flip_to_different_art_writes_exactly_one() {
3305        let mut albums = BTreeMap::new();
3306        albums.insert(
3307            "root".to_string(),
3308            AlbumArt {
3309                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3310                folder_webp: None,
3311            },
3312        );
3313        // The new most-played variant carries genuinely different art.
3314        let members = vec![
3315            album_member(
3316                album_clip("a", 1, "t0", "old-art", ""),
3317                "root",
3318                "c/al/a.flac",
3319            ),
3320            album_member(
3321                album_clip("b", 9, "t1", "new-art", ""),
3322                "root",
3323                "c/al/b.flac",
3324            ),
3325        ];
3326        let desired = album_desired(&members, false);
3327        let actions = plan_album_artifacts(&desired, &albums, true);
3328        assert_eq!(actions.len(), 1);
3329        assert!(matches!(
3330            &actions[0],
3331            Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3332        ));
3333    }
3334
3335    #[test]
3336    fn one_write_per_album_regardless_of_clip_count() {
3337        let members: Vec<Desired> = (0..200)
3338            .map(|i| {
3339                album_member(
3340                    album_clip(
3341                        &format!("clip-{i:03}"),
3342                        i as u64,
3343                        &format!("t{i:03}"),
3344                        &format!("art-{i:03}"),
3345                        &format!("vid-{i:03}"),
3346                    ),
3347                    "root",
3348                    &format!("c/al/clip-{i:03}.flac"),
3349                )
3350            })
3351            .collect();
3352        let desired = album_desired(&members, true);
3353        assert_eq!(desired.len(), 1);
3354        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3355        // Exactly one folder.jpg and one cover.webp for the whole 200-clip album.
3356        assert_eq!(actions.len(), 2);
3357        assert_eq!(
3358            actions
3359                .iter()
3360                .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3361                .count(),
3362            2
3363        );
3364    }
3365
3366    #[test]
3367    fn emptied_album_deletes_only_when_can_delete() {
3368        let mut albums = BTreeMap::new();
3369        albums.insert(
3370            "root".to_string(),
3371            AlbumArt {
3372                folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3373                folder_webp: Some(stored("c/al/cover.webp", "hw")),
3374            },
3375        );
3376        // No album desires this root any more (it emptied out this run).
3377        let desired: Vec<AlbumDesired> = Vec::new();
3378
3379        // Gated off: an incomplete/unsafe listing removes nothing.
3380        assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3381
3382        // Gated on: both stored kinds are removed, sorted by kind.
3383        let actions = plan_album_artifacts(&desired, &albums, true);
3384        assert_eq!(
3385            actions,
3386            vec![
3387                Action::DeleteArtifact {
3388                    kind: ArtifactKind::FolderJpg,
3389                    path: "c/al/folder.jpg".to_string(),
3390                    owner_id: "root".to_string(),
3391                },
3392                Action::DeleteArtifact {
3393                    kind: ArtifactKind::FolderWebp,
3394                    path: "c/al/cover.webp".to_string(),
3395                    owner_id: "root".to_string(),
3396                },
3397            ]
3398        );
3399    }
3400
3401    #[test]
3402    fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3403        let mut albums = BTreeMap::new();
3404        albums.insert(
3405            "root".to_string(),
3406            AlbumArt {
3407                folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3408                folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3409            },
3410        );
3411        // The album is still present with the same folder.jpg, but animated
3412        // covers are now off, so the webp source has disappeared.
3413        let members = vec![album_member(
3414            album_clip("a", 1, "t0", "art-a", "vid-a"),
3415            "root",
3416            "c/al/a.flac",
3417        )];
3418        let desired = album_desired(&members, false);
3419
3420        assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3421
3422        let actions = plan_album_artifacts(&desired, &albums, true);
3423        assert_eq!(
3424            actions,
3425            vec![Action::DeleteArtifact {
3426                kind: ArtifactKind::FolderWebp,
3427                path: "c/al/cover.webp".to_string(),
3428                owner_id: "root".to_string(),
3429            }]
3430        );
3431    }
3432
3433    #[test]
3434    fn plan_album_artifacts_is_deterministically_ordered() {
3435        let members = vec![
3436            album_member(
3437                album_clip("a", 1, "t0", "art-a", "vid-a"),
3438                "r2",
3439                "c/al2/a.flac",
3440            ),
3441            album_member(
3442                album_clip("b", 1, "t0", "art-b", "vid-b"),
3443                "r1",
3444                "c/al1/b.flac",
3445            ),
3446        ];
3447        let desired = album_desired(&members, true);
3448        let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3449        let keys: Vec<(&str, ArtifactKind)> = actions
3450            .iter()
3451            .map(|a| match a {
3452                Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3453                _ => unreachable!(),
3454            })
3455            .collect();
3456        assert_eq!(
3457            keys,
3458            vec![
3459                ("r1", ArtifactKind::FolderJpg),
3460                ("r1", ArtifactKind::FolderWebp),
3461                ("r2", ArtifactKind::FolderJpg),
3462                ("r2", ArtifactKind::FolderWebp),
3463            ]
3464        );
3465    }
3466
3467    // ── Phase 9: playlist artifacts ─────────────────────────────────
3468
3469    fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3470        PlaylistDesired {
3471            id: id.to_owned(),
3472            name: name.to_owned(),
3473            path: path.to_owned(),
3474            content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3475            hash: hash.to_owned(),
3476        }
3477    }
3478
3479    fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3480        PlaylistState {
3481            name: name.to_owned(),
3482            path: path.to_owned(),
3483            hash: hash.to_owned(),
3484        }
3485    }
3486
3487    fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3488        entries
3489            .iter()
3490            .map(|(id, state)| ((*id).to_owned(), state.clone()))
3491            .collect()
3492    }
3493
3494    #[test]
3495    fn playlist_write_emitted_for_a_new_playlist() {
3496        let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3497        let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3498        assert_eq!(
3499            actions,
3500            vec![Action::WriteArtifact {
3501                kind: ArtifactKind::Playlist,
3502                path: "Road Trip.m3u8".to_owned(),
3503                source_url: String::new(),
3504                hash: "h1".to_owned(),
3505                owner_id: "pl1".to_owned(),
3506                content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3507            }]
3508        );
3509    }
3510
3511    #[test]
3512    fn playlist_write_emitted_when_hash_changes() {
3513        // Same id and path, different content hash (a member's title, an order
3514        // flip, a new path) — the m3u8 is rewritten (B1).
3515        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3516        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3517        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3518        assert_eq!(actions.len(), 1);
3519        assert!(matches!(
3520            &actions[0],
3521            Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3522        ));
3523    }
3524
3525    #[test]
3526    fn playlist_unchanged_is_idempotent() {
3527        let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3528        let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3529        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3530        assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3531    }
3532
3533    #[test]
3534    fn playlist_rename_writes_new_and_deletes_old_path() {
3535        // The playlist was renamed on Suno, so its sanitised path changed: write
3536        // the new file and delete the old one, both under the full delete gate.
3537        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3538        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3539        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3540        assert_eq!(
3541            actions,
3542            vec![
3543                Action::WriteArtifact {
3544                    kind: ArtifactKind::Playlist,
3545                    path: "Summer.m3u8".to_owned(),
3546                    source_url: String::new(),
3547                    hash: "h2".to_owned(),
3548                    owner_id: "pl1".to_owned(),
3549                    content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3550                },
3551                Action::DeleteArtifact {
3552                    kind: ArtifactKind::Playlist,
3553                    path: "Spring.m3u8".to_owned(),
3554                    owner_id: "pl1".to_owned(),
3555                },
3556            ]
3557        );
3558    }
3559
3560    #[test]
3561    fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3562        // A rename still writes the new file, but the OLD-path cleanup is a
3563        // delete and is gated: no can_delete means no removal (B2).
3564        let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3565        let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3566        let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3567        assert_eq!(actions.len(), 1);
3568        assert!(matches!(
3569            &actions[0],
3570            Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3571        ));
3572        assert!(
3573            !actions
3574                .iter()
3575                .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3576            "old path must not be deleted when deletes are disallowed"
3577        );
3578    }
3579
3580    #[test]
3581    fn playlist_stale_removed_only_under_full_gate() {
3582        // A stored playlist absent from desired is stale. It is deleted only when
3583        // BOTH can_delete and list_fully_enumerated hold.
3584        let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3585
3586        let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3587        assert_eq!(
3588            deleted,
3589            vec![Action::DeleteArtifact {
3590                kind: ArtifactKind::Playlist,
3591                path: "Gone.m3u8".to_owned(),
3592                owner_id: "gone".to_owned(),
3593            }]
3594        );
3595
3596        // Any gate off → no delete.
3597        assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3598        assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3599        assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3600    }
3601
3602    #[test]
3603    fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3604        // B2 BLOCKER: when the /api/playlist/me listing fails, the caller passes
3605        // an empty desired and list_fully_enumerated=false. Even with a
3606        // non-empty store and can_delete, NOTHING is planned — every existing
3607        // .m3u8 is left untouched.
3608        let stored = pl_store(&[
3609            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3610            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3611        ]);
3612        let actions = plan_playlist_artifacts(&[], &stored, true, false);
3613        assert!(
3614            actions.is_empty(),
3615            "a failed playlist listing must plan zero actions, got {actions:?}"
3616        );
3617    }
3618
3619    #[test]
3620    fn b2_empty_list_deletes_only_when_fully_enumerated() {
3621        // An empty desired that contradicts a non-empty store is a genuine
3622        // wipe ONLY when the listing was fully enumerated (and can_delete). That
3623        // path IS a mass delete — the CLI cap/confirmation then guards it — but
3624        // an unreliable listing (not fully enumerated) plans nothing here (B2).
3625        let stored = pl_store(&[
3626            ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3627            ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3628        ]);
3629
3630        // Not fully enumerated: zero deletes (the safety valve).
3631        assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3632
3633        // Fully enumerated and allowed: both are deleted (the caller's cap
3634        // catches this mass removal).
3635        let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3636        assert_eq!(
3637            wiped
3638                .iter()
3639                .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3640                .count(),
3641            2
3642        );
3643    }
3644
3645    #[test]
3646    fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3647        // A playlist whose member fetch failed is excluded upstream from BOTH
3648        // desired and the stored map handed here, so it is neither rewritten nor
3649        // treated as stale: its .m3u8 survives while a sibling reconciles.
3650        // `pl_ok` reconciles; `pl_fail` is simply absent from both maps.
3651        let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3652        let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3653        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3654        // Only the healthy playlist is rewritten; nothing references pl_fail.
3655        assert_eq!(actions.len(), 1);
3656        assert!(matches!(
3657            &actions[0],
3658            Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3659        ));
3660        assert!(
3661            !actions.iter().any(|a| match a {
3662                Action::WriteArtifact { owner_id, .. }
3663                | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3664                _ => false,
3665            }),
3666            "a protected (failed-member) playlist must have no action"
3667        );
3668    }
3669
3670    #[test]
3671    fn playlist_rename_collision_downgrades_the_delete() {
3672        // pl1 renames Old -> Shared.m3u8; pl2 already renders Shared.m3u8 this
3673        // run. The delete of pl1's old path is fine, but a delete must never
3674        // alias a write target, so if the OLD path equals another write target
3675        // it is downgraded. Here we force the collision: pl1's old path is the
3676        // very path pl2 writes.
3677        let desired = vec![
3678            pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3679            pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3680        ];
3681        let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3682        let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3683        // No DeleteArtifact survives against a path some write produces.
3684        let write_paths: BTreeSet<&str> = actions
3685            .iter()
3686            .filter_map(|a| match a {
3687                Action::WriteArtifact { path, .. } => Some(path.as_str()),
3688                _ => None,
3689            })
3690            .collect();
3691        for a in &actions {
3692            if let Action::DeleteArtifact { path, .. } = a {
3693                assert!(
3694                    !write_paths.contains(path.as_str()),
3695                    "a playlist delete aliases a write target: {path}"
3696                );
3697            }
3698        }
3699    }
3700}
3701
3702/// Property-based tests that lock the delete guard against random inputs.
3703///
3704/// These complement the deterministic unit tests above. The generators are
3705/// bounded (a small clip-id space, short paths and hashes) so the cases stay
3706/// cheap and CI stays stable, and failure persistence is disabled so a run
3707/// never leaves regression files behind.
3708///
3709/// The generators are fully random: `trashed`, `private`, source `modes`, and
3710/// the persisted `preserve` marker are all exercised, and the desired list may
3711/// hold duplicate ids so aggregation is covered too. The invariants below are
3712/// written to hold for every such input, so the trashed delete path is no
3713/// longer a special case hidden from the property tests.
3714#[cfg(test)]
3715mod proptests {
3716    use super::*;
3717    use proptest::collection::{btree_map, hash_map, vec};
3718    use proptest::prelude::*;
3719    use std::collections::BTreeSet;
3720
3721    type DesiredFields = (
3722        String,
3723        AudioFormat,
3724        String,
3725        String,
3726        Vec<SourceMode>,
3727        bool,
3728        bool,
3729    );
3730
3731    fn audio_format() -> impl Strategy<Value = AudioFormat> {
3732        prop_oneof![
3733            Just(AudioFormat::Mp3),
3734            Just(AudioFormat::Flac),
3735            Just(AudioFormat::Wav),
3736        ]
3737    }
3738
3739    fn source_mode() -> impl Strategy<Value = SourceMode> {
3740        prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3741    }
3742
3743    // A small id space forces overlap between the manifest and the desired set,
3744    // so deletes, renames, retags, and downloads all get exercised.
3745    fn clip_id() -> impl Strategy<Value = String> {
3746        (0u8..8).prop_map(|n| format!("c{n}"))
3747    }
3748
3749    fn small_path() -> impl Strategy<Value = String> {
3750        (0u8..6).prop_map(|n| format!("path{n}"))
3751    }
3752
3753    // The manifest entry path is the source of every `Delete.path`, so it must
3754    // occasionally be empty for INV9 to actually exercise the empty-path guard.
3755    fn manifest_path() -> impl Strategy<Value = String> {
3756        prop_oneof![
3757            1 => Just(String::new()),
3758            6 => small_path(),
3759        ]
3760    }
3761
3762    fn small_hash() -> impl Strategy<Value = String> {
3763        (0u8..4).prop_map(|n| format!("h{n}"))
3764    }
3765
3766    fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3767        (
3768            manifest_path(),
3769            audio_format(),
3770            small_hash(),
3771            small_hash(),
3772            0u64..4,
3773            any::<bool>(),
3774        )
3775            .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3776                ManifestEntry {
3777                    path,
3778                    format,
3779                    meta_hash,
3780                    art_hash,
3781                    size,
3782                    preserve,
3783                    ..Default::default()
3784                }
3785            })
3786    }
3787
3788    fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3789        btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3790    }
3791
3792    fn local_file() -> impl Strategy<Value = LocalFile> {
3793        (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3794    }
3795
3796    fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3797        hash_map(clip_id(), local_file(), 0..8)
3798    }
3799
3800    fn source_status() -> impl Strategy<Value = SourceStatus> {
3801        (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3802            mode,
3803            fully_enumerated,
3804        })
3805    }
3806
3807    fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3808        vec(source_status(), 0..5)
3809    }
3810
3811    fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3812        vec(
3813            any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3814                mode: SourceMode::Copy,
3815                fully_enumerated,
3816            }),
3817            1..5,
3818        )
3819    }
3820
3821    fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3822        (
3823            small_path(),
3824            audio_format(),
3825            small_hash(),
3826            small_hash(),
3827            vec(source_mode(), 1..3),
3828            any::<bool>(),
3829            any::<bool>(),
3830        )
3831    }
3832
3833    fn build_desired(id: String, fields: DesiredFields) -> Desired {
3834        let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3835        let clip = Clip {
3836            id,
3837            title: "t".to_string(),
3838            ..Default::default()
3839        };
3840        Desired {
3841            lineage: LineageContext::own_root(&clip),
3842            clip,
3843            path,
3844            format,
3845            meta_hash,
3846            art_hash,
3847            modes,
3848            trashed,
3849            private,
3850            artifacts: Vec::new(),
3851        }
3852    }
3853
3854    // A desired list over the shared id space that may hold duplicate ids, so
3855    // aggregation and the trashed/private/copy folds are all exercised.
3856    fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3857        vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3858            items
3859                .into_iter()
3860                .map(|(id, fields)| build_desired(id, fields))
3861                .collect()
3862        })
3863    }
3864
3865    fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3866        desired.iter().map(|d| d.clip.id.as_str()).collect()
3867    }
3868
3869    // Ids protected from deletion: any duplicate that is private or copy-held
3870    // protects the whole id, mirroring the aggregation's union semantics.
3871    fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3872        desired
3873            .iter()
3874            .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3875            .map(|d| d.clip.id.as_str())
3876            .collect()
3877    }
3878
3879    // Ids with at least one non-trashed duplicate: the trashed fold is an
3880    // intersection, so one live duplicate keeps the clip.
3881    fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3882        desired
3883            .iter()
3884            .filter(|d| !d.trashed)
3885            .map(|d| d.clip.id.as_str())
3886            .collect()
3887    }
3888
3889    fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3890        plan.actions
3891            .iter()
3892            .filter_map(|a| match a {
3893                Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3894                _ => None,
3895            })
3896            .collect()
3897    }
3898
3899    fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3900        plan.actions
3901            .iter()
3902            .filter_map(|a| match a {
3903                Action::Download { path, .. } | Action::Reformat { path, .. } => {
3904                    Some(path.as_str())
3905                }
3906                Action::Rename { to, .. } => Some(to.as_str()),
3907                _ => None,
3908            })
3909            .collect()
3910    }
3911
3912    proptest! {
3913        #![proptest_config(ProptestConfig {
3914            cases: 256,
3915            failure_persistence: None,
3916            ..ProptestConfig::default()
3917        })]
3918
3919        // INVARIANT 1: a desired clip is deleted only when every one of its
3920        // duplicates is trashed; one live (non-trashed) duplicate keeps it.
3921        #[test]
3922        fn inv1_desired_clip_deleted_only_when_fully_trashed(
3923            manifest in manifest_strategy(),
3924            desired in desired_strategy(),
3925            local in local_strategy(),
3926            sources in sources_strategy(),
3927        ) {
3928            let plan = reconcile(&manifest, &desired, &local, &sources);
3929            let present = desired_ids(&desired);
3930            let live = non_trashed_ids(&desired);
3931            for id in delete_clip_ids(&plan) {
3932                prop_assert!(
3933                    !(present.contains(id) && live.contains(id)),
3934                    "deleted a desired clip with a non-trashed duplicate: {id}"
3935                );
3936            }
3937        }
3938
3939        // INVARIANT 2: a single not-fully-enumerated mirror source (truncated,
3940        // partial, empty, or failed listing) suppresses every deletion, trashed
3941        // clips included.
3942        #[test]
3943        fn inv2_no_delete_when_any_mirror_unenumerated(
3944            manifest in manifest_strategy(),
3945            desired in desired_strategy(),
3946            local in local_strategy(),
3947            mut sources in sources_strategy(),
3948        ) {
3949            sources.push(SourceStatus {
3950                mode: SourceMode::Mirror,
3951                fully_enumerated: false,
3952            });
3953            let plan = reconcile(&manifest, &desired, &local, &sources);
3954            prop_assert_eq!(plan.deletes(), 0);
3955        }
3956
3957        // INVARIANT 3: a copy-only run is additive and never deletes.
3958        #[test]
3959        fn inv3_all_copy_sources_means_no_deletes(
3960            manifest in manifest_strategy(),
3961            desired in desired_strategy(),
3962            local in local_strategy(),
3963            sources in copy_sources_strategy(),
3964        ) {
3965            let plan = reconcile(&manifest, &desired, &local, &sources);
3966            prop_assert_eq!(plan.deletes(), 0);
3967        }
3968
3969        // INVARIANT 4: identical inputs always yield an identical plan, and the
3970        // plan does not depend on the order of the desired or source lists.
3971        #[test]
3972        fn inv4_plan_is_deterministic(
3973            manifest in manifest_strategy(),
3974            desired in desired_strategy(),
3975            local in local_strategy(),
3976            sources in sources_strategy(),
3977        ) {
3978            let plan = reconcile(&manifest, &desired, &local, &sources);
3979
3980            let again = reconcile(&manifest, &desired, &local, &sources);
3981            prop_assert_eq!(&plan, &again);
3982
3983            let mut desired_rev = desired.clone();
3984            desired_rev.reverse();
3985            let mut sources_rev = sources.clone();
3986            sources_rev.reverse();
3987            let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3988            prop_assert_eq!(&plan, &shuffled);
3989        }
3990
3991        // INVARIANT 5: every Delete names a clip that exists in the manifest.
3992        #[test]
3993        fn inv5_every_delete_is_in_the_manifest(
3994            manifest in manifest_strategy(),
3995            desired in desired_strategy(),
3996            local in local_strategy(),
3997            sources in sources_strategy(),
3998        ) {
3999            let plan = reconcile(&manifest, &desired, &local, &sources);
4000            for id in delete_clip_ids(&plan) {
4001                prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4002            }
4003        }
4004
4005        // INVARIANT 6: never delete a copy-held or private clip, whether that
4006        // protection is in the current selection or persisted on the manifest.
4007        #[test]
4008        fn inv6_never_deletes_protected_clip(
4009            manifest in manifest_strategy(),
4010            desired in desired_strategy(),
4011            local in local_strategy(),
4012            sources in sources_strategy(),
4013        ) {
4014            let plan = reconcile(&manifest, &desired, &local, &sources);
4015            let protected = protected_ids(&desired);
4016            for id in delete_clip_ids(&plan) {
4017                prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4018                let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4019                prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4020            }
4021        }
4022
4023        // INVARIANT 7: every Delete requires deletion to be allowed for the run,
4024        // so the trashed path is no longer an exception to the enumeration guard.
4025        #[test]
4026        fn inv7_no_delete_unless_deletion_allowed(
4027            manifest in manifest_strategy(),
4028            desired in desired_strategy(),
4029            local in local_strategy(),
4030            sources in sources_strategy(),
4031        ) {
4032            let plan = reconcile(&manifest, &desired, &local, &sources);
4033            if !deletion_allowed(&sources) {
4034                prop_assert_eq!(plan.deletes(), 0);
4035            }
4036        }
4037
4038        // INVARIANT 8: at most one Delete per clip id.
4039        #[test]
4040        fn inv8_at_most_one_delete_per_clip(
4041            manifest in manifest_strategy(),
4042            desired in desired_strategy(),
4043            local in local_strategy(),
4044            sources in sources_strategy(),
4045        ) {
4046            let plan = reconcile(&manifest, &desired, &local, &sources);
4047            let ids = delete_clip_ids(&plan);
4048            let unique: BTreeSet<&str> = ids.iter().copied().collect();
4049            prop_assert_eq!(ids.len(), unique.len());
4050        }
4051
4052        // INVARIANT 9: no Delete carries an empty path.
4053        #[test]
4054        fn inv9_no_delete_with_empty_path(
4055            manifest in manifest_strategy(),
4056            desired in desired_strategy(),
4057            local in local_strategy(),
4058            sources in sources_strategy(),
4059        ) {
4060            let plan = reconcile(&manifest, &desired, &local, &sources);
4061            for action in &plan.actions {
4062                if let Action::Delete { path, .. } = action {
4063                    prop_assert!(!path.is_empty(), "delete with an empty path");
4064                }
4065            }
4066        }
4067
4068        // INVARIANT 10: no Delete path equals a file another action writes this
4069        // run, so a deletion can never clobber a just-written file.
4070        #[test]
4071        fn inv10_no_delete_aliases_a_write_target(
4072            manifest in manifest_strategy(),
4073            desired in desired_strategy(),
4074            local in local_strategy(),
4075            sources in sources_strategy(),
4076        ) {
4077            let plan = reconcile(&manifest, &desired, &local, &sources);
4078            let targets = write_target_paths(&plan);
4079            for action in &plan.actions {
4080                if let Action::Delete { path, .. } = action {
4081                    prop_assert!(
4082                        !targets.contains(path.as_str()),
4083                        "delete path {path} aliases a write target"
4084                    );
4085                }
4086            }
4087        }
4088    }
4089}