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