Skip to main content

suno_core/
area.rs

1//! The multi-area sync planner: the pure decision logic for what is
2//! authoritative and what may be deleted across a run's areas (library, liked
3//! feed, playlists). Lifted from the CLI so the deletion-authority logic is
4//! covered by the core suite, beside the leaf predicates it composes in
5//! [`crate::reconcile`].
6
7use std::collections::{BTreeSet, HashMap, HashSet};
8
9use crate::desired::{LIKED_PLAYLIST_ID, PlaylistInput, build_playlist_desired};
10use crate::graph::LineageStore;
11use crate::model::Clip;
12use crate::reconcile::{
13    Desired, PlaylistDesired, SourceStatus, area_authoritative, area_fully_enumerated,
14    deletion_allowed,
15};
16use crate::vocab::SourceMode;
17
18/// One area's listing outcome for the multi-area planner.
19///
20/// The `authoritative_ignoring_empty` flag is the area's completeness verdict
21/// *before* the empty-mirror guard (§5), which [`area_enumerated`] applies later
22/// against the final mode, so a copy-verb override that turns a Mirror area Copy
23/// re-scores an empty area correctly. It is only ever produced by
24/// [`area_authoritative`] via [`AreaListing::listed`], so the #248 filter-loss
25/// guard cannot be bypassed by an out-of-band value.
26pub struct AreaListing {
27    kind: AreaKind,
28    /// The resolved (pre copy-override) mode for this area.
29    mode: SourceMode,
30    /// The area's downloadable clips.
31    clips: Vec<Clip>,
32    /// Completeness modulo the empty-mirror guard: `true` when the listing
33    /// drained, was not deliberately narrowed, and lost no member to the
34    /// downloadable filter.
35    authoritative_ignoring_empty: bool,
36}
37
38/// Which kind of area a listing came from, carrying playlist identity so its
39/// `.m3u8` can be maintained by id and name.
40pub enum AreaKind {
41    Library,
42    Liked,
43    Playlist { id: String, name: String },
44}
45
46impl AreaListing {
47    /// A drained listing. The authority flag is computed from the raw listing
48    /// signals via [`area_authoritative`], so the #248 guard is unbypassable
49    /// from outside the crate: the fields are private, and although in-crate
50    /// tests may construct directly, every production path goes through this
51    /// constructor.
52    pub fn listed(
53        kind: AreaKind,
54        mode: SourceMode,
55        clips: Vec<Clip>,
56        complete: bool,
57        any_filtered: bool,
58        narrowed: bool,
59    ) -> Self {
60        Self {
61            kind,
62            mode,
63            clips,
64            authoritative_ignoring_empty: area_authoritative(complete, any_filtered, narrowed),
65        }
66    }
67
68    /// A failed or empty listing: it holds no clips and is never authoritative,
69    /// so it suppresses deletion without ever vanishing from the sources (§6).
70    pub fn failed(kind: AreaKind, mode: SourceMode) -> Self {
71        Self {
72            kind,
73            mode,
74            clips: Vec::new(),
75            authoritative_ignoring_empty: false,
76        }
77    }
78
79    /// A playlist area whose listing could not be resolved or fetched (§6).
80    pub fn unresolved_playlist(mode: SourceMode) -> Self {
81        Self::failed(
82            AreaKind::Playlist {
83                id: String::new(),
84                name: String::new(),
85            },
86            mode,
87        )
88    }
89
90    /// The area's downloadable clips.
91    pub fn clips(&self) -> &[Clip] {
92        &self.clips
93    }
94}
95
96/// This area's mode after the copy-verb / force-additive override.
97pub fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
98    if force_copy {
99        SourceMode::Copy
100    } else {
101        area.mode
102    }
103}
104
105/// Whether this area is authoritative for deletion, applying the empty-mirror
106/// guard (§5) against the final mode.
107#[must_use]
108pub fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
109    area_fully_enumerated(
110        area.authoritative_ignoring_empty,
111        area.clips.is_empty(),
112        area_mode(area, force_copy),
113    )
114}
115
116/// Whether a Library area is present and fully enumerated (the implicit
117/// protector counts; `library="off"` leaves no Library area, so this is false).
118#[must_use]
119pub fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
120    areas
121        .iter()
122        .any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
123}
124
125/// The per-source enumeration status of every area, for the deletion verdict.
126#[must_use]
127pub fn source_statuses(areas: &[AreaListing], force_copy: bool) -> Vec<SourceStatus> {
128    areas
129        .iter()
130        .map(|area| SourceStatus {
131            mode: area_mode(area, force_copy),
132            fully_enumerated: area_enumerated(area, force_copy),
133        })
134        .collect()
135}
136
137/// Whether first-use adoption can confirm identity from this run's listing.
138///
139/// An authoritative Library is the usual anchor, but a fully-enumerated Mirror
140/// source of any kind (e.g. a playlist under `library="off"`) also arms
141/// deletion. Deleting against an account this library was never pinned to is
142/// the hole the owner pin closes (#149), so such a run is treated as enumerated:
143/// `adopt_decision` then confirms identity by clip overlap and aborts on a
144/// foreign account instead of skipping the pin.
145#[must_use]
146pub fn adoption_enumerated(areas: &[AreaListing], force_copy: bool) -> bool {
147    library_authoritative(areas, force_copy)
148        || deletion_allowed(&source_statuses(areas, force_copy))
149}
150
151/// Build the clip union across areas in canonical order, first area winning per
152/// id so the Library payload is kept (H1).
153pub fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
154    let mut seen: HashSet<String> = HashSet::new();
155    let mut union: Vec<Clip> = Vec::new();
156    for area in areas {
157        for clip in &area.clips {
158            if seen.insert(clip.id.clone()) {
159                union.push(clip.clone());
160            }
161        }
162    }
163    union
164}
165
166/// Whether the scoped `.m3u8` desired set may authorise playlist deletes: the
167/// union was not truncated (`members_intact`) and every playlist-rendering area
168/// (Playlist, Liked) fully enumerated. Library areas render no `.m3u8`, so they
169/// never gate this. Mirrors the async twin, which reports non-enumerated when a
170/// listing is partial rather than authorising a delete from a partial view (B2).
171#[must_use]
172pub fn playlists_enumerated(areas: &[AreaListing], force_copy: bool, members_intact: bool) -> bool {
173    members_intact
174        && areas.iter().all(|area| match area.kind {
175            AreaKind::Library => true,
176            AreaKind::Liked | AreaKind::Playlist { .. } => area_enumerated(area, force_copy),
177        })
178}
179
180/// Build the `.m3u8` desired state for an area-scoped run (no authoritative
181/// Library). Every playlist and liked area that fully enumerated its members
182/// (`area_enumerated`) is rendered, so a scoped additive run (`copy --playlist X`,
183/// a bare `sync --playlist X`, a config-driven `playlists = "copy"`, or
184/// `--library=off --playlist X`) still writes its `.m3u8`; a member missing from
185/// the narrowed union renders as a `# (not in library)` comment, never a dangling
186/// path. An area that did NOT fully enumerate (a truncated or failed listing) is
187/// not rendered and its id is protected, so no `.m3u8` is rewritten from a partial
188/// view. Deletion is a separate, stricter gate: the returned flag also requires
189/// `members_intact` (the union was not truncated by `--limit`/`--since`), so a
190/// scoped write never authorises a playlist delete from a partial set (B2/D3).
191pub fn build_scoped_playlist_desired(
192    areas: &[AreaListing],
193    desired: &[Desired],
194    store: &LineageStore,
195    protected: &mut BTreeSet<String>,
196    force_copy: bool,
197    members_intact: bool,
198) -> (Vec<PlaylistDesired>, bool) {
199    let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
200    for area in areas {
201        match &area.kind {
202            AreaKind::Playlist { id, name } => {
203                if !id.is_empty() && area_enumerated(area, force_copy) {
204                    owned.push((id.clone(), name.clone(), area.clips.clone()));
205                } else if !id.is_empty() {
206                    protected.insert(id.clone());
207                }
208            }
209            AreaKind::Liked => {
210                if area_enumerated(area, force_copy) {
211                    owned.push((
212                        LIKED_PLAYLIST_ID.to_owned(),
213                        "Liked Songs".to_owned(),
214                        area.clips.clone(),
215                    ));
216                } else {
217                    protected.insert(LIKED_PLAYLIST_ID.to_owned());
218                }
219            }
220            AreaKind::Library => {}
221        }
222    }
223    let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
224    // Protect every stored playlist this run is not authoritatively rewriting, so
225    // a non-selected playlist's `.m3u8` is never treated as stale.
226    for id in store.playlists.keys() {
227        if !rendered.contains(id.as_str()) {
228            protected.insert(id.clone());
229        }
230    }
231    let inputs: Vec<PlaylistInput<'_>> = owned
232        .iter()
233        .map(|(id, name, members)| PlaylistInput {
234            id: id.as_str(),
235            name: name.as_str(),
236            members: members.as_slice(),
237        })
238        .collect();
239    (
240        build_playlist_desired(&inputs, desired),
241        playlists_enumerated(areas, force_copy, members_intact),
242    )
243}
244
245/// Fold every area's clips into `modes_by_id`, mapping each clip id to the
246/// deduplicated, canonical-order list of every area mode holding it.
247///
248/// `areas` is processed in canonical area order (Library, Liked, Playlists), each
249/// area's mode taken after the copy-verb override via [`area_mode`], and each
250/// clip's modes are normalised to `[Mirror, Copy]` order, mirroring
251/// `aggregate_desired` so a clip held by both a mirror and a copy area is
252/// copy-protected (SYNC-8).
253pub fn build_modes_by_id(
254    areas: &[AreaListing],
255    force_copy: bool,
256) -> HashMap<String, Vec<SourceMode>> {
257    let mut map: HashMap<String, (bool, bool)> = HashMap::new();
258    for area in areas {
259        let mode = area_mode(area, force_copy);
260        for clip in &area.clips {
261            let entry = map.entry(clip.id.clone()).or_insert((false, false));
262            match mode {
263                SourceMode::Mirror => entry.0 = true,
264                SourceMode::Copy => entry.1 = true,
265            }
266        }
267    }
268    map.into_iter()
269        .map(|(id, (mirror, copy))| {
270            let mut modes = Vec::new();
271            if mirror {
272                modes.push(SourceMode::Mirror);
273            }
274            if copy {
275                modes.push(SourceMode::Copy);
276            }
277            (id, modes)
278        })
279        .collect()
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{
286        Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
287        build_desired, narrows_downloads, reconcile,
288    };
289
290    fn tclip(id: &str) -> Clip {
291        Clip {
292            id: id.to_owned(),
293            title: "Song".to_owned(),
294            handle: "alice".to_owned(),
295            ..Default::default()
296        }
297    }
298
299    fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
300        AreaListing {
301            kind,
302            mode,
303            clips: ids.iter().map(|id| tclip(id)).collect(),
304            authoritative_ignoring_empty: authoritative,
305        }
306    }
307
308    // Test 5: an empty Mirror area is never authoritative (a legitimately empty
309    // mirror is indistinguishable from a dropped listing), so deletion is
310    // suppressed. An empty Copy area stays enumerated (it protects nothing).
311    #[test]
312    fn empty_mirror_area_is_not_enumerated() {
313        let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
314        assert!(!area_enumerated(&mirror, false));
315        let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
316        assert!(area_enumerated(&copy, false));
317        // A non-empty mirror that fully listed is authoritative.
318        let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
319        assert!(area_enumerated(&full, false));
320    }
321
322    // A run under `library="off"` that mirrors a fully-enumerated playlist can
323    // delete, so first-use adoption must confirm identity (enumerated == true)
324    // rather than SkipPin into a delete against an unconfirmed account (#149).
325    #[test]
326    fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
327        let playlist = |mode, ids: &[&str], auth| {
328            area(
329                AreaKind::Playlist {
330                    id: "p".into(),
331                    name: "P".into(),
332                },
333                mode,
334                ids,
335                auth,
336            )
337        };
338        // library="off" + a fully-enumerated Mirror playlist arms deletion.
339        assert!(adoption_enumerated(
340            &[playlist(SourceMode::Mirror, &["pl"], true)],
341            false
342        ));
343        // A copy-only run cannot delete, so identity need not be confirmed.
344        assert!(!adoption_enumerated(
345            &[playlist(SourceMode::Copy, &["pl"], true)],
346            false
347        ));
348        // An empty mirror (a dropped or ambiguous listing) is not authoritative.
349        assert!(!adoption_enumerated(
350            &[playlist(SourceMode::Mirror, &[], true)],
351            false
352        ));
353        // A partial (non-authoritative) mirror listing does not arm adoption.
354        assert!(!adoption_enumerated(
355            &[playlist(SourceMode::Mirror, &["pl"], false)],
356            false
357        ));
358        // A force-copy (additive) run never deletes, so never forces the pin.
359        assert!(!adoption_enumerated(
360            &[playlist(SourceMode::Mirror, &["pl"], true)],
361            true
362        ));
363        // The classic authoritative-library anchor still counts.
364        assert!(adoption_enumerated(
365            &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
366            false,
367        ));
368    }
369
370    // library_authoritative counts the implicit protector but is false for
371    // `library="off"` (no library area at all).
372    #[test]
373    fn library_authoritative_counts_protector_not_off() {
374        let with_protector = vec![
375            area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
376            area(
377                AreaKind::Playlist {
378                    id: "p".into(),
379                    name: "P".into(),
380                },
381                SourceMode::Mirror,
382                &["pl"],
383                true,
384            ),
385        ];
386        assert!(library_authoritative(&with_protector, false));
387
388        let off = vec![area(
389            AreaKind::Playlist {
390                id: "p".into(),
391                name: "P".into(),
392            },
393            SourceMode::Mirror,
394            &["pl"],
395            true,
396        )];
397        assert!(!library_authoritative(&off, false));
398    }
399
400    /// (can_delete, library_authoritative, truncate) for a set of areas, exactly
401    /// as `run_one` computes them, for the #148 scenario traces.
402    fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
403        let can_delete = deletion_allowed(&source_statuses(areas, false));
404        let lib_auth = library_authoritative(areas, false);
405        (
406            can_delete,
407            lib_auth,
408            narrows_downloads(can_delete, lib_auth),
409        )
410    }
411
412    fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
413        area(
414            AreaKind::Playlist {
415                id: "p".into(),
416                name: "P".into(),
417            },
418            mode,
419            ids,
420            authoritative,
421        )
422    }
423
424    // The #148 behaviour change at the area level: a narrowed playlist mirror
425    // neither enumerates nor arms deletion; the same listing un-narrowed does both.
426    #[test]
427    fn narrowed_playlist_mirror_disarms_deletion() {
428        let narrowed = pl_area(
429            SourceMode::Mirror,
430            &["a"],
431            area_authoritative(true, false, true),
432        );
433        assert!(!area_enumerated(&narrowed, false));
434        assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
435
436        let full = pl_area(
437            SourceMode::Mirror,
438            &["a"],
439            area_authoritative(true, false, false),
440        );
441        assert!(area_enumerated(&full, false));
442        assert!(deletion_allowed(&source_statuses(&[full], false)));
443    }
444
445    // #148 scenario (c): a narrowed playlist mirror WITH the injected full-library
446    // protector does not delete (the playlist disarms) and does not narrow
447    // downloads (the protector lists the whole library, which drives index/art).
448    #[test]
449    fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
450        let areas = vec![
451            area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
452            pl_area(
453                SourceMode::Mirror,
454                &["pl"],
455                area_authoritative(true, false, true),
456            ),
457        ];
458        let (can_delete, lib_auth, truncate) = verdict(&areas);
459        assert!(!can_delete, "narrowed playlist mirror is disarmed");
460        assert!(lib_auth, "the protector is an authoritative library");
461        assert!(
462            !truncate,
463            "the full library is listed, so downloads are not narrowed"
464        );
465    }
466
467    // #148 scenario (d): a narrowed playlist mirror under `library="off"` (no
468    // protector) does not delete and DOES narrow downloads, matching a narrowed
469    // library-only or liked run.
470    #[test]
471    fn narrowed_playlist_off_disarms_and_narrows() {
472        let areas = vec![pl_area(
473            SourceMode::Mirror,
474            &["pl"],
475            area_authoritative(true, false, true),
476        )];
477        let (can_delete, lib_auth, truncate) = verdict(&areas);
478        assert!(!can_delete, "narrowed playlist mirror is disarmed");
479        assert!(!lib_auth, "library=off leaves no library area");
480        assert!(
481            truncate,
482            "no armed deletion and no full library, so downloads narrow"
483        );
484    }
485
486    // #148 regression guard for scenario (e): a configured unfiltered
487    // `library="mirror"` lists the whole feed regardless of `--limit`/`--since`,
488    // so it stays armed and authoritative. The fix must NOT disarm it — that is
489    // the #149/D2 guarantee that closes the token-swap hole.
490    #[test]
491    fn configured_full_library_mirror_still_deletes_when_narrowed() {
492        let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
493        let (can_delete, lib_auth, truncate) = verdict(&areas);
494        assert!(
495            can_delete,
496            "the configured full-library mirror still deletes"
497        );
498        assert!(lib_auth);
499        assert!(
500            !truncate,
501            "the full library is listed, so downloads are not narrowed"
502        );
503    }
504
505    // A narrowed `library="off"` mirror playlist cannot delete (#148), so first-use
506    // adoption skips the pin rather than confirming identity — the #149 rule that
507    // only a delete-capable run must confirm the account composes cleanly.
508    #[test]
509    fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
510        let areas = vec![pl_area(
511            SourceMode::Mirror,
512            &["pl"],
513            area_authoritative(true, false, true),
514        )];
515        assert!(!adoption_enumerated(&areas, false));
516    }
517
518    // H1: the union keeps the first area's payload per id (Library wins over a
519    // later playlist copy of the same clip).
520    #[test]
521    fn union_keeps_first_area_payload() {
522        let mut lib = tclip("shared");
523        lib.title = "Library".to_owned();
524        let mut pl = tclip("shared");
525        pl.title = "Playlist".to_owned();
526        let areas = vec![
527            AreaListing {
528                kind: AreaKind::Library,
529                mode: SourceMode::Copy,
530                clips: vec![lib, tclip("lib-only")],
531                authoritative_ignoring_empty: true,
532            },
533            AreaListing {
534                kind: AreaKind::Playlist {
535                    id: "p".into(),
536                    name: "P".into(),
537                },
538                mode: SourceMode::Mirror,
539                clips: vec![pl],
540                authoritative_ignoring_empty: true,
541            },
542        ];
543        let union = union_clips(&areas);
544        assert_eq!(union.len(), 2);
545        assert_eq!(union[0].id, "shared");
546        assert_eq!(union[0].title, "Library");
547        assert_eq!(union[1].id, "lib-only");
548    }
549
550    #[test]
551    fn a_failed_area_suppresses_deletion_for_the_run() {
552        let areas = [
553            area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
554            // Playlist listing failed: empty and non-authoritative.
555            area(
556                AreaKind::Playlist {
557                    id: "p".into(),
558                    name: "P".into(),
559                },
560                SourceMode::Mirror,
561                &[],
562                false,
563            ),
564        ];
565        let sources: Vec<SourceStatus> = areas
566            .iter()
567            .map(|a| SourceStatus {
568                mode: area_mode(a, false),
569                fully_enumerated: area_enumerated(a, false),
570            })
571            .collect();
572        assert!(!deletion_allowed(&sources));
573    }
574
575    // Test 8: with every area enumerated, a mixed Mirror + Copy selection deletes
576    // only orphans exclusive to a Mirror area; a Copy area's orphan is protected
577    // and the run remains armed.
578    #[test]
579    fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
580        let areas = vec![
581            area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
582            area(
583                AreaKind::Playlist {
584                    id: "p".into(),
585                    name: "P".into(),
586                },
587                SourceMode::Copy,
588                &["c-live"],
589                true,
590            ),
591        ];
592        let sources: Vec<SourceStatus> = areas
593            .iter()
594            .map(|a| SourceStatus {
595                mode: area_mode(a, false),
596                fully_enumerated: area_enumerated(a, false),
597            })
598            .collect();
599        assert!(deletion_allowed(&sources));
600
601        let modes = build_modes_by_id(&areas, false);
602        let union = union_clips(&areas);
603        let desired = build_desired(
604            &union.iter().collect::<Vec<_>>(),
605            AudioFormat::Flac,
606            &modes,
607            &HashMap::new(),
608            &BTreeSet::new(),
609            &BTreeSet::new(),
610            ArtifactToggles::default(),
611            &NamingConfig::default(),
612        );
613
614        let mut manifest = Manifest::new();
615        // Orphans: one previously from the mirror area, one from the copy area.
616        for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
617            manifest.insert(
618                id,
619                ManifestEntry {
620                    path: format!("{id}.flac"),
621                    format: AudioFormat::Flac,
622                    size: 100,
623                    // The copy-area orphan carries the preserve marker a prior copy
624                    // run stamped, so it can never be deleted.
625                    preserve: id == "c-orphan",
626                    ..Default::default()
627                },
628            );
629        }
630        let local: HashMap<String, LocalFile> = manifest
631            .iter()
632            .map(|(id, _)| {
633                (
634                    id.clone(),
635                    LocalFile {
636                        exists: true,
637                        size: 100,
638                    },
639                )
640            })
641            .collect();
642        let plan = reconcile(&manifest, &desired, &local, &sources);
643        let deleted: Vec<&str> = plan
644            .actions
645            .iter()
646            .filter_map(|a| match a {
647                Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
648                _ => None,
649            })
650            .collect();
651        assert_eq!(deleted, vec!["m-orphan"]);
652    }
653
654    // Test 7 (SYNC-8): a clip held by a Mirror and a Copy area is stamped
655    // `[Mirror, Copy]`, so build_desired carries the Copy protection.
656    #[test]
657    fn build_modes_by_id_copy_wins_and_dedups() {
658        let areas = [
659            area(AreaKind::Liked, SourceMode::Mirror, &["a", "b"], true),
660            pl_area(SourceMode::Copy, &["b", "c"], true),
661        ];
662        let map = build_modes_by_id(&areas, false);
663        assert_eq!(map["a"], vec![SourceMode::Mirror]);
664        assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
665        assert_eq!(map["c"], vec![SourceMode::Copy]);
666    }
667
668    // playlists_enumerated is the scoped `.m3u8` delete gate: true only when the
669    // union is intact and every playlist-rendering area fully enumerated.
670    #[test]
671    fn playlists_enumerated_gates_on_every_playlist_area() {
672        // Every playlist/liked area enumerated and the union intact -> true.
673        assert!(playlists_enumerated(
674            &[
675                area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
676                pl_area(SourceMode::Mirror, &["b"], true),
677            ],
678            false,
679            true,
680        ));
681
682        // A failed playlist listing (empty, non-authoritative) disarms the gate.
683        assert!(!playlists_enumerated(
684            &[
685                area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
686                AreaListing::unresolved_playlist(SourceMode::Mirror),
687            ],
688            false,
689            true,
690        ));
691
692        // A failed liked listing disarms the gate too.
693        assert!(!playlists_enumerated(
694            &[
695                AreaListing::failed(AreaKind::Liked, SourceMode::Mirror),
696                pl_area(SourceMode::Mirror, &["b"], true),
697            ],
698            false,
699            true,
700        ));
701
702        // A truncated union disarms the gate even when every area enumerated.
703        assert!(!playlists_enumerated(
704            &[
705                area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
706                pl_area(SourceMode::Mirror, &["b"], true),
707            ],
708            false,
709            false,
710        ));
711    }
712
713    // Library areas render no `.m3u8`, so a library-only (or empty) set never
714    // gates the playlist delete authority: the flag just follows members_intact.
715    #[test]
716    fn playlists_enumerated_ignores_library_only_sets() {
717        assert!(playlists_enumerated(
718            &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
719            false,
720            true,
721        ));
722        assert!(!playlists_enumerated(
723            &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
724            false,
725            false,
726        ));
727        assert!(playlists_enumerated(&[], false, true));
728        assert!(!playlists_enumerated(&[], false, false));
729    }
730
731    // ---- F4 (#357): the scoped `.m3u8` write gate decoupled from the delete gate ----
732
733    /// Build a real `desired` audio set for `clips`, so a rendered `.m3u8`
734    /// resolves each present member to its relative path (and a missing one to a
735    /// `# (not in library)` comment).
736    fn desired_for(clips: &[&Clip]) -> Vec<Desired> {
737        let modes: HashMap<String, Vec<SourceMode>> = clips
738            .iter()
739            .map(|c| (c.id.clone(), vec![SourceMode::Copy]))
740            .collect();
741        build_desired(
742            clips,
743            AudioFormat::Flac,
744            &modes,
745            &HashMap::new(),
746            &BTreeSet::new(),
747            &BTreeSet::new(),
748            ArtifactToggles::default(),
749            &NamingConfig::default(),
750        )
751    }
752
753    fn pl_area_named(
754        id: &str,
755        name: &str,
756        mode: SourceMode,
757        ids: &[&str],
758        authoritative: bool,
759    ) -> AreaListing {
760        area(
761            AreaKind::Playlist {
762                id: id.to_owned(),
763                name: name.to_owned(),
764            },
765            mode,
766            ids,
767            authoritative,
768        )
769    }
770
771    #[test]
772    fn additive_scoped_run_renders_selected_playlist() {
773        // An additive scoped run has `members_intact == false` (no armed mirror,
774        // no authoritative library), yet a fully-enumerated Copy playlist area
775        // must still WRITE its `.m3u8`. Before the fix the write was wrongly gated
776        // on `members_intact`, so `copy --playlist X` never created `X.m3u8`.
777        let ca = tclip("a");
778        let desired = desired_for(&[&ca]);
779        let areas = [pl_area_named(
780            "holiday",
781            "Holiday",
782            SourceMode::Copy,
783            &["a"],
784            true,
785        )];
786        let store = LineageStore::default();
787        let mut protected = BTreeSet::new();
788        let (rendered, delete_gate) =
789            build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
790
791        assert_eq!(rendered.len(), 1, "the selected playlist is rendered");
792        assert_eq!(rendered[0].id, "holiday");
793        assert!(!protected.contains("holiday"), "it is owned, not protected");
794        assert!(
795            !delete_gate,
796            "the delete gate still tracks members_intact (false here)"
797        );
798    }
799
800    #[test]
801    fn narrowed_scoped_run_still_protects_playlist() {
802        // A genuinely narrowed run (`--limit`/`--since`) sets the area's
803        // `narrowed` flag, so `area_enumerated` is false and the playlist is
804        // protected, never rendered from a partial member set. This is the safety
805        // the write gate now relies on instead of `members_intact`.
806        let ca = tclip("a");
807        let desired = desired_for(&[&ca]);
808        let narrowed = pl_area_named(
809            "holiday",
810            "Holiday",
811            SourceMode::Copy,
812            &["a"],
813            area_authoritative(true, false, true),
814        );
815        let store = LineageStore::default();
816        let mut protected = BTreeSet::new();
817        let (rendered, _) = build_scoped_playlist_desired(
818            &[narrowed],
819            &desired,
820            &store,
821            &mut protected,
822            false,
823            false,
824        );
825        assert!(rendered.is_empty(), "a narrowed area is not rendered");
826        assert!(protected.contains("holiday"), "and its id is protected");
827    }
828
829    #[test]
830    fn failed_member_listing_still_protects() {
831        // A failed/partial member listing is never rendered from a partial view.
832        let ca = tclip("a");
833        let desired = desired_for(&[&ca]);
834        let store = LineageStore::default();
835
836        // An unresolved playlist (empty id, non-authoritative) renders nothing.
837        let mut protected = BTreeSet::new();
838        let (rendered, _) = build_scoped_playlist_desired(
839            &[AreaListing::unresolved_playlist(SourceMode::Copy)],
840            &desired,
841            &store,
842            &mut protected,
843            false,
844            false,
845        );
846        assert!(rendered.is_empty(), "an unresolved listing renders nothing");
847
848        // A named but failed listing is protected, not rendered.
849        let mut protected = BTreeSet::new();
850        let failed = AreaListing::failed(
851            AreaKind::Playlist {
852                id: "holiday".to_owned(),
853                name: "Holiday".to_owned(),
854            },
855            SourceMode::Copy,
856        );
857        let (rendered, _) = build_scoped_playlist_desired(
858            &[failed],
859            &desired,
860            &store,
861            &mut protected,
862            false,
863            false,
864        );
865        assert!(rendered.is_empty());
866        assert!(
867            protected.contains("holiday"),
868            "a failed listing is protected"
869        );
870    }
871
872    #[test]
873    fn liked_area_follows_the_same_rule() {
874        // The Liked area follows the identical write rule: an enumerated Liked
875        // area renders its `.m3u8` even when `members_intact == false`, and a
876        // failed listing is protected instead.
877        let ca = tclip("a");
878        let desired = desired_for(&[&ca]);
879        let store = LineageStore::default();
880
881        let liked = area(AreaKind::Liked, SourceMode::Copy, &["a"], true);
882        let mut protected = BTreeSet::new();
883        let (rendered, _) =
884            build_scoped_playlist_desired(&[liked], &desired, &store, &mut protected, false, false);
885        assert_eq!(rendered.len(), 1);
886        assert_eq!(rendered[0].id, LIKED_PLAYLIST_ID);
887        assert!(!protected.contains(LIKED_PLAYLIST_ID));
888
889        let failed = AreaListing::failed(AreaKind::Liked, SourceMode::Copy);
890        let mut protected = BTreeSet::new();
891        let (rendered, _) = build_scoped_playlist_desired(
892            &[failed],
893            &desired,
894            &store,
895            &mut protected,
896            false,
897            false,
898        );
899        assert!(rendered.is_empty());
900        assert!(protected.contains(LIKED_PLAYLIST_ID));
901    }
902
903    #[test]
904    fn scoped_render_shows_missing_member_as_comment() {
905        // A member not downloaded this run renders as a `# (not in library)`
906        // comment, never a dangling path, so a partial `desired` never corrupts
907        // the `.m3u8`.
908        let ca = tclip("a");
909        let desired = desired_for(&[&ca]); // only "a" is in the library
910        let areas = [pl_area_named(
911            "holiday",
912            "Holiday",
913            SourceMode::Copy,
914            &["a", "b"],
915            true,
916        )];
917        let store = LineageStore::default();
918        let mut protected = BTreeSet::new();
919        let (rendered, _) =
920            build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
921
922        assert_eq!(rendered.len(), 1);
923        let content = &rendered[0].content;
924        assert!(
925            content.contains("# (not in library)"),
926            "a missing member is a comment, not a dangling path"
927        );
928        assert!(
929            content.contains(&desired[0].path),
930            "the present member keeps its audio path"
931        );
932    }
933
934    #[test]
935    fn scoped_write_gate_decoupled_from_delete_gate() {
936        // The write gate (rendering) is decoupled from `members_intact`, but the
937        // DELETE gate (the returned flag) still tracks it: a non-intact run
938        // renders yet authorises no delete; an intact, enumerated run does both.
939        let ca = tclip("a");
940        let desired = desired_for(&[&ca]);
941        let areas = [pl_area_named(
942            "holiday",
943            "Holiday",
944            SourceMode::Copy,
945            &["a"],
946            true,
947        )];
948        let store = LineageStore::default();
949
950        let mut protected = BTreeSet::new();
951        let (rendered, gate) =
952            build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
953        assert_eq!(rendered.len(), 1, "writes with members_intact = false");
954        assert!(!gate, "but authorises no delete");
955
956        let mut protected = BTreeSet::new();
957        let (rendered, gate) =
958            build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, true);
959        assert_eq!(rendered.len(), 1);
960        assert!(gate, "an intact, enumerated run authorises deletes");
961    }
962}