1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36
37use crate::config::AudioFormat;
38use crate::graph::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43
44#[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}
124
125#[derive(Debug, Clone, PartialEq)]
130pub struct DesiredArtifact {
131 pub kind: ArtifactKind,
133 pub path: String,
135 pub source_url: String,
138 pub hash: String,
140 pub content: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq)]
157pub struct AlbumDesired {
158 pub root_id: String,
160 pub folder_jpg: Option<DesiredArtifact>,
162 pub folder_webp: Option<DesiredArtifact>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct PlaylistDesired {
178 pub id: String,
181 pub name: String,
183 pub path: String,
185 pub content: String,
187 pub hash: String,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub struct LocalFile {
194 pub exists: bool,
196 pub size: u64,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct SourceStatus {
203 pub mode: SourceMode,
205 pub fully_enumerated: bool,
207}
208
209#[derive(Debug, Clone, PartialEq)]
211pub enum Action {
212 Download {
214 clip: Clip,
215 lineage: LineageContext,
216 path: String,
217 format: AudioFormat,
218 },
219 Reformat {
225 clip: Clip,
226 path: String,
227 from_path: String,
228 from: AudioFormat,
229 to: AudioFormat,
230 },
231 Retag {
233 clip: Clip,
234 lineage: LineageContext,
235 path: String,
236 },
237 Rename { from: String, to: String },
239 Delete { path: String, clip_id: String },
241 Skip { clip_id: String },
243 WriteArtifact {
255 kind: ArtifactKind,
256 path: String,
257 source_url: String,
258 hash: String,
259 owner_id: String,
260 content: Option<String>,
261 },
262 DeleteArtifact {
269 kind: ArtifactKind,
270 path: String,
271 owner_id: String,
272 },
273}
274
275#[derive(Debug, Clone, Default, PartialEq)]
280pub struct Plan {
281 pub actions: Vec<Action>,
283}
284
285impl Plan {
286 pub fn len(&self) -> usize {
288 self.actions.len()
289 }
290
291 pub fn is_empty(&self) -> bool {
293 self.actions.is_empty()
294 }
295
296 pub fn downloads(&self) -> usize {
298 self.count(|a| matches!(a, Action::Download { .. }))
299 }
300
301 pub fn reformats(&self) -> usize {
303 self.count(|a| matches!(a, Action::Reformat { .. }))
304 }
305
306 pub fn retags(&self) -> usize {
308 self.count(|a| matches!(a, Action::Retag { .. }))
309 }
310
311 pub fn renames(&self) -> usize {
313 self.count(|a| matches!(a, Action::Rename { .. }))
314 }
315
316 pub fn deletes(&self) -> usize {
318 self.count(|a| matches!(a, Action::Delete { .. }))
319 }
320
321 pub fn skips(&self) -> usize {
323 self.count(|a| matches!(a, Action::Skip { .. }))
324 }
325
326 pub fn artifact_writes(&self) -> usize {
328 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
329 }
330
331 pub fn artifact_deletes(&self) -> usize {
333 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
334 }
335
336 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
337 self.actions.iter().filter(|a| pred(a)).count()
338 }
339}
340
341pub fn reconcile(
356 manifest: &Manifest,
357 desired: &[Desired],
358 local: &HashMap<String, LocalFile>,
359 sources: &[SourceStatus],
360) -> Plan {
361 let mut actions: Vec<Action> = Vec::new();
362
363 let desired = aggregate_desired(desired);
365 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
366
367 let can_delete = deletion_allowed(sources);
368
369 for d in &desired {
370 let before = actions.len();
375 plan_desired(d, manifest, local, can_delete, &mut actions);
376 let audio_deleted = actions[before..]
377 .iter()
378 .any(|a| matches!(a, Action::Delete { .. }));
379 if audio_deleted {
380 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
381 } else {
382 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
383 }
384 }
385
386 for (clip_id, _entry) in manifest.iter() {
388 if desired_ids.contains(clip_id.as_str()) {
389 continue;
390 }
391 match delete_action(clip_id, manifest, can_delete) {
392 Some(action) => {
393 actions.push(action);
394 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
396 }
397 None => actions.push(Action::Skip {
400 clip_id: clip_id.clone(),
401 }),
402 }
403 }
404
405 suppress_path_aliasing(&mut actions);
406 Plan { actions }
407}
408
409pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
420 let mut saw_mirror = false;
421 for status in sources {
422 if !status.fully_enumerated {
423 return false;
424 }
425 if status.mode == SourceMode::Mirror {
426 saw_mirror = true;
427 }
428 }
429 saw_mirror
430}
431
432fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
438 if !can_delete {
439 return None;
440 }
441 let entry = manifest.get(clip_id)?;
442 if entry.path.is_empty() || entry.preserve {
443 return None;
444 }
445 Some(Action::Delete {
446 path: entry.path.clone(),
447 clip_id: clip_id.to_string(),
448 })
449}
450
451fn delete_artifact_action(
461 owner_id: &str,
462 kind: ArtifactKind,
463 path: &str,
464 manifest: &Manifest,
465 can_delete: bool,
466) -> Option<Action> {
467 if !can_delete {
468 return None;
469 }
470 let entry = manifest.get(owner_id)?;
471 if path.is_empty() || entry.preserve {
472 return None;
473 }
474 Some(Action::DeleteArtifact {
475 kind,
476 path: path.to_string(),
477 owner_id: owner_id.to_string(),
478 })
479}
480
481fn is_per_clip_kind(kind: ArtifactKind) -> bool {
487 matches!(
488 kind,
489 ArtifactKind::CoverJpg
490 | ArtifactKind::CoverWebp
491 | ArtifactKind::DetailsTxt
492 | ArtifactKind::LyricsTxt
493 | ArtifactKind::Lrc
494 | ArtifactKind::VideoMp4
495 )
496}
497
498fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
524 match kind {
525 ArtifactKind::CoverJpg
526 | ArtifactKind::CoverWebp
527 | ArtifactKind::LyricsTxt
528 | ArtifactKind::Lrc
529 | ArtifactKind::VideoMp4 => false,
530 ArtifactKind::DetailsTxt
531 | ArtifactKind::FolderJpg
532 | ArtifactKind::FolderWebp
533 | ArtifactKind::Playlist => true,
534 }
535}
536
537fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
542 match kind {
543 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
544 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
545 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
546 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
547 ArtifactKind::Lrc => entry.lrc.as_ref(),
548 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
549 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
550 }
551}
552
553fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
556 let mut out = Vec::new();
557 if let Some(state) = &entry.cover_jpg {
558 out.push((ArtifactKind::CoverJpg, state));
559 }
560 if let Some(state) = &entry.cover_webp {
561 out.push((ArtifactKind::CoverWebp, state));
562 }
563 if let Some(state) = &entry.details_txt {
564 out.push((ArtifactKind::DetailsTxt, state));
565 }
566 if let Some(state) = &entry.lyrics_txt {
567 out.push((ArtifactKind::LyricsTxt, state));
568 }
569 if let Some(state) = &entry.lrc {
570 out.push((ArtifactKind::Lrc, state));
571 }
572 if let Some(state) = &entry.video_mp4 {
573 out.push((ArtifactKind::VideoMp4, state));
574 }
575 out
576}
577
578pub(crate) fn set_manifest_artifact(
585 entry: &mut ManifestEntry,
586 kind: ArtifactKind,
587 state: Option<ArtifactState>,
588) {
589 match kind {
590 ArtifactKind::CoverJpg => entry.cover_jpg = state,
591 ArtifactKind::CoverWebp => entry.cover_webp = state,
592 ArtifactKind::DetailsTxt => entry.details_txt = state,
593 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
594 ArtifactKind::Lrc => entry.lrc = state,
595 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
596 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
597 }
598}
599
600fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
610 let owner_id = d.clip.id.as_str();
611 let entry = manifest.get(owner_id);
612
613 for artifact in &d.artifacts {
614 if !is_per_clip_kind(artifact.kind) {
618 continue;
619 }
620 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
629 None => true,
630 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
631 };
632 if needs_write {
633 out.push(Action::WriteArtifact {
634 kind: artifact.kind,
635 path: artifact.path.clone(),
636 source_url: artifact.source_url.clone(),
637 hash: artifact.hash.clone(),
638 owner_id: owner_id.to_string(),
639 content: artifact.content.clone(),
640 });
641 }
642 }
643
644 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
649 if !protected_now && let Some(entry) = entry {
650 let desired_kinds: BTreeSet<ArtifactKind> = d
651 .artifacts
652 .iter()
653 .filter(|a| is_per_clip_kind(a.kind))
654 .map(|a| a.kind)
655 .collect();
656 for (kind, state) in manifest_artifacts(entry) {
657 if removed_kind_delete_eligible(kind)
663 && !desired_kinds.contains(&kind)
664 && let Some(action) =
665 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
666 {
667 out.push(action);
668 }
669 }
670 }
671}
672
673fn co_delete_artifacts(
679 owner_id: &str,
680 manifest: &Manifest,
681 can_delete: bool,
682 out: &mut Vec<Action>,
683) {
684 let Some(entry) = manifest.get(owner_id) else {
685 return;
686 };
687 for (kind, state) in manifest_artifacts(entry) {
688 if let Some(action) =
689 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
690 {
691 out.push(action);
692 }
693 }
694}
695
696fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
703 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
704 for d in desired {
705 match by_id.get_mut(d.clip.id.as_str()) {
706 None => {
707 by_id.insert(d.clip.id.as_str(), d.clone());
708 }
709 Some(acc) => {
710 let take = rep_key(d) < rep_key(acc);
711 acc.private = acc.private || d.private;
712 acc.trashed = acc.trashed && d.trashed;
713 for mode in &d.modes {
714 if !acc.modes.contains(mode) {
715 acc.modes.push(*mode);
716 }
717 }
718 if take {
719 acc.clip = d.clip.clone();
720 acc.path = d.path.clone();
721 acc.format = d.format;
722 acc.meta_hash = d.meta_hash.clone();
723 acc.art_hash = d.art_hash.clone();
724 acc.artifacts = d.artifacts.clone();
725 }
726 }
727 }
728 }
729 let mut out: Vec<Desired> = by_id.into_values().collect();
730 for d in &mut out {
731 let has_mirror = d.modes.contains(&SourceMode::Mirror);
733 let has_copy = d.modes.contains(&SourceMode::Copy);
734 d.modes.clear();
735 if has_mirror {
736 d.modes.push(SourceMode::Mirror);
737 }
738 if has_copy {
739 d.modes.push(SourceMode::Copy);
740 }
741 }
742 out
743}
744
745fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
748 let format = match d.format {
749 AudioFormat::Mp3 => 0,
750 AudioFormat::Flac => 1,
751 AudioFormat::Wav => 2,
752 };
753 (
754 d.path.as_str(),
755 d.meta_hash.as_str(),
756 d.art_hash.as_str(),
757 format,
758 )
759}
760
761fn suppress_path_aliasing(actions: &mut [Action]) {
766 let targets: BTreeSet<String> = actions
767 .iter()
768 .filter_map(|a| match a {
769 Action::Download { path, .. }
770 | Action::Reformat { path, .. }
771 | Action::WriteArtifact { path, .. } => Some(path.clone()),
772 Action::Rename { to, .. } => Some(to.clone()),
773 _ => None,
774 })
775 .collect();
776 for a in actions.iter_mut() {
777 if let Action::Delete { path, clip_id } = a
778 && targets.contains(path.as_str())
779 {
780 *a = Action::Skip {
781 clip_id: clip_id.clone(),
782 };
783 }
784 if let Action::DeleteArtifact { path, owner_id, .. } = a
785 && targets.contains(path.as_str())
786 {
787 *a = Action::Skip {
788 clip_id: owner_id.clone(),
789 };
790 }
791 }
792}
793
794fn plan_desired(
796 d: &Desired,
797 manifest: &Manifest,
798 local: &HashMap<String, LocalFile>,
799 can_delete: bool,
800 out: &mut Vec<Action>,
801) {
802 let clip_id = d.clip.id.as_str();
803 let copy_held = d.modes.contains(&SourceMode::Copy);
804
805 if d.trashed && !d.private && !copy_held {
811 match delete_action(clip_id, manifest, can_delete) {
812 Some(action) => out.push(action),
813 None => out.push(Action::Skip {
814 clip_id: clip_id.to_string(),
815 }),
816 }
817 return;
818 }
819
820 let Some(entry) = manifest.get(clip_id) else {
821 out.push(Action::Download {
823 clip: d.clip.clone(),
824 lineage: d.lineage.clone(),
825 path: d.path.clone(),
826 format: d.format,
827 });
828 return;
829 };
830
831 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
834 if missing {
835 out.push(Action::Download {
836 clip: d.clip.clone(),
837 lineage: d.lineage.clone(),
838 path: d.path.clone(),
839 format: d.format,
840 });
841 return;
842 }
843
844 if d.format != entry.format {
845 out.push(Action::Reformat {
848 clip: d.clip.clone(),
849 path: d.path.clone(),
850 from_path: entry.path.clone(),
851 from: entry.format,
852 to: d.format,
853 });
854 return;
855 }
856
857 if d.path != entry.path {
858 out.push(Action::Rename {
859 from: entry.path.clone(),
860 to: d.path.clone(),
861 });
862 if meta_or_art_changed(d, entry) {
864 out.push(Action::Retag {
865 clip: d.clip.clone(),
866 lineage: d.lineage.clone(),
867 path: d.path.clone(),
868 });
869 }
870 return;
871 }
872
873 if meta_or_art_changed(d, entry) {
874 out.push(Action::Retag {
875 clip: d.clip.clone(),
876 lineage: d.lineage.clone(),
877 path: entry.path.clone(),
878 });
879 return;
880 }
881
882 out.push(Action::Skip {
883 clip_id: clip_id.to_string(),
884 });
885}
886
887fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
889 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
890}
891
892pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
912 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
913 for d in desired {
914 groups
915 .entry(d.lineage.root_id.as_str())
916 .or_default()
917 .push(d);
918 }
919
920 groups
921 .into_iter()
922 .map(|(root_id, members)| {
923 let album_dir = album_dir_of(&members);
924 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
925 kind: ArtifactKind::FolderJpg,
926 path: album_child(&album_dir, "folder.jpg"),
927 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
928 hash: art_hash(&source.clip),
929 content: None,
930 });
931 let folder_webp = animated_covers
932 .then(|| folder_webp_source(&members))
933 .flatten()
934 .map(|source| DesiredArtifact {
935 kind: ArtifactKind::FolderWebp,
936 path: album_child(&album_dir, "cover.webp"),
937 source_url: source.clip.video_cover_url.clone(),
938 hash: art_url_hash(&source.clip.video_cover_url),
939 content: None,
940 });
941 AlbumDesired {
942 root_id: root_id.to_owned(),
943 folder_jpg,
944 folder_webp,
945 }
946 })
947 .collect()
948}
949
950fn album_dir_of(members: &[&Desired]) -> String {
955 members
956 .iter()
957 .map(|d| parent_dir(&d.path))
958 .min()
959 .unwrap_or("")
960 .to_owned()
961}
962
963fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
969 members
970 .iter()
971 .copied()
972 .filter(|d| {
973 d.clip
974 .selected_image_url()
975 .is_some_and(|url| !url.is_empty())
976 })
977 .min_by(|a, b| {
978 b.clip
979 .play_count
980 .cmp(&a.clip.play_count)
981 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
982 .then_with(|| a.clip.id.cmp(&b.clip.id))
983 })
984}
985
986fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
991 members
992 .iter()
993 .copied()
994 .filter(|d| !d.clip.video_cover_url.is_empty())
995 .min_by(|a, b| {
996 a.clip
997 .created_at
998 .cmp(&b.clip.created_at)
999 .then_with(|| a.clip.id.cmp(&b.clip.id))
1000 })
1001}
1002
1003fn parent_dir(path: &str) -> &str {
1005 match path.rsplit_once('/') {
1006 Some((dir, _)) => dir,
1007 None => "",
1008 }
1009}
1010
1011fn album_child(album_dir: &str, name: &str) -> String {
1014 if album_dir.is_empty() {
1015 name.to_owned()
1016 } else {
1017 format!("{album_dir}/{name}")
1018 }
1019}
1020
1021pub fn plan_album_artifacts(
1040 desired: &[AlbumDesired],
1041 albums: &BTreeMap<String, AlbumArt>,
1042 can_delete: bool,
1043) -> Vec<Action> {
1044 let mut actions: Vec<Action> = Vec::new();
1045 let by_root: BTreeMap<&str, &AlbumDesired> =
1046 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1047
1048 for d in desired {
1049 let stored = albums.get(&d.root_id);
1050 for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
1051 .into_iter()
1052 .flatten()
1053 {
1054 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1055 None => true,
1056 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1057 };
1058 if needs_write {
1059 actions.push(Action::WriteArtifact {
1060 kind: artifact.kind,
1061 path: artifact.path.clone(),
1062 source_url: artifact.source_url.clone(),
1063 hash: artifact.hash.clone(),
1064 owner_id: d.root_id.clone(),
1065 content: None,
1066 });
1067 }
1068 }
1069 }
1070
1071 if can_delete {
1073 for (root_id, art) in albums {
1074 for (kind, state) in album_artifacts(art) {
1075 let desired_here = by_root
1076 .get(root_id.as_str())
1077 .is_some_and(|d| album_desires_kind(d, kind));
1078 if !desired_here && !state.path.is_empty() {
1079 actions.push(Action::DeleteArtifact {
1080 kind,
1081 path: state.path.clone(),
1082 owner_id: root_id.clone(),
1083 });
1084 }
1085 }
1086 }
1087 }
1088
1089 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1090 actions
1091}
1092
1093fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1096 let mut out = Vec::new();
1097 if let Some(state) = &art.folder_jpg {
1098 out.push((ArtifactKind::FolderJpg, state));
1099 }
1100 if let Some(state) = &art.folder_webp {
1101 out.push((ArtifactKind::FolderWebp, state));
1102 }
1103 out
1104}
1105
1106fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1108 match kind {
1109 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1110 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1111 ArtifactKind::CoverJpg
1112 | ArtifactKind::CoverWebp
1113 | ArtifactKind::DetailsTxt
1114 | ArtifactKind::LyricsTxt
1115 | ArtifactKind::Lrc
1116 | ArtifactKind::VideoMp4
1117 | ArtifactKind::Playlist => false,
1118 }
1119}
1120
1121fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1123 match action {
1124 Action::WriteArtifact { owner_id, kind, .. }
1125 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1126 _ => ("", ArtifactKind::CoverJpg),
1127 }
1128}
1129
1130pub fn plan_playlist_artifacts(
1163 desired: &[PlaylistDesired],
1164 stored: &BTreeMap<String, PlaylistState>,
1165 can_delete: bool,
1166 list_fully_enumerated: bool,
1167) -> Vec<Action> {
1168 let mut actions: Vec<Action> = Vec::new();
1169 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1170 let deletes_allowed = can_delete && list_fully_enumerated;
1173
1174 for d in desired {
1175 let stored_here = stored.get(&d.id);
1176 let needs_write = match stored_here {
1177 None => true,
1178 Some(state) => state.hash != d.hash || state.path != d.path,
1179 };
1180 if needs_write {
1181 actions.push(Action::WriteArtifact {
1182 kind: ArtifactKind::Playlist,
1183 path: d.path.clone(),
1184 source_url: String::new(),
1185 hash: d.hash.clone(),
1186 owner_id: d.id.clone(),
1187 content: Some(d.content.clone()),
1188 });
1189 }
1190 if deletes_allowed
1192 && let Some(state) = stored_here
1193 && !state.path.is_empty()
1194 && state.path != d.path
1195 {
1196 actions.push(Action::DeleteArtifact {
1197 kind: ArtifactKind::Playlist,
1198 path: state.path.clone(),
1199 owner_id: d.id.clone(),
1200 });
1201 }
1202 }
1203
1204 if deletes_allowed {
1207 for (id, state) in stored {
1208 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1209 actions.push(Action::DeleteArtifact {
1210 kind: ArtifactKind::Playlist,
1211 path: state.path.clone(),
1212 owner_id: id.clone(),
1213 });
1214 }
1215 }
1216 }
1217
1218 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1219 suppress_path_aliasing(&mut actions);
1222 actions
1223}
1224
1225fn playlist_action_key(action: &Action) -> (&str, u8) {
1228 match action {
1229 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1230 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1231 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1232 _ => ("", 3),
1233 }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use crate::hash::content_hash;
1240
1241 fn clip(id: &str) -> Clip {
1242 Clip {
1243 id: id.to_string(),
1244 title: "Song".to_string(),
1245 ..Default::default()
1246 }
1247 }
1248
1249 fn lineage(id: &str) -> LineageContext {
1250 LineageContext::own_root(&clip(id))
1251 }
1252
1253 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1254 ManifestEntry {
1255 path: path.to_string(),
1256 format,
1257 meta_hash: meta.to_string(),
1258 art_hash: art.to_string(),
1259 size: 100,
1260 preserve: false,
1261 ..Default::default()
1262 }
1263 }
1264
1265 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1266 ManifestEntry {
1267 preserve: true,
1268 ..entry(path, format, meta, art)
1269 }
1270 }
1271
1272 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1273 Desired {
1274 clip: clip(id),
1275 lineage: lineage(id),
1276 path: path.to_string(),
1277 format,
1278 meta_hash: meta.to_string(),
1279 art_hash: art.to_string(),
1280 modes: vec![SourceMode::Mirror],
1281 trashed: false,
1282 private: false,
1283 artifacts: Vec::new(),
1284 }
1285 }
1286
1287 fn present(size: u64) -> LocalFile {
1288 LocalFile { exists: true, size }
1289 }
1290
1291 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1292 [(id.to_string(), present(100))].into_iter().collect()
1293 }
1294
1295 fn mirror_ok() -> Vec<SourceStatus> {
1296 vec![SourceStatus {
1297 mode: SourceMode::Mirror,
1298 fully_enumerated: true,
1299 }]
1300 }
1301
1302 #[test]
1305 fn not_in_manifest_downloads() {
1306 let manifest = Manifest::new();
1307 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1308 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1309 assert_eq!(
1310 plan.actions,
1311 vec![Action::Download {
1312 clip: clip("a"),
1313 lineage: lineage("a"),
1314 path: "a.flac".to_string(),
1315 format: AudioFormat::Flac,
1316 }]
1317 );
1318 }
1319
1320 #[test]
1321 fn unchanged_clip_skips() {
1322 let mut manifest = Manifest::new();
1323 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1324 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1325 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1326 assert_eq!(
1327 plan.actions,
1328 vec![Action::Skip {
1329 clip_id: "a".to_string()
1330 }]
1331 );
1332 }
1333
1334 #[test]
1335 fn meta_change_retags_in_place() {
1336 let mut manifest = Manifest::new();
1337 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1338 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1339 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1340 assert_eq!(
1341 plan.actions,
1342 vec![Action::Retag {
1343 clip: clip("a"),
1344 lineage: lineage("a"),
1345 path: "a.flac".to_string(),
1346 }]
1347 );
1348 }
1349
1350 #[test]
1351 fn art_change_retags_in_place() {
1352 let mut manifest = Manifest::new();
1353 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1354 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1355 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1356 assert_eq!(
1357 plan.actions,
1358 vec![Action::Retag {
1359 clip: clip("a"),
1360 lineage: lineage("a"),
1361 path: "a.flac".to_string(),
1362 }]
1363 );
1364 }
1365
1366 #[test]
1367 fn rename_when_path_changes() {
1368 let mut manifest = Manifest::new();
1369 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1370 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1371 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1372 assert_eq!(
1373 plan.actions,
1374 vec![Action::Rename {
1375 from: "old/a.flac".to_string(),
1376 to: "new/a.flac".to_string(),
1377 }]
1378 );
1379 }
1380
1381 #[test]
1382 fn rename_with_meta_change_also_retags() {
1383 let mut manifest = Manifest::new();
1384 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1385 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1386 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1387 assert_eq!(
1388 plan.actions,
1389 vec![
1390 Action::Rename {
1391 from: "old/a.flac".to_string(),
1392 to: "new/a.flac".to_string(),
1393 },
1394 Action::Retag {
1395 clip: clip("a"),
1396 lineage: lineage("a"),
1397 path: "new/a.flac".to_string(),
1398 },
1399 ]
1400 );
1401 }
1402
1403 #[test]
1404 fn rename_without_meta_change_does_not_retag() {
1405 let mut manifest = Manifest::new();
1406 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1407 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1408 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1409 assert_eq!(plan.renames(), 1);
1410 assert_eq!(plan.retags(), 0);
1411 }
1412
1413 #[test]
1414 fn bulk_album_rename_moves_and_retags_without_redownload() {
1415 let mut manifest = Manifest::new();
1420 for id in ["a", "b", "c"] {
1421 manifest.insert(
1422 id,
1423 entry(
1424 &format!("Creator/Old Album/{id}.flac"),
1425 AudioFormat::Flac,
1426 "old-meta",
1427 "art",
1428 ),
1429 );
1430 }
1431 let d: Vec<Desired> = ["a", "b", "c"]
1432 .iter()
1433 .map(|id| {
1434 desired(
1435 id,
1436 &format!("Creator/New Album/{id}.flac"),
1437 AudioFormat::Flac,
1438 "new-meta",
1439 "art",
1440 )
1441 })
1442 .collect();
1443 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1444 .iter()
1445 .map(|id| (id.to_string(), present(100)))
1446 .collect();
1447
1448 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1449
1450 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1451 assert_eq!(
1452 plan.retags(),
1453 3,
1454 "the album tag change retags each in place"
1455 );
1456 assert_eq!(
1457 plan.downloads(),
1458 0,
1459 "an album rename must never re-download"
1460 );
1461 assert_eq!(
1462 plan.deletes(),
1463 0,
1464 "deletion safety: a rename deletes nothing"
1465 );
1466 for id in ["a", "b", "c"] {
1467 assert!(plan.actions.contains(&Action::Rename {
1468 from: format!("Creator/Old Album/{id}.flac"),
1469 to: format!("Creator/New Album/{id}.flac"),
1470 }));
1471 }
1472 }
1473
1474 #[test]
1475 fn format_change_reformats() {
1476 let mut manifest = Manifest::new();
1477 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1478 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1479 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1480 assert_eq!(
1481 plan.actions,
1482 vec![Action::Reformat {
1483 clip: clip("a"),
1484 path: "a.mp3".to_string(),
1485 from_path: "a.flac".to_string(),
1486 from: AudioFormat::Flac,
1487 to: AudioFormat::Mp3,
1488 }]
1489 );
1490 }
1491
1492 #[test]
1493 fn format_change_takes_precedence_over_rename_and_retag() {
1494 let mut manifest = Manifest::new();
1497 manifest.insert(
1498 "a",
1499 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1500 );
1501 let d = vec![desired(
1502 "a",
1503 "new/a.mp3",
1504 AudioFormat::Mp3,
1505 "new",
1506 "new-art",
1507 )];
1508 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1509 assert_eq!(plan.reformats(), 1);
1510 assert_eq!(plan.renames(), 0);
1511 assert_eq!(plan.retags(), 0);
1512 }
1513
1514 #[test]
1517 fn zero_length_file_downloads_even_when_hashes_match() {
1518 let mut manifest = Manifest::new();
1519 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1520 let local: HashMap<String, LocalFile> = [(
1521 "a".to_string(),
1522 LocalFile {
1523 exists: true,
1524 size: 0,
1525 },
1526 )]
1527 .into_iter()
1528 .collect();
1529 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1530 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1531 assert_eq!(plan.downloads(), 1);
1532 assert_eq!(plan.skips(), 0);
1533 }
1534
1535 #[test]
1536 fn missing_file_downloads_even_when_hashes_match() {
1537 let mut manifest = Manifest::new();
1538 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1539 let local: HashMap<String, LocalFile> = [(
1540 "a".to_string(),
1541 LocalFile {
1542 exists: false,
1543 size: 0,
1544 },
1545 )]
1546 .into_iter()
1547 .collect();
1548 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1549 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1550 assert_eq!(plan.downloads(), 1);
1551 }
1552
1553 #[test]
1554 fn absent_local_probe_treated_as_missing() {
1555 let mut manifest = Manifest::new();
1557 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1558 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1559 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1560 assert_eq!(plan.downloads(), 1);
1561 }
1562
1563 #[test]
1564 fn missing_file_download_wins_over_format_difference() {
1565 let mut manifest = Manifest::new();
1568 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1569 let local: HashMap<String, LocalFile> = [(
1570 "a".to_string(),
1571 LocalFile {
1572 exists: false,
1573 size: 0,
1574 },
1575 )]
1576 .into_iter()
1577 .collect();
1578 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1579 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1580 assert_eq!(plan.downloads(), 1);
1581 assert_eq!(plan.reformats(), 0);
1582 }
1583
1584 #[test]
1587 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1588 let mut trashed = clip("a");
1593 trashed.status = "complete".to_string();
1594 trashed.is_trashed = true;
1595 assert!(crate::is_downloadable(&trashed));
1596
1597 let mut manifest = Manifest::new();
1598 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1599 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1600 d.clip = trashed;
1601 d.trashed = true;
1602 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1603 assert_eq!(
1604 plan.actions,
1605 vec![Action::Delete {
1606 path: "a.flac".to_string(),
1607 clip_id: "a".to_string(),
1608 }]
1609 );
1610 }
1611
1612 #[test]
1613 fn trashed_clip_deletes_local_file() {
1614 let mut manifest = Manifest::new();
1615 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1616 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1617 d.trashed = true;
1618 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1619 assert_eq!(
1620 plan.actions,
1621 vec![Action::Delete {
1622 path: "a.flac".to_string(),
1623 clip_id: "a".to_string(),
1624 }]
1625 );
1626 }
1627
1628 #[test]
1629 fn trashed_clip_not_in_manifest_skips() {
1630 let manifest = Manifest::new();
1632 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1633 d.trashed = true;
1634 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1635 assert_eq!(
1636 plan.actions,
1637 vec![Action::Skip {
1638 clip_id: "a".to_string()
1639 }]
1640 );
1641 }
1642
1643 #[test]
1644 fn private_clip_is_kept() {
1645 let mut manifest = Manifest::new();
1646 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1647 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1648 d.private = true;
1649 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1650 assert_eq!(
1651 plan.actions,
1652 vec![Action::Skip {
1653 clip_id: "a".to_string()
1654 }]
1655 );
1656 }
1657
1658 #[test]
1659 fn private_beats_trashed_never_deletes() {
1660 let mut manifest = Manifest::new();
1662 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1663 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1664 d.trashed = true;
1665 d.private = true;
1666 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1667 assert_eq!(plan.deletes(), 0);
1668 assert_eq!(plan.skips(), 1);
1669 }
1670
1671 #[test]
1672 fn copy_held_trashed_clip_is_not_deleted() {
1673 let mut manifest = Manifest::new();
1676 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1677 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1678 d.modes = vec![SourceMode::Copy];
1679 d.trashed = true;
1680 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1681 assert_eq!(plan.deletes(), 0);
1682 assert_eq!(
1683 plan.actions,
1684 vec![Action::Skip {
1685 clip_id: "a".to_string()
1686 }]
1687 );
1688 }
1689
1690 #[test]
1693 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1694 let mut manifest = Manifest::new();
1695 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1696 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1697 assert_eq!(
1698 plan.actions,
1699 vec![Action::Delete {
1700 path: "gone.flac".to_string(),
1701 clip_id: "gone".to_string(),
1702 }]
1703 );
1704 }
1705
1706 #[test]
1707 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1708 let mut manifest = Manifest::new();
1709 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1710 let sources = vec![
1711 SourceStatus {
1712 mode: SourceMode::Mirror,
1713 fully_enumerated: true,
1714 },
1715 SourceStatus {
1716 mode: SourceMode::Mirror,
1717 fully_enumerated: false,
1718 },
1719 ];
1720 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1721 assert_eq!(plan.deletes(), 0);
1722 assert_eq!(
1723 plan.actions,
1724 vec![Action::Skip {
1725 clip_id: "gone".to_string()
1726 }]
1727 );
1728 }
1729
1730 #[test]
1731 fn empty_listing_cannot_cause_deletion() {
1732 let mut manifest = Manifest::new();
1735 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1736 let sources = vec![SourceStatus {
1737 mode: SourceMode::Mirror,
1738 fully_enumerated: false,
1739 }];
1740 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1741 assert_eq!(plan.deletes(), 0);
1742 assert_eq!(plan.skips(), 1);
1743 }
1744
1745 #[test]
1746 fn no_mirror_sources_means_no_deletion() {
1747 let mut manifest = Manifest::new();
1749 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1750 let copy_only = vec![SourceStatus {
1751 mode: SourceMode::Copy,
1752 fully_enumerated: true,
1753 }];
1754 assert_eq!(
1755 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1756 0
1757 );
1758 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1759 }
1760
1761 #[test]
1762 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1763 let mut manifest = Manifest::new();
1764 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1765 let sources = vec![
1766 SourceStatus {
1767 mode: SourceMode::Copy,
1768 fully_enumerated: true,
1769 },
1770 SourceStatus {
1771 mode: SourceMode::Mirror,
1772 fully_enumerated: false,
1773 },
1774 ];
1775 assert_eq!(
1776 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1777 0
1778 );
1779 }
1780
1781 #[test]
1782 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1783 let mut manifest = Manifest::new();
1787 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1788 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1789 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1790 held.modes = vec![SourceMode::Copy];
1791 let local: HashMap<String, LocalFile> = [
1792 ("keep".to_string(), present(100)),
1793 ("gone".to_string(), present(100)),
1794 ]
1795 .into_iter()
1796 .collect();
1797 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1798 assert!(plan.actions.contains(&Action::Skip {
1799 clip_id: "keep".to_string()
1800 }));
1801 assert!(plan.actions.contains(&Action::Delete {
1802 path: "gone.flac".to_string(),
1803 clip_id: "gone".to_string(),
1804 }));
1805 assert!(
1807 !plan
1808 .actions
1809 .iter()
1810 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1811 );
1812 }
1813
1814 #[test]
1817 fn orphan_with_preserve_marker_is_kept() {
1818 let mut manifest = Manifest::new();
1821 manifest.insert(
1822 "gone",
1823 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1824 );
1825 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1826 assert_eq!(plan.deletes(), 0);
1827 assert_eq!(
1828 plan.actions,
1829 vec![Action::Skip {
1830 clip_id: "gone".to_string()
1831 }]
1832 );
1833 }
1834
1835 #[test]
1836 fn trashed_clip_with_preserve_marker_is_kept() {
1837 let mut manifest = Manifest::new();
1840 manifest.insert(
1841 "a",
1842 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1843 );
1844 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1845 d.trashed = true;
1846 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1847 assert_eq!(plan.deletes(), 0);
1848 assert_eq!(plan.skips(), 1);
1849 }
1850
1851 #[test]
1854 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1855 let mut manifest = Manifest::new();
1857 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1858 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1859 d.trashed = true;
1860 let sources = vec![SourceStatus {
1861 mode: SourceMode::Mirror,
1862 fully_enumerated: false,
1863 }];
1864 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1865 assert_eq!(plan.deletes(), 0);
1866 assert_eq!(plan.skips(), 1);
1867 }
1868
1869 #[test]
1870 fn trashed_clip_kept_when_sources_empty() {
1871 let mut manifest = Manifest::new();
1874 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1875 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1876 d.trashed = true;
1877 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1878 assert_eq!(plan.deletes(), 0);
1879 assert_eq!(plan.skips(), 1);
1880 }
1881
1882 #[test]
1883 fn failed_copy_listing_suppresses_orphan_deletion() {
1884 let mut manifest = Manifest::new();
1887 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1888 let sources = vec![
1889 SourceStatus {
1890 mode: SourceMode::Mirror,
1891 fully_enumerated: true,
1892 },
1893 SourceStatus {
1894 mode: SourceMode::Copy,
1895 fully_enumerated: false,
1896 },
1897 ];
1898 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1899 assert_eq!(plan.deletes(), 0);
1900 }
1901
1902 #[test]
1903 fn failed_copy_listing_suppresses_trashed_deletion() {
1904 let mut manifest = Manifest::new();
1905 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1906 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1907 d.trashed = true;
1908 let sources = vec![
1909 SourceStatus {
1910 mode: SourceMode::Mirror,
1911 fully_enumerated: true,
1912 },
1913 SourceStatus {
1914 mode: SourceMode::Copy,
1915 fully_enumerated: false,
1916 },
1917 ];
1918 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1919 assert_eq!(plan.deletes(), 0);
1920 assert_eq!(plan.skips(), 1);
1921 }
1922
1923 #[test]
1924 fn empty_path_entry_never_deletes() {
1925 let mut manifest = Manifest::new();
1928 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1929 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1930 assert_eq!(plan.deletes(), 0);
1931 assert_eq!(
1932 plan.actions,
1933 vec![Action::Skip {
1934 clip_id: "gone".to_string()
1935 }]
1936 );
1937 }
1938
1939 #[test]
1942 fn delete_suppressed_when_path_aliases_rename_target() {
1943 let mut manifest = Manifest::new();
1946 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1947 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1948 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1949 let local: HashMap<String, LocalFile> = [
1950 ("a".to_string(), present(100)),
1951 ("b".to_string(), present(100)),
1952 ]
1953 .into_iter()
1954 .collect();
1955 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1956 assert!(plan.actions.contains(&Action::Rename {
1957 from: "old/a.flac".to_string(),
1958 to: "new/a.flac".to_string(),
1959 }));
1960 assert!(
1962 !plan
1963 .actions
1964 .iter()
1965 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1966 );
1967 assert!(plan.actions.contains(&Action::Skip {
1968 clip_id: "b".to_string()
1969 }));
1970 }
1971
1972 #[test]
1973 fn delete_suppressed_when_path_aliases_download_target() {
1974 let mut manifest = Manifest::new();
1976 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1977 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1978 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1979 assert!(
1980 !plan
1981 .actions
1982 .iter()
1983 .any(|a| matches!(a, Action::Delete { .. }))
1984 );
1985 assert_eq!(plan.downloads(), 1);
1986 }
1987
1988 #[test]
1989 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1990 let mut actions = vec![
1995 Action::Rename {
1996 from: "old/song.flac".to_string(),
1997 to: "new/cover.jpg".to_string(),
1998 },
1999 Action::DeleteArtifact {
2000 kind: ArtifactKind::CoverJpg,
2001 path: "new/cover.jpg".to_string(),
2002 owner_id: "a".to_string(),
2003 },
2004 ];
2005 suppress_path_aliasing(&mut actions);
2006 assert!(
2008 !actions
2009 .iter()
2010 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2011 "a sidecar delete must not alias a rename target"
2012 );
2013 assert!(actions.contains(&Action::Skip {
2014 clip_id: "a".to_string()
2015 }));
2016 assert!(actions.contains(&Action::Rename {
2018 from: "old/song.flac".to_string(),
2019 to: "new/cover.jpg".to_string(),
2020 }));
2021 }
2022
2023 #[test]
2024 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2025 let mut actions = vec![
2028 Action::WriteArtifact {
2029 kind: ArtifactKind::FolderJpg,
2030 path: "creator/album/folder.jpg".to_string(),
2031 source_url: "https://art/large.jpg".to_string(),
2032 hash: "h".to_string(),
2033 owner_id: "root".to_string(),
2034 content: None,
2035 },
2036 Action::DeleteArtifact {
2037 kind: ArtifactKind::FolderJpg,
2038 path: "creator/album/folder.jpg".to_string(),
2039 owner_id: "root-old".to_string(),
2040 },
2041 ];
2042 suppress_path_aliasing(&mut actions);
2043 assert!(
2044 !actions
2045 .iter()
2046 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2047 );
2048 assert!(actions.contains(&Action::Skip {
2049 clip_id: "root-old".to_string()
2050 }));
2051 }
2052
2053 #[test]
2056 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2057 let mut manifest = Manifest::new();
2060 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2061 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2062 copy_entry.modes = vec![SourceMode::Copy];
2063 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2064 trashed_entry.modes = vec![SourceMode::Mirror];
2065 trashed_entry.trashed = true;
2066 let plan = reconcile(
2067 &manifest,
2068 &[copy_entry, trashed_entry],
2069 &local_present("a"),
2070 &mirror_ok(),
2071 );
2072 assert_eq!(plan.deletes(), 0);
2073 assert_eq!(plan.skips(), 1);
2074 }
2075
2076 #[test]
2077 fn duplicate_trashed_does_not_defeat_private_sibling() {
2078 let mut manifest = Manifest::new();
2079 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2080 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2081 private_entry.private = true;
2082 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2083 trashed_entry.trashed = true;
2084 let plan = reconcile(
2085 &manifest,
2086 &[private_entry, trashed_entry],
2087 &local_present("a"),
2088 &mirror_ok(),
2089 );
2090 assert_eq!(plan.deletes(), 0);
2091 assert_eq!(plan.skips(), 1);
2092 }
2093
2094 #[test]
2095 fn duplicate_trashed_deletes_only_when_all_trashed() {
2096 let mut manifest = Manifest::new();
2098 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2099 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2100 first.trashed = true;
2101 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2102 second.trashed = true;
2103 let plan = reconcile(
2104 &manifest,
2105 &[first, second],
2106 &local_present("a"),
2107 &mirror_ok(),
2108 );
2109 assert_eq!(plan.deletes(), 1);
2110 }
2111
2112 #[test]
2113 fn duplicate_desired_unions_modes() {
2114 let mut manifest = Manifest::new();
2116 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2117 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2118 mirror_entry.modes = vec![SourceMode::Mirror];
2119 mirror_entry.trashed = true;
2120 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2121 copy_entry.modes = vec![SourceMode::Copy];
2122 let plan = reconcile(
2123 &manifest,
2124 &[mirror_entry, copy_entry],
2125 &local_present("a"),
2126 &mirror_ok(),
2127 );
2128 assert_eq!(plan.deletes(), 0);
2130 }
2131
2132 #[test]
2135 fn private_new_clip_downloads() {
2136 let manifest = Manifest::new();
2139 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2140 d.private = true;
2141 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2142 assert_eq!(plan.downloads(), 1);
2143 }
2144
2145 #[test]
2146 fn private_zero_length_file_redownloads() {
2147 let mut manifest = Manifest::new();
2148 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2149 let local: HashMap<String, LocalFile> = [(
2150 "a".to_string(),
2151 LocalFile {
2152 exists: true,
2153 size: 0,
2154 },
2155 )]
2156 .into_iter()
2157 .collect();
2158 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2159 d.private = true;
2160 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2161 assert_eq!(plan.downloads(), 1);
2162 }
2163
2164 #[test]
2165 fn private_meta_change_retags() {
2166 let mut manifest = Manifest::new();
2167 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2168 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2169 d.private = true;
2170 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2171 assert_eq!(plan.retags(), 1);
2172 assert_eq!(plan.deletes(), 0);
2173 }
2174
2175 #[test]
2176 fn absent_private_clip_protected_by_preserve_marker() {
2177 let mut manifest = Manifest::new();
2180 manifest.insert(
2181 "a",
2182 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2183 );
2184 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2185 assert_eq!(plan.deletes(), 0);
2186 assert_eq!(plan.skips(), 1);
2187 }
2188
2189 #[test]
2192 fn output_is_deterministic_regardless_of_input_order() {
2193 let mut manifest = Manifest::new();
2194 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2195 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2196 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2197 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2198 .iter()
2199 .map(|id| (id.to_string(), present(100)))
2200 .collect();
2201
2202 let forward = vec![
2203 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2204 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2205 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2206 ];
2207 let mut reversed = forward.clone();
2208 reversed.reverse();
2209
2210 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2211 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2212 assert_eq!(p1.actions, p2.actions);
2213
2214 let ids: Vec<&str> = p1
2217 .actions
2218 .iter()
2219 .map(|a| match a {
2220 Action::Skip { clip_id } => clip_id.as_str(),
2221 Action::Retag { clip, .. } => clip.id.as_str(),
2222 Action::Download { clip, .. } => clip.id.as_str(),
2223 Action::Delete { clip_id, .. } => clip_id.as_str(),
2224 Action::Reformat { clip, .. } => clip.id.as_str(),
2225 Action::Rename { to, .. } => to.as_str(),
2226 Action::WriteArtifact { owner_id, .. }
2227 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2228 })
2229 .collect();
2230 assert_eq!(ids, ["a", "b", "c", "z"]);
2231 }
2232
2233 #[test]
2234 fn empty_inputs_do_not_panic() {
2235 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2236 assert!(plan.is_empty());
2237 assert_eq!(plan.len(), 0);
2238 }
2239
2240 #[test]
2241 fn empty_desired_with_full_manifest_deletes_all() {
2242 let mut manifest = Manifest::new();
2243 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2244 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2245 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2246 assert_eq!(plan.deletes(), 2);
2247 }
2248
2249 #[test]
2250 fn full_desired_with_empty_manifest_downloads_all() {
2251 let d = vec![
2252 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2253 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2254 ];
2255 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2256 assert_eq!(plan.downloads(), 2);
2257 }
2258
2259 #[test]
2260 fn plan_counts_sum_to_len() {
2261 let mut manifest = Manifest::new();
2262 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2263 manifest.insert(
2264 "retag",
2265 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2266 );
2267 manifest.insert(
2268 "reformat",
2269 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2270 );
2271 manifest.insert(
2272 "rename",
2273 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2274 );
2275 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2276 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2277 .iter()
2278 .map(|id| (id.to_string(), present(100)))
2279 .collect();
2280 let d = vec![
2281 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2282 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2283 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2284 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2285 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2286 ];
2287 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2288 let summed = plan.downloads()
2289 + plan.reformats()
2290 + plan.retags()
2291 + plan.renames()
2292 + plan.deletes()
2293 + plan.skips();
2294 assert_eq!(summed, plan.len());
2295 assert_eq!(plan.downloads(), 1);
2296 assert_eq!(plan.reformats(), 1);
2297 assert_eq!(plan.retags(), 1);
2298 assert_eq!(plan.renames(), 1);
2299 assert_eq!(plan.deletes(), 1);
2300 assert_eq!(plan.skips(), 1);
2301 }
2302
2303 fn cover(path: &str, hash: &str) -> ArtifactState {
2306 ArtifactState {
2307 path: path.to_string(),
2308 hash: hash.to_string(),
2309 }
2310 }
2311
2312 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2313 DesiredArtifact {
2314 kind,
2315 path: path.to_string(),
2316 source_url: url.to_string(),
2317 hash: hash.to_string(),
2318 content: None,
2319 }
2320 }
2321
2322 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2324 DesiredArtifact {
2325 kind,
2326 path: path.to_string(),
2327 source_url: String::new(),
2328 hash: content_hash(body),
2329 content: Some(body.to_string()),
2330 }
2331 }
2332
2333 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2335 Desired {
2336 artifacts: arts,
2337 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2338 }
2339 }
2340
2341 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2343 ManifestEntry {
2344 cover_jpg: Some(cover(cover_path, cover_hash)),
2345 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2346 }
2347 }
2348
2349 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2350 plan.actions
2351 .iter()
2352 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2353 .collect()
2354 }
2355
2356 #[test]
2357 fn write_artifact_emitted_when_manifest_lacks_it() {
2358 let mut manifest = Manifest::new();
2361 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2362 let d = vec![desired_arts(
2363 "a",
2364 vec![art(
2365 ArtifactKind::CoverJpg,
2366 "a/cover.jpg",
2367 "https://art/a",
2368 "h1",
2369 )],
2370 )];
2371 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2372 assert_eq!(plan.artifact_writes(), 1);
2373 assert_eq!(plan.artifact_deletes(), 0);
2374 assert_eq!(plan.skips(), 1);
2375 assert_eq!(
2376 write_artifacts(&plan)[0],
2377 &Action::WriteArtifact {
2378 kind: ArtifactKind::CoverJpg,
2379 path: "a/cover.jpg".to_string(),
2380 source_url: "https://art/a".to_string(),
2381 hash: "h1".to_string(),
2382 owner_id: "a".to_string(),
2383 content: None,
2384 }
2385 );
2386 }
2387
2388 #[test]
2389 fn write_artifact_emitted_when_hash_differs() {
2390 let mut manifest = Manifest::new();
2393 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2394 let d = vec![desired_arts(
2395 "a",
2396 vec![art(
2397 ArtifactKind::CoverJpg,
2398 "a/cover.jpg",
2399 "https://art/a",
2400 "new",
2401 )],
2402 )];
2403 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2404 assert_eq!(plan.artifact_writes(), 1);
2405 assert_eq!(plan.artifact_deletes(), 0);
2406 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2407 assert_eq!(hash, "new");
2408 } else {
2409 panic!("expected a WriteArtifact");
2410 }
2411 }
2412
2413 #[test]
2414 fn write_artifact_skipped_when_hash_matches() {
2415 let mut manifest = Manifest::new();
2417 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2418 let d = vec![desired_arts(
2419 "a",
2420 vec![art(
2421 ArtifactKind::CoverJpg,
2422 "a/cover.jpg",
2423 "https://art/a",
2424 "h1",
2425 )],
2426 )];
2427 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2428 assert_eq!(plan.artifact_writes(), 0);
2429 assert_eq!(plan.artifact_deletes(), 0);
2430 assert_eq!(
2431 plan.actions,
2432 vec![Action::Skip {
2433 clip_id: "a".to_string()
2434 }]
2435 );
2436 }
2437
2438 #[test]
2439 fn removed_kind_cover_is_kept_not_deleted() {
2440 let mut manifest = Manifest::new();
2445 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2446 let d = vec![desired_arts("a", vec![])];
2447 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2448 assert_eq!(plan.artifact_deletes(), 0);
2449 assert_eq!(plan.artifact_writes(), 0);
2450 assert_eq!(plan.deletes(), 0);
2452 assert_eq!(
2453 plan.actions,
2454 vec![Action::Skip {
2455 clip_id: "a".to_string()
2456 }]
2457 );
2458 assert!(!plan.actions.iter().any(|a| matches!(
2459 a,
2460 Action::DeleteArtifact {
2461 kind: ArtifactKind::CoverJpg,
2462 ..
2463 }
2464 )));
2465 }
2466
2467 #[test]
2468 fn delete_artifact_never_on_incomplete_listing() {
2469 let mut manifest = Manifest::new();
2474 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2475 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2476 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2477 let sources = vec![SourceStatus {
2478 mode: SourceMode::Mirror,
2479 fully_enumerated: false,
2480 }];
2481 let local: HashMap<String, LocalFile> = [
2482 ("a".to_string(), present(100)),
2483 ("b".to_string(), present(100)),
2484 ]
2485 .into_iter()
2486 .collect();
2487 let plan = reconcile(&manifest, &d, &local, &sources);
2488 assert_eq!(plan.artifact_deletes(), 0);
2489 assert_eq!(plan.deletes(), 0);
2490 }
2491
2492 #[test]
2493 fn delete_artifact_never_when_entry_preserved() {
2494 let mut manifest = Manifest::new();
2497 let preserved = ManifestEntry {
2498 preserve: true,
2499 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2500 };
2501 manifest.insert("a", preserved);
2502 let d = vec![desired_arts("a", vec![])];
2503 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2504 assert_eq!(plan.artifact_deletes(), 0);
2505 }
2506
2507 #[test]
2508 fn co_delete_never_when_path_empty() {
2509 let mut manifest = Manifest::new();
2513 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2514 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2515 assert_eq!(plan.deletes(), 1);
2516 assert_eq!(plan.artifact_deletes(), 0);
2517 }
2518
2519 #[test]
2520 fn co_delete_absent_clip_deletes_audio_and_cover() {
2521 let mut manifest = Manifest::new();
2524 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2525 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2526 assert_eq!(plan.deletes(), 1);
2527 assert_eq!(plan.artifact_deletes(), 1);
2528 assert!(plan.actions.contains(&Action::Delete {
2529 path: "gone.flac".to_string(),
2530 clip_id: "gone".to_string(),
2531 }));
2532 assert!(plan.actions.contains(&Action::DeleteArtifact {
2533 kind: ArtifactKind::CoverJpg,
2534 path: "gone/cover.jpg".to_string(),
2535 owner_id: "gone".to_string(),
2536 }));
2537 }
2538
2539 #[test]
2540 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2541 let mut manifest = Manifest::new();
2543 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2544 let sources = vec![SourceStatus {
2545 mode: SourceMode::Mirror,
2546 fully_enumerated: false,
2547 }];
2548 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2549 assert_eq!(plan.deletes(), 0);
2550 assert_eq!(plan.artifact_deletes(), 0);
2551 }
2552
2553 #[test]
2554 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2555 let mut manifest = Manifest::new();
2557 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2558 let mut d = desired_arts("a", vec![]);
2559 d.trashed = true;
2560 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2561 assert_eq!(plan.deletes(), 1);
2562 assert_eq!(plan.artifact_deletes(), 1);
2563 }
2564
2565 #[test]
2566 fn co_delete_trashed_suppressed_when_not_enumerated() {
2567 let mut manifest = Manifest::new();
2569 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2570 let mut d = desired_arts("a", vec![]);
2571 d.trashed = true;
2572 let sources = vec![SourceStatus {
2573 mode: SourceMode::Mirror,
2574 fully_enumerated: false,
2575 }];
2576 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2577 assert_eq!(plan.deletes(), 0);
2578 assert_eq!(plan.artifact_deletes(), 0);
2579 assert_eq!(plan.skips(), 1);
2580 }
2581
2582 #[test]
2583 fn co_delete_trashed_suppressed_when_preserved() {
2584 let mut manifest = Manifest::new();
2586 let preserved = ManifestEntry {
2587 preserve: true,
2588 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2589 };
2590 manifest.insert("a", preserved);
2591 let mut d = desired_arts("a", vec![]);
2592 d.trashed = true;
2593 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2594 assert_eq!(plan.deletes(), 0);
2595 assert_eq!(plan.artifact_deletes(), 0);
2596 }
2597
2598 #[test]
2601 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2602 let mut manifest = Manifest::new();
2605 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2606 let d = vec![desired_arts(
2607 "a",
2608 vec![text_art(
2609 ArtifactKind::DetailsTxt,
2610 "a.details.txt",
2611 "Title: A\n",
2612 )],
2613 )];
2614 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2615 assert_eq!(plan.artifact_writes(), 1);
2616 assert_eq!(plan.artifact_deletes(), 0);
2617 assert_eq!(
2618 write_artifacts(&plan)[0],
2619 &Action::WriteArtifact {
2620 kind: ArtifactKind::DetailsTxt,
2621 path: "a.details.txt".to_string(),
2622 source_url: String::new(),
2623 hash: content_hash("Title: A\n"),
2624 owner_id: "a".to_string(),
2625 content: Some("Title: A\n".to_string()),
2626 }
2627 );
2628 }
2629
2630 #[test]
2631 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2632 let mut manifest = Manifest::new();
2637 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2638 let body = "[re:rs-suno]\nla la\n";
2639 let d = vec![desired_arts(
2640 "a",
2641 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2642 )];
2643 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2644 assert_eq!(plan.artifact_writes(), 1);
2645 assert_eq!(plan.artifact_deletes(), 0);
2646 assert_eq!(
2647 write_artifacts(&plan)[0],
2648 &Action::WriteArtifact {
2649 kind: ArtifactKind::Lrc,
2650 path: "a.lrc".to_string(),
2651 source_url: String::new(),
2652 hash: content_hash(body),
2653 owner_id: "a".to_string(),
2654 content: Some(body.to_string()),
2655 }
2656 );
2657 }
2658
2659 #[test]
2660 fn text_sidecars_skipped_when_hash_and_path_match() {
2661 let mut manifest = Manifest::new();
2663 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2664 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2665 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2666 manifest.insert("a", e);
2667 let d = vec![desired_arts(
2668 "a",
2669 vec![
2670 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2671 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2672 ],
2673 )];
2674 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2675 assert_eq!(plan.artifact_writes(), 0);
2676 assert_eq!(plan.artifact_deletes(), 0);
2677 }
2678
2679 #[test]
2680 fn details_rewritten_when_content_hash_differs() {
2681 let mut manifest = Manifest::new();
2684 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2685 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2686 manifest.insert("a", e);
2687 let d = vec![desired_arts(
2688 "a",
2689 vec![text_art(
2690 ArtifactKind::DetailsTxt,
2691 "a.details.txt",
2692 "Title: New\n",
2693 )],
2694 )];
2695 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2696 assert_eq!(plan.artifact_writes(), 1);
2697 assert_eq!(plan.artifact_deletes(), 0);
2698 }
2699
2700 #[test]
2701 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2702 let mut manifest = Manifest::new();
2706 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2707 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2708 manifest.insert("a", e);
2709 let d = vec![desired_arts(
2710 "a",
2711 vec![text_art(
2712 ArtifactKind::LyricsTxt,
2713 "a.lyrics.txt",
2714 "new words\n",
2715 )],
2716 )];
2717 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2718 assert_eq!(plan.artifact_writes(), 1);
2720 assert_eq!(plan.retags(), 0);
2721 }
2722
2723 #[test]
2724 fn text_sidecar_relocated_when_path_differs() {
2725 let mut manifest = Manifest::new();
2728 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2729 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2730 manifest.insert("a", e);
2731 let d = vec![desired_arts(
2732 "a",
2733 vec![text_art(
2734 ArtifactKind::DetailsTxt,
2735 "new/a.details.txt",
2736 "Title: A\n",
2737 )],
2738 )];
2739 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2740 assert_eq!(plan.artifact_writes(), 1);
2741 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2742 assert_eq!(path, "new/a.details.txt");
2743 } else {
2744 panic!("expected a WriteArtifact");
2745 }
2746 }
2747
2748 #[test]
2749 fn details_removed_kind_is_deleted_when_feature_off() {
2750 let mut manifest = Manifest::new();
2753 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2754 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2755 manifest.insert("a", e);
2756 let d = vec![desired_arts("a", vec![])];
2757 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2758 assert_eq!(plan.artifact_deletes(), 1);
2759 assert!(plan.actions.contains(&Action::DeleteArtifact {
2760 kind: ArtifactKind::DetailsTxt,
2761 path: "a.details.txt".to_string(),
2762 owner_id: "a".to_string(),
2763 }));
2764 }
2765
2766 #[test]
2767 fn lyrics_removed_kind_is_kept_not_deleted() {
2768 let mut manifest = Manifest::new();
2772 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2773 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2774 manifest.insert("a", e);
2775 let d = vec![desired_arts("a", vec![])];
2776 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2777 assert_eq!(plan.artifact_deletes(), 0);
2778 assert_eq!(plan.deletes(), 0);
2779 }
2780
2781 #[test]
2782 fn lrc_removed_kind_is_kept_not_deleted() {
2783 let mut manifest = Manifest::new();
2786 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2787 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2788 manifest.insert("a", e);
2789 let d = vec![desired_arts("a", vec![])];
2790 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2791 assert_eq!(plan.artifact_deletes(), 0);
2792 assert_eq!(plan.deletes(), 0);
2793 }
2794
2795 #[test]
2796 fn video_mp4_removed_kind_is_kept_not_deleted() {
2797 let mut manifest = Manifest::new();
2801 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2802 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
2803 manifest.insert("a", e);
2804 let d = vec![desired_arts("a", vec![])];
2805 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2806 assert_eq!(plan.artifact_deletes(), 0);
2807 assert_eq!(plan.deletes(), 0);
2808 }
2809
2810 #[test]
2811 fn video_mp4_written_when_manifest_lacks_it() {
2812 let mut manifest = Manifest::new();
2815 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2816 let d = vec![desired_arts(
2817 "a",
2818 vec![art(
2819 ArtifactKind::VideoMp4,
2820 "a/song.mp4",
2821 "https://cdn/a/video.mp4",
2822 "vid-hash",
2823 )],
2824 )];
2825 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2826 assert_eq!(plan.artifact_writes(), 1);
2827 assert_eq!(
2828 write_artifacts(&plan)[0],
2829 &Action::WriteArtifact {
2830 kind: ArtifactKind::VideoMp4,
2831 path: "a/song.mp4".to_string(),
2832 source_url: "https://cdn/a/video.mp4".to_string(),
2833 hash: "vid-hash".to_string(),
2834 owner_id: "a".to_string(),
2835 content: None,
2836 }
2837 );
2838 }
2839
2840 #[test]
2841 fn details_removed_kind_not_deleted_on_incomplete_listing() {
2842 let mut manifest = Manifest::new();
2845 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2846 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2847 manifest.insert("a", e);
2848 let d = vec![desired_arts("a", vec![])];
2849 let sources = vec![SourceStatus {
2850 mode: SourceMode::Mirror,
2851 fully_enumerated: false,
2852 }];
2853 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2854 assert_eq!(plan.artifact_deletes(), 0);
2855 }
2856
2857 #[test]
2858 fn details_removed_kind_not_deleted_when_preserved() {
2859 let mut manifest = Manifest::new();
2862 let mut e = ManifestEntry {
2863 preserve: true,
2864 ..entry("a.flac", AudioFormat::Flac, "m", "art")
2865 };
2866 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2867 manifest.insert("a", e);
2868 let d = vec![desired_arts("a", vec![])];
2869 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2870 assert_eq!(plan.artifact_deletes(), 0);
2871 }
2872
2873 #[test]
2874 fn co_delete_orphan_removes_every_text_sidecar() {
2875 let mut manifest = Manifest::new();
2879 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2880 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2881 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2882 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2883 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2884 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
2885 manifest.insert("gone", e);
2886 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2887 assert_eq!(plan.deletes(), 1);
2888 assert_eq!(plan.artifact_deletes(), 5);
2889 for (kind, path) in [
2890 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2891 (ArtifactKind::DetailsTxt, "gone.details.txt"),
2892 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2893 (ArtifactKind::Lrc, "gone.lrc"),
2894 (ArtifactKind::VideoMp4, "gone/song.mp4"),
2895 ] {
2896 assert!(
2897 plan.actions.contains(&Action::DeleteArtifact {
2898 kind,
2899 path: path.to_string(),
2900 owner_id: "gone".to_string(),
2901 }),
2902 "missing co-delete for {kind:?}"
2903 );
2904 }
2905 }
2906
2907 #[test]
2908 fn co_delete_trashed_removes_every_text_sidecar() {
2909 let mut manifest = Manifest::new();
2911 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2912 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2913 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2914 manifest.insert("a", e);
2915 let mut d = desired_arts("a", vec![]);
2916 d.trashed = true;
2917 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2918 assert_eq!(plan.deletes(), 1);
2919 assert_eq!(plan.artifact_deletes(), 2);
2920 }
2921
2922 #[test]
2923 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2924 let mut manifest = Manifest::new();
2927 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2928 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2929 let d = vec![desired_arts(
2932 "a",
2933 vec![art(
2934 ArtifactKind::CoverJpg,
2935 "shared/cover.jpg",
2936 "https://art/a",
2937 "h2",
2938 )],
2939 )];
2940 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2941 assert_eq!(plan.artifact_writes(), 1);
2942 assert!(!plan.actions.iter().any(
2944 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2945 ));
2946 assert!(plan.actions.contains(&Action::Delete {
2948 path: "b.flac".to_string(),
2949 clip_id: "b".to_string(),
2950 }));
2951 }
2952
2953 #[test]
2954 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2955 let mut manifest = Manifest::new();
2957 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2958 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2959 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2960 assert_eq!(plan.downloads(), 1);
2961 assert!(
2962 !plan
2963 .actions
2964 .iter()
2965 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2966 );
2967 }
2968
2969 #[test]
2970 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2971 let build = |with_art: bool| {
2975 let mut manifest = Manifest::new();
2976 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2977 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2978 manifest.insert(
2979 "trash",
2980 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2981 );
2982 let keep = if with_art {
2983 desired_arts(
2984 "keep",
2985 vec![art(
2986 ArtifactKind::CoverJpg,
2987 "keep/cover.jpg",
2988 "https://art/keep",
2989 "h1",
2990 )],
2991 )
2992 } else {
2993 desired_arts("keep", vec![])
2994 };
2995 let mut trash = desired_arts("trash", vec![]);
2996 trash.trashed = true;
2997 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2998 .iter()
2999 .map(|id| (id.to_string(), present(100)))
3000 .collect();
3001 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3002 };
3003
3004 let with = build(true);
3005 let without = build(false);
3006
3007 let audio = |plan: &Plan| -> Vec<Action> {
3009 plan.actions
3010 .iter()
3011 .filter(|a| {
3012 !matches!(
3013 a,
3014 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3015 )
3016 })
3017 .cloned()
3018 .collect()
3019 };
3020 assert_eq!(audio(&with), audio(&without));
3021 assert_eq!(with.deletes(), without.deletes());
3022 assert_eq!(with.deletes(), 2);
3024 assert_eq!(with.artifact_deletes(), 2);
3028 assert_eq!(with.artifact_writes(), 0);
3029 }
3030
3031 #[test]
3034 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3035 let mut manifest = Manifest::new();
3041 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3042 assert!(!manifest.get("a").unwrap().preserve);
3043
3044 let private = Desired {
3046 private: true,
3047 ..desired_arts("a", vec![])
3048 };
3049 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3050 assert_eq!(plan.artifact_deletes(), 0);
3051
3052 let copy_held = Desired {
3054 modes: vec![SourceMode::Copy],
3055 ..desired_arts("a", vec![])
3056 };
3057 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3058 assert_eq!(plan.artifact_deletes(), 0);
3059 }
3060
3061 #[test]
3062 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3063 let mut manifest = Manifest::new();
3069 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3070 let d = vec![desired_arts(
3071 "a",
3072 vec![art(
3073 ArtifactKind::CoverJpg,
3074 "new/cover.jpg",
3075 "https://art/a",
3076 "h1",
3077 )],
3078 )];
3079 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3080 assert_eq!(plan.artifact_writes(), 1);
3081 assert_eq!(plan.artifact_deletes(), 0);
3082 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3083 assert_eq!(path, "new/cover.jpg");
3084 } else {
3085 panic!("expected a WriteArtifact");
3086 }
3087 }
3088
3089 #[test]
3090 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3091 let mut manifest = Manifest::new();
3095 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3096 let d = vec![desired_arts(
3097 "a",
3098 vec![
3099 art(
3100 ArtifactKind::FolderJpg,
3101 "a/folder.jpg",
3102 "https://art/folder",
3103 "hf",
3104 ),
3105 art(
3106 ArtifactKind::Playlist,
3107 "a/list.m3u",
3108 "https://art/list",
3109 "hp",
3110 ),
3111 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3112 ],
3113 )];
3114 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3115 assert_eq!(plan.artifact_writes(), 1);
3116 let paths: Vec<&str> = plan
3117 .actions
3118 .iter()
3119 .filter_map(|a| match a {
3120 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3121 _ => None,
3122 })
3123 .collect();
3124 assert_eq!(paths, vec!["a/cover.jpg"]);
3125 }
3126
3127 #[test]
3128 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3129 let mut manifest = Manifest::new();
3130 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3131 let d = vec![desired_arts(
3132 "a",
3133 vec![art(
3134 ArtifactKind::FolderWebp,
3135 "a/folder.webp",
3136 "https://art/folder",
3137 "hf",
3138 )],
3139 )];
3140 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3141 assert_eq!(plan.artifact_writes(), 0);
3142 assert_eq!(plan.artifact_deletes(), 0);
3143 }
3144
3145 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3148 Clip {
3149 id: id.to_string(),
3150 title: "Song".to_string(),
3151 image_large_url: image.to_string(),
3152 video_cover_url: video.to_string(),
3153 play_count,
3154 created_at: created_at.to_string(),
3155 ..Default::default()
3156 }
3157 }
3158
3159 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3160 let mut lineage = LineageContext::own_root(&clip);
3161 lineage.root_id = root_id.to_string();
3162 Desired {
3163 clip,
3164 lineage,
3165 path: path.to_string(),
3166 format: AudioFormat::Flac,
3167 meta_hash: "m".to_string(),
3168 art_hash: "a".to_string(),
3169 modes: vec![SourceMode::Mirror],
3170 trashed: false,
3171 private: false,
3172 artifacts: Vec::new(),
3173 }
3174 }
3175
3176 fn stored(path: &str, hash: &str) -> ArtifactState {
3177 ArtifactState {
3178 path: path.to_string(),
3179 hash: hash.to_string(),
3180 }
3181 }
3182
3183 #[test]
3184 fn folder_jpg_source_is_most_played() {
3185 let members = vec![
3186 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3187 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3188 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3189 ];
3190 let albums = album_desired(&members, false);
3191 assert_eq!(albums.len(), 1);
3192 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3193 assert_eq!(jpg.hash, art_url_hash("art-b"));
3195 assert_eq!(jpg.source_url, "art-b");
3196 assert_eq!(jpg.path, "c/al/folder.jpg");
3197 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3198 }
3199
3200 #[test]
3201 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3202 let by_time = vec![
3204 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3205 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3206 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3207 ];
3208 let jpg = album_desired(&by_time, false)[0]
3209 .folder_jpg
3210 .clone()
3211 .unwrap();
3212 assert_eq!(jpg.source_url, "art-y");
3213
3214 let by_id = vec![
3216 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3217 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3218 ];
3219 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3220 assert_eq!(jpg.source_url, "art-g");
3221 }
3222
3223 #[test]
3224 fn folder_webp_source_is_first_created_animated() {
3225 let members = vec![
3226 album_member(
3227 album_clip("a", 9, "t2", "art-a", "vid-a"),
3228 "root",
3229 "c/al/a.flac",
3230 ),
3231 album_member(
3232 album_clip("b", 1, "t0", "art-b", "vid-b"),
3233 "root",
3234 "c/al/b.flac",
3235 ),
3236 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3237 ];
3238 let webp = album_desired(&members, true)[0]
3239 .folder_webp
3240 .clone()
3241 .unwrap();
3242 assert_eq!(webp.source_url, "vid-b");
3244 assert_eq!(webp.hash, art_url_hash("vid-b"));
3245 assert_eq!(webp.path, "c/al/cover.webp");
3246 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3247 }
3248
3249 #[test]
3250 fn animated_covers_off_yields_no_folder_webp() {
3251 let members = vec![album_member(
3252 album_clip("a", 1, "t0", "art-a", "vid-a"),
3253 "root",
3254 "c/al/a.flac",
3255 )];
3256 let off = album_desired(&members, false);
3257 assert!(off[0].folder_webp.is_none());
3258 let on = album_desired(&members, true);
3259 assert!(on[0].folder_webp.is_some());
3260 }
3261
3262 #[test]
3263 fn album_with_no_art_yields_no_folder_jpg() {
3264 let members = vec![album_member(
3265 album_clip("a", 3, "t0", "", ""),
3266 "root",
3267 "c/al/a.flac",
3268 )];
3269 let albums = album_desired(&members, true);
3270 assert!(albums[0].folder_jpg.is_none());
3271 assert!(albums[0].folder_webp.is_none());
3272 }
3273
3274 #[test]
3275 fn album_desired_groups_by_root_id() {
3276 let members = vec![
3277 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3278 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3279 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3280 ];
3281 let albums = album_desired(&members, false);
3282 assert_eq!(albums.len(), 2);
3283 assert_eq!(albums[0].root_id, "r1");
3284 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3285 assert_eq!(
3286 albums[0].folder_jpg.as_ref().unwrap().path,
3287 "c/al1/folder.jpg"
3288 );
3289 assert_eq!(albums[1].root_id, "r2");
3290 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3291 assert_eq!(
3292 albums[1].folder_jpg.as_ref().unwrap().path,
3293 "c/al2/folder.jpg"
3294 );
3295 }
3296
3297 #[test]
3298 fn plan_writes_folder_art_when_store_empty() {
3299 let members = vec![album_member(
3300 album_clip("a", 1, "t0", "art-a", "vid-a"),
3301 "root",
3302 "c/al/a.flac",
3303 )];
3304 let desired = album_desired(&members, true);
3305 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3306 assert_eq!(
3307 actions,
3308 vec![
3309 Action::WriteArtifact {
3310 kind: ArtifactKind::FolderJpg,
3311 path: "c/al/folder.jpg".to_string(),
3312 source_url: "art-a".to_string(),
3313 hash: art_url_hash("art-a"),
3314 owner_id: "root".to_string(),
3315 content: None,
3316 },
3317 Action::WriteArtifact {
3318 kind: ArtifactKind::FolderWebp,
3319 path: "c/al/cover.webp".to_string(),
3320 source_url: "vid-a".to_string(),
3321 hash: art_url_hash("vid-a"),
3322 owner_id: "root".to_string(),
3323 content: None,
3324 },
3325 ]
3326 );
3327 }
3328
3329 #[test]
3330 fn plan_skips_when_hash_and_path_match() {
3331 let members = vec![album_member(
3332 album_clip("a", 1, "t0", "art-a", ""),
3333 "root",
3334 "c/al/a.flac",
3335 )];
3336 let desired = album_desired(&members, false);
3337 let mut albums = BTreeMap::new();
3338 albums.insert(
3339 "root".to_string(),
3340 AlbumArt {
3341 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3342 folder_webp: None,
3343 },
3344 );
3345 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3346 }
3347
3348 #[test]
3349 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3350 let members = vec![album_member(
3351 album_clip("a", 1, "t0", "art-a", ""),
3352 "root",
3353 "c/al/a.flac",
3354 )];
3355 let desired = album_desired(&members, false);
3356 let mut albums = BTreeMap::new();
3357 albums.insert(
3358 "root".to_string(),
3359 AlbumArt {
3360 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3361 folder_webp: None,
3362 },
3363 );
3364 let actions = plan_album_artifacts(&desired, &albums, true);
3365 assert_eq!(actions.len(), 1);
3366 assert!(matches!(
3367 &actions[0],
3368 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3369 ));
3370 }
3371
3372 #[test]
3373 fn h1_most_played_flip_to_same_art_writes_nothing() {
3374 let run1 = vec![
3376 album_member(
3377 album_clip("a", 9, "t0", "same-art", ""),
3378 "root",
3379 "c/al/a.flac",
3380 ),
3381 album_member(
3382 album_clip("b", 1, "t1", "same-art", ""),
3383 "root",
3384 "c/al/b.flac",
3385 ),
3386 ];
3387 let desired1 = album_desired(&run1, false);
3388 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3389 assert_eq!(write1.len(), 1);
3390
3391 let mut albums = BTreeMap::new();
3393 if let Action::WriteArtifact {
3394 path,
3395 hash,
3396 owner_id,
3397 ..
3398 } = &write1[0]
3399 {
3400 albums.insert(
3401 owner_id.clone(),
3402 AlbumArt {
3403 folder_jpg: Some(stored(path, hash)),
3404 folder_webp: None,
3405 },
3406 );
3407 }
3408
3409 let run2 = vec![
3411 album_member(
3412 album_clip("a", 1, "t0", "same-art", ""),
3413 "root",
3414 "c/al/a.flac",
3415 ),
3416 album_member(
3417 album_clip("b", 9, "t1", "same-art", ""),
3418 "root",
3419 "c/al/b.flac",
3420 ),
3421 ];
3422 let desired2 = album_desired(&run2, false);
3423 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3425 }
3426
3427 #[test]
3428 fn h1_flip_to_different_art_writes_exactly_one() {
3429 let mut albums = BTreeMap::new();
3430 albums.insert(
3431 "root".to_string(),
3432 AlbumArt {
3433 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3434 folder_webp: None,
3435 },
3436 );
3437 let members = vec![
3439 album_member(
3440 album_clip("a", 1, "t0", "old-art", ""),
3441 "root",
3442 "c/al/a.flac",
3443 ),
3444 album_member(
3445 album_clip("b", 9, "t1", "new-art", ""),
3446 "root",
3447 "c/al/b.flac",
3448 ),
3449 ];
3450 let desired = album_desired(&members, false);
3451 let actions = plan_album_artifacts(&desired, &albums, true);
3452 assert_eq!(actions.len(), 1);
3453 assert!(matches!(
3454 &actions[0],
3455 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3456 ));
3457 }
3458
3459 #[test]
3460 fn one_write_per_album_regardless_of_clip_count() {
3461 let members: Vec<Desired> = (0..200)
3462 .map(|i| {
3463 album_member(
3464 album_clip(
3465 &format!("clip-{i:03}"),
3466 i as u64,
3467 &format!("t{i:03}"),
3468 &format!("art-{i:03}"),
3469 &format!("vid-{i:03}"),
3470 ),
3471 "root",
3472 &format!("c/al/clip-{i:03}.flac"),
3473 )
3474 })
3475 .collect();
3476 let desired = album_desired(&members, true);
3477 assert_eq!(desired.len(), 1);
3478 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3479 assert_eq!(actions.len(), 2);
3481 assert_eq!(
3482 actions
3483 .iter()
3484 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3485 .count(),
3486 2
3487 );
3488 }
3489
3490 #[test]
3491 fn emptied_album_deletes_only_when_can_delete() {
3492 let mut albums = BTreeMap::new();
3493 albums.insert(
3494 "root".to_string(),
3495 AlbumArt {
3496 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3497 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3498 },
3499 );
3500 let desired: Vec<AlbumDesired> = Vec::new();
3502
3503 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3505
3506 let actions = plan_album_artifacts(&desired, &albums, true);
3508 assert_eq!(
3509 actions,
3510 vec![
3511 Action::DeleteArtifact {
3512 kind: ArtifactKind::FolderJpg,
3513 path: "c/al/folder.jpg".to_string(),
3514 owner_id: "root".to_string(),
3515 },
3516 Action::DeleteArtifact {
3517 kind: ArtifactKind::FolderWebp,
3518 path: "c/al/cover.webp".to_string(),
3519 owner_id: "root".to_string(),
3520 },
3521 ]
3522 );
3523 }
3524
3525 #[test]
3526 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3527 let mut albums = BTreeMap::new();
3528 albums.insert(
3529 "root".to_string(),
3530 AlbumArt {
3531 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3532 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3533 },
3534 );
3535 let members = vec![album_member(
3538 album_clip("a", 1, "t0", "art-a", "vid-a"),
3539 "root",
3540 "c/al/a.flac",
3541 )];
3542 let desired = album_desired(&members, false);
3543
3544 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3545
3546 let actions = plan_album_artifacts(&desired, &albums, true);
3547 assert_eq!(
3548 actions,
3549 vec![Action::DeleteArtifact {
3550 kind: ArtifactKind::FolderWebp,
3551 path: "c/al/cover.webp".to_string(),
3552 owner_id: "root".to_string(),
3553 }]
3554 );
3555 }
3556
3557 #[test]
3558 fn plan_album_artifacts_is_deterministically_ordered() {
3559 let members = vec![
3560 album_member(
3561 album_clip("a", 1, "t0", "art-a", "vid-a"),
3562 "r2",
3563 "c/al2/a.flac",
3564 ),
3565 album_member(
3566 album_clip("b", 1, "t0", "art-b", "vid-b"),
3567 "r1",
3568 "c/al1/b.flac",
3569 ),
3570 ];
3571 let desired = album_desired(&members, true);
3572 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3573 let keys: Vec<(&str, ArtifactKind)> = actions
3574 .iter()
3575 .map(|a| match a {
3576 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3577 _ => unreachable!(),
3578 })
3579 .collect();
3580 assert_eq!(
3581 keys,
3582 vec![
3583 ("r1", ArtifactKind::FolderJpg),
3584 ("r1", ArtifactKind::FolderWebp),
3585 ("r2", ArtifactKind::FolderJpg),
3586 ("r2", ArtifactKind::FolderWebp),
3587 ]
3588 );
3589 }
3590
3591 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3594 PlaylistDesired {
3595 id: id.to_owned(),
3596 name: name.to_owned(),
3597 path: path.to_owned(),
3598 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3599 hash: hash.to_owned(),
3600 }
3601 }
3602
3603 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3604 PlaylistState {
3605 name: name.to_owned(),
3606 path: path.to_owned(),
3607 hash: hash.to_owned(),
3608 }
3609 }
3610
3611 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3612 entries
3613 .iter()
3614 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3615 .collect()
3616 }
3617
3618 #[test]
3619 fn playlist_write_emitted_for_a_new_playlist() {
3620 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3621 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3622 assert_eq!(
3623 actions,
3624 vec![Action::WriteArtifact {
3625 kind: ArtifactKind::Playlist,
3626 path: "Road Trip.m3u8".to_owned(),
3627 source_url: String::new(),
3628 hash: "h1".to_owned(),
3629 owner_id: "pl1".to_owned(),
3630 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3631 }]
3632 );
3633 }
3634
3635 #[test]
3636 fn playlist_write_emitted_when_hash_changes() {
3637 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3640 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3641 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3642 assert_eq!(actions.len(), 1);
3643 assert!(matches!(
3644 &actions[0],
3645 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3646 ));
3647 }
3648
3649 #[test]
3650 fn playlist_unchanged_is_idempotent() {
3651 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3652 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3653 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3654 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3655 }
3656
3657 #[test]
3658 fn playlist_rename_writes_new_and_deletes_old_path() {
3659 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3662 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3663 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3664 assert_eq!(
3665 actions,
3666 vec![
3667 Action::WriteArtifact {
3668 kind: ArtifactKind::Playlist,
3669 path: "Summer.m3u8".to_owned(),
3670 source_url: String::new(),
3671 hash: "h2".to_owned(),
3672 owner_id: "pl1".to_owned(),
3673 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3674 },
3675 Action::DeleteArtifact {
3676 kind: ArtifactKind::Playlist,
3677 path: "Spring.m3u8".to_owned(),
3678 owner_id: "pl1".to_owned(),
3679 },
3680 ]
3681 );
3682 }
3683
3684 #[test]
3685 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3686 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3689 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3690 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3691 assert_eq!(actions.len(), 1);
3692 assert!(matches!(
3693 &actions[0],
3694 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3695 ));
3696 assert!(
3697 !actions
3698 .iter()
3699 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3700 "old path must not be deleted when deletes are disallowed"
3701 );
3702 }
3703
3704 #[test]
3705 fn playlist_stale_removed_only_under_full_gate() {
3706 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3709
3710 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3711 assert_eq!(
3712 deleted,
3713 vec![Action::DeleteArtifact {
3714 kind: ArtifactKind::Playlist,
3715 path: "Gone.m3u8".to_owned(),
3716 owner_id: "gone".to_owned(),
3717 }]
3718 );
3719
3720 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3722 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3723 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3724 }
3725
3726 #[test]
3727 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3728 let stored = pl_store(&[
3733 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3734 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3735 ]);
3736 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3737 assert!(
3738 actions.is_empty(),
3739 "a failed playlist listing must plan zero actions, got {actions:?}"
3740 );
3741 }
3742
3743 #[test]
3744 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3745 let stored = pl_store(&[
3750 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3751 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3752 ]);
3753
3754 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3756
3757 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3760 assert_eq!(
3761 wiped
3762 .iter()
3763 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3764 .count(),
3765 2
3766 );
3767 }
3768
3769 #[test]
3770 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3771 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3776 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3777 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3778 assert_eq!(actions.len(), 1);
3780 assert!(matches!(
3781 &actions[0],
3782 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3783 ));
3784 assert!(
3785 !actions.iter().any(|a| match a {
3786 Action::WriteArtifact { owner_id, .. }
3787 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3788 _ => false,
3789 }),
3790 "a protected (failed-member) playlist must have no action"
3791 );
3792 }
3793
3794 #[test]
3795 fn playlist_rename_collision_downgrades_the_delete() {
3796 let desired = vec![
3802 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3803 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3804 ];
3805 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3806 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3807 let write_paths: BTreeSet<&str> = actions
3809 .iter()
3810 .filter_map(|a| match a {
3811 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3812 _ => None,
3813 })
3814 .collect();
3815 for a in &actions {
3816 if let Action::DeleteArtifact { path, .. } = a {
3817 assert!(
3818 !write_paths.contains(path.as_str()),
3819 "a playlist delete aliases a write target: {path}"
3820 );
3821 }
3822 }
3823 }
3824}
3825
3826#[cfg(test)]
3839mod proptests {
3840 use super::*;
3841 use proptest::collection::{btree_map, hash_map, vec};
3842 use proptest::prelude::*;
3843 use std::collections::BTreeSet;
3844
3845 type DesiredFields = (
3846 String,
3847 AudioFormat,
3848 String,
3849 String,
3850 Vec<SourceMode>,
3851 bool,
3852 bool,
3853 );
3854
3855 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3856 prop_oneof![
3857 Just(AudioFormat::Mp3),
3858 Just(AudioFormat::Flac),
3859 Just(AudioFormat::Wav),
3860 ]
3861 }
3862
3863 fn source_mode() -> impl Strategy<Value = SourceMode> {
3864 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3865 }
3866
3867 fn clip_id() -> impl Strategy<Value = String> {
3870 (0u8..8).prop_map(|n| format!("c{n}"))
3871 }
3872
3873 fn small_path() -> impl Strategy<Value = String> {
3874 (0u8..6).prop_map(|n| format!("path{n}"))
3875 }
3876
3877 fn manifest_path() -> impl Strategy<Value = String> {
3880 prop_oneof![
3881 1 => Just(String::new()),
3882 6 => small_path(),
3883 ]
3884 }
3885
3886 fn small_hash() -> impl Strategy<Value = String> {
3887 (0u8..4).prop_map(|n| format!("h{n}"))
3888 }
3889
3890 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3891 (
3892 manifest_path(),
3893 audio_format(),
3894 small_hash(),
3895 small_hash(),
3896 0u64..4,
3897 any::<bool>(),
3898 )
3899 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3900 ManifestEntry {
3901 path,
3902 format,
3903 meta_hash,
3904 art_hash,
3905 size,
3906 preserve,
3907 ..Default::default()
3908 }
3909 })
3910 }
3911
3912 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3913 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3914 }
3915
3916 fn local_file() -> impl Strategy<Value = LocalFile> {
3917 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3918 }
3919
3920 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3921 hash_map(clip_id(), local_file(), 0..8)
3922 }
3923
3924 fn source_status() -> impl Strategy<Value = SourceStatus> {
3925 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3926 mode,
3927 fully_enumerated,
3928 })
3929 }
3930
3931 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3932 vec(source_status(), 0..5)
3933 }
3934
3935 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3936 vec(
3937 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3938 mode: SourceMode::Copy,
3939 fully_enumerated,
3940 }),
3941 1..5,
3942 )
3943 }
3944
3945 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3946 (
3947 small_path(),
3948 audio_format(),
3949 small_hash(),
3950 small_hash(),
3951 vec(source_mode(), 1..3),
3952 any::<bool>(),
3953 any::<bool>(),
3954 )
3955 }
3956
3957 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3958 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3959 let clip = Clip {
3960 id,
3961 title: "t".to_string(),
3962 ..Default::default()
3963 };
3964 Desired {
3965 lineage: LineageContext::own_root(&clip),
3966 clip,
3967 path,
3968 format,
3969 meta_hash,
3970 art_hash,
3971 modes,
3972 trashed,
3973 private,
3974 artifacts: Vec::new(),
3975 }
3976 }
3977
3978 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3981 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3982 items
3983 .into_iter()
3984 .map(|(id, fields)| build_desired(id, fields))
3985 .collect()
3986 })
3987 }
3988
3989 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3990 desired.iter().map(|d| d.clip.id.as_str()).collect()
3991 }
3992
3993 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3996 desired
3997 .iter()
3998 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3999 .map(|d| d.clip.id.as_str())
4000 .collect()
4001 }
4002
4003 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
4006 desired
4007 .iter()
4008 .filter(|d| !d.trashed)
4009 .map(|d| d.clip.id.as_str())
4010 .collect()
4011 }
4012
4013 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
4014 plan.actions
4015 .iter()
4016 .filter_map(|a| match a {
4017 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
4018 _ => None,
4019 })
4020 .collect()
4021 }
4022
4023 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
4024 plan.actions
4025 .iter()
4026 .filter_map(|a| match a {
4027 Action::Download { path, .. } | Action::Reformat { path, .. } => {
4028 Some(path.as_str())
4029 }
4030 Action::Rename { to, .. } => Some(to.as_str()),
4031 _ => None,
4032 })
4033 .collect()
4034 }
4035
4036 proptest! {
4037 #![proptest_config(ProptestConfig {
4038 cases: 256,
4039 failure_persistence: None,
4040 ..ProptestConfig::default()
4041 })]
4042
4043 #[test]
4046 fn inv1_desired_clip_deleted_only_when_fully_trashed(
4047 manifest in manifest_strategy(),
4048 desired in desired_strategy(),
4049 local in local_strategy(),
4050 sources in sources_strategy(),
4051 ) {
4052 let plan = reconcile(&manifest, &desired, &local, &sources);
4053 let present = desired_ids(&desired);
4054 let live = non_trashed_ids(&desired);
4055 for id in delete_clip_ids(&plan) {
4056 prop_assert!(
4057 !(present.contains(id) && live.contains(id)),
4058 "deleted a desired clip with a non-trashed duplicate: {id}"
4059 );
4060 }
4061 }
4062
4063 #[test]
4067 fn inv2_no_delete_when_any_mirror_unenumerated(
4068 manifest in manifest_strategy(),
4069 desired in desired_strategy(),
4070 local in local_strategy(),
4071 mut sources in sources_strategy(),
4072 ) {
4073 sources.push(SourceStatus {
4074 mode: SourceMode::Mirror,
4075 fully_enumerated: false,
4076 });
4077 let plan = reconcile(&manifest, &desired, &local, &sources);
4078 prop_assert_eq!(plan.deletes(), 0);
4079 }
4080
4081 #[test]
4083 fn inv3_all_copy_sources_means_no_deletes(
4084 manifest in manifest_strategy(),
4085 desired in desired_strategy(),
4086 local in local_strategy(),
4087 sources in copy_sources_strategy(),
4088 ) {
4089 let plan = reconcile(&manifest, &desired, &local, &sources);
4090 prop_assert_eq!(plan.deletes(), 0);
4091 }
4092
4093 #[test]
4096 fn inv4_plan_is_deterministic(
4097 manifest in manifest_strategy(),
4098 desired in desired_strategy(),
4099 local in local_strategy(),
4100 sources in sources_strategy(),
4101 ) {
4102 let plan = reconcile(&manifest, &desired, &local, &sources);
4103
4104 let again = reconcile(&manifest, &desired, &local, &sources);
4105 prop_assert_eq!(&plan, &again);
4106
4107 let mut desired_rev = desired.clone();
4108 desired_rev.reverse();
4109 let mut sources_rev = sources.clone();
4110 sources_rev.reverse();
4111 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
4112 prop_assert_eq!(&plan, &shuffled);
4113 }
4114
4115 #[test]
4117 fn inv5_every_delete_is_in_the_manifest(
4118 manifest in manifest_strategy(),
4119 desired in desired_strategy(),
4120 local in local_strategy(),
4121 sources in sources_strategy(),
4122 ) {
4123 let plan = reconcile(&manifest, &desired, &local, &sources);
4124 for id in delete_clip_ids(&plan) {
4125 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4126 }
4127 }
4128
4129 #[test]
4132 fn inv6_never_deletes_protected_clip(
4133 manifest in manifest_strategy(),
4134 desired in desired_strategy(),
4135 local in local_strategy(),
4136 sources in sources_strategy(),
4137 ) {
4138 let plan = reconcile(&manifest, &desired, &local, &sources);
4139 let protected = protected_ids(&desired);
4140 for id in delete_clip_ids(&plan) {
4141 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4142 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4143 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4144 }
4145 }
4146
4147 #[test]
4150 fn inv7_no_delete_unless_deletion_allowed(
4151 manifest in manifest_strategy(),
4152 desired in desired_strategy(),
4153 local in local_strategy(),
4154 sources in sources_strategy(),
4155 ) {
4156 let plan = reconcile(&manifest, &desired, &local, &sources);
4157 if !deletion_allowed(&sources) {
4158 prop_assert_eq!(plan.deletes(), 0);
4159 }
4160 }
4161
4162 #[test]
4164 fn inv8_at_most_one_delete_per_clip(
4165 manifest in manifest_strategy(),
4166 desired in desired_strategy(),
4167 local in local_strategy(),
4168 sources in sources_strategy(),
4169 ) {
4170 let plan = reconcile(&manifest, &desired, &local, &sources);
4171 let ids = delete_clip_ids(&plan);
4172 let unique: BTreeSet<&str> = ids.iter().copied().collect();
4173 prop_assert_eq!(ids.len(), unique.len());
4174 }
4175
4176 #[test]
4178 fn inv9_no_delete_with_empty_path(
4179 manifest in manifest_strategy(),
4180 desired in desired_strategy(),
4181 local in local_strategy(),
4182 sources in sources_strategy(),
4183 ) {
4184 let plan = reconcile(&manifest, &desired, &local, &sources);
4185 for action in &plan.actions {
4186 if let Action::Delete { path, .. } = action {
4187 prop_assert!(!path.is_empty(), "delete with an empty path");
4188 }
4189 }
4190 }
4191
4192 #[test]
4195 fn inv10_no_delete_aliases_a_write_target(
4196 manifest in manifest_strategy(),
4197 desired in desired_strategy(),
4198 local in local_strategy(),
4199 sources in sources_strategy(),
4200 ) {
4201 let plan = reconcile(&manifest, &desired, &local, &sources);
4202 let targets = write_target_paths(&plan);
4203 for action in &plan.actions {
4204 if let Action::Delete { path, .. } = action {
4205 prop_assert!(
4206 !targets.contains(path.as_str()),
4207 "delete path {path} aliases a write target"
4208 );
4209 }
4210 }
4211 }
4212 }
4213}