1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36
37use crate::config::{AudioFormat, StemFormat};
38use crate::graph::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum ArtifactKind {
57 CoverJpg,
59 CoverWebp,
61 DetailsTxt,
63 LyricsTxt,
65 Lrc,
67 VideoMp4,
70 FolderJpg,
72 FolderWebp,
74 Playlist,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum SourceMode {
82 Mirror,
84 Copy,
86}
87
88#[derive(Debug, Clone, PartialEq)]
95pub struct Desired {
96 pub clip: Clip,
98 pub lineage: LineageContext,
101 pub path: String,
103 pub format: AudioFormat,
105 pub meta_hash: String,
107 pub art_hash: String,
109 pub modes: Vec<SourceMode>,
111 pub trashed: bool,
113 pub private: bool,
115 pub artifacts: Vec<DesiredArtifact>,
123 pub stems: Option<Vec<DesiredStem>>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct DesiredStem {
146 pub key: String,
150 pub stem_id: String,
154 pub path: String,
157 pub source_url: String,
161 pub format: StemFormat,
164 pub hash: String,
166}
167
168#[derive(Debug, Clone, PartialEq)]
173pub struct DesiredArtifact {
174 pub kind: ArtifactKind,
176 pub path: String,
178 pub source_url: String,
181 pub hash: String,
183 pub content: Option<String>,
187}
188
189#[derive(Debug, Clone, PartialEq)]
200pub struct AlbumDesired {
201 pub root_id: String,
203 pub folder_jpg: Option<DesiredArtifact>,
205 pub folder_webp: Option<DesiredArtifact>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct PlaylistDesired {
221 pub id: String,
224 pub name: String,
226 pub path: String,
228 pub content: String,
230 pub hash: String,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub struct LocalFile {
237 pub exists: bool,
239 pub size: u64,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub struct SourceStatus {
246 pub mode: SourceMode,
248 pub fully_enumerated: bool,
250}
251
252#[derive(Debug, Clone, PartialEq)]
254pub enum Action {
255 Download {
257 clip: Clip,
258 lineage: LineageContext,
259 path: String,
260 format: AudioFormat,
261 },
262 Reformat {
268 clip: Clip,
269 path: String,
270 from_path: String,
271 from: AudioFormat,
272 to: AudioFormat,
273 },
274 Retag {
276 clip: Clip,
277 lineage: LineageContext,
278 path: String,
279 },
280 Rename { from: String, to: String },
282 Delete { path: String, clip_id: String },
284 Skip { clip_id: String },
286 WriteArtifact {
298 kind: ArtifactKind,
299 path: String,
300 source_url: String,
301 hash: String,
302 owner_id: String,
303 content: Option<String>,
304 },
305 DeleteArtifact {
312 kind: ArtifactKind,
313 path: String,
314 owner_id: String,
315 },
316 WriteStem {
326 clip_id: String,
327 key: String,
328 stem_id: String,
329 path: String,
330 source_url: String,
331 format: StemFormat,
332 hash: String,
333 },
334 DeleteStem {
343 clip_id: String,
344 key: String,
345 path: String,
346 },
347}
348
349#[derive(Debug, Clone, Default, PartialEq)]
354pub struct Plan {
355 pub actions: Vec<Action>,
357}
358
359impl Plan {
360 pub fn len(&self) -> usize {
362 self.actions.len()
363 }
364
365 pub fn is_empty(&self) -> bool {
367 self.actions.is_empty()
368 }
369
370 pub fn downloads(&self) -> usize {
372 self.count(|a| matches!(a, Action::Download { .. }))
373 }
374
375 pub fn reformats(&self) -> usize {
377 self.count(|a| matches!(a, Action::Reformat { .. }))
378 }
379
380 pub fn retags(&self) -> usize {
382 self.count(|a| matches!(a, Action::Retag { .. }))
383 }
384
385 pub fn renames(&self) -> usize {
387 self.count(|a| matches!(a, Action::Rename { .. }))
388 }
389
390 pub fn deletes(&self) -> usize {
392 self.count(|a| matches!(a, Action::Delete { .. }))
393 }
394
395 pub fn skips(&self) -> usize {
397 self.count(|a| matches!(a, Action::Skip { .. }))
398 }
399
400 pub fn artifact_writes(&self) -> usize {
402 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
403 }
404
405 pub fn artifact_deletes(&self) -> usize {
407 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
408 }
409
410 pub fn stem_writes(&self) -> usize {
412 self.count(|a| matches!(a, Action::WriteStem { .. }))
413 }
414
415 pub fn stem_deletes(&self) -> usize {
417 self.count(|a| matches!(a, Action::DeleteStem { .. }))
418 }
419
420 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
421 self.actions.iter().filter(|a| pred(a)).count()
422 }
423}
424
425pub fn reconcile(
440 manifest: &Manifest,
441 desired: &[Desired],
442 local: &HashMap<String, LocalFile>,
443 sources: &[SourceStatus],
444) -> Plan {
445 let mut actions: Vec<Action> = Vec::new();
446
447 let desired = aggregate_desired(desired);
449 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
450
451 let can_delete = deletion_allowed(sources);
452
453 for d in &desired {
454 let before = actions.len();
459 plan_desired(d, manifest, local, can_delete, &mut actions);
460 let audio_deleted = actions[before..]
461 .iter()
462 .any(|a| matches!(a, Action::Delete { .. }));
463 if audio_deleted {
464 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
465 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
466 } else {
467 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
468 plan_clip_stems(d, manifest, can_delete, &mut actions);
469 }
470 }
471
472 for (clip_id, _entry) in manifest.iter() {
474 if desired_ids.contains(clip_id.as_str()) {
475 continue;
476 }
477 match delete_action(clip_id, manifest, can_delete) {
478 Some(action) => {
479 actions.push(action);
480 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
483 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
484 }
485 None => actions.push(Action::Skip {
488 clip_id: clip_id.clone(),
489 }),
490 }
491 }
492
493 suppress_path_aliasing(&mut actions);
494 Plan { actions }
495}
496
497pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
508 let mut saw_mirror = false;
509 for status in sources {
510 if !status.fully_enumerated {
511 return false;
512 }
513 if status.mode == SourceMode::Mirror {
514 saw_mirror = true;
515 }
516 }
517 saw_mirror
518}
519
520fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
526 if !can_delete {
527 return None;
528 }
529 let entry = manifest.get(clip_id)?;
530 if entry.path.is_empty() || entry.preserve {
531 return None;
532 }
533 Some(Action::Delete {
534 path: entry.path.clone(),
535 clip_id: clip_id.to_string(),
536 })
537}
538
539fn delete_artifact_action(
549 owner_id: &str,
550 kind: ArtifactKind,
551 path: &str,
552 manifest: &Manifest,
553 can_delete: bool,
554) -> Option<Action> {
555 if !can_delete {
556 return None;
557 }
558 let entry = manifest.get(owner_id)?;
559 if path.is_empty() || entry.preserve {
560 return None;
561 }
562 Some(Action::DeleteArtifact {
563 kind,
564 path: path.to_string(),
565 owner_id: owner_id.to_string(),
566 })
567}
568
569fn is_per_clip_kind(kind: ArtifactKind) -> bool {
575 matches!(
576 kind,
577 ArtifactKind::CoverJpg
578 | ArtifactKind::CoverWebp
579 | ArtifactKind::DetailsTxt
580 | ArtifactKind::LyricsTxt
581 | ArtifactKind::Lrc
582 | ArtifactKind::VideoMp4
583 )
584}
585
586fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
612 match kind {
613 ArtifactKind::CoverJpg
614 | ArtifactKind::CoverWebp
615 | ArtifactKind::LyricsTxt
616 | ArtifactKind::Lrc
617 | ArtifactKind::VideoMp4 => false,
618 ArtifactKind::DetailsTxt
619 | ArtifactKind::FolderJpg
620 | ArtifactKind::FolderWebp
621 | ArtifactKind::Playlist => true,
622 }
623}
624
625fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
630 match kind {
631 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
632 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
633 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
634 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
635 ArtifactKind::Lrc => entry.lrc.as_ref(),
636 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
637 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
638 }
639}
640
641fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
644 let mut out = Vec::new();
645 if let Some(state) = &entry.cover_jpg {
646 out.push((ArtifactKind::CoverJpg, state));
647 }
648 if let Some(state) = &entry.cover_webp {
649 out.push((ArtifactKind::CoverWebp, state));
650 }
651 if let Some(state) = &entry.details_txt {
652 out.push((ArtifactKind::DetailsTxt, state));
653 }
654 if let Some(state) = &entry.lyrics_txt {
655 out.push((ArtifactKind::LyricsTxt, state));
656 }
657 if let Some(state) = &entry.lrc {
658 out.push((ArtifactKind::Lrc, state));
659 }
660 if let Some(state) = &entry.video_mp4 {
661 out.push((ArtifactKind::VideoMp4, state));
662 }
663 out
664}
665
666pub(crate) fn set_manifest_artifact(
673 entry: &mut ManifestEntry,
674 kind: ArtifactKind,
675 state: Option<ArtifactState>,
676) {
677 match kind {
678 ArtifactKind::CoverJpg => entry.cover_jpg = state,
679 ArtifactKind::CoverWebp => entry.cover_webp = state,
680 ArtifactKind::DetailsTxt => entry.details_txt = state,
681 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
682 ArtifactKind::Lrc => entry.lrc = state,
683 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
684 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
685 }
686}
687
688pub(crate) fn set_manifest_stem(
694 entry: &mut ManifestEntry,
695 key: &str,
696 state: Option<ArtifactState>,
697) {
698 match state {
699 Some(state) => {
700 entry.stems.insert(key.to_string(), state);
701 }
702 None => {
703 entry.stems.remove(key);
704 }
705 }
706}
707
708fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
718 let owner_id = d.clip.id.as_str();
719 let entry = manifest.get(owner_id);
720
721 for artifact in &d.artifacts {
722 if !is_per_clip_kind(artifact.kind) {
726 continue;
727 }
728 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
737 None => true,
738 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
739 };
740 if needs_write {
741 out.push(Action::WriteArtifact {
742 kind: artifact.kind,
743 path: artifact.path.clone(),
744 source_url: artifact.source_url.clone(),
745 hash: artifact.hash.clone(),
746 owner_id: owner_id.to_string(),
747 content: artifact.content.clone(),
748 });
749 }
750 }
751
752 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
757 if !protected_now && let Some(entry) = entry {
758 let desired_kinds: BTreeSet<ArtifactKind> = d
759 .artifacts
760 .iter()
761 .filter(|a| is_per_clip_kind(a.kind))
762 .map(|a| a.kind)
763 .collect();
764 for (kind, state) in manifest_artifacts(entry) {
765 if removed_kind_delete_eligible(kind)
771 && !desired_kinds.contains(&kind)
772 && let Some(action) =
773 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
774 {
775 out.push(action);
776 }
777 }
778 }
779}
780
781fn co_delete_artifacts(
787 owner_id: &str,
788 manifest: &Manifest,
789 can_delete: bool,
790 out: &mut Vec<Action>,
791) {
792 let Some(entry) = manifest.get(owner_id) else {
793 return;
794 };
795 for (kind, state) in manifest_artifacts(entry) {
796 if let Some(action) =
797 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
798 {
799 out.push(action);
800 }
801 }
802}
803
804fn delete_stem_action(
813 clip_id: &str,
814 key: &str,
815 path: &str,
816 manifest: &Manifest,
817 can_delete: bool,
818) -> Option<Action> {
819 if !can_delete {
820 return None;
821 }
822 let entry = manifest.get(clip_id)?;
823 if path.is_empty() || entry.preserve {
824 return None;
825 }
826 Some(Action::DeleteStem {
827 clip_id: clip_id.to_string(),
828 key: key.to_string(),
829 path: path.to_string(),
830 })
831}
832
833fn plan_clip_stems(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
849 let Some(desired_stems) = &d.stems else {
850 return;
851 };
852 let clip_id = d.clip.id.as_str();
853 let entry = manifest.get(clip_id);
854
855 for stem in desired_stems {
856 let needs_write = match entry.and_then(|e| e.stems.get(&stem.key)) {
857 None => true,
858 Some(state) => state.hash != stem.hash || state.path != stem.path,
859 };
860 if needs_write {
861 out.push(Action::WriteStem {
862 clip_id: clip_id.to_string(),
863 key: stem.key.clone(),
864 stem_id: stem.stem_id.clone(),
865 path: stem.path.clone(),
866 source_url: stem.source_url.clone(),
867 format: stem.format,
868 hash: stem.hash.clone(),
869 });
870 }
871 }
872
873 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
874 if !protected_now && let Some(entry) = entry {
875 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
876 for (key, state) in &entry.stems {
877 if !desired_keys.contains(key.as_str())
883 && let Some(action) =
884 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
885 {
886 out.push(action);
887 }
888 }
889 }
890}
891
892fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
900 let Some(entry) = manifest.get(clip_id) else {
901 return;
902 };
903 for (key, state) in &entry.stems {
904 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
905 out.push(action);
906 }
907 }
908}
909
910fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
917 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
918 for d in desired {
919 match by_id.get_mut(d.clip.id.as_str()) {
920 None => {
921 by_id.insert(d.clip.id.as_str(), d.clone());
922 }
923 Some(acc) => {
924 let take = rep_key(d) < rep_key(acc);
925 acc.private = acc.private || d.private;
926 acc.trashed = acc.trashed && d.trashed;
927 for mode in &d.modes {
928 if !acc.modes.contains(mode) {
929 acc.modes.push(*mode);
930 }
931 }
932 if take {
933 acc.clip = d.clip.clone();
934 acc.path = d.path.clone();
935 acc.format = d.format;
936 acc.meta_hash = d.meta_hash.clone();
937 acc.art_hash = d.art_hash.clone();
938 acc.artifacts = d.artifacts.clone();
939 acc.stems = d.stems.clone();
940 }
941 }
942 }
943 }
944 let mut out: Vec<Desired> = by_id.into_values().collect();
945 for d in &mut out {
946 let has_mirror = d.modes.contains(&SourceMode::Mirror);
948 let has_copy = d.modes.contains(&SourceMode::Copy);
949 d.modes.clear();
950 if has_mirror {
951 d.modes.push(SourceMode::Mirror);
952 }
953 if has_copy {
954 d.modes.push(SourceMode::Copy);
955 }
956 }
957 out
958}
959
960fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
963 let format = match d.format {
964 AudioFormat::Mp3 => 0,
965 AudioFormat::Flac => 1,
966 AudioFormat::Wav => 2,
967 };
968 (
969 d.path.as_str(),
970 d.meta_hash.as_str(),
971 d.art_hash.as_str(),
972 format,
973 )
974}
975
976fn suppress_path_aliasing(actions: &mut [Action]) {
982 let targets: BTreeSet<String> = actions
983 .iter()
984 .filter_map(|a| match a {
985 Action::Download { path, .. }
986 | Action::Reformat { path, .. }
987 | Action::WriteArtifact { path, .. }
988 | Action::WriteStem { path, .. } => Some(path.clone()),
989 Action::Rename { to, .. } => Some(to.clone()),
990 _ => None,
991 })
992 .collect();
993 for a in actions.iter_mut() {
994 if let Action::Delete { path, clip_id } = a
995 && targets.contains(path.as_str())
996 {
997 *a = Action::Skip {
998 clip_id: clip_id.clone(),
999 };
1000 }
1001 if let Action::DeleteArtifact { path, owner_id, .. } = a
1002 && targets.contains(path.as_str())
1003 {
1004 *a = Action::Skip {
1005 clip_id: owner_id.clone(),
1006 };
1007 }
1008 if let Action::DeleteStem { path, clip_id, .. } = a
1009 && targets.contains(path.as_str())
1010 {
1011 *a = Action::Skip {
1012 clip_id: clip_id.clone(),
1013 };
1014 }
1015 }
1016}
1017
1018fn plan_desired(
1020 d: &Desired,
1021 manifest: &Manifest,
1022 local: &HashMap<String, LocalFile>,
1023 can_delete: bool,
1024 out: &mut Vec<Action>,
1025) {
1026 let clip_id = d.clip.id.as_str();
1027 let copy_held = d.modes.contains(&SourceMode::Copy);
1028
1029 if d.trashed && !d.private && !copy_held {
1035 match delete_action(clip_id, manifest, can_delete) {
1036 Some(action) => out.push(action),
1037 None => out.push(Action::Skip {
1038 clip_id: clip_id.to_string(),
1039 }),
1040 }
1041 return;
1042 }
1043
1044 let Some(entry) = manifest.get(clip_id) else {
1045 out.push(Action::Download {
1047 clip: d.clip.clone(),
1048 lineage: d.lineage.clone(),
1049 path: d.path.clone(),
1050 format: d.format,
1051 });
1052 return;
1053 };
1054
1055 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1058 if missing {
1059 out.push(Action::Download {
1060 clip: d.clip.clone(),
1061 lineage: d.lineage.clone(),
1062 path: d.path.clone(),
1063 format: d.format,
1064 });
1065 return;
1066 }
1067
1068 if d.format != entry.format {
1069 out.push(Action::Reformat {
1072 clip: d.clip.clone(),
1073 path: d.path.clone(),
1074 from_path: entry.path.clone(),
1075 from: entry.format,
1076 to: d.format,
1077 });
1078 return;
1079 }
1080
1081 if d.path != entry.path {
1082 out.push(Action::Rename {
1083 from: entry.path.clone(),
1084 to: d.path.clone(),
1085 });
1086 if meta_or_art_changed(d, entry) {
1088 out.push(Action::Retag {
1089 clip: d.clip.clone(),
1090 lineage: d.lineage.clone(),
1091 path: d.path.clone(),
1092 });
1093 }
1094 return;
1095 }
1096
1097 if meta_or_art_changed(d, entry) {
1098 out.push(Action::Retag {
1099 clip: d.clip.clone(),
1100 lineage: d.lineage.clone(),
1101 path: entry.path.clone(),
1102 });
1103 return;
1104 }
1105
1106 out.push(Action::Skip {
1107 clip_id: clip_id.to_string(),
1108 });
1109}
1110
1111fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1113 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1114}
1115
1116pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
1136 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1137 for d in desired {
1138 groups
1139 .entry(d.lineage.root_id.as_str())
1140 .or_default()
1141 .push(d);
1142 }
1143
1144 groups
1145 .into_iter()
1146 .map(|(root_id, members)| {
1147 let album_dir = album_dir_of(&members);
1148 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1149 kind: ArtifactKind::FolderJpg,
1150 path: album_child(&album_dir, "folder.jpg"),
1151 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1152 hash: art_hash(&source.clip),
1153 content: None,
1154 });
1155 let folder_webp = animated_covers
1156 .then(|| folder_webp_source(&members))
1157 .flatten()
1158 .map(|source| DesiredArtifact {
1159 kind: ArtifactKind::FolderWebp,
1160 path: album_child(&album_dir, "cover.webp"),
1161 source_url: source.clip.video_cover_url.clone(),
1162 hash: art_url_hash(&source.clip.video_cover_url),
1163 content: None,
1164 });
1165 AlbumDesired {
1166 root_id: root_id.to_owned(),
1167 folder_jpg,
1168 folder_webp,
1169 }
1170 })
1171 .collect()
1172}
1173
1174fn album_dir_of(members: &[&Desired]) -> String {
1179 members
1180 .iter()
1181 .map(|d| parent_dir(&d.path))
1182 .min()
1183 .unwrap_or("")
1184 .to_owned()
1185}
1186
1187fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1193 members
1194 .iter()
1195 .copied()
1196 .filter(|d| {
1197 d.clip
1198 .selected_image_url()
1199 .is_some_and(|url| !url.is_empty())
1200 })
1201 .min_by(|a, b| {
1202 b.clip
1203 .play_count
1204 .cmp(&a.clip.play_count)
1205 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1206 .then_with(|| a.clip.id.cmp(&b.clip.id))
1207 })
1208}
1209
1210fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1215 members
1216 .iter()
1217 .copied()
1218 .filter(|d| !d.clip.video_cover_url.is_empty())
1219 .min_by(|a, b| {
1220 a.clip
1221 .created_at
1222 .cmp(&b.clip.created_at)
1223 .then_with(|| a.clip.id.cmp(&b.clip.id))
1224 })
1225}
1226
1227fn parent_dir(path: &str) -> &str {
1229 match path.rsplit_once('/') {
1230 Some((dir, _)) => dir,
1231 None => "",
1232 }
1233}
1234
1235fn album_child(album_dir: &str, name: &str) -> String {
1238 if album_dir.is_empty() {
1239 name.to_owned()
1240 } else {
1241 format!("{album_dir}/{name}")
1242 }
1243}
1244
1245pub fn plan_album_artifacts(
1264 desired: &[AlbumDesired],
1265 albums: &BTreeMap<String, AlbumArt>,
1266 can_delete: bool,
1267) -> Vec<Action> {
1268 let mut actions: Vec<Action> = Vec::new();
1269 let by_root: BTreeMap<&str, &AlbumDesired> =
1270 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1271
1272 for d in desired {
1273 let stored = albums.get(&d.root_id);
1274 for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
1275 .into_iter()
1276 .flatten()
1277 {
1278 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1279 None => true,
1280 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1281 };
1282 if needs_write {
1283 actions.push(Action::WriteArtifact {
1284 kind: artifact.kind,
1285 path: artifact.path.clone(),
1286 source_url: artifact.source_url.clone(),
1287 hash: artifact.hash.clone(),
1288 owner_id: d.root_id.clone(),
1289 content: None,
1290 });
1291 }
1292 }
1293 }
1294
1295 if can_delete {
1297 for (root_id, art) in albums {
1298 for (kind, state) in album_artifacts(art) {
1299 let desired_here = by_root
1300 .get(root_id.as_str())
1301 .is_some_and(|d| album_desires_kind(d, kind));
1302 if !desired_here && !state.path.is_empty() {
1303 actions.push(Action::DeleteArtifact {
1304 kind,
1305 path: state.path.clone(),
1306 owner_id: root_id.clone(),
1307 });
1308 }
1309 }
1310 }
1311 }
1312
1313 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1314 actions
1315}
1316
1317fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1320 let mut out = Vec::new();
1321 if let Some(state) = &art.folder_jpg {
1322 out.push((ArtifactKind::FolderJpg, state));
1323 }
1324 if let Some(state) = &art.folder_webp {
1325 out.push((ArtifactKind::FolderWebp, state));
1326 }
1327 out
1328}
1329
1330fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1332 match kind {
1333 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1334 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1335 ArtifactKind::CoverJpg
1336 | ArtifactKind::CoverWebp
1337 | ArtifactKind::DetailsTxt
1338 | ArtifactKind::LyricsTxt
1339 | ArtifactKind::Lrc
1340 | ArtifactKind::VideoMp4
1341 | ArtifactKind::Playlist => false,
1342 }
1343}
1344
1345fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1347 match action {
1348 Action::WriteArtifact { owner_id, kind, .. }
1349 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1350 _ => ("", ArtifactKind::CoverJpg),
1351 }
1352}
1353
1354pub fn plan_playlist_artifacts(
1387 desired: &[PlaylistDesired],
1388 stored: &BTreeMap<String, PlaylistState>,
1389 can_delete: bool,
1390 list_fully_enumerated: bool,
1391) -> Vec<Action> {
1392 let mut actions: Vec<Action> = Vec::new();
1393 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1394 let deletes_allowed = can_delete && list_fully_enumerated;
1397
1398 for d in desired {
1399 let stored_here = stored.get(&d.id);
1400 let needs_write = match stored_here {
1401 None => true,
1402 Some(state) => state.hash != d.hash || state.path != d.path,
1403 };
1404 if needs_write {
1405 actions.push(Action::WriteArtifact {
1406 kind: ArtifactKind::Playlist,
1407 path: d.path.clone(),
1408 source_url: String::new(),
1409 hash: d.hash.clone(),
1410 owner_id: d.id.clone(),
1411 content: Some(d.content.clone()),
1412 });
1413 }
1414 if deletes_allowed
1416 && let Some(state) = stored_here
1417 && !state.path.is_empty()
1418 && state.path != d.path
1419 {
1420 actions.push(Action::DeleteArtifact {
1421 kind: ArtifactKind::Playlist,
1422 path: state.path.clone(),
1423 owner_id: d.id.clone(),
1424 });
1425 }
1426 }
1427
1428 if deletes_allowed {
1431 for (id, state) in stored {
1432 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1433 actions.push(Action::DeleteArtifact {
1434 kind: ArtifactKind::Playlist,
1435 path: state.path.clone(),
1436 owner_id: id.clone(),
1437 });
1438 }
1439 }
1440 }
1441
1442 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1443 suppress_path_aliasing(&mut actions);
1446 actions
1447}
1448
1449fn playlist_action_key(action: &Action) -> (&str, u8) {
1452 match action {
1453 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1454 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1455 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1456 _ => ("", 3),
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 use crate::hash::content_hash;
1464
1465 fn clip(id: &str) -> Clip {
1466 Clip {
1467 id: id.to_string(),
1468 title: "Song".to_string(),
1469 ..Default::default()
1470 }
1471 }
1472
1473 fn lineage(id: &str) -> LineageContext {
1474 LineageContext::own_root(&clip(id))
1475 }
1476
1477 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1478 ManifestEntry {
1479 path: path.to_string(),
1480 format,
1481 meta_hash: meta.to_string(),
1482 art_hash: art.to_string(),
1483 size: 100,
1484 preserve: false,
1485 ..Default::default()
1486 }
1487 }
1488
1489 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1490 ManifestEntry {
1491 preserve: true,
1492 ..entry(path, format, meta, art)
1493 }
1494 }
1495
1496 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1497 Desired {
1498 clip: clip(id),
1499 lineage: lineage(id),
1500 path: path.to_string(),
1501 format,
1502 meta_hash: meta.to_string(),
1503 art_hash: art.to_string(),
1504 modes: vec![SourceMode::Mirror],
1505 trashed: false,
1506 private: false,
1507 artifacts: Vec::new(),
1508 stems: None,
1509 }
1510 }
1511
1512 fn present(size: u64) -> LocalFile {
1513 LocalFile { exists: true, size }
1514 }
1515
1516 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1517 [(id.to_string(), present(100))].into_iter().collect()
1518 }
1519
1520 fn mirror_ok() -> Vec<SourceStatus> {
1521 vec![SourceStatus {
1522 mode: SourceMode::Mirror,
1523 fully_enumerated: true,
1524 }]
1525 }
1526
1527 #[test]
1530 fn not_in_manifest_downloads() {
1531 let manifest = Manifest::new();
1532 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1533 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1534 assert_eq!(
1535 plan.actions,
1536 vec![Action::Download {
1537 clip: clip("a"),
1538 lineage: lineage("a"),
1539 path: "a.flac".to_string(),
1540 format: AudioFormat::Flac,
1541 }]
1542 );
1543 }
1544
1545 #[test]
1546 fn unchanged_clip_skips() {
1547 let mut manifest = Manifest::new();
1548 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1549 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1550 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1551 assert_eq!(
1552 plan.actions,
1553 vec![Action::Skip {
1554 clip_id: "a".to_string()
1555 }]
1556 );
1557 }
1558
1559 #[test]
1560 fn meta_change_retags_in_place() {
1561 let mut manifest = Manifest::new();
1562 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1563 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1564 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1565 assert_eq!(
1566 plan.actions,
1567 vec![Action::Retag {
1568 clip: clip("a"),
1569 lineage: lineage("a"),
1570 path: "a.flac".to_string(),
1571 }]
1572 );
1573 }
1574
1575 #[test]
1576 fn art_change_retags_in_place() {
1577 let mut manifest = Manifest::new();
1578 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1579 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1580 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1581 assert_eq!(
1582 plan.actions,
1583 vec![Action::Retag {
1584 clip: clip("a"),
1585 lineage: lineage("a"),
1586 path: "a.flac".to_string(),
1587 }]
1588 );
1589 }
1590
1591 #[test]
1592 fn rename_when_path_changes() {
1593 let mut manifest = Manifest::new();
1594 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1595 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1596 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1597 assert_eq!(
1598 plan.actions,
1599 vec![Action::Rename {
1600 from: "old/a.flac".to_string(),
1601 to: "new/a.flac".to_string(),
1602 }]
1603 );
1604 }
1605
1606 #[test]
1607 fn rename_with_meta_change_also_retags() {
1608 let mut manifest = Manifest::new();
1609 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1610 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1611 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1612 assert_eq!(
1613 plan.actions,
1614 vec![
1615 Action::Rename {
1616 from: "old/a.flac".to_string(),
1617 to: "new/a.flac".to_string(),
1618 },
1619 Action::Retag {
1620 clip: clip("a"),
1621 lineage: lineage("a"),
1622 path: "new/a.flac".to_string(),
1623 },
1624 ]
1625 );
1626 }
1627
1628 #[test]
1629 fn rename_without_meta_change_does_not_retag() {
1630 let mut manifest = Manifest::new();
1631 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1632 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1633 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1634 assert_eq!(plan.renames(), 1);
1635 assert_eq!(plan.retags(), 0);
1636 }
1637
1638 #[test]
1639 fn bulk_album_rename_moves_and_retags_without_redownload() {
1640 let mut manifest = Manifest::new();
1645 for id in ["a", "b", "c"] {
1646 manifest.insert(
1647 id,
1648 entry(
1649 &format!("Creator/Old Album/{id}.flac"),
1650 AudioFormat::Flac,
1651 "old-meta",
1652 "art",
1653 ),
1654 );
1655 }
1656 let d: Vec<Desired> = ["a", "b", "c"]
1657 .iter()
1658 .map(|id| {
1659 desired(
1660 id,
1661 &format!("Creator/New Album/{id}.flac"),
1662 AudioFormat::Flac,
1663 "new-meta",
1664 "art",
1665 )
1666 })
1667 .collect();
1668 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1669 .iter()
1670 .map(|id| (id.to_string(), present(100)))
1671 .collect();
1672
1673 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1674
1675 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1676 assert_eq!(
1677 plan.retags(),
1678 3,
1679 "the album tag change retags each in place"
1680 );
1681 assert_eq!(
1682 plan.downloads(),
1683 0,
1684 "an album rename must never re-download"
1685 );
1686 assert_eq!(
1687 plan.deletes(),
1688 0,
1689 "deletion safety: a rename deletes nothing"
1690 );
1691 for id in ["a", "b", "c"] {
1692 assert!(plan.actions.contains(&Action::Rename {
1693 from: format!("Creator/Old Album/{id}.flac"),
1694 to: format!("Creator/New Album/{id}.flac"),
1695 }));
1696 }
1697 }
1698
1699 #[test]
1700 fn format_change_reformats() {
1701 let mut manifest = Manifest::new();
1702 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1703 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1704 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1705 assert_eq!(
1706 plan.actions,
1707 vec![Action::Reformat {
1708 clip: clip("a"),
1709 path: "a.mp3".to_string(),
1710 from_path: "a.flac".to_string(),
1711 from: AudioFormat::Flac,
1712 to: AudioFormat::Mp3,
1713 }]
1714 );
1715 }
1716
1717 #[test]
1718 fn format_change_takes_precedence_over_rename_and_retag() {
1719 let mut manifest = Manifest::new();
1722 manifest.insert(
1723 "a",
1724 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1725 );
1726 let d = vec![desired(
1727 "a",
1728 "new/a.mp3",
1729 AudioFormat::Mp3,
1730 "new",
1731 "new-art",
1732 )];
1733 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1734 assert_eq!(plan.reformats(), 1);
1735 assert_eq!(plan.renames(), 0);
1736 assert_eq!(plan.retags(), 0);
1737 }
1738
1739 #[test]
1742 fn zero_length_file_downloads_even_when_hashes_match() {
1743 let mut manifest = Manifest::new();
1744 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1745 let local: HashMap<String, LocalFile> = [(
1746 "a".to_string(),
1747 LocalFile {
1748 exists: true,
1749 size: 0,
1750 },
1751 )]
1752 .into_iter()
1753 .collect();
1754 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1755 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1756 assert_eq!(plan.downloads(), 1);
1757 assert_eq!(plan.skips(), 0);
1758 }
1759
1760 #[test]
1761 fn missing_file_downloads_even_when_hashes_match() {
1762 let mut manifest = Manifest::new();
1763 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1764 let local: HashMap<String, LocalFile> = [(
1765 "a".to_string(),
1766 LocalFile {
1767 exists: false,
1768 size: 0,
1769 },
1770 )]
1771 .into_iter()
1772 .collect();
1773 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1774 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1775 assert_eq!(plan.downloads(), 1);
1776 }
1777
1778 #[test]
1779 fn absent_local_probe_treated_as_missing() {
1780 let mut manifest = Manifest::new();
1782 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1783 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1784 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1785 assert_eq!(plan.downloads(), 1);
1786 }
1787
1788 #[test]
1789 fn missing_file_download_wins_over_format_difference() {
1790 let mut manifest = Manifest::new();
1793 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1794 let local: HashMap<String, LocalFile> = [(
1795 "a".to_string(),
1796 LocalFile {
1797 exists: false,
1798 size: 0,
1799 },
1800 )]
1801 .into_iter()
1802 .collect();
1803 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1804 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1805 assert_eq!(plan.downloads(), 1);
1806 assert_eq!(plan.reformats(), 0);
1807 }
1808
1809 #[test]
1812 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1813 let mut trashed = clip("a");
1818 trashed.status = "complete".to_string();
1819 trashed.is_trashed = true;
1820 assert!(crate::is_downloadable(&trashed));
1821
1822 let mut manifest = Manifest::new();
1823 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1824 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1825 d.clip = trashed;
1826 d.trashed = true;
1827 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1828 assert_eq!(
1829 plan.actions,
1830 vec![Action::Delete {
1831 path: "a.flac".to_string(),
1832 clip_id: "a".to_string(),
1833 }]
1834 );
1835 }
1836
1837 #[test]
1838 fn trashed_clip_deletes_local_file() {
1839 let mut manifest = Manifest::new();
1840 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1841 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1842 d.trashed = true;
1843 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1844 assert_eq!(
1845 plan.actions,
1846 vec![Action::Delete {
1847 path: "a.flac".to_string(),
1848 clip_id: "a".to_string(),
1849 }]
1850 );
1851 }
1852
1853 #[test]
1854 fn trashed_clip_not_in_manifest_skips() {
1855 let manifest = Manifest::new();
1857 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1858 d.trashed = true;
1859 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1860 assert_eq!(
1861 plan.actions,
1862 vec![Action::Skip {
1863 clip_id: "a".to_string()
1864 }]
1865 );
1866 }
1867
1868 #[test]
1869 fn private_clip_is_kept() {
1870 let mut manifest = Manifest::new();
1871 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1872 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1873 d.private = true;
1874 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1875 assert_eq!(
1876 plan.actions,
1877 vec![Action::Skip {
1878 clip_id: "a".to_string()
1879 }]
1880 );
1881 }
1882
1883 #[test]
1884 fn private_beats_trashed_never_deletes() {
1885 let mut manifest = Manifest::new();
1887 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1888 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1889 d.trashed = true;
1890 d.private = true;
1891 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1892 assert_eq!(plan.deletes(), 0);
1893 assert_eq!(plan.skips(), 1);
1894 }
1895
1896 #[test]
1897 fn copy_held_trashed_clip_is_not_deleted() {
1898 let mut manifest = Manifest::new();
1901 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1902 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1903 d.modes = vec![SourceMode::Copy];
1904 d.trashed = true;
1905 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1906 assert_eq!(plan.deletes(), 0);
1907 assert_eq!(
1908 plan.actions,
1909 vec![Action::Skip {
1910 clip_id: "a".to_string()
1911 }]
1912 );
1913 }
1914
1915 #[test]
1918 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1919 let mut manifest = Manifest::new();
1920 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1921 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1922 assert_eq!(
1923 plan.actions,
1924 vec![Action::Delete {
1925 path: "gone.flac".to_string(),
1926 clip_id: "gone".to_string(),
1927 }]
1928 );
1929 }
1930
1931 #[test]
1932 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1933 let mut manifest = Manifest::new();
1934 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1935 let sources = vec![
1936 SourceStatus {
1937 mode: SourceMode::Mirror,
1938 fully_enumerated: true,
1939 },
1940 SourceStatus {
1941 mode: SourceMode::Mirror,
1942 fully_enumerated: false,
1943 },
1944 ];
1945 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1946 assert_eq!(plan.deletes(), 0);
1947 assert_eq!(
1948 plan.actions,
1949 vec![Action::Skip {
1950 clip_id: "gone".to_string()
1951 }]
1952 );
1953 }
1954
1955 #[test]
1956 fn empty_listing_cannot_cause_deletion() {
1957 let mut manifest = Manifest::new();
1960 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1961 let sources = vec![SourceStatus {
1962 mode: SourceMode::Mirror,
1963 fully_enumerated: false,
1964 }];
1965 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1966 assert_eq!(plan.deletes(), 0);
1967 assert_eq!(plan.skips(), 1);
1968 }
1969
1970 #[test]
1971 fn no_mirror_sources_means_no_deletion() {
1972 let mut manifest = Manifest::new();
1974 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1975 let copy_only = vec![SourceStatus {
1976 mode: SourceMode::Copy,
1977 fully_enumerated: true,
1978 }];
1979 assert_eq!(
1980 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1981 0
1982 );
1983 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1984 }
1985
1986 #[test]
1987 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1988 let mut manifest = Manifest::new();
1989 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1990 let sources = vec![
1991 SourceStatus {
1992 mode: SourceMode::Copy,
1993 fully_enumerated: true,
1994 },
1995 SourceStatus {
1996 mode: SourceMode::Mirror,
1997 fully_enumerated: false,
1998 },
1999 ];
2000 assert_eq!(
2001 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2002 0
2003 );
2004 }
2005
2006 #[test]
2007 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2008 let mut manifest = Manifest::new();
2012 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2013 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2014 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2015 held.modes = vec![SourceMode::Copy];
2016 let local: HashMap<String, LocalFile> = [
2017 ("keep".to_string(), present(100)),
2018 ("gone".to_string(), present(100)),
2019 ]
2020 .into_iter()
2021 .collect();
2022 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2023 assert!(plan.actions.contains(&Action::Skip {
2024 clip_id: "keep".to_string()
2025 }));
2026 assert!(plan.actions.contains(&Action::Delete {
2027 path: "gone.flac".to_string(),
2028 clip_id: "gone".to_string(),
2029 }));
2030 assert!(
2032 !plan
2033 .actions
2034 .iter()
2035 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2036 );
2037 }
2038
2039 #[test]
2042 fn orphan_with_preserve_marker_is_kept() {
2043 let mut manifest = Manifest::new();
2046 manifest.insert(
2047 "gone",
2048 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2049 );
2050 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2051 assert_eq!(plan.deletes(), 0);
2052 assert_eq!(
2053 plan.actions,
2054 vec![Action::Skip {
2055 clip_id: "gone".to_string()
2056 }]
2057 );
2058 }
2059
2060 #[test]
2061 fn trashed_clip_with_preserve_marker_is_kept() {
2062 let mut manifest = Manifest::new();
2065 manifest.insert(
2066 "a",
2067 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2068 );
2069 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2070 d.trashed = true;
2071 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2072 assert_eq!(plan.deletes(), 0);
2073 assert_eq!(plan.skips(), 1);
2074 }
2075
2076 #[test]
2079 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2080 let mut manifest = Manifest::new();
2082 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2083 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2084 d.trashed = true;
2085 let sources = vec![SourceStatus {
2086 mode: SourceMode::Mirror,
2087 fully_enumerated: false,
2088 }];
2089 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2090 assert_eq!(plan.deletes(), 0);
2091 assert_eq!(plan.skips(), 1);
2092 }
2093
2094 #[test]
2095 fn trashed_clip_kept_when_sources_empty() {
2096 let mut manifest = Manifest::new();
2099 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2100 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2101 d.trashed = true;
2102 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2103 assert_eq!(plan.deletes(), 0);
2104 assert_eq!(plan.skips(), 1);
2105 }
2106
2107 #[test]
2108 fn failed_copy_listing_suppresses_orphan_deletion() {
2109 let mut manifest = Manifest::new();
2112 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2113 let sources = vec![
2114 SourceStatus {
2115 mode: SourceMode::Mirror,
2116 fully_enumerated: true,
2117 },
2118 SourceStatus {
2119 mode: SourceMode::Copy,
2120 fully_enumerated: false,
2121 },
2122 ];
2123 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2124 assert_eq!(plan.deletes(), 0);
2125 }
2126
2127 #[test]
2128 fn failed_copy_listing_suppresses_trashed_deletion() {
2129 let mut manifest = Manifest::new();
2130 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2131 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2132 d.trashed = true;
2133 let sources = vec![
2134 SourceStatus {
2135 mode: SourceMode::Mirror,
2136 fully_enumerated: true,
2137 },
2138 SourceStatus {
2139 mode: SourceMode::Copy,
2140 fully_enumerated: false,
2141 },
2142 ];
2143 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2144 assert_eq!(plan.deletes(), 0);
2145 assert_eq!(plan.skips(), 1);
2146 }
2147
2148 #[test]
2149 fn empty_path_entry_never_deletes() {
2150 let mut manifest = Manifest::new();
2153 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2154 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2155 assert_eq!(plan.deletes(), 0);
2156 assert_eq!(
2157 plan.actions,
2158 vec![Action::Skip {
2159 clip_id: "gone".to_string()
2160 }]
2161 );
2162 }
2163
2164 #[test]
2167 fn delete_suppressed_when_path_aliases_rename_target() {
2168 let mut manifest = Manifest::new();
2171 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2172 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2173 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2174 let local: HashMap<String, LocalFile> = [
2175 ("a".to_string(), present(100)),
2176 ("b".to_string(), present(100)),
2177 ]
2178 .into_iter()
2179 .collect();
2180 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2181 assert!(plan.actions.contains(&Action::Rename {
2182 from: "old/a.flac".to_string(),
2183 to: "new/a.flac".to_string(),
2184 }));
2185 assert!(
2187 !plan
2188 .actions
2189 .iter()
2190 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2191 );
2192 assert!(plan.actions.contains(&Action::Skip {
2193 clip_id: "b".to_string()
2194 }));
2195 }
2196
2197 #[test]
2198 fn delete_suppressed_when_path_aliases_download_target() {
2199 let mut manifest = Manifest::new();
2201 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2202 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2203 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2204 assert!(
2205 !plan
2206 .actions
2207 .iter()
2208 .any(|a| matches!(a, Action::Delete { .. }))
2209 );
2210 assert_eq!(plan.downloads(), 1);
2211 }
2212
2213 #[test]
2214 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2215 let mut actions = vec![
2220 Action::Rename {
2221 from: "old/song.flac".to_string(),
2222 to: "new/cover.jpg".to_string(),
2223 },
2224 Action::DeleteArtifact {
2225 kind: ArtifactKind::CoverJpg,
2226 path: "new/cover.jpg".to_string(),
2227 owner_id: "a".to_string(),
2228 },
2229 ];
2230 suppress_path_aliasing(&mut actions);
2231 assert!(
2233 !actions
2234 .iter()
2235 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2236 "a sidecar delete must not alias a rename target"
2237 );
2238 assert!(actions.contains(&Action::Skip {
2239 clip_id: "a".to_string()
2240 }));
2241 assert!(actions.contains(&Action::Rename {
2243 from: "old/song.flac".to_string(),
2244 to: "new/cover.jpg".to_string(),
2245 }));
2246 }
2247
2248 #[test]
2249 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2250 let mut actions = vec![
2253 Action::WriteArtifact {
2254 kind: ArtifactKind::FolderJpg,
2255 path: "creator/album/folder.jpg".to_string(),
2256 source_url: "https://art/large.jpg".to_string(),
2257 hash: "h".to_string(),
2258 owner_id: "root".to_string(),
2259 content: None,
2260 },
2261 Action::DeleteArtifact {
2262 kind: ArtifactKind::FolderJpg,
2263 path: "creator/album/folder.jpg".to_string(),
2264 owner_id: "root-old".to_string(),
2265 },
2266 ];
2267 suppress_path_aliasing(&mut actions);
2268 assert!(
2269 !actions
2270 .iter()
2271 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2272 );
2273 assert!(actions.contains(&Action::Skip {
2274 clip_id: "root-old".to_string()
2275 }));
2276 }
2277
2278 #[test]
2281 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2282 let mut manifest = Manifest::new();
2285 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2286 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2287 copy_entry.modes = vec![SourceMode::Copy];
2288 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2289 trashed_entry.modes = vec![SourceMode::Mirror];
2290 trashed_entry.trashed = true;
2291 let plan = reconcile(
2292 &manifest,
2293 &[copy_entry, trashed_entry],
2294 &local_present("a"),
2295 &mirror_ok(),
2296 );
2297 assert_eq!(plan.deletes(), 0);
2298 assert_eq!(plan.skips(), 1);
2299 }
2300
2301 #[test]
2302 fn duplicate_trashed_does_not_defeat_private_sibling() {
2303 let mut manifest = Manifest::new();
2304 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2305 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2306 private_entry.private = true;
2307 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2308 trashed_entry.trashed = true;
2309 let plan = reconcile(
2310 &manifest,
2311 &[private_entry, trashed_entry],
2312 &local_present("a"),
2313 &mirror_ok(),
2314 );
2315 assert_eq!(plan.deletes(), 0);
2316 assert_eq!(plan.skips(), 1);
2317 }
2318
2319 #[test]
2320 fn duplicate_trashed_deletes_only_when_all_trashed() {
2321 let mut manifest = Manifest::new();
2323 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2324 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2325 first.trashed = true;
2326 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2327 second.trashed = true;
2328 let plan = reconcile(
2329 &manifest,
2330 &[first, second],
2331 &local_present("a"),
2332 &mirror_ok(),
2333 );
2334 assert_eq!(plan.deletes(), 1);
2335 }
2336
2337 #[test]
2338 fn duplicate_desired_unions_modes() {
2339 let mut manifest = Manifest::new();
2341 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2342 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2343 mirror_entry.modes = vec![SourceMode::Mirror];
2344 mirror_entry.trashed = true;
2345 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2346 copy_entry.modes = vec![SourceMode::Copy];
2347 let plan = reconcile(
2348 &manifest,
2349 &[mirror_entry, copy_entry],
2350 &local_present("a"),
2351 &mirror_ok(),
2352 );
2353 assert_eq!(plan.deletes(), 0);
2355 }
2356
2357 #[test]
2360 fn private_new_clip_downloads() {
2361 let manifest = Manifest::new();
2364 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2365 d.private = true;
2366 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2367 assert_eq!(plan.downloads(), 1);
2368 }
2369
2370 #[test]
2371 fn private_zero_length_file_redownloads() {
2372 let mut manifest = Manifest::new();
2373 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2374 let local: HashMap<String, LocalFile> = [(
2375 "a".to_string(),
2376 LocalFile {
2377 exists: true,
2378 size: 0,
2379 },
2380 )]
2381 .into_iter()
2382 .collect();
2383 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2384 d.private = true;
2385 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2386 assert_eq!(plan.downloads(), 1);
2387 }
2388
2389 #[test]
2390 fn private_meta_change_retags() {
2391 let mut manifest = Manifest::new();
2392 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2393 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2394 d.private = true;
2395 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2396 assert_eq!(plan.retags(), 1);
2397 assert_eq!(plan.deletes(), 0);
2398 }
2399
2400 #[test]
2401 fn absent_private_clip_protected_by_preserve_marker() {
2402 let mut manifest = Manifest::new();
2405 manifest.insert(
2406 "a",
2407 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2408 );
2409 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2410 assert_eq!(plan.deletes(), 0);
2411 assert_eq!(plan.skips(), 1);
2412 }
2413
2414 #[test]
2417 fn output_is_deterministic_regardless_of_input_order() {
2418 let mut manifest = Manifest::new();
2419 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2420 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2421 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2422 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2423 .iter()
2424 .map(|id| (id.to_string(), present(100)))
2425 .collect();
2426
2427 let forward = vec![
2428 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2429 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2430 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2431 ];
2432 let mut reversed = forward.clone();
2433 reversed.reverse();
2434
2435 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2436 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2437 assert_eq!(p1.actions, p2.actions);
2438
2439 let ids: Vec<&str> = p1
2442 .actions
2443 .iter()
2444 .map(|a| match a {
2445 Action::Skip { clip_id } => clip_id.as_str(),
2446 Action::Retag { clip, .. } => clip.id.as_str(),
2447 Action::Download { clip, .. } => clip.id.as_str(),
2448 Action::Delete { clip_id, .. } => clip_id.as_str(),
2449 Action::Reformat { clip, .. } => clip.id.as_str(),
2450 Action::Rename { to, .. } => to.as_str(),
2451 Action::WriteArtifact { owner_id, .. }
2452 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2453 Action::WriteStem { clip_id, .. } | Action::DeleteStem { clip_id, .. } => {
2454 clip_id.as_str()
2455 }
2456 })
2457 .collect();
2458 assert_eq!(ids, ["a", "b", "c", "z"]);
2459 }
2460
2461 #[test]
2462 fn empty_inputs_do_not_panic() {
2463 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2464 assert!(plan.is_empty());
2465 assert_eq!(plan.len(), 0);
2466 }
2467
2468 #[test]
2469 fn empty_desired_with_full_manifest_deletes_all() {
2470 let mut manifest = Manifest::new();
2471 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2472 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2473 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2474 assert_eq!(plan.deletes(), 2);
2475 }
2476
2477 #[test]
2478 fn full_desired_with_empty_manifest_downloads_all() {
2479 let d = vec![
2480 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2481 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2482 ];
2483 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2484 assert_eq!(plan.downloads(), 2);
2485 }
2486
2487 #[test]
2488 fn plan_counts_sum_to_len() {
2489 let mut manifest = Manifest::new();
2490 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2491 manifest.insert(
2492 "retag",
2493 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2494 );
2495 manifest.insert(
2496 "reformat",
2497 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2498 );
2499 manifest.insert(
2500 "rename",
2501 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2502 );
2503 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2504 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2505 .iter()
2506 .map(|id| (id.to_string(), present(100)))
2507 .collect();
2508 let d = vec![
2509 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2510 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2511 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2512 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2513 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2514 ];
2515 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2516 let summed = plan.downloads()
2517 + plan.reformats()
2518 + plan.retags()
2519 + plan.renames()
2520 + plan.deletes()
2521 + plan.skips();
2522 assert_eq!(summed, plan.len());
2523 assert_eq!(plan.downloads(), 1);
2524 assert_eq!(plan.reformats(), 1);
2525 assert_eq!(plan.retags(), 1);
2526 assert_eq!(plan.renames(), 1);
2527 assert_eq!(plan.deletes(), 1);
2528 assert_eq!(plan.skips(), 1);
2529 }
2530
2531 fn cover(path: &str, hash: &str) -> ArtifactState {
2534 ArtifactState {
2535 path: path.to_string(),
2536 hash: hash.to_string(),
2537 }
2538 }
2539
2540 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2541 DesiredArtifact {
2542 kind,
2543 path: path.to_string(),
2544 source_url: url.to_string(),
2545 hash: hash.to_string(),
2546 content: None,
2547 }
2548 }
2549
2550 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2552 DesiredArtifact {
2553 kind,
2554 path: path.to_string(),
2555 source_url: String::new(),
2556 hash: content_hash(body),
2557 content: Some(body.to_string()),
2558 }
2559 }
2560
2561 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2563 Desired {
2564 artifacts: arts,
2565 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2566 }
2567 }
2568
2569 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2571 ManifestEntry {
2572 cover_jpg: Some(cover(cover_path, cover_hash)),
2573 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2574 }
2575 }
2576
2577 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2578 plan.actions
2579 .iter()
2580 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2581 .collect()
2582 }
2583
2584 #[test]
2585 fn write_artifact_emitted_when_manifest_lacks_it() {
2586 let mut manifest = Manifest::new();
2589 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2590 let d = vec![desired_arts(
2591 "a",
2592 vec![art(
2593 ArtifactKind::CoverJpg,
2594 "a/cover.jpg",
2595 "https://art/a",
2596 "h1",
2597 )],
2598 )];
2599 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2600 assert_eq!(plan.artifact_writes(), 1);
2601 assert_eq!(plan.artifact_deletes(), 0);
2602 assert_eq!(plan.skips(), 1);
2603 assert_eq!(
2604 write_artifacts(&plan)[0],
2605 &Action::WriteArtifact {
2606 kind: ArtifactKind::CoverJpg,
2607 path: "a/cover.jpg".to_string(),
2608 source_url: "https://art/a".to_string(),
2609 hash: "h1".to_string(),
2610 owner_id: "a".to_string(),
2611 content: None,
2612 }
2613 );
2614 }
2615
2616 #[test]
2617 fn write_artifact_emitted_when_hash_differs() {
2618 let mut manifest = Manifest::new();
2621 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2622 let d = vec![desired_arts(
2623 "a",
2624 vec![art(
2625 ArtifactKind::CoverJpg,
2626 "a/cover.jpg",
2627 "https://art/a",
2628 "new",
2629 )],
2630 )];
2631 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2632 assert_eq!(plan.artifact_writes(), 1);
2633 assert_eq!(plan.artifact_deletes(), 0);
2634 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2635 assert_eq!(hash, "new");
2636 } else {
2637 panic!("expected a WriteArtifact");
2638 }
2639 }
2640
2641 #[test]
2642 fn write_artifact_skipped_when_hash_matches() {
2643 let mut manifest = Manifest::new();
2645 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2646 let d = vec![desired_arts(
2647 "a",
2648 vec![art(
2649 ArtifactKind::CoverJpg,
2650 "a/cover.jpg",
2651 "https://art/a",
2652 "h1",
2653 )],
2654 )];
2655 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2656 assert_eq!(plan.artifact_writes(), 0);
2657 assert_eq!(plan.artifact_deletes(), 0);
2658 assert_eq!(
2659 plan.actions,
2660 vec![Action::Skip {
2661 clip_id: "a".to_string()
2662 }]
2663 );
2664 }
2665
2666 #[test]
2667 fn removed_kind_cover_is_kept_not_deleted() {
2668 let mut manifest = Manifest::new();
2673 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2674 let d = vec![desired_arts("a", vec![])];
2675 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2676 assert_eq!(plan.artifact_deletes(), 0);
2677 assert_eq!(plan.artifact_writes(), 0);
2678 assert_eq!(plan.deletes(), 0);
2680 assert_eq!(
2681 plan.actions,
2682 vec![Action::Skip {
2683 clip_id: "a".to_string()
2684 }]
2685 );
2686 assert!(!plan.actions.iter().any(|a| matches!(
2687 a,
2688 Action::DeleteArtifact {
2689 kind: ArtifactKind::CoverJpg,
2690 ..
2691 }
2692 )));
2693 }
2694
2695 #[test]
2696 fn delete_artifact_never_on_incomplete_listing() {
2697 let mut manifest = Manifest::new();
2702 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2703 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2704 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2705 let sources = vec![SourceStatus {
2706 mode: SourceMode::Mirror,
2707 fully_enumerated: false,
2708 }];
2709 let local: HashMap<String, LocalFile> = [
2710 ("a".to_string(), present(100)),
2711 ("b".to_string(), present(100)),
2712 ]
2713 .into_iter()
2714 .collect();
2715 let plan = reconcile(&manifest, &d, &local, &sources);
2716 assert_eq!(plan.artifact_deletes(), 0);
2717 assert_eq!(plan.deletes(), 0);
2718 }
2719
2720 #[test]
2721 fn delete_artifact_never_when_entry_preserved() {
2722 let mut manifest = Manifest::new();
2725 let preserved = ManifestEntry {
2726 preserve: true,
2727 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2728 };
2729 manifest.insert("a", preserved);
2730 let d = vec![desired_arts("a", vec![])];
2731 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2732 assert_eq!(plan.artifact_deletes(), 0);
2733 }
2734
2735 #[test]
2736 fn co_delete_never_when_path_empty() {
2737 let mut manifest = Manifest::new();
2741 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2742 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2743 assert_eq!(plan.deletes(), 1);
2744 assert_eq!(plan.artifact_deletes(), 0);
2745 }
2746
2747 #[test]
2748 fn co_delete_absent_clip_deletes_audio_and_cover() {
2749 let mut manifest = Manifest::new();
2752 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2753 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2754 assert_eq!(plan.deletes(), 1);
2755 assert_eq!(plan.artifact_deletes(), 1);
2756 assert!(plan.actions.contains(&Action::Delete {
2757 path: "gone.flac".to_string(),
2758 clip_id: "gone".to_string(),
2759 }));
2760 assert!(plan.actions.contains(&Action::DeleteArtifact {
2761 kind: ArtifactKind::CoverJpg,
2762 path: "gone/cover.jpg".to_string(),
2763 owner_id: "gone".to_string(),
2764 }));
2765 }
2766
2767 #[test]
2768 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2769 let mut manifest = Manifest::new();
2771 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2772 let sources = vec![SourceStatus {
2773 mode: SourceMode::Mirror,
2774 fully_enumerated: false,
2775 }];
2776 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2777 assert_eq!(plan.deletes(), 0);
2778 assert_eq!(plan.artifact_deletes(), 0);
2779 }
2780
2781 #[test]
2782 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2783 let mut manifest = Manifest::new();
2785 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2786 let mut d = desired_arts("a", vec![]);
2787 d.trashed = true;
2788 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2789 assert_eq!(plan.deletes(), 1);
2790 assert_eq!(plan.artifact_deletes(), 1);
2791 }
2792
2793 #[test]
2794 fn co_delete_trashed_suppressed_when_not_enumerated() {
2795 let mut manifest = Manifest::new();
2797 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2798 let mut d = desired_arts("a", vec![]);
2799 d.trashed = true;
2800 let sources = vec![SourceStatus {
2801 mode: SourceMode::Mirror,
2802 fully_enumerated: false,
2803 }];
2804 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2805 assert_eq!(plan.deletes(), 0);
2806 assert_eq!(plan.artifact_deletes(), 0);
2807 assert_eq!(plan.skips(), 1);
2808 }
2809
2810 #[test]
2811 fn co_delete_trashed_suppressed_when_preserved() {
2812 let mut manifest = Manifest::new();
2814 let preserved = ManifestEntry {
2815 preserve: true,
2816 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2817 };
2818 manifest.insert("a", preserved);
2819 let mut d = desired_arts("a", vec![]);
2820 d.trashed = true;
2821 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2822 assert_eq!(plan.deletes(), 0);
2823 assert_eq!(plan.artifact_deletes(), 0);
2824 }
2825
2826 #[test]
2829 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2830 let mut manifest = Manifest::new();
2833 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2834 let d = vec![desired_arts(
2835 "a",
2836 vec![text_art(
2837 ArtifactKind::DetailsTxt,
2838 "a.details.txt",
2839 "Title: A\n",
2840 )],
2841 )];
2842 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2843 assert_eq!(plan.artifact_writes(), 1);
2844 assert_eq!(plan.artifact_deletes(), 0);
2845 assert_eq!(
2846 write_artifacts(&plan)[0],
2847 &Action::WriteArtifact {
2848 kind: ArtifactKind::DetailsTxt,
2849 path: "a.details.txt".to_string(),
2850 source_url: String::new(),
2851 hash: content_hash("Title: A\n"),
2852 owner_id: "a".to_string(),
2853 content: Some("Title: A\n".to_string()),
2854 }
2855 );
2856 }
2857
2858 #[test]
2859 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2860 let mut manifest = Manifest::new();
2865 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2866 let body = "[re:rs-suno]\nla la\n";
2867 let d = vec![desired_arts(
2868 "a",
2869 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2870 )];
2871 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2872 assert_eq!(plan.artifact_writes(), 1);
2873 assert_eq!(plan.artifact_deletes(), 0);
2874 assert_eq!(
2875 write_artifacts(&plan)[0],
2876 &Action::WriteArtifact {
2877 kind: ArtifactKind::Lrc,
2878 path: "a.lrc".to_string(),
2879 source_url: String::new(),
2880 hash: content_hash(body),
2881 owner_id: "a".to_string(),
2882 content: Some(body.to_string()),
2883 }
2884 );
2885 }
2886
2887 #[test]
2888 fn text_sidecars_skipped_when_hash_and_path_match() {
2889 let mut manifest = Manifest::new();
2891 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2892 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2893 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2894 manifest.insert("a", e);
2895 let d = vec![desired_arts(
2896 "a",
2897 vec![
2898 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2899 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2900 ],
2901 )];
2902 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2903 assert_eq!(plan.artifact_writes(), 0);
2904 assert_eq!(plan.artifact_deletes(), 0);
2905 }
2906
2907 #[test]
2908 fn details_rewritten_when_content_hash_differs() {
2909 let mut manifest = Manifest::new();
2912 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2913 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2914 manifest.insert("a", e);
2915 let d = vec![desired_arts(
2916 "a",
2917 vec![text_art(
2918 ArtifactKind::DetailsTxt,
2919 "a.details.txt",
2920 "Title: New\n",
2921 )],
2922 )];
2923 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2924 assert_eq!(plan.artifact_writes(), 1);
2925 assert_eq!(plan.artifact_deletes(), 0);
2926 }
2927
2928 #[test]
2929 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2930 let mut manifest = Manifest::new();
2934 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2935 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2936 manifest.insert("a", e);
2937 let d = vec![desired_arts(
2938 "a",
2939 vec![text_art(
2940 ArtifactKind::LyricsTxt,
2941 "a.lyrics.txt",
2942 "new words\n",
2943 )],
2944 )];
2945 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2946 assert_eq!(plan.artifact_writes(), 1);
2948 assert_eq!(plan.retags(), 0);
2949 }
2950
2951 #[test]
2952 fn text_sidecar_relocated_when_path_differs() {
2953 let mut manifest = Manifest::new();
2956 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2957 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2958 manifest.insert("a", e);
2959 let d = vec![desired_arts(
2960 "a",
2961 vec![text_art(
2962 ArtifactKind::DetailsTxt,
2963 "new/a.details.txt",
2964 "Title: A\n",
2965 )],
2966 )];
2967 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2968 assert_eq!(plan.artifact_writes(), 1);
2969 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2970 assert_eq!(path, "new/a.details.txt");
2971 } else {
2972 panic!("expected a WriteArtifact");
2973 }
2974 }
2975
2976 #[test]
2977 fn details_removed_kind_is_deleted_when_feature_off() {
2978 let mut manifest = Manifest::new();
2981 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2982 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2983 manifest.insert("a", e);
2984 let d = vec![desired_arts("a", vec![])];
2985 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2986 assert_eq!(plan.artifact_deletes(), 1);
2987 assert!(plan.actions.contains(&Action::DeleteArtifact {
2988 kind: ArtifactKind::DetailsTxt,
2989 path: "a.details.txt".to_string(),
2990 owner_id: "a".to_string(),
2991 }));
2992 }
2993
2994 #[test]
2995 fn lyrics_removed_kind_is_kept_not_deleted() {
2996 let mut manifest = Manifest::new();
3000 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3001 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3002 manifest.insert("a", e);
3003 let d = vec![desired_arts("a", vec![])];
3004 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3005 assert_eq!(plan.artifact_deletes(), 0);
3006 assert_eq!(plan.deletes(), 0);
3007 }
3008
3009 #[test]
3010 fn lrc_removed_kind_is_kept_not_deleted() {
3011 let mut manifest = Manifest::new();
3014 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3015 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3016 manifest.insert("a", e);
3017 let d = vec![desired_arts("a", vec![])];
3018 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3019 assert_eq!(plan.artifact_deletes(), 0);
3020 assert_eq!(plan.deletes(), 0);
3021 }
3022
3023 #[test]
3024 fn video_mp4_removed_kind_is_kept_not_deleted() {
3025 let mut manifest = Manifest::new();
3029 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3030 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3031 manifest.insert("a", e);
3032 let d = vec![desired_arts("a", vec![])];
3033 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3034 assert_eq!(plan.artifact_deletes(), 0);
3035 assert_eq!(plan.deletes(), 0);
3036 }
3037
3038 #[test]
3039 fn video_mp4_written_when_manifest_lacks_it() {
3040 let mut manifest = Manifest::new();
3043 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3044 let d = vec![desired_arts(
3045 "a",
3046 vec![art(
3047 ArtifactKind::VideoMp4,
3048 "a/song.mp4",
3049 "https://cdn/a/video.mp4",
3050 "vid-hash",
3051 )],
3052 )];
3053 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3054 assert_eq!(plan.artifact_writes(), 1);
3055 assert_eq!(
3056 write_artifacts(&plan)[0],
3057 &Action::WriteArtifact {
3058 kind: ArtifactKind::VideoMp4,
3059 path: "a/song.mp4".to_string(),
3060 source_url: "https://cdn/a/video.mp4".to_string(),
3061 hash: "vid-hash".to_string(),
3062 owner_id: "a".to_string(),
3063 content: None,
3064 }
3065 );
3066 }
3067
3068 #[test]
3069 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3070 let mut manifest = Manifest::new();
3073 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3074 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3075 manifest.insert("a", e);
3076 let d = vec![desired_arts("a", vec![])];
3077 let sources = vec![SourceStatus {
3078 mode: SourceMode::Mirror,
3079 fully_enumerated: false,
3080 }];
3081 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3082 assert_eq!(plan.artifact_deletes(), 0);
3083 }
3084
3085 #[test]
3086 fn details_removed_kind_not_deleted_when_preserved() {
3087 let mut manifest = Manifest::new();
3090 let mut e = ManifestEntry {
3091 preserve: true,
3092 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3093 };
3094 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3095 manifest.insert("a", e);
3096 let d = vec![desired_arts("a", vec![])];
3097 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3098 assert_eq!(plan.artifact_deletes(), 0);
3099 }
3100
3101 #[test]
3102 fn co_delete_orphan_removes_every_text_sidecar() {
3103 let mut manifest = Manifest::new();
3107 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3108 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3109 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3110 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3111 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3112 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3113 manifest.insert("gone", e);
3114 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3115 assert_eq!(plan.deletes(), 1);
3116 assert_eq!(plan.artifact_deletes(), 5);
3117 for (kind, path) in [
3118 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3119 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3120 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3121 (ArtifactKind::Lrc, "gone.lrc"),
3122 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3123 ] {
3124 assert!(
3125 plan.actions.contains(&Action::DeleteArtifact {
3126 kind,
3127 path: path.to_string(),
3128 owner_id: "gone".to_string(),
3129 }),
3130 "missing co-delete for {kind:?}"
3131 );
3132 }
3133 }
3134
3135 #[test]
3136 fn co_delete_trashed_removes_every_text_sidecar() {
3137 let mut manifest = Manifest::new();
3139 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3140 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3141 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3142 manifest.insert("a", e);
3143 let mut d = desired_arts("a", vec![]);
3144 d.trashed = true;
3145 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3146 assert_eq!(plan.deletes(), 1);
3147 assert_eq!(plan.artifact_deletes(), 2);
3148 }
3149
3150 #[test]
3151 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3152 let mut manifest = Manifest::new();
3155 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3156 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3157 let d = vec![desired_arts(
3160 "a",
3161 vec![art(
3162 ArtifactKind::CoverJpg,
3163 "shared/cover.jpg",
3164 "https://art/a",
3165 "h2",
3166 )],
3167 )];
3168 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3169 assert_eq!(plan.artifact_writes(), 1);
3170 assert!(!plan.actions.iter().any(
3172 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3173 ));
3174 assert!(plan.actions.contains(&Action::Delete {
3176 path: "b.flac".to_string(),
3177 clip_id: "b".to_string(),
3178 }));
3179 }
3180
3181 #[test]
3182 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3183 let mut manifest = Manifest::new();
3185 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3186 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3187 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3188 assert_eq!(plan.downloads(), 1);
3189 assert!(
3190 !plan
3191 .actions
3192 .iter()
3193 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3194 );
3195 }
3196
3197 #[test]
3198 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3199 let build = |with_art: bool| {
3203 let mut manifest = Manifest::new();
3204 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3205 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3206 manifest.insert(
3207 "trash",
3208 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3209 );
3210 let keep = if with_art {
3211 desired_arts(
3212 "keep",
3213 vec![art(
3214 ArtifactKind::CoverJpg,
3215 "keep/cover.jpg",
3216 "https://art/keep",
3217 "h1",
3218 )],
3219 )
3220 } else {
3221 desired_arts("keep", vec![])
3222 };
3223 let mut trash = desired_arts("trash", vec![]);
3224 trash.trashed = true;
3225 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3226 .iter()
3227 .map(|id| (id.to_string(), present(100)))
3228 .collect();
3229 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3230 };
3231
3232 let with = build(true);
3233 let without = build(false);
3234
3235 let audio = |plan: &Plan| -> Vec<Action> {
3237 plan.actions
3238 .iter()
3239 .filter(|a| {
3240 !matches!(
3241 a,
3242 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3243 )
3244 })
3245 .cloned()
3246 .collect()
3247 };
3248 assert_eq!(audio(&with), audio(&without));
3249 assert_eq!(with.deletes(), without.deletes());
3250 assert_eq!(with.deletes(), 2);
3252 assert_eq!(with.artifact_deletes(), 2);
3256 assert_eq!(with.artifact_writes(), 0);
3257 }
3258
3259 #[test]
3262 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3263 let mut manifest = Manifest::new();
3269 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3270 assert!(!manifest.get("a").unwrap().preserve);
3271
3272 let private = Desired {
3274 private: true,
3275 ..desired_arts("a", vec![])
3276 };
3277 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3278 assert_eq!(plan.artifact_deletes(), 0);
3279
3280 let copy_held = Desired {
3282 modes: vec![SourceMode::Copy],
3283 ..desired_arts("a", vec![])
3284 };
3285 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3286 assert_eq!(plan.artifact_deletes(), 0);
3287 }
3288
3289 #[test]
3290 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3291 let mut manifest = Manifest::new();
3297 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3298 let d = vec![desired_arts(
3299 "a",
3300 vec![art(
3301 ArtifactKind::CoverJpg,
3302 "new/cover.jpg",
3303 "https://art/a",
3304 "h1",
3305 )],
3306 )];
3307 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3308 assert_eq!(plan.artifact_writes(), 1);
3309 assert_eq!(plan.artifact_deletes(), 0);
3310 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3311 assert_eq!(path, "new/cover.jpg");
3312 } else {
3313 panic!("expected a WriteArtifact");
3314 }
3315 }
3316
3317 #[test]
3318 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3319 let mut manifest = Manifest::new();
3323 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3324 let d = vec![desired_arts(
3325 "a",
3326 vec![
3327 art(
3328 ArtifactKind::FolderJpg,
3329 "a/folder.jpg",
3330 "https://art/folder",
3331 "hf",
3332 ),
3333 art(
3334 ArtifactKind::Playlist,
3335 "a/list.m3u",
3336 "https://art/list",
3337 "hp",
3338 ),
3339 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3340 ],
3341 )];
3342 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3343 assert_eq!(plan.artifact_writes(), 1);
3344 let paths: Vec<&str> = plan
3345 .actions
3346 .iter()
3347 .filter_map(|a| match a {
3348 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3349 _ => None,
3350 })
3351 .collect();
3352 assert_eq!(paths, vec!["a/cover.jpg"]);
3353 }
3354
3355 #[test]
3356 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3357 let mut manifest = Manifest::new();
3358 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3359 let d = vec![desired_arts(
3360 "a",
3361 vec![art(
3362 ArtifactKind::FolderWebp,
3363 "a/folder.webp",
3364 "https://art/folder",
3365 "hf",
3366 )],
3367 )];
3368 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3369 assert_eq!(plan.artifact_writes(), 0);
3370 assert_eq!(plan.artifact_deletes(), 0);
3371 }
3372
3373 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3376 Clip {
3377 id: id.to_string(),
3378 title: "Song".to_string(),
3379 image_large_url: image.to_string(),
3380 video_cover_url: video.to_string(),
3381 play_count,
3382 created_at: created_at.to_string(),
3383 ..Default::default()
3384 }
3385 }
3386
3387 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3388 let mut lineage = LineageContext::own_root(&clip);
3389 lineage.root_id = root_id.to_string();
3390 Desired {
3391 clip,
3392 lineage,
3393 path: path.to_string(),
3394 format: AudioFormat::Flac,
3395 meta_hash: "m".to_string(),
3396 art_hash: "a".to_string(),
3397 modes: vec![SourceMode::Mirror],
3398 trashed: false,
3399 private: false,
3400 artifacts: Vec::new(),
3401 stems: None,
3402 }
3403 }
3404
3405 fn stored(path: &str, hash: &str) -> ArtifactState {
3406 ArtifactState {
3407 path: path.to_string(),
3408 hash: hash.to_string(),
3409 }
3410 }
3411
3412 #[test]
3413 fn folder_jpg_source_is_most_played() {
3414 let members = vec![
3415 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3416 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3417 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3418 ];
3419 let albums = album_desired(&members, false);
3420 assert_eq!(albums.len(), 1);
3421 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3422 assert_eq!(jpg.hash, art_url_hash("art-b"));
3424 assert_eq!(jpg.source_url, "art-b");
3425 assert_eq!(jpg.path, "c/al/folder.jpg");
3426 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3427 }
3428
3429 #[test]
3430 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3431 let by_time = vec![
3433 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3434 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3435 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3436 ];
3437 let jpg = album_desired(&by_time, false)[0]
3438 .folder_jpg
3439 .clone()
3440 .unwrap();
3441 assert_eq!(jpg.source_url, "art-y");
3442
3443 let by_id = vec![
3445 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3446 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3447 ];
3448 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3449 assert_eq!(jpg.source_url, "art-g");
3450 }
3451
3452 #[test]
3453 fn folder_webp_source_is_first_created_animated() {
3454 let members = vec![
3455 album_member(
3456 album_clip("a", 9, "t2", "art-a", "vid-a"),
3457 "root",
3458 "c/al/a.flac",
3459 ),
3460 album_member(
3461 album_clip("b", 1, "t0", "art-b", "vid-b"),
3462 "root",
3463 "c/al/b.flac",
3464 ),
3465 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3466 ];
3467 let webp = album_desired(&members, true)[0]
3468 .folder_webp
3469 .clone()
3470 .unwrap();
3471 assert_eq!(webp.source_url, "vid-b");
3473 assert_eq!(webp.hash, art_url_hash("vid-b"));
3474 assert_eq!(webp.path, "c/al/cover.webp");
3475 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3476 }
3477
3478 #[test]
3479 fn animated_covers_off_yields_no_folder_webp() {
3480 let members = vec![album_member(
3481 album_clip("a", 1, "t0", "art-a", "vid-a"),
3482 "root",
3483 "c/al/a.flac",
3484 )];
3485 let off = album_desired(&members, false);
3486 assert!(off[0].folder_webp.is_none());
3487 let on = album_desired(&members, true);
3488 assert!(on[0].folder_webp.is_some());
3489 }
3490
3491 #[test]
3492 fn album_with_no_art_yields_no_folder_jpg() {
3493 let members = vec![album_member(
3494 album_clip("a", 3, "t0", "", ""),
3495 "root",
3496 "c/al/a.flac",
3497 )];
3498 let albums = album_desired(&members, true);
3499 assert!(albums[0].folder_jpg.is_none());
3500 assert!(albums[0].folder_webp.is_none());
3501 }
3502
3503 #[test]
3504 fn album_desired_groups_by_root_id() {
3505 let members = vec![
3506 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3507 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3508 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3509 ];
3510 let albums = album_desired(&members, false);
3511 assert_eq!(albums.len(), 2);
3512 assert_eq!(albums[0].root_id, "r1");
3513 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3514 assert_eq!(
3515 albums[0].folder_jpg.as_ref().unwrap().path,
3516 "c/al1/folder.jpg"
3517 );
3518 assert_eq!(albums[1].root_id, "r2");
3519 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3520 assert_eq!(
3521 albums[1].folder_jpg.as_ref().unwrap().path,
3522 "c/al2/folder.jpg"
3523 );
3524 }
3525
3526 #[test]
3527 fn plan_writes_folder_art_when_store_empty() {
3528 let members = vec![album_member(
3529 album_clip("a", 1, "t0", "art-a", "vid-a"),
3530 "root",
3531 "c/al/a.flac",
3532 )];
3533 let desired = album_desired(&members, true);
3534 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3535 assert_eq!(
3536 actions,
3537 vec![
3538 Action::WriteArtifact {
3539 kind: ArtifactKind::FolderJpg,
3540 path: "c/al/folder.jpg".to_string(),
3541 source_url: "art-a".to_string(),
3542 hash: art_url_hash("art-a"),
3543 owner_id: "root".to_string(),
3544 content: None,
3545 },
3546 Action::WriteArtifact {
3547 kind: ArtifactKind::FolderWebp,
3548 path: "c/al/cover.webp".to_string(),
3549 source_url: "vid-a".to_string(),
3550 hash: art_url_hash("vid-a"),
3551 owner_id: "root".to_string(),
3552 content: None,
3553 },
3554 ]
3555 );
3556 }
3557
3558 #[test]
3559 fn plan_skips_when_hash_and_path_match() {
3560 let members = vec![album_member(
3561 album_clip("a", 1, "t0", "art-a", ""),
3562 "root",
3563 "c/al/a.flac",
3564 )];
3565 let desired = album_desired(&members, false);
3566 let mut albums = BTreeMap::new();
3567 albums.insert(
3568 "root".to_string(),
3569 AlbumArt {
3570 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3571 folder_webp: None,
3572 },
3573 );
3574 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3575 }
3576
3577 #[test]
3578 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3579 let members = vec![album_member(
3580 album_clip("a", 1, "t0", "art-a", ""),
3581 "root",
3582 "c/al/a.flac",
3583 )];
3584 let desired = album_desired(&members, false);
3585 let mut albums = BTreeMap::new();
3586 albums.insert(
3587 "root".to_string(),
3588 AlbumArt {
3589 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3590 folder_webp: None,
3591 },
3592 );
3593 let actions = plan_album_artifacts(&desired, &albums, true);
3594 assert_eq!(actions.len(), 1);
3595 assert!(matches!(
3596 &actions[0],
3597 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3598 ));
3599 }
3600
3601 #[test]
3602 fn h1_most_played_flip_to_same_art_writes_nothing() {
3603 let run1 = vec![
3605 album_member(
3606 album_clip("a", 9, "t0", "same-art", ""),
3607 "root",
3608 "c/al/a.flac",
3609 ),
3610 album_member(
3611 album_clip("b", 1, "t1", "same-art", ""),
3612 "root",
3613 "c/al/b.flac",
3614 ),
3615 ];
3616 let desired1 = album_desired(&run1, false);
3617 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3618 assert_eq!(write1.len(), 1);
3619
3620 let mut albums = BTreeMap::new();
3622 if let Action::WriteArtifact {
3623 path,
3624 hash,
3625 owner_id,
3626 ..
3627 } = &write1[0]
3628 {
3629 albums.insert(
3630 owner_id.clone(),
3631 AlbumArt {
3632 folder_jpg: Some(stored(path, hash)),
3633 folder_webp: None,
3634 },
3635 );
3636 }
3637
3638 let run2 = vec![
3640 album_member(
3641 album_clip("a", 1, "t0", "same-art", ""),
3642 "root",
3643 "c/al/a.flac",
3644 ),
3645 album_member(
3646 album_clip("b", 9, "t1", "same-art", ""),
3647 "root",
3648 "c/al/b.flac",
3649 ),
3650 ];
3651 let desired2 = album_desired(&run2, false);
3652 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3654 }
3655
3656 #[test]
3657 fn h1_flip_to_different_art_writes_exactly_one() {
3658 let mut albums = BTreeMap::new();
3659 albums.insert(
3660 "root".to_string(),
3661 AlbumArt {
3662 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3663 folder_webp: None,
3664 },
3665 );
3666 let members = vec![
3668 album_member(
3669 album_clip("a", 1, "t0", "old-art", ""),
3670 "root",
3671 "c/al/a.flac",
3672 ),
3673 album_member(
3674 album_clip("b", 9, "t1", "new-art", ""),
3675 "root",
3676 "c/al/b.flac",
3677 ),
3678 ];
3679 let desired = album_desired(&members, false);
3680 let actions = plan_album_artifacts(&desired, &albums, true);
3681 assert_eq!(actions.len(), 1);
3682 assert!(matches!(
3683 &actions[0],
3684 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3685 ));
3686 }
3687
3688 #[test]
3689 fn one_write_per_album_regardless_of_clip_count() {
3690 let members: Vec<Desired> = (0..200)
3691 .map(|i| {
3692 album_member(
3693 album_clip(
3694 &format!("clip-{i:03}"),
3695 i as u64,
3696 &format!("t{i:03}"),
3697 &format!("art-{i:03}"),
3698 &format!("vid-{i:03}"),
3699 ),
3700 "root",
3701 &format!("c/al/clip-{i:03}.flac"),
3702 )
3703 })
3704 .collect();
3705 let desired = album_desired(&members, true);
3706 assert_eq!(desired.len(), 1);
3707 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3708 assert_eq!(actions.len(), 2);
3710 assert_eq!(
3711 actions
3712 .iter()
3713 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3714 .count(),
3715 2
3716 );
3717 }
3718
3719 #[test]
3720 fn emptied_album_deletes_only_when_can_delete() {
3721 let mut albums = BTreeMap::new();
3722 albums.insert(
3723 "root".to_string(),
3724 AlbumArt {
3725 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3726 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3727 },
3728 );
3729 let desired: Vec<AlbumDesired> = Vec::new();
3731
3732 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3734
3735 let actions = plan_album_artifacts(&desired, &albums, true);
3737 assert_eq!(
3738 actions,
3739 vec![
3740 Action::DeleteArtifact {
3741 kind: ArtifactKind::FolderJpg,
3742 path: "c/al/folder.jpg".to_string(),
3743 owner_id: "root".to_string(),
3744 },
3745 Action::DeleteArtifact {
3746 kind: ArtifactKind::FolderWebp,
3747 path: "c/al/cover.webp".to_string(),
3748 owner_id: "root".to_string(),
3749 },
3750 ]
3751 );
3752 }
3753
3754 #[test]
3755 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3756 let mut albums = BTreeMap::new();
3757 albums.insert(
3758 "root".to_string(),
3759 AlbumArt {
3760 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3761 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3762 },
3763 );
3764 let members = vec![album_member(
3767 album_clip("a", 1, "t0", "art-a", "vid-a"),
3768 "root",
3769 "c/al/a.flac",
3770 )];
3771 let desired = album_desired(&members, false);
3772
3773 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3774
3775 let actions = plan_album_artifacts(&desired, &albums, true);
3776 assert_eq!(
3777 actions,
3778 vec![Action::DeleteArtifact {
3779 kind: ArtifactKind::FolderWebp,
3780 path: "c/al/cover.webp".to_string(),
3781 owner_id: "root".to_string(),
3782 }]
3783 );
3784 }
3785
3786 #[test]
3787 fn plan_album_artifacts_is_deterministically_ordered() {
3788 let members = vec![
3789 album_member(
3790 album_clip("a", 1, "t0", "art-a", "vid-a"),
3791 "r2",
3792 "c/al2/a.flac",
3793 ),
3794 album_member(
3795 album_clip("b", 1, "t0", "art-b", "vid-b"),
3796 "r1",
3797 "c/al1/b.flac",
3798 ),
3799 ];
3800 let desired = album_desired(&members, true);
3801 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3802 let keys: Vec<(&str, ArtifactKind)> = actions
3803 .iter()
3804 .map(|a| match a {
3805 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3806 _ => unreachable!(),
3807 })
3808 .collect();
3809 assert_eq!(
3810 keys,
3811 vec![
3812 ("r1", ArtifactKind::FolderJpg),
3813 ("r1", ArtifactKind::FolderWebp),
3814 ("r2", ArtifactKind::FolderJpg),
3815 ("r2", ArtifactKind::FolderWebp),
3816 ]
3817 );
3818 }
3819
3820 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3823 PlaylistDesired {
3824 id: id.to_owned(),
3825 name: name.to_owned(),
3826 path: path.to_owned(),
3827 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3828 hash: hash.to_owned(),
3829 }
3830 }
3831
3832 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3833 PlaylistState {
3834 name: name.to_owned(),
3835 path: path.to_owned(),
3836 hash: hash.to_owned(),
3837 }
3838 }
3839
3840 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3841 entries
3842 .iter()
3843 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3844 .collect()
3845 }
3846
3847 #[test]
3848 fn playlist_write_emitted_for_a_new_playlist() {
3849 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3850 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3851 assert_eq!(
3852 actions,
3853 vec![Action::WriteArtifact {
3854 kind: ArtifactKind::Playlist,
3855 path: "Road Trip.m3u8".to_owned(),
3856 source_url: String::new(),
3857 hash: "h1".to_owned(),
3858 owner_id: "pl1".to_owned(),
3859 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3860 }]
3861 );
3862 }
3863
3864 #[test]
3865 fn playlist_write_emitted_when_hash_changes() {
3866 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3869 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3870 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3871 assert_eq!(actions.len(), 1);
3872 assert!(matches!(
3873 &actions[0],
3874 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3875 ));
3876 }
3877
3878 #[test]
3879 fn playlist_unchanged_is_idempotent() {
3880 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3881 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3882 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3883 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3884 }
3885
3886 #[test]
3887 fn playlist_rename_writes_new_and_deletes_old_path() {
3888 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3891 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3892 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3893 assert_eq!(
3894 actions,
3895 vec![
3896 Action::WriteArtifact {
3897 kind: ArtifactKind::Playlist,
3898 path: "Summer.m3u8".to_owned(),
3899 source_url: String::new(),
3900 hash: "h2".to_owned(),
3901 owner_id: "pl1".to_owned(),
3902 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3903 },
3904 Action::DeleteArtifact {
3905 kind: ArtifactKind::Playlist,
3906 path: "Spring.m3u8".to_owned(),
3907 owner_id: "pl1".to_owned(),
3908 },
3909 ]
3910 );
3911 }
3912
3913 #[test]
3914 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3915 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3918 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3919 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3920 assert_eq!(actions.len(), 1);
3921 assert!(matches!(
3922 &actions[0],
3923 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3924 ));
3925 assert!(
3926 !actions
3927 .iter()
3928 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3929 "old path must not be deleted when deletes are disallowed"
3930 );
3931 }
3932
3933 #[test]
3934 fn playlist_stale_removed_only_under_full_gate() {
3935 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3938
3939 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3940 assert_eq!(
3941 deleted,
3942 vec![Action::DeleteArtifact {
3943 kind: ArtifactKind::Playlist,
3944 path: "Gone.m3u8".to_owned(),
3945 owner_id: "gone".to_owned(),
3946 }]
3947 );
3948
3949 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3951 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3952 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3953 }
3954
3955 #[test]
3956 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3957 let stored = pl_store(&[
3962 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3963 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3964 ]);
3965 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3966 assert!(
3967 actions.is_empty(),
3968 "a failed playlist listing must plan zero actions, got {actions:?}"
3969 );
3970 }
3971
3972 #[test]
3973 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3974 let stored = pl_store(&[
3979 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3980 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3981 ]);
3982
3983 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3985
3986 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3989 assert_eq!(
3990 wiped
3991 .iter()
3992 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3993 .count(),
3994 2
3995 );
3996 }
3997
3998 #[test]
3999 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4000 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4005 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4006 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4007 assert_eq!(actions.len(), 1);
4009 assert!(matches!(
4010 &actions[0],
4011 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4012 ));
4013 assert!(
4014 !actions.iter().any(|a| match a {
4015 Action::WriteArtifact { owner_id, .. }
4016 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4017 _ => false,
4018 }),
4019 "a protected (failed-member) playlist must have no action"
4020 );
4021 }
4022
4023 #[test]
4024 fn playlist_rename_collision_downgrades_the_delete() {
4025 let desired = vec![
4031 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4032 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4033 ];
4034 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4035 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4036 let write_paths: BTreeSet<&str> = actions
4038 .iter()
4039 .filter_map(|a| match a {
4040 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4041 _ => None,
4042 })
4043 .collect();
4044 for a in &actions {
4045 if let Action::DeleteArtifact { path, .. } = a {
4046 assert!(
4047 !write_paths.contains(path.as_str()),
4048 "a playlist delete aliases a write target: {path}"
4049 );
4050 }
4051 }
4052 }
4053
4054 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4057 DesiredStem {
4058 key: key.to_string(),
4059 stem_id: key.to_string(),
4060 path: path.to_string(),
4061 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4062 format: StemFormat::Mp3,
4063 hash: hash.to_string(),
4064 }
4065 }
4066
4067 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4069 Desired {
4070 stems,
4071 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4072 }
4073 }
4074
4075 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4077 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4078 for (key, path, hash) in stems {
4079 e.stems.insert(
4080 key.to_string(),
4081 ArtifactState {
4082 path: path.to_string(),
4083 hash: hash.to_string(),
4084 },
4085 );
4086 }
4087 e
4088 }
4089
4090 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4091 plan.actions
4092 .iter()
4093 .filter_map(|a| match a {
4094 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4095 _ => None,
4096 })
4097 .collect()
4098 }
4099
4100 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4101 plan.actions
4102 .iter()
4103 .filter_map(|a| match a {
4104 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4105 _ => None,
4106 })
4107 .collect()
4108 }
4109
4110 #[test]
4111 fn stems_none_keeps_every_existing_stem() {
4112 let mut manifest = Manifest::new();
4115 manifest.insert(
4116 "a",
4117 entry_with_stems(
4118 "a",
4119 &[
4120 ("voc", "a.stems/voc.mp3", "h1"),
4121 ("drm", "a.stems/drm.mp3", "h2"),
4122 ],
4123 ),
4124 );
4125 let d = vec![stem_desired("a", None)];
4126 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4127 assert_eq!(plan.stem_writes(), 0);
4128 assert_eq!(plan.stem_deletes(), 0);
4129 }
4130
4131 #[test]
4132 fn stems_authoritative_writes_missing_stems() {
4133 let mut manifest = Manifest::new();
4134 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4135 let d = vec![stem_desired(
4136 "a",
4137 Some(vec![
4138 dstem("voc", "a.stems/voc.mp3", "h1"),
4139 dstem("drm", "a.stems/drm.mp3", "h2"),
4140 ]),
4141 )];
4142 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4143 assert_eq!(
4144 stem_writes(&plan),
4145 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4146 );
4147 assert_eq!(plan.stem_deletes(), 0);
4148 }
4149
4150 #[test]
4151 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4152 let mut manifest = Manifest::new();
4153 manifest.insert(
4155 "a",
4156 entry_with_stems(
4157 "a",
4158 &[
4159 ("voc", "a.stems/voc.mp3", "h1"),
4160 ("drm", "a.stems/drm.mp3", "h2"),
4161 ("bas", "old.stems/bas.mp3", "h3"),
4162 ],
4163 ),
4164 );
4165 let d = vec![stem_desired(
4166 "a",
4167 Some(vec![
4168 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
4172 )];
4173 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4174 assert_eq!(
4175 stem_writes(&plan),
4176 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
4177 );
4178 assert_eq!(plan.stem_deletes(), 0);
4179 }
4180
4181 #[test]
4182 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
4183 let mut manifest = Manifest::new();
4186 manifest.insert(
4187 "a",
4188 entry_with_stems(
4189 "a",
4190 &[
4191 ("voc", "a.stems/voc.mp3", "h1"),
4192 ("drm", "a.stems/drm.mp3", "h2"),
4193 ],
4194 ),
4195 );
4196 let d = vec![stem_desired(
4197 "a",
4198 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4199 )];
4200 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4201 assert_eq!(plan.stem_writes(), 0);
4202 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
4203 }
4204
4205 #[test]
4206 fn stems_removal_needs_deletion_allowed() {
4207 let mut manifest = Manifest::new();
4210 manifest.insert(
4211 "a",
4212 entry_with_stems(
4213 "a",
4214 &[
4215 ("voc", "a.stems/voc.mp3", "h1"),
4216 ("drm", "a.stems/drm.mp3", "h2"),
4217 ],
4218 ),
4219 );
4220 let d = vec![stem_desired(
4221 "a",
4222 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4223 )];
4224
4225 let incomplete = vec![SourceStatus {
4226 mode: SourceMode::Mirror,
4227 fully_enumerated: false,
4228 }];
4229 assert_eq!(
4230 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
4231 0
4232 );
4233
4234 let copy_only = vec![SourceStatus {
4235 mode: SourceMode::Copy,
4236 fully_enumerated: true,
4237 }];
4238 assert_eq!(
4239 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
4240 0
4241 );
4242 }
4243
4244 #[test]
4245 fn stems_removal_skipped_for_preserved_or_protected_clip() {
4246 let mut manifest = Manifest::new();
4247 let mut e = entry_with_stems(
4248 "a",
4249 &[
4250 ("voc", "a.stems/voc.mp3", "h1"),
4251 ("drm", "a.stems/drm.mp3", "h2"),
4252 ],
4253 );
4254 e.preserve = true;
4255 manifest.insert("a", e);
4256 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
4257
4258 let d = vec![stem_desired("a", authoritative.clone())];
4260 assert_eq!(
4261 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
4262 0
4263 );
4264
4265 let mut manifest2 = Manifest::new();
4267 manifest2.insert(
4268 "a",
4269 entry_with_stems(
4270 "a",
4271 &[
4272 ("voc", "a.stems/voc.mp3", "h1"),
4273 ("drm", "a.stems/drm.mp3", "h2"),
4274 ],
4275 ),
4276 );
4277 let held = Desired {
4278 modes: vec![SourceMode::Mirror, SourceMode::Copy],
4279 stems: authoritative,
4280 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4281 };
4282 assert_eq!(
4283 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
4284 0
4285 );
4286 }
4287
4288 #[test]
4289 fn stems_are_co_deleted_when_the_song_is_trashed() {
4290 let mut manifest = Manifest::new();
4293 manifest.insert(
4294 "a",
4295 entry_with_stems(
4296 "a",
4297 &[
4298 ("voc", "a.stems/voc.mp3", "h1"),
4299 ("drm", "a.stems/drm.mp3", "h2"),
4300 ],
4301 ),
4302 );
4303 let trashed = Desired {
4304 trashed: true,
4305 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4306 };
4307 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
4308 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
4309 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
4310 deleted.sort_unstable();
4311 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
4312 }
4313
4314 #[test]
4315 fn stems_are_co_deleted_for_an_absent_clip() {
4316 let mut manifest = Manifest::new();
4317 manifest.insert(
4318 "a",
4319 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4320 );
4321 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
4323 assert_eq!(plan.deletes(), 1);
4324 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
4325 }
4326
4327 #[test]
4328 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
4329 let mut manifest = Manifest::new();
4331 manifest.insert(
4332 "a",
4333 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4334 );
4335 let incomplete = vec![SourceStatus {
4336 mode: SourceMode::Mirror,
4337 fully_enumerated: false,
4338 }];
4339 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
4340 assert_eq!(plan.deletes(), 0);
4341 assert_eq!(plan.stem_deletes(), 0);
4342 }
4343
4344 #[test]
4345 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
4346 let mut manifest = Manifest::new();
4350 manifest.insert(
4351 "a",
4352 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
4353 );
4354 let d = vec![stem_desired(
4355 "a",
4356 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
4357 )];
4358 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4359 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
4362 assert!(
4363 !plan.actions.iter().any(|a| matches!(
4364 a,
4365 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
4366 )),
4367 "a stem delete must never alias a stem write target"
4368 );
4369 }
4370}
4371
4372#[cfg(test)]
4385mod proptests {
4386 use super::*;
4387 use proptest::collection::{btree_map, hash_map, vec};
4388 use proptest::prelude::*;
4389 use std::collections::BTreeSet;
4390
4391 type DesiredFields = (
4392 String,
4393 AudioFormat,
4394 String,
4395 String,
4396 Vec<SourceMode>,
4397 bool,
4398 bool,
4399 );
4400
4401 fn audio_format() -> impl Strategy<Value = AudioFormat> {
4402 prop_oneof![
4403 Just(AudioFormat::Mp3),
4404 Just(AudioFormat::Flac),
4405 Just(AudioFormat::Wav),
4406 ]
4407 }
4408
4409 fn source_mode() -> impl Strategy<Value = SourceMode> {
4410 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
4411 }
4412
4413 fn clip_id() -> impl Strategy<Value = String> {
4416 (0u8..8).prop_map(|n| format!("c{n}"))
4417 }
4418
4419 fn small_path() -> impl Strategy<Value = String> {
4420 (0u8..6).prop_map(|n| format!("path{n}"))
4421 }
4422
4423 fn manifest_path() -> impl Strategy<Value = String> {
4426 prop_oneof![
4427 1 => Just(String::new()),
4428 6 => small_path(),
4429 ]
4430 }
4431
4432 fn small_hash() -> impl Strategy<Value = String> {
4433 (0u8..4).prop_map(|n| format!("h{n}"))
4434 }
4435
4436 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
4437 (
4438 manifest_path(),
4439 audio_format(),
4440 small_hash(),
4441 small_hash(),
4442 0u64..4,
4443 any::<bool>(),
4444 )
4445 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
4446 ManifestEntry {
4447 path,
4448 format,
4449 meta_hash,
4450 art_hash,
4451 size,
4452 preserve,
4453 ..Default::default()
4454 }
4455 })
4456 }
4457
4458 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
4459 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
4460 }
4461
4462 fn local_file() -> impl Strategy<Value = LocalFile> {
4463 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
4464 }
4465
4466 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
4467 hash_map(clip_id(), local_file(), 0..8)
4468 }
4469
4470 fn source_status() -> impl Strategy<Value = SourceStatus> {
4471 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
4472 mode,
4473 fully_enumerated,
4474 })
4475 }
4476
4477 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4478 vec(source_status(), 0..5)
4479 }
4480
4481 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4482 vec(
4483 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
4484 mode: SourceMode::Copy,
4485 fully_enumerated,
4486 }),
4487 1..5,
4488 )
4489 }
4490
4491 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
4492 (
4493 small_path(),
4494 audio_format(),
4495 small_hash(),
4496 small_hash(),
4497 vec(source_mode(), 1..3),
4498 any::<bool>(),
4499 any::<bool>(),
4500 )
4501 }
4502
4503 fn build_desired(id: String, fields: DesiredFields) -> Desired {
4504 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
4505 let clip = Clip {
4506 id,
4507 title: "t".to_string(),
4508 ..Default::default()
4509 };
4510 Desired {
4511 lineage: LineageContext::own_root(&clip),
4512 clip,
4513 path,
4514 format,
4515 meta_hash,
4516 art_hash,
4517 modes,
4518 trashed,
4519 private,
4520 artifacts: Vec::new(),
4521 stems: None,
4522 }
4523 }
4524
4525 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
4528 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
4529 items
4530 .into_iter()
4531 .map(|(id, fields)| build_desired(id, fields))
4532 .collect()
4533 })
4534 }
4535
4536 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
4537 desired.iter().map(|d| d.clip.id.as_str()).collect()
4538 }
4539
4540 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
4543 desired
4544 .iter()
4545 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
4546 .map(|d| d.clip.id.as_str())
4547 .collect()
4548 }
4549
4550 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
4553 desired
4554 .iter()
4555 .filter(|d| !d.trashed)
4556 .map(|d| d.clip.id.as_str())
4557 .collect()
4558 }
4559
4560 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
4561 plan.actions
4562 .iter()
4563 .filter_map(|a| match a {
4564 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
4565 _ => None,
4566 })
4567 .collect()
4568 }
4569
4570 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
4571 plan.actions
4572 .iter()
4573 .filter_map(|a| match a {
4574 Action::Download { path, .. } | Action::Reformat { path, .. } => {
4575 Some(path.as_str())
4576 }
4577 Action::Rename { to, .. } => Some(to.as_str()),
4578 _ => None,
4579 })
4580 .collect()
4581 }
4582
4583 proptest! {
4584 #![proptest_config(ProptestConfig {
4585 cases: 256,
4586 failure_persistence: None,
4587 ..ProptestConfig::default()
4588 })]
4589
4590 #[test]
4593 fn inv1_desired_clip_deleted_only_when_fully_trashed(
4594 manifest in manifest_strategy(),
4595 desired in desired_strategy(),
4596 local in local_strategy(),
4597 sources in sources_strategy(),
4598 ) {
4599 let plan = reconcile(&manifest, &desired, &local, &sources);
4600 let present = desired_ids(&desired);
4601 let live = non_trashed_ids(&desired);
4602 for id in delete_clip_ids(&plan) {
4603 prop_assert!(
4604 !(present.contains(id) && live.contains(id)),
4605 "deleted a desired clip with a non-trashed duplicate: {id}"
4606 );
4607 }
4608 }
4609
4610 #[test]
4614 fn inv2_no_delete_when_any_mirror_unenumerated(
4615 manifest in manifest_strategy(),
4616 desired in desired_strategy(),
4617 local in local_strategy(),
4618 mut sources in sources_strategy(),
4619 ) {
4620 sources.push(SourceStatus {
4621 mode: SourceMode::Mirror,
4622 fully_enumerated: false,
4623 });
4624 let plan = reconcile(&manifest, &desired, &local, &sources);
4625 prop_assert_eq!(plan.deletes(), 0);
4626 }
4627
4628 #[test]
4630 fn inv3_all_copy_sources_means_no_deletes(
4631 manifest in manifest_strategy(),
4632 desired in desired_strategy(),
4633 local in local_strategy(),
4634 sources in copy_sources_strategy(),
4635 ) {
4636 let plan = reconcile(&manifest, &desired, &local, &sources);
4637 prop_assert_eq!(plan.deletes(), 0);
4638 }
4639
4640 #[test]
4643 fn inv4_plan_is_deterministic(
4644 manifest in manifest_strategy(),
4645 desired in desired_strategy(),
4646 local in local_strategy(),
4647 sources in sources_strategy(),
4648 ) {
4649 let plan = reconcile(&manifest, &desired, &local, &sources);
4650
4651 let again = reconcile(&manifest, &desired, &local, &sources);
4652 prop_assert_eq!(&plan, &again);
4653
4654 let mut desired_rev = desired.clone();
4655 desired_rev.reverse();
4656 let mut sources_rev = sources.clone();
4657 sources_rev.reverse();
4658 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
4659 prop_assert_eq!(&plan, &shuffled);
4660 }
4661
4662 #[test]
4664 fn inv5_every_delete_is_in_the_manifest(
4665 manifest in manifest_strategy(),
4666 desired in desired_strategy(),
4667 local in local_strategy(),
4668 sources in sources_strategy(),
4669 ) {
4670 let plan = reconcile(&manifest, &desired, &local, &sources);
4671 for id in delete_clip_ids(&plan) {
4672 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4673 }
4674 }
4675
4676 #[test]
4679 fn inv6_never_deletes_protected_clip(
4680 manifest in manifest_strategy(),
4681 desired in desired_strategy(),
4682 local in local_strategy(),
4683 sources in sources_strategy(),
4684 ) {
4685 let plan = reconcile(&manifest, &desired, &local, &sources);
4686 let protected = protected_ids(&desired);
4687 for id in delete_clip_ids(&plan) {
4688 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4689 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4690 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4691 }
4692 }
4693
4694 #[test]
4697 fn inv7_no_delete_unless_deletion_allowed(
4698 manifest in manifest_strategy(),
4699 desired in desired_strategy(),
4700 local in local_strategy(),
4701 sources in sources_strategy(),
4702 ) {
4703 let plan = reconcile(&manifest, &desired, &local, &sources);
4704 if !deletion_allowed(&sources) {
4705 prop_assert_eq!(plan.deletes(), 0);
4706 }
4707 }
4708
4709 #[test]
4711 fn inv8_at_most_one_delete_per_clip(
4712 manifest in manifest_strategy(),
4713 desired in desired_strategy(),
4714 local in local_strategy(),
4715 sources in sources_strategy(),
4716 ) {
4717 let plan = reconcile(&manifest, &desired, &local, &sources);
4718 let ids = delete_clip_ids(&plan);
4719 let unique: BTreeSet<&str> = ids.iter().copied().collect();
4720 prop_assert_eq!(ids.len(), unique.len());
4721 }
4722
4723 #[test]
4725 fn inv9_no_delete_with_empty_path(
4726 manifest in manifest_strategy(),
4727 desired in desired_strategy(),
4728 local in local_strategy(),
4729 sources in sources_strategy(),
4730 ) {
4731 let plan = reconcile(&manifest, &desired, &local, &sources);
4732 for action in &plan.actions {
4733 if let Action::Delete { path, .. } = action {
4734 prop_assert!(!path.is_empty(), "delete with an empty path");
4735 }
4736 }
4737 }
4738
4739 #[test]
4742 fn inv10_no_delete_aliases_a_write_target(
4743 manifest in manifest_strategy(),
4744 desired in desired_strategy(),
4745 local in local_strategy(),
4746 sources in sources_strategy(),
4747 ) {
4748 let plan = reconcile(&manifest, &desired, &local, &sources);
4749 let targets = write_target_paths(&plan);
4750 for action in &plan.actions {
4751 if let Action::Delete { path, .. } = action {
4752 prop_assert!(
4753 !targets.contains(path.as_str()),
4754 "delete path {path} aliases a write target"
4755 );
4756 }
4757 }
4758 }
4759 }
4760}