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