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 format_change_reformats() {
1415 let mut manifest = Manifest::new();
1416 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1417 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1418 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1419 assert_eq!(
1420 plan.actions,
1421 vec![Action::Reformat {
1422 clip: clip("a"),
1423 path: "a.mp3".to_string(),
1424 from_path: "a.flac".to_string(),
1425 from: AudioFormat::Flac,
1426 to: AudioFormat::Mp3,
1427 }]
1428 );
1429 }
1430
1431 #[test]
1432 fn format_change_takes_precedence_over_rename_and_retag() {
1433 let mut manifest = Manifest::new();
1436 manifest.insert(
1437 "a",
1438 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1439 );
1440 let d = vec![desired(
1441 "a",
1442 "new/a.mp3",
1443 AudioFormat::Mp3,
1444 "new",
1445 "new-art",
1446 )];
1447 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1448 assert_eq!(plan.reformats(), 1);
1449 assert_eq!(plan.renames(), 0);
1450 assert_eq!(plan.retags(), 0);
1451 }
1452
1453 #[test]
1456 fn zero_length_file_downloads_even_when_hashes_match() {
1457 let mut manifest = Manifest::new();
1458 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1459 let local: HashMap<String, LocalFile> = [(
1460 "a".to_string(),
1461 LocalFile {
1462 exists: true,
1463 size: 0,
1464 },
1465 )]
1466 .into_iter()
1467 .collect();
1468 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1469 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1470 assert_eq!(plan.downloads(), 1);
1471 assert_eq!(plan.skips(), 0);
1472 }
1473
1474 #[test]
1475 fn missing_file_downloads_even_when_hashes_match() {
1476 let mut manifest = Manifest::new();
1477 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1478 let local: HashMap<String, LocalFile> = [(
1479 "a".to_string(),
1480 LocalFile {
1481 exists: false,
1482 size: 0,
1483 },
1484 )]
1485 .into_iter()
1486 .collect();
1487 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1488 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1489 assert_eq!(plan.downloads(), 1);
1490 }
1491
1492 #[test]
1493 fn absent_local_probe_treated_as_missing() {
1494 let mut manifest = Manifest::new();
1496 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1497 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1498 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1499 assert_eq!(plan.downloads(), 1);
1500 }
1501
1502 #[test]
1503 fn missing_file_download_wins_over_format_difference() {
1504 let mut manifest = Manifest::new();
1507 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1508 let local: HashMap<String, LocalFile> = [(
1509 "a".to_string(),
1510 LocalFile {
1511 exists: false,
1512 size: 0,
1513 },
1514 )]
1515 .into_iter()
1516 .collect();
1517 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1518 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1519 assert_eq!(plan.downloads(), 1);
1520 assert_eq!(plan.reformats(), 0);
1521 }
1522
1523 #[test]
1526 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1527 let mut trashed = clip("a");
1532 trashed.status = "complete".to_string();
1533 trashed.is_trashed = true;
1534 assert!(crate::is_downloadable(&trashed));
1535
1536 let mut manifest = Manifest::new();
1537 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1538 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1539 d.clip = trashed;
1540 d.trashed = true;
1541 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1542 assert_eq!(
1543 plan.actions,
1544 vec![Action::Delete {
1545 path: "a.flac".to_string(),
1546 clip_id: "a".to_string(),
1547 }]
1548 );
1549 }
1550
1551 #[test]
1552 fn trashed_clip_deletes_local_file() {
1553 let mut manifest = Manifest::new();
1554 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1555 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1556 d.trashed = true;
1557 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1558 assert_eq!(
1559 plan.actions,
1560 vec![Action::Delete {
1561 path: "a.flac".to_string(),
1562 clip_id: "a".to_string(),
1563 }]
1564 );
1565 }
1566
1567 #[test]
1568 fn trashed_clip_not_in_manifest_skips() {
1569 let manifest = Manifest::new();
1571 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1572 d.trashed = true;
1573 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1574 assert_eq!(
1575 plan.actions,
1576 vec![Action::Skip {
1577 clip_id: "a".to_string()
1578 }]
1579 );
1580 }
1581
1582 #[test]
1583 fn private_clip_is_kept() {
1584 let mut manifest = Manifest::new();
1585 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1586 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1587 d.private = true;
1588 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1589 assert_eq!(
1590 plan.actions,
1591 vec![Action::Skip {
1592 clip_id: "a".to_string()
1593 }]
1594 );
1595 }
1596
1597 #[test]
1598 fn private_beats_trashed_never_deletes() {
1599 let mut manifest = Manifest::new();
1601 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1602 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1603 d.trashed = true;
1604 d.private = true;
1605 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1606 assert_eq!(plan.deletes(), 0);
1607 assert_eq!(plan.skips(), 1);
1608 }
1609
1610 #[test]
1611 fn copy_held_trashed_clip_is_not_deleted() {
1612 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.modes = vec![SourceMode::Copy];
1618 d.trashed = true;
1619 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1620 assert_eq!(plan.deletes(), 0);
1621 assert_eq!(
1622 plan.actions,
1623 vec![Action::Skip {
1624 clip_id: "a".to_string()
1625 }]
1626 );
1627 }
1628
1629 #[test]
1632 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1633 let mut manifest = Manifest::new();
1634 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1635 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1636 assert_eq!(
1637 plan.actions,
1638 vec![Action::Delete {
1639 path: "gone.flac".to_string(),
1640 clip_id: "gone".to_string(),
1641 }]
1642 );
1643 }
1644
1645 #[test]
1646 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1647 let mut manifest = Manifest::new();
1648 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1649 let sources = vec![
1650 SourceStatus {
1651 mode: SourceMode::Mirror,
1652 fully_enumerated: true,
1653 },
1654 SourceStatus {
1655 mode: SourceMode::Mirror,
1656 fully_enumerated: false,
1657 },
1658 ];
1659 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1660 assert_eq!(plan.deletes(), 0);
1661 assert_eq!(
1662 plan.actions,
1663 vec![Action::Skip {
1664 clip_id: "gone".to_string()
1665 }]
1666 );
1667 }
1668
1669 #[test]
1670 fn empty_listing_cannot_cause_deletion() {
1671 let mut manifest = Manifest::new();
1674 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1675 let sources = vec![SourceStatus {
1676 mode: SourceMode::Mirror,
1677 fully_enumerated: false,
1678 }];
1679 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1680 assert_eq!(plan.deletes(), 0);
1681 assert_eq!(plan.skips(), 1);
1682 }
1683
1684 #[test]
1685 fn no_mirror_sources_means_no_deletion() {
1686 let mut manifest = Manifest::new();
1688 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1689 let copy_only = vec![SourceStatus {
1690 mode: SourceMode::Copy,
1691 fully_enumerated: true,
1692 }];
1693 assert_eq!(
1694 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1695 0
1696 );
1697 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1698 }
1699
1700 #[test]
1701 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1702 let mut manifest = Manifest::new();
1703 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1704 let sources = vec![
1705 SourceStatus {
1706 mode: SourceMode::Copy,
1707 fully_enumerated: true,
1708 },
1709 SourceStatus {
1710 mode: SourceMode::Mirror,
1711 fully_enumerated: false,
1712 },
1713 ];
1714 assert_eq!(
1715 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1716 0
1717 );
1718 }
1719
1720 #[test]
1721 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1722 let mut manifest = Manifest::new();
1726 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1727 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1728 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1729 held.modes = vec![SourceMode::Copy];
1730 let local: HashMap<String, LocalFile> = [
1731 ("keep".to_string(), present(100)),
1732 ("gone".to_string(), present(100)),
1733 ]
1734 .into_iter()
1735 .collect();
1736 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1737 assert!(plan.actions.contains(&Action::Skip {
1738 clip_id: "keep".to_string()
1739 }));
1740 assert!(plan.actions.contains(&Action::Delete {
1741 path: "gone.flac".to_string(),
1742 clip_id: "gone".to_string(),
1743 }));
1744 assert!(
1746 !plan
1747 .actions
1748 .iter()
1749 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1750 );
1751 }
1752
1753 #[test]
1756 fn orphan_with_preserve_marker_is_kept() {
1757 let mut manifest = Manifest::new();
1760 manifest.insert(
1761 "gone",
1762 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1763 );
1764 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1765 assert_eq!(plan.deletes(), 0);
1766 assert_eq!(
1767 plan.actions,
1768 vec![Action::Skip {
1769 clip_id: "gone".to_string()
1770 }]
1771 );
1772 }
1773
1774 #[test]
1775 fn trashed_clip_with_preserve_marker_is_kept() {
1776 let mut manifest = Manifest::new();
1779 manifest.insert(
1780 "a",
1781 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1782 );
1783 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1784 d.trashed = true;
1785 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1786 assert_eq!(plan.deletes(), 0);
1787 assert_eq!(plan.skips(), 1);
1788 }
1789
1790 #[test]
1793 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1794 let mut manifest = Manifest::new();
1796 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1797 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1798 d.trashed = true;
1799 let sources = vec![SourceStatus {
1800 mode: SourceMode::Mirror,
1801 fully_enumerated: false,
1802 }];
1803 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1804 assert_eq!(plan.deletes(), 0);
1805 assert_eq!(plan.skips(), 1);
1806 }
1807
1808 #[test]
1809 fn trashed_clip_kept_when_sources_empty() {
1810 let mut manifest = Manifest::new();
1813 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1814 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1815 d.trashed = true;
1816 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1817 assert_eq!(plan.deletes(), 0);
1818 assert_eq!(plan.skips(), 1);
1819 }
1820
1821 #[test]
1822 fn failed_copy_listing_suppresses_orphan_deletion() {
1823 let mut manifest = Manifest::new();
1826 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1827 let sources = vec![
1828 SourceStatus {
1829 mode: SourceMode::Mirror,
1830 fully_enumerated: true,
1831 },
1832 SourceStatus {
1833 mode: SourceMode::Copy,
1834 fully_enumerated: false,
1835 },
1836 ];
1837 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1838 assert_eq!(plan.deletes(), 0);
1839 }
1840
1841 #[test]
1842 fn failed_copy_listing_suppresses_trashed_deletion() {
1843 let mut manifest = Manifest::new();
1844 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1845 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1846 d.trashed = true;
1847 let sources = vec![
1848 SourceStatus {
1849 mode: SourceMode::Mirror,
1850 fully_enumerated: true,
1851 },
1852 SourceStatus {
1853 mode: SourceMode::Copy,
1854 fully_enumerated: false,
1855 },
1856 ];
1857 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1858 assert_eq!(plan.deletes(), 0);
1859 assert_eq!(plan.skips(), 1);
1860 }
1861
1862 #[test]
1863 fn empty_path_entry_never_deletes() {
1864 let mut manifest = Manifest::new();
1867 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1868 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1869 assert_eq!(plan.deletes(), 0);
1870 assert_eq!(
1871 plan.actions,
1872 vec![Action::Skip {
1873 clip_id: "gone".to_string()
1874 }]
1875 );
1876 }
1877
1878 #[test]
1881 fn delete_suppressed_when_path_aliases_rename_target() {
1882 let mut manifest = Manifest::new();
1885 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1886 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1887 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1888 let local: HashMap<String, LocalFile> = [
1889 ("a".to_string(), present(100)),
1890 ("b".to_string(), present(100)),
1891 ]
1892 .into_iter()
1893 .collect();
1894 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1895 assert!(plan.actions.contains(&Action::Rename {
1896 from: "old/a.flac".to_string(),
1897 to: "new/a.flac".to_string(),
1898 }));
1899 assert!(
1901 !plan
1902 .actions
1903 .iter()
1904 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1905 );
1906 assert!(plan.actions.contains(&Action::Skip {
1907 clip_id: "b".to_string()
1908 }));
1909 }
1910
1911 #[test]
1912 fn delete_suppressed_when_path_aliases_download_target() {
1913 let mut manifest = Manifest::new();
1915 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1916 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1917 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1918 assert!(
1919 !plan
1920 .actions
1921 .iter()
1922 .any(|a| matches!(a, Action::Delete { .. }))
1923 );
1924 assert_eq!(plan.downloads(), 1);
1925 }
1926
1927 #[test]
1928 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1929 let mut actions = vec![
1934 Action::Rename {
1935 from: "old/song.flac".to_string(),
1936 to: "new/cover.jpg".to_string(),
1937 },
1938 Action::DeleteArtifact {
1939 kind: ArtifactKind::CoverJpg,
1940 path: "new/cover.jpg".to_string(),
1941 owner_id: "a".to_string(),
1942 },
1943 ];
1944 suppress_path_aliasing(&mut actions);
1945 assert!(
1947 !actions
1948 .iter()
1949 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1950 "a sidecar delete must not alias a rename target"
1951 );
1952 assert!(actions.contains(&Action::Skip {
1953 clip_id: "a".to_string()
1954 }));
1955 assert!(actions.contains(&Action::Rename {
1957 from: "old/song.flac".to_string(),
1958 to: "new/cover.jpg".to_string(),
1959 }));
1960 }
1961
1962 #[test]
1963 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1964 let mut actions = vec![
1967 Action::WriteArtifact {
1968 kind: ArtifactKind::FolderJpg,
1969 path: "creator/album/folder.jpg".to_string(),
1970 source_url: "https://art/large.jpg".to_string(),
1971 hash: "h".to_string(),
1972 owner_id: "root".to_string(),
1973 content: None,
1974 },
1975 Action::DeleteArtifact {
1976 kind: ArtifactKind::FolderJpg,
1977 path: "creator/album/folder.jpg".to_string(),
1978 owner_id: "root-old".to_string(),
1979 },
1980 ];
1981 suppress_path_aliasing(&mut actions);
1982 assert!(
1983 !actions
1984 .iter()
1985 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1986 );
1987 assert!(actions.contains(&Action::Skip {
1988 clip_id: "root-old".to_string()
1989 }));
1990 }
1991
1992 #[test]
1995 fn duplicate_trashed_does_not_defeat_copy_sibling() {
1996 let mut manifest = Manifest::new();
1999 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2000 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2001 copy_entry.modes = vec![SourceMode::Copy];
2002 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2003 trashed_entry.modes = vec![SourceMode::Mirror];
2004 trashed_entry.trashed = true;
2005 let plan = reconcile(
2006 &manifest,
2007 &[copy_entry, trashed_entry],
2008 &local_present("a"),
2009 &mirror_ok(),
2010 );
2011 assert_eq!(plan.deletes(), 0);
2012 assert_eq!(plan.skips(), 1);
2013 }
2014
2015 #[test]
2016 fn duplicate_trashed_does_not_defeat_private_sibling() {
2017 let mut manifest = Manifest::new();
2018 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2019 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2020 private_entry.private = true;
2021 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2022 trashed_entry.trashed = true;
2023 let plan = reconcile(
2024 &manifest,
2025 &[private_entry, trashed_entry],
2026 &local_present("a"),
2027 &mirror_ok(),
2028 );
2029 assert_eq!(plan.deletes(), 0);
2030 assert_eq!(plan.skips(), 1);
2031 }
2032
2033 #[test]
2034 fn duplicate_trashed_deletes_only_when_all_trashed() {
2035 let mut manifest = Manifest::new();
2037 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2038 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2039 first.trashed = true;
2040 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2041 second.trashed = true;
2042 let plan = reconcile(
2043 &manifest,
2044 &[first, second],
2045 &local_present("a"),
2046 &mirror_ok(),
2047 );
2048 assert_eq!(plan.deletes(), 1);
2049 }
2050
2051 #[test]
2052 fn duplicate_desired_unions_modes() {
2053 let mut manifest = Manifest::new();
2055 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2056 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2057 mirror_entry.modes = vec![SourceMode::Mirror];
2058 mirror_entry.trashed = true;
2059 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2060 copy_entry.modes = vec![SourceMode::Copy];
2061 let plan = reconcile(
2062 &manifest,
2063 &[mirror_entry, copy_entry],
2064 &local_present("a"),
2065 &mirror_ok(),
2066 );
2067 assert_eq!(plan.deletes(), 0);
2069 }
2070
2071 #[test]
2074 fn private_new_clip_downloads() {
2075 let manifest = Manifest::new();
2078 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2079 d.private = true;
2080 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2081 assert_eq!(plan.downloads(), 1);
2082 }
2083
2084 #[test]
2085 fn private_zero_length_file_redownloads() {
2086 let mut manifest = Manifest::new();
2087 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2088 let local: HashMap<String, LocalFile> = [(
2089 "a".to_string(),
2090 LocalFile {
2091 exists: true,
2092 size: 0,
2093 },
2094 )]
2095 .into_iter()
2096 .collect();
2097 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2098 d.private = true;
2099 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2100 assert_eq!(plan.downloads(), 1);
2101 }
2102
2103 #[test]
2104 fn private_meta_change_retags() {
2105 let mut manifest = Manifest::new();
2106 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2107 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2108 d.private = true;
2109 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2110 assert_eq!(plan.retags(), 1);
2111 assert_eq!(plan.deletes(), 0);
2112 }
2113
2114 #[test]
2115 fn absent_private_clip_protected_by_preserve_marker() {
2116 let mut manifest = Manifest::new();
2119 manifest.insert(
2120 "a",
2121 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2122 );
2123 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2124 assert_eq!(plan.deletes(), 0);
2125 assert_eq!(plan.skips(), 1);
2126 }
2127
2128 #[test]
2131 fn output_is_deterministic_regardless_of_input_order() {
2132 let mut manifest = Manifest::new();
2133 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2134 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2135 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2136 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2137 .iter()
2138 .map(|id| (id.to_string(), present(100)))
2139 .collect();
2140
2141 let forward = vec![
2142 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2143 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2144 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2145 ];
2146 let mut reversed = forward.clone();
2147 reversed.reverse();
2148
2149 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2150 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2151 assert_eq!(p1.actions, p2.actions);
2152
2153 let ids: Vec<&str> = p1
2156 .actions
2157 .iter()
2158 .map(|a| match a {
2159 Action::Skip { clip_id } => clip_id.as_str(),
2160 Action::Retag { clip, .. } => clip.id.as_str(),
2161 Action::Download { clip, .. } => clip.id.as_str(),
2162 Action::Delete { clip_id, .. } => clip_id.as_str(),
2163 Action::Reformat { clip, .. } => clip.id.as_str(),
2164 Action::Rename { to, .. } => to.as_str(),
2165 Action::WriteArtifact { owner_id, .. }
2166 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2167 })
2168 .collect();
2169 assert_eq!(ids, ["a", "b", "c", "z"]);
2170 }
2171
2172 #[test]
2173 fn empty_inputs_do_not_panic() {
2174 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2175 assert!(plan.is_empty());
2176 assert_eq!(plan.len(), 0);
2177 }
2178
2179 #[test]
2180 fn empty_desired_with_full_manifest_deletes_all() {
2181 let mut manifest = Manifest::new();
2182 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2183 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2184 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2185 assert_eq!(plan.deletes(), 2);
2186 }
2187
2188 #[test]
2189 fn full_desired_with_empty_manifest_downloads_all() {
2190 let d = vec![
2191 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2192 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2193 ];
2194 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2195 assert_eq!(plan.downloads(), 2);
2196 }
2197
2198 #[test]
2199 fn plan_counts_sum_to_len() {
2200 let mut manifest = Manifest::new();
2201 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2202 manifest.insert(
2203 "retag",
2204 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2205 );
2206 manifest.insert(
2207 "reformat",
2208 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2209 );
2210 manifest.insert(
2211 "rename",
2212 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2213 );
2214 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2215 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2216 .iter()
2217 .map(|id| (id.to_string(), present(100)))
2218 .collect();
2219 let d = vec![
2220 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2221 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2222 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2223 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2224 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2225 ];
2226 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2227 let summed = plan.downloads()
2228 + plan.reformats()
2229 + plan.retags()
2230 + plan.renames()
2231 + plan.deletes()
2232 + plan.skips();
2233 assert_eq!(summed, plan.len());
2234 assert_eq!(plan.downloads(), 1);
2235 assert_eq!(plan.reformats(), 1);
2236 assert_eq!(plan.retags(), 1);
2237 assert_eq!(plan.renames(), 1);
2238 assert_eq!(plan.deletes(), 1);
2239 assert_eq!(plan.skips(), 1);
2240 }
2241
2242 fn cover(path: &str, hash: &str) -> ArtifactState {
2245 ArtifactState {
2246 path: path.to_string(),
2247 hash: hash.to_string(),
2248 }
2249 }
2250
2251 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2252 DesiredArtifact {
2253 kind,
2254 path: path.to_string(),
2255 source_url: url.to_string(),
2256 hash: hash.to_string(),
2257 content: None,
2258 }
2259 }
2260
2261 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2263 DesiredArtifact {
2264 kind,
2265 path: path.to_string(),
2266 source_url: String::new(),
2267 hash: content_hash(body),
2268 content: Some(body.to_string()),
2269 }
2270 }
2271
2272 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2274 Desired {
2275 artifacts: arts,
2276 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2277 }
2278 }
2279
2280 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2282 ManifestEntry {
2283 cover_jpg: Some(cover(cover_path, cover_hash)),
2284 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2285 }
2286 }
2287
2288 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2289 plan.actions
2290 .iter()
2291 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2292 .collect()
2293 }
2294
2295 #[test]
2296 fn write_artifact_emitted_when_manifest_lacks_it() {
2297 let mut manifest = Manifest::new();
2300 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2301 let d = vec![desired_arts(
2302 "a",
2303 vec![art(
2304 ArtifactKind::CoverJpg,
2305 "a/cover.jpg",
2306 "https://art/a",
2307 "h1",
2308 )],
2309 )];
2310 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2311 assert_eq!(plan.artifact_writes(), 1);
2312 assert_eq!(plan.artifact_deletes(), 0);
2313 assert_eq!(plan.skips(), 1);
2314 assert_eq!(
2315 write_artifacts(&plan)[0],
2316 &Action::WriteArtifact {
2317 kind: ArtifactKind::CoverJpg,
2318 path: "a/cover.jpg".to_string(),
2319 source_url: "https://art/a".to_string(),
2320 hash: "h1".to_string(),
2321 owner_id: "a".to_string(),
2322 content: None,
2323 }
2324 );
2325 }
2326
2327 #[test]
2328 fn write_artifact_emitted_when_hash_differs() {
2329 let mut manifest = Manifest::new();
2332 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2333 let d = vec![desired_arts(
2334 "a",
2335 vec![art(
2336 ArtifactKind::CoverJpg,
2337 "a/cover.jpg",
2338 "https://art/a",
2339 "new",
2340 )],
2341 )];
2342 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2343 assert_eq!(plan.artifact_writes(), 1);
2344 assert_eq!(plan.artifact_deletes(), 0);
2345 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2346 assert_eq!(hash, "new");
2347 } else {
2348 panic!("expected a WriteArtifact");
2349 }
2350 }
2351
2352 #[test]
2353 fn write_artifact_skipped_when_hash_matches() {
2354 let mut manifest = Manifest::new();
2356 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2357 let d = vec![desired_arts(
2358 "a",
2359 vec![art(
2360 ArtifactKind::CoverJpg,
2361 "a/cover.jpg",
2362 "https://art/a",
2363 "h1",
2364 )],
2365 )];
2366 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2367 assert_eq!(plan.artifact_writes(), 0);
2368 assert_eq!(plan.artifact_deletes(), 0);
2369 assert_eq!(
2370 plan.actions,
2371 vec![Action::Skip {
2372 clip_id: "a".to_string()
2373 }]
2374 );
2375 }
2376
2377 #[test]
2378 fn removed_kind_cover_is_kept_not_deleted() {
2379 let mut manifest = Manifest::new();
2384 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2385 let d = vec![desired_arts("a", vec![])];
2386 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2387 assert_eq!(plan.artifact_deletes(), 0);
2388 assert_eq!(plan.artifact_writes(), 0);
2389 assert_eq!(plan.deletes(), 0);
2391 assert_eq!(
2392 plan.actions,
2393 vec![Action::Skip {
2394 clip_id: "a".to_string()
2395 }]
2396 );
2397 assert!(!plan.actions.iter().any(|a| matches!(
2398 a,
2399 Action::DeleteArtifact {
2400 kind: ArtifactKind::CoverJpg,
2401 ..
2402 }
2403 )));
2404 }
2405
2406 #[test]
2407 fn delete_artifact_never_on_incomplete_listing() {
2408 let mut manifest = Manifest::new();
2413 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2414 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2415 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2416 let sources = vec![SourceStatus {
2417 mode: SourceMode::Mirror,
2418 fully_enumerated: false,
2419 }];
2420 let local: HashMap<String, LocalFile> = [
2421 ("a".to_string(), present(100)),
2422 ("b".to_string(), present(100)),
2423 ]
2424 .into_iter()
2425 .collect();
2426 let plan = reconcile(&manifest, &d, &local, &sources);
2427 assert_eq!(plan.artifact_deletes(), 0);
2428 assert_eq!(plan.deletes(), 0);
2429 }
2430
2431 #[test]
2432 fn delete_artifact_never_when_entry_preserved() {
2433 let mut manifest = Manifest::new();
2436 let preserved = ManifestEntry {
2437 preserve: true,
2438 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2439 };
2440 manifest.insert("a", preserved);
2441 let d = vec![desired_arts("a", vec![])];
2442 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2443 assert_eq!(plan.artifact_deletes(), 0);
2444 }
2445
2446 #[test]
2447 fn co_delete_never_when_path_empty() {
2448 let mut manifest = Manifest::new();
2452 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2453 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2454 assert_eq!(plan.deletes(), 1);
2455 assert_eq!(plan.artifact_deletes(), 0);
2456 }
2457
2458 #[test]
2459 fn co_delete_absent_clip_deletes_audio_and_cover() {
2460 let mut manifest = Manifest::new();
2463 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2464 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2465 assert_eq!(plan.deletes(), 1);
2466 assert_eq!(plan.artifact_deletes(), 1);
2467 assert!(plan.actions.contains(&Action::Delete {
2468 path: "gone.flac".to_string(),
2469 clip_id: "gone".to_string(),
2470 }));
2471 assert!(plan.actions.contains(&Action::DeleteArtifact {
2472 kind: ArtifactKind::CoverJpg,
2473 path: "gone/cover.jpg".to_string(),
2474 owner_id: "gone".to_string(),
2475 }));
2476 }
2477
2478 #[test]
2479 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2480 let mut manifest = Manifest::new();
2482 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2483 let sources = vec![SourceStatus {
2484 mode: SourceMode::Mirror,
2485 fully_enumerated: false,
2486 }];
2487 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2488 assert_eq!(plan.deletes(), 0);
2489 assert_eq!(plan.artifact_deletes(), 0);
2490 }
2491
2492 #[test]
2493 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2494 let mut manifest = Manifest::new();
2496 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2497 let mut d = desired_arts("a", vec![]);
2498 d.trashed = true;
2499 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2500 assert_eq!(plan.deletes(), 1);
2501 assert_eq!(plan.artifact_deletes(), 1);
2502 }
2503
2504 #[test]
2505 fn co_delete_trashed_suppressed_when_not_enumerated() {
2506 let mut manifest = Manifest::new();
2508 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2509 let mut d = desired_arts("a", vec![]);
2510 d.trashed = true;
2511 let sources = vec![SourceStatus {
2512 mode: SourceMode::Mirror,
2513 fully_enumerated: false,
2514 }];
2515 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2516 assert_eq!(plan.deletes(), 0);
2517 assert_eq!(plan.artifact_deletes(), 0);
2518 assert_eq!(plan.skips(), 1);
2519 }
2520
2521 #[test]
2522 fn co_delete_trashed_suppressed_when_preserved() {
2523 let mut manifest = Manifest::new();
2525 let preserved = ManifestEntry {
2526 preserve: true,
2527 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2528 };
2529 manifest.insert("a", preserved);
2530 let mut d = desired_arts("a", vec![]);
2531 d.trashed = true;
2532 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2533 assert_eq!(plan.deletes(), 0);
2534 assert_eq!(plan.artifact_deletes(), 0);
2535 }
2536
2537 #[test]
2540 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2541 let mut manifest = Manifest::new();
2544 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2545 let d = vec![desired_arts(
2546 "a",
2547 vec![text_art(
2548 ArtifactKind::DetailsTxt,
2549 "a.details.txt",
2550 "Title: A\n",
2551 )],
2552 )];
2553 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2554 assert_eq!(plan.artifact_writes(), 1);
2555 assert_eq!(plan.artifact_deletes(), 0);
2556 assert_eq!(
2557 write_artifacts(&plan)[0],
2558 &Action::WriteArtifact {
2559 kind: ArtifactKind::DetailsTxt,
2560 path: "a.details.txt".to_string(),
2561 source_url: String::new(),
2562 hash: content_hash("Title: A\n"),
2563 owner_id: "a".to_string(),
2564 content: Some("Title: A\n".to_string()),
2565 }
2566 );
2567 }
2568
2569 #[test]
2570 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2571 let mut manifest = Manifest::new();
2576 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2577 let body = "[re:rs-suno]\nla la\n";
2578 let d = vec![desired_arts(
2579 "a",
2580 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2581 )];
2582 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2583 assert_eq!(plan.artifact_writes(), 1);
2584 assert_eq!(plan.artifact_deletes(), 0);
2585 assert_eq!(
2586 write_artifacts(&plan)[0],
2587 &Action::WriteArtifact {
2588 kind: ArtifactKind::Lrc,
2589 path: "a.lrc".to_string(),
2590 source_url: String::new(),
2591 hash: content_hash(body),
2592 owner_id: "a".to_string(),
2593 content: Some(body.to_string()),
2594 }
2595 );
2596 }
2597
2598 #[test]
2599 fn text_sidecars_skipped_when_hash_and_path_match() {
2600 let mut manifest = Manifest::new();
2602 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2603 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2604 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2605 manifest.insert("a", e);
2606 let d = vec![desired_arts(
2607 "a",
2608 vec![
2609 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2610 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2611 ],
2612 )];
2613 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2614 assert_eq!(plan.artifact_writes(), 0);
2615 assert_eq!(plan.artifact_deletes(), 0);
2616 }
2617
2618 #[test]
2619 fn details_rewritten_when_content_hash_differs() {
2620 let mut manifest = Manifest::new();
2623 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2624 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2625 manifest.insert("a", e);
2626 let d = vec![desired_arts(
2627 "a",
2628 vec![text_art(
2629 ArtifactKind::DetailsTxt,
2630 "a.details.txt",
2631 "Title: New\n",
2632 )],
2633 )];
2634 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2635 assert_eq!(plan.artifact_writes(), 1);
2636 assert_eq!(plan.artifact_deletes(), 0);
2637 }
2638
2639 #[test]
2640 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2641 let mut manifest = Manifest::new();
2645 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2646 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2647 manifest.insert("a", e);
2648 let d = vec![desired_arts(
2649 "a",
2650 vec![text_art(
2651 ArtifactKind::LyricsTxt,
2652 "a.lyrics.txt",
2653 "new words\n",
2654 )],
2655 )];
2656 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2657 assert_eq!(plan.artifact_writes(), 1);
2659 assert_eq!(plan.retags(), 0);
2660 }
2661
2662 #[test]
2663 fn text_sidecar_relocated_when_path_differs() {
2664 let mut manifest = Manifest::new();
2667 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2668 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2669 manifest.insert("a", e);
2670 let d = vec![desired_arts(
2671 "a",
2672 vec![text_art(
2673 ArtifactKind::DetailsTxt,
2674 "new/a.details.txt",
2675 "Title: A\n",
2676 )],
2677 )];
2678 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2679 assert_eq!(plan.artifact_writes(), 1);
2680 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2681 assert_eq!(path, "new/a.details.txt");
2682 } else {
2683 panic!("expected a WriteArtifact");
2684 }
2685 }
2686
2687 #[test]
2688 fn details_removed_kind_is_deleted_when_feature_off() {
2689 let mut manifest = Manifest::new();
2692 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2693 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2694 manifest.insert("a", e);
2695 let d = vec![desired_arts("a", vec![])];
2696 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2697 assert_eq!(plan.artifact_deletes(), 1);
2698 assert!(plan.actions.contains(&Action::DeleteArtifact {
2699 kind: ArtifactKind::DetailsTxt,
2700 path: "a.details.txt".to_string(),
2701 owner_id: "a".to_string(),
2702 }));
2703 }
2704
2705 #[test]
2706 fn lyrics_removed_kind_is_kept_not_deleted() {
2707 let mut manifest = Manifest::new();
2711 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2712 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2713 manifest.insert("a", e);
2714 let d = vec![desired_arts("a", vec![])];
2715 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2716 assert_eq!(plan.artifact_deletes(), 0);
2717 assert_eq!(plan.deletes(), 0);
2718 }
2719
2720 #[test]
2721 fn lrc_removed_kind_is_kept_not_deleted() {
2722 let mut manifest = Manifest::new();
2725 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2726 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2727 manifest.insert("a", e);
2728 let d = vec![desired_arts("a", vec![])];
2729 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2730 assert_eq!(plan.artifact_deletes(), 0);
2731 assert_eq!(plan.deletes(), 0);
2732 }
2733
2734 #[test]
2735 fn video_mp4_removed_kind_is_kept_not_deleted() {
2736 let mut manifest = Manifest::new();
2740 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2741 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
2742 manifest.insert("a", e);
2743 let d = vec![desired_arts("a", vec![])];
2744 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2745 assert_eq!(plan.artifact_deletes(), 0);
2746 assert_eq!(plan.deletes(), 0);
2747 }
2748
2749 #[test]
2750 fn video_mp4_written_when_manifest_lacks_it() {
2751 let mut manifest = Manifest::new();
2754 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2755 let d = vec![desired_arts(
2756 "a",
2757 vec![art(
2758 ArtifactKind::VideoMp4,
2759 "a/song.mp4",
2760 "https://cdn/a/video.mp4",
2761 "vid-hash",
2762 )],
2763 )];
2764 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2765 assert_eq!(plan.artifact_writes(), 1);
2766 assert_eq!(
2767 write_artifacts(&plan)[0],
2768 &Action::WriteArtifact {
2769 kind: ArtifactKind::VideoMp4,
2770 path: "a/song.mp4".to_string(),
2771 source_url: "https://cdn/a/video.mp4".to_string(),
2772 hash: "vid-hash".to_string(),
2773 owner_id: "a".to_string(),
2774 content: None,
2775 }
2776 );
2777 }
2778
2779 #[test]
2780 fn details_removed_kind_not_deleted_on_incomplete_listing() {
2781 let mut manifest = Manifest::new();
2784 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2785 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2786 manifest.insert("a", e);
2787 let d = vec![desired_arts("a", vec![])];
2788 let sources = vec![SourceStatus {
2789 mode: SourceMode::Mirror,
2790 fully_enumerated: false,
2791 }];
2792 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2793 assert_eq!(plan.artifact_deletes(), 0);
2794 }
2795
2796 #[test]
2797 fn details_removed_kind_not_deleted_when_preserved() {
2798 let mut manifest = Manifest::new();
2801 let mut e = ManifestEntry {
2802 preserve: true,
2803 ..entry("a.flac", AudioFormat::Flac, "m", "art")
2804 };
2805 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2806 manifest.insert("a", e);
2807 let d = vec![desired_arts("a", vec![])];
2808 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2809 assert_eq!(plan.artifact_deletes(), 0);
2810 }
2811
2812 #[test]
2813 fn co_delete_orphan_removes_every_text_sidecar() {
2814 let mut manifest = Manifest::new();
2818 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2819 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2820 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2821 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2822 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2823 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
2824 manifest.insert("gone", e);
2825 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2826 assert_eq!(plan.deletes(), 1);
2827 assert_eq!(plan.artifact_deletes(), 5);
2828 for (kind, path) in [
2829 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2830 (ArtifactKind::DetailsTxt, "gone.details.txt"),
2831 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2832 (ArtifactKind::Lrc, "gone.lrc"),
2833 (ArtifactKind::VideoMp4, "gone/song.mp4"),
2834 ] {
2835 assert!(
2836 plan.actions.contains(&Action::DeleteArtifact {
2837 kind,
2838 path: path.to_string(),
2839 owner_id: "gone".to_string(),
2840 }),
2841 "missing co-delete for {kind:?}"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn co_delete_trashed_removes_every_text_sidecar() {
2848 let mut manifest = Manifest::new();
2850 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2851 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2852 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2853 manifest.insert("a", e);
2854 let mut d = desired_arts("a", vec![]);
2855 d.trashed = true;
2856 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2857 assert_eq!(plan.deletes(), 1);
2858 assert_eq!(plan.artifact_deletes(), 2);
2859 }
2860
2861 #[test]
2862 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2863 let mut manifest = Manifest::new();
2866 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2867 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2868 let d = vec![desired_arts(
2871 "a",
2872 vec![art(
2873 ArtifactKind::CoverJpg,
2874 "shared/cover.jpg",
2875 "https://art/a",
2876 "h2",
2877 )],
2878 )];
2879 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2880 assert_eq!(plan.artifact_writes(), 1);
2881 assert!(!plan.actions.iter().any(
2883 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2884 ));
2885 assert!(plan.actions.contains(&Action::Delete {
2887 path: "b.flac".to_string(),
2888 clip_id: "b".to_string(),
2889 }));
2890 }
2891
2892 #[test]
2893 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2894 let mut manifest = Manifest::new();
2896 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2897 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2898 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2899 assert_eq!(plan.downloads(), 1);
2900 assert!(
2901 !plan
2902 .actions
2903 .iter()
2904 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2905 );
2906 }
2907
2908 #[test]
2909 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2910 let build = |with_art: bool| {
2914 let mut manifest = Manifest::new();
2915 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2916 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2917 manifest.insert(
2918 "trash",
2919 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2920 );
2921 let keep = if with_art {
2922 desired_arts(
2923 "keep",
2924 vec![art(
2925 ArtifactKind::CoverJpg,
2926 "keep/cover.jpg",
2927 "https://art/keep",
2928 "h1",
2929 )],
2930 )
2931 } else {
2932 desired_arts("keep", vec![])
2933 };
2934 let mut trash = desired_arts("trash", vec![]);
2935 trash.trashed = true;
2936 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2937 .iter()
2938 .map(|id| (id.to_string(), present(100)))
2939 .collect();
2940 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2941 };
2942
2943 let with = build(true);
2944 let without = build(false);
2945
2946 let audio = |plan: &Plan| -> Vec<Action> {
2948 plan.actions
2949 .iter()
2950 .filter(|a| {
2951 !matches!(
2952 a,
2953 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2954 )
2955 })
2956 .cloned()
2957 .collect()
2958 };
2959 assert_eq!(audio(&with), audio(&without));
2960 assert_eq!(with.deletes(), without.deletes());
2961 assert_eq!(with.deletes(), 2);
2963 assert_eq!(with.artifact_deletes(), 2);
2967 assert_eq!(with.artifact_writes(), 0);
2968 }
2969
2970 #[test]
2973 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2974 let mut manifest = Manifest::new();
2980 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2981 assert!(!manifest.get("a").unwrap().preserve);
2982
2983 let private = Desired {
2985 private: true,
2986 ..desired_arts("a", vec![])
2987 };
2988 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2989 assert_eq!(plan.artifact_deletes(), 0);
2990
2991 let copy_held = Desired {
2993 modes: vec![SourceMode::Copy],
2994 ..desired_arts("a", vec![])
2995 };
2996 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2997 assert_eq!(plan.artifact_deletes(), 0);
2998 }
2999
3000 #[test]
3001 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3002 let mut manifest = Manifest::new();
3008 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3009 let d = vec![desired_arts(
3010 "a",
3011 vec![art(
3012 ArtifactKind::CoverJpg,
3013 "new/cover.jpg",
3014 "https://art/a",
3015 "h1",
3016 )],
3017 )];
3018 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3019 assert_eq!(plan.artifact_writes(), 1);
3020 assert_eq!(plan.artifact_deletes(), 0);
3021 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3022 assert_eq!(path, "new/cover.jpg");
3023 } else {
3024 panic!("expected a WriteArtifact");
3025 }
3026 }
3027
3028 #[test]
3029 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3030 let mut manifest = Manifest::new();
3034 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3035 let d = vec![desired_arts(
3036 "a",
3037 vec![
3038 art(
3039 ArtifactKind::FolderJpg,
3040 "a/folder.jpg",
3041 "https://art/folder",
3042 "hf",
3043 ),
3044 art(
3045 ArtifactKind::Playlist,
3046 "a/list.m3u",
3047 "https://art/list",
3048 "hp",
3049 ),
3050 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3051 ],
3052 )];
3053 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3054 assert_eq!(plan.artifact_writes(), 1);
3055 let paths: Vec<&str> = plan
3056 .actions
3057 .iter()
3058 .filter_map(|a| match a {
3059 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3060 _ => None,
3061 })
3062 .collect();
3063 assert_eq!(paths, vec!["a/cover.jpg"]);
3064 }
3065
3066 #[test]
3067 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3068 let mut manifest = Manifest::new();
3069 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3070 let d = vec![desired_arts(
3071 "a",
3072 vec![art(
3073 ArtifactKind::FolderWebp,
3074 "a/folder.webp",
3075 "https://art/folder",
3076 "hf",
3077 )],
3078 )];
3079 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3080 assert_eq!(plan.artifact_writes(), 0);
3081 assert_eq!(plan.artifact_deletes(), 0);
3082 }
3083
3084 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3087 Clip {
3088 id: id.to_string(),
3089 title: "Song".to_string(),
3090 image_large_url: image.to_string(),
3091 video_cover_url: video.to_string(),
3092 play_count,
3093 created_at: created_at.to_string(),
3094 ..Default::default()
3095 }
3096 }
3097
3098 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3099 let mut lineage = LineageContext::own_root(&clip);
3100 lineage.root_id = root_id.to_string();
3101 Desired {
3102 clip,
3103 lineage,
3104 path: path.to_string(),
3105 format: AudioFormat::Flac,
3106 meta_hash: "m".to_string(),
3107 art_hash: "a".to_string(),
3108 modes: vec![SourceMode::Mirror],
3109 trashed: false,
3110 private: false,
3111 artifacts: Vec::new(),
3112 }
3113 }
3114
3115 fn stored(path: &str, hash: &str) -> ArtifactState {
3116 ArtifactState {
3117 path: path.to_string(),
3118 hash: hash.to_string(),
3119 }
3120 }
3121
3122 #[test]
3123 fn folder_jpg_source_is_most_played() {
3124 let members = vec![
3125 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3126 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3127 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3128 ];
3129 let albums = album_desired(&members, false);
3130 assert_eq!(albums.len(), 1);
3131 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3132 assert_eq!(jpg.hash, art_url_hash("art-b"));
3134 assert_eq!(jpg.source_url, "art-b");
3135 assert_eq!(jpg.path, "c/al/folder.jpg");
3136 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3137 }
3138
3139 #[test]
3140 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3141 let by_time = vec![
3143 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3144 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3145 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3146 ];
3147 let jpg = album_desired(&by_time, false)[0]
3148 .folder_jpg
3149 .clone()
3150 .unwrap();
3151 assert_eq!(jpg.source_url, "art-y");
3152
3153 let by_id = vec![
3155 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3156 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3157 ];
3158 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3159 assert_eq!(jpg.source_url, "art-g");
3160 }
3161
3162 #[test]
3163 fn folder_webp_source_is_first_created_animated() {
3164 let members = vec![
3165 album_member(
3166 album_clip("a", 9, "t2", "art-a", "vid-a"),
3167 "root",
3168 "c/al/a.flac",
3169 ),
3170 album_member(
3171 album_clip("b", 1, "t0", "art-b", "vid-b"),
3172 "root",
3173 "c/al/b.flac",
3174 ),
3175 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3176 ];
3177 let webp = album_desired(&members, true)[0]
3178 .folder_webp
3179 .clone()
3180 .unwrap();
3181 assert_eq!(webp.source_url, "vid-b");
3183 assert_eq!(webp.hash, art_url_hash("vid-b"));
3184 assert_eq!(webp.path, "c/al/cover.webp");
3185 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3186 }
3187
3188 #[test]
3189 fn animated_covers_off_yields_no_folder_webp() {
3190 let members = vec![album_member(
3191 album_clip("a", 1, "t0", "art-a", "vid-a"),
3192 "root",
3193 "c/al/a.flac",
3194 )];
3195 let off = album_desired(&members, false);
3196 assert!(off[0].folder_webp.is_none());
3197 let on = album_desired(&members, true);
3198 assert!(on[0].folder_webp.is_some());
3199 }
3200
3201 #[test]
3202 fn album_with_no_art_yields_no_folder_jpg() {
3203 let members = vec![album_member(
3204 album_clip("a", 3, "t0", "", ""),
3205 "root",
3206 "c/al/a.flac",
3207 )];
3208 let albums = album_desired(&members, true);
3209 assert!(albums[0].folder_jpg.is_none());
3210 assert!(albums[0].folder_webp.is_none());
3211 }
3212
3213 #[test]
3214 fn album_desired_groups_by_root_id() {
3215 let members = vec![
3216 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3217 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3218 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3219 ];
3220 let albums = album_desired(&members, false);
3221 assert_eq!(albums.len(), 2);
3222 assert_eq!(albums[0].root_id, "r1");
3223 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3224 assert_eq!(
3225 albums[0].folder_jpg.as_ref().unwrap().path,
3226 "c/al1/folder.jpg"
3227 );
3228 assert_eq!(albums[1].root_id, "r2");
3229 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3230 assert_eq!(
3231 albums[1].folder_jpg.as_ref().unwrap().path,
3232 "c/al2/folder.jpg"
3233 );
3234 }
3235
3236 #[test]
3237 fn plan_writes_folder_art_when_store_empty() {
3238 let members = vec![album_member(
3239 album_clip("a", 1, "t0", "art-a", "vid-a"),
3240 "root",
3241 "c/al/a.flac",
3242 )];
3243 let desired = album_desired(&members, true);
3244 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3245 assert_eq!(
3246 actions,
3247 vec![
3248 Action::WriteArtifact {
3249 kind: ArtifactKind::FolderJpg,
3250 path: "c/al/folder.jpg".to_string(),
3251 source_url: "art-a".to_string(),
3252 hash: art_url_hash("art-a"),
3253 owner_id: "root".to_string(),
3254 content: None,
3255 },
3256 Action::WriteArtifact {
3257 kind: ArtifactKind::FolderWebp,
3258 path: "c/al/cover.webp".to_string(),
3259 source_url: "vid-a".to_string(),
3260 hash: art_url_hash("vid-a"),
3261 owner_id: "root".to_string(),
3262 content: None,
3263 },
3264 ]
3265 );
3266 }
3267
3268 #[test]
3269 fn plan_skips_when_hash_and_path_match() {
3270 let members = vec![album_member(
3271 album_clip("a", 1, "t0", "art-a", ""),
3272 "root",
3273 "c/al/a.flac",
3274 )];
3275 let desired = album_desired(&members, false);
3276 let mut albums = BTreeMap::new();
3277 albums.insert(
3278 "root".to_string(),
3279 AlbumArt {
3280 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3281 folder_webp: None,
3282 },
3283 );
3284 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3285 }
3286
3287 #[test]
3288 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3289 let members = vec![album_member(
3290 album_clip("a", 1, "t0", "art-a", ""),
3291 "root",
3292 "c/al/a.flac",
3293 )];
3294 let desired = album_desired(&members, false);
3295 let mut albums = BTreeMap::new();
3296 albums.insert(
3297 "root".to_string(),
3298 AlbumArt {
3299 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3300 folder_webp: None,
3301 },
3302 );
3303 let actions = plan_album_artifacts(&desired, &albums, true);
3304 assert_eq!(actions.len(), 1);
3305 assert!(matches!(
3306 &actions[0],
3307 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3308 ));
3309 }
3310
3311 #[test]
3312 fn h1_most_played_flip_to_same_art_writes_nothing() {
3313 let run1 = vec![
3315 album_member(
3316 album_clip("a", 9, "t0", "same-art", ""),
3317 "root",
3318 "c/al/a.flac",
3319 ),
3320 album_member(
3321 album_clip("b", 1, "t1", "same-art", ""),
3322 "root",
3323 "c/al/b.flac",
3324 ),
3325 ];
3326 let desired1 = album_desired(&run1, false);
3327 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3328 assert_eq!(write1.len(), 1);
3329
3330 let mut albums = BTreeMap::new();
3332 if let Action::WriteArtifact {
3333 path,
3334 hash,
3335 owner_id,
3336 ..
3337 } = &write1[0]
3338 {
3339 albums.insert(
3340 owner_id.clone(),
3341 AlbumArt {
3342 folder_jpg: Some(stored(path, hash)),
3343 folder_webp: None,
3344 },
3345 );
3346 }
3347
3348 let run2 = vec![
3350 album_member(
3351 album_clip("a", 1, "t0", "same-art", ""),
3352 "root",
3353 "c/al/a.flac",
3354 ),
3355 album_member(
3356 album_clip("b", 9, "t1", "same-art", ""),
3357 "root",
3358 "c/al/b.flac",
3359 ),
3360 ];
3361 let desired2 = album_desired(&run2, false);
3362 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3364 }
3365
3366 #[test]
3367 fn h1_flip_to_different_art_writes_exactly_one() {
3368 let mut albums = BTreeMap::new();
3369 albums.insert(
3370 "root".to_string(),
3371 AlbumArt {
3372 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3373 folder_webp: None,
3374 },
3375 );
3376 let members = vec![
3378 album_member(
3379 album_clip("a", 1, "t0", "old-art", ""),
3380 "root",
3381 "c/al/a.flac",
3382 ),
3383 album_member(
3384 album_clip("b", 9, "t1", "new-art", ""),
3385 "root",
3386 "c/al/b.flac",
3387 ),
3388 ];
3389 let desired = album_desired(&members, false);
3390 let actions = plan_album_artifacts(&desired, &albums, true);
3391 assert_eq!(actions.len(), 1);
3392 assert!(matches!(
3393 &actions[0],
3394 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3395 ));
3396 }
3397
3398 #[test]
3399 fn one_write_per_album_regardless_of_clip_count() {
3400 let members: Vec<Desired> = (0..200)
3401 .map(|i| {
3402 album_member(
3403 album_clip(
3404 &format!("clip-{i:03}"),
3405 i as u64,
3406 &format!("t{i:03}"),
3407 &format!("art-{i:03}"),
3408 &format!("vid-{i:03}"),
3409 ),
3410 "root",
3411 &format!("c/al/clip-{i:03}.flac"),
3412 )
3413 })
3414 .collect();
3415 let desired = album_desired(&members, true);
3416 assert_eq!(desired.len(), 1);
3417 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3418 assert_eq!(actions.len(), 2);
3420 assert_eq!(
3421 actions
3422 .iter()
3423 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3424 .count(),
3425 2
3426 );
3427 }
3428
3429 #[test]
3430 fn emptied_album_deletes_only_when_can_delete() {
3431 let mut albums = BTreeMap::new();
3432 albums.insert(
3433 "root".to_string(),
3434 AlbumArt {
3435 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3436 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3437 },
3438 );
3439 let desired: Vec<AlbumDesired> = Vec::new();
3441
3442 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3444
3445 let actions = plan_album_artifacts(&desired, &albums, true);
3447 assert_eq!(
3448 actions,
3449 vec![
3450 Action::DeleteArtifact {
3451 kind: ArtifactKind::FolderJpg,
3452 path: "c/al/folder.jpg".to_string(),
3453 owner_id: "root".to_string(),
3454 },
3455 Action::DeleteArtifact {
3456 kind: ArtifactKind::FolderWebp,
3457 path: "c/al/cover.webp".to_string(),
3458 owner_id: "root".to_string(),
3459 },
3460 ]
3461 );
3462 }
3463
3464 #[test]
3465 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3466 let mut albums = BTreeMap::new();
3467 albums.insert(
3468 "root".to_string(),
3469 AlbumArt {
3470 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3471 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3472 },
3473 );
3474 let members = vec![album_member(
3477 album_clip("a", 1, "t0", "art-a", "vid-a"),
3478 "root",
3479 "c/al/a.flac",
3480 )];
3481 let desired = album_desired(&members, false);
3482
3483 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3484
3485 let actions = plan_album_artifacts(&desired, &albums, true);
3486 assert_eq!(
3487 actions,
3488 vec![Action::DeleteArtifact {
3489 kind: ArtifactKind::FolderWebp,
3490 path: "c/al/cover.webp".to_string(),
3491 owner_id: "root".to_string(),
3492 }]
3493 );
3494 }
3495
3496 #[test]
3497 fn plan_album_artifacts_is_deterministically_ordered() {
3498 let members = vec![
3499 album_member(
3500 album_clip("a", 1, "t0", "art-a", "vid-a"),
3501 "r2",
3502 "c/al2/a.flac",
3503 ),
3504 album_member(
3505 album_clip("b", 1, "t0", "art-b", "vid-b"),
3506 "r1",
3507 "c/al1/b.flac",
3508 ),
3509 ];
3510 let desired = album_desired(&members, true);
3511 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3512 let keys: Vec<(&str, ArtifactKind)> = actions
3513 .iter()
3514 .map(|a| match a {
3515 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3516 _ => unreachable!(),
3517 })
3518 .collect();
3519 assert_eq!(
3520 keys,
3521 vec![
3522 ("r1", ArtifactKind::FolderJpg),
3523 ("r1", ArtifactKind::FolderWebp),
3524 ("r2", ArtifactKind::FolderJpg),
3525 ("r2", ArtifactKind::FolderWebp),
3526 ]
3527 );
3528 }
3529
3530 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3533 PlaylistDesired {
3534 id: id.to_owned(),
3535 name: name.to_owned(),
3536 path: path.to_owned(),
3537 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3538 hash: hash.to_owned(),
3539 }
3540 }
3541
3542 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3543 PlaylistState {
3544 name: name.to_owned(),
3545 path: path.to_owned(),
3546 hash: hash.to_owned(),
3547 }
3548 }
3549
3550 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3551 entries
3552 .iter()
3553 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3554 .collect()
3555 }
3556
3557 #[test]
3558 fn playlist_write_emitted_for_a_new_playlist() {
3559 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3560 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3561 assert_eq!(
3562 actions,
3563 vec![Action::WriteArtifact {
3564 kind: ArtifactKind::Playlist,
3565 path: "Road Trip.m3u8".to_owned(),
3566 source_url: String::new(),
3567 hash: "h1".to_owned(),
3568 owner_id: "pl1".to_owned(),
3569 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3570 }]
3571 );
3572 }
3573
3574 #[test]
3575 fn playlist_write_emitted_when_hash_changes() {
3576 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3579 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3580 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3581 assert_eq!(actions.len(), 1);
3582 assert!(matches!(
3583 &actions[0],
3584 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3585 ));
3586 }
3587
3588 #[test]
3589 fn playlist_unchanged_is_idempotent() {
3590 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3591 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3592 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3593 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3594 }
3595
3596 #[test]
3597 fn playlist_rename_writes_new_and_deletes_old_path() {
3598 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3601 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3602 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3603 assert_eq!(
3604 actions,
3605 vec![
3606 Action::WriteArtifact {
3607 kind: ArtifactKind::Playlist,
3608 path: "Summer.m3u8".to_owned(),
3609 source_url: String::new(),
3610 hash: "h2".to_owned(),
3611 owner_id: "pl1".to_owned(),
3612 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3613 },
3614 Action::DeleteArtifact {
3615 kind: ArtifactKind::Playlist,
3616 path: "Spring.m3u8".to_owned(),
3617 owner_id: "pl1".to_owned(),
3618 },
3619 ]
3620 );
3621 }
3622
3623 #[test]
3624 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3625 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3628 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3629 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3630 assert_eq!(actions.len(), 1);
3631 assert!(matches!(
3632 &actions[0],
3633 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3634 ));
3635 assert!(
3636 !actions
3637 .iter()
3638 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3639 "old path must not be deleted when deletes are disallowed"
3640 );
3641 }
3642
3643 #[test]
3644 fn playlist_stale_removed_only_under_full_gate() {
3645 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3648
3649 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3650 assert_eq!(
3651 deleted,
3652 vec![Action::DeleteArtifact {
3653 kind: ArtifactKind::Playlist,
3654 path: "Gone.m3u8".to_owned(),
3655 owner_id: "gone".to_owned(),
3656 }]
3657 );
3658
3659 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3661 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3662 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3663 }
3664
3665 #[test]
3666 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3667 let stored = pl_store(&[
3672 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3673 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3674 ]);
3675 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3676 assert!(
3677 actions.is_empty(),
3678 "a failed playlist listing must plan zero actions, got {actions:?}"
3679 );
3680 }
3681
3682 #[test]
3683 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3684 let stored = pl_store(&[
3689 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3690 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3691 ]);
3692
3693 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3695
3696 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3699 assert_eq!(
3700 wiped
3701 .iter()
3702 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3703 .count(),
3704 2
3705 );
3706 }
3707
3708 #[test]
3709 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3710 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3715 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3716 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3717 assert_eq!(actions.len(), 1);
3719 assert!(matches!(
3720 &actions[0],
3721 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3722 ));
3723 assert!(
3724 !actions.iter().any(|a| match a {
3725 Action::WriteArtifact { owner_id, .. }
3726 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3727 _ => false,
3728 }),
3729 "a protected (failed-member) playlist must have no action"
3730 );
3731 }
3732
3733 #[test]
3734 fn playlist_rename_collision_downgrades_the_delete() {
3735 let desired = vec![
3741 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3742 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3743 ];
3744 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3745 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3746 let write_paths: BTreeSet<&str> = actions
3748 .iter()
3749 .filter_map(|a| match a {
3750 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3751 _ => None,
3752 })
3753 .collect();
3754 for a in &actions {
3755 if let Action::DeleteArtifact { path, .. } = a {
3756 assert!(
3757 !write_paths.contains(path.as_str()),
3758 "a playlist delete aliases a write target: {path}"
3759 );
3760 }
3761 }
3762 }
3763}
3764
3765#[cfg(test)]
3778mod proptests {
3779 use super::*;
3780 use proptest::collection::{btree_map, hash_map, vec};
3781 use proptest::prelude::*;
3782 use std::collections::BTreeSet;
3783
3784 type DesiredFields = (
3785 String,
3786 AudioFormat,
3787 String,
3788 String,
3789 Vec<SourceMode>,
3790 bool,
3791 bool,
3792 );
3793
3794 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3795 prop_oneof![
3796 Just(AudioFormat::Mp3),
3797 Just(AudioFormat::Flac),
3798 Just(AudioFormat::Wav),
3799 ]
3800 }
3801
3802 fn source_mode() -> impl Strategy<Value = SourceMode> {
3803 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3804 }
3805
3806 fn clip_id() -> impl Strategy<Value = String> {
3809 (0u8..8).prop_map(|n| format!("c{n}"))
3810 }
3811
3812 fn small_path() -> impl Strategy<Value = String> {
3813 (0u8..6).prop_map(|n| format!("path{n}"))
3814 }
3815
3816 fn manifest_path() -> impl Strategy<Value = String> {
3819 prop_oneof![
3820 1 => Just(String::new()),
3821 6 => small_path(),
3822 ]
3823 }
3824
3825 fn small_hash() -> impl Strategy<Value = String> {
3826 (0u8..4).prop_map(|n| format!("h{n}"))
3827 }
3828
3829 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3830 (
3831 manifest_path(),
3832 audio_format(),
3833 small_hash(),
3834 small_hash(),
3835 0u64..4,
3836 any::<bool>(),
3837 )
3838 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3839 ManifestEntry {
3840 path,
3841 format,
3842 meta_hash,
3843 art_hash,
3844 size,
3845 preserve,
3846 ..Default::default()
3847 }
3848 })
3849 }
3850
3851 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3852 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3853 }
3854
3855 fn local_file() -> impl Strategy<Value = LocalFile> {
3856 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3857 }
3858
3859 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3860 hash_map(clip_id(), local_file(), 0..8)
3861 }
3862
3863 fn source_status() -> impl Strategy<Value = SourceStatus> {
3864 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3865 mode,
3866 fully_enumerated,
3867 })
3868 }
3869
3870 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3871 vec(source_status(), 0..5)
3872 }
3873
3874 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3875 vec(
3876 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3877 mode: SourceMode::Copy,
3878 fully_enumerated,
3879 }),
3880 1..5,
3881 )
3882 }
3883
3884 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3885 (
3886 small_path(),
3887 audio_format(),
3888 small_hash(),
3889 small_hash(),
3890 vec(source_mode(), 1..3),
3891 any::<bool>(),
3892 any::<bool>(),
3893 )
3894 }
3895
3896 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3897 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3898 let clip = Clip {
3899 id,
3900 title: "t".to_string(),
3901 ..Default::default()
3902 };
3903 Desired {
3904 lineage: LineageContext::own_root(&clip),
3905 clip,
3906 path,
3907 format,
3908 meta_hash,
3909 art_hash,
3910 modes,
3911 trashed,
3912 private,
3913 artifacts: Vec::new(),
3914 }
3915 }
3916
3917 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3920 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3921 items
3922 .into_iter()
3923 .map(|(id, fields)| build_desired(id, fields))
3924 .collect()
3925 })
3926 }
3927
3928 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3929 desired.iter().map(|d| d.clip.id.as_str()).collect()
3930 }
3931
3932 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3935 desired
3936 .iter()
3937 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3938 .map(|d| d.clip.id.as_str())
3939 .collect()
3940 }
3941
3942 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3945 desired
3946 .iter()
3947 .filter(|d| !d.trashed)
3948 .map(|d| d.clip.id.as_str())
3949 .collect()
3950 }
3951
3952 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3953 plan.actions
3954 .iter()
3955 .filter_map(|a| match a {
3956 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3957 _ => None,
3958 })
3959 .collect()
3960 }
3961
3962 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3963 plan.actions
3964 .iter()
3965 .filter_map(|a| match a {
3966 Action::Download { path, .. } | Action::Reformat { path, .. } => {
3967 Some(path.as_str())
3968 }
3969 Action::Rename { to, .. } => Some(to.as_str()),
3970 _ => None,
3971 })
3972 .collect()
3973 }
3974
3975 proptest! {
3976 #![proptest_config(ProptestConfig {
3977 cases: 256,
3978 failure_persistence: None,
3979 ..ProptestConfig::default()
3980 })]
3981
3982 #[test]
3985 fn inv1_desired_clip_deleted_only_when_fully_trashed(
3986 manifest in manifest_strategy(),
3987 desired in desired_strategy(),
3988 local in local_strategy(),
3989 sources in sources_strategy(),
3990 ) {
3991 let plan = reconcile(&manifest, &desired, &local, &sources);
3992 let present = desired_ids(&desired);
3993 let live = non_trashed_ids(&desired);
3994 for id in delete_clip_ids(&plan) {
3995 prop_assert!(
3996 !(present.contains(id) && live.contains(id)),
3997 "deleted a desired clip with a non-trashed duplicate: {id}"
3998 );
3999 }
4000 }
4001
4002 #[test]
4006 fn inv2_no_delete_when_any_mirror_unenumerated(
4007 manifest in manifest_strategy(),
4008 desired in desired_strategy(),
4009 local in local_strategy(),
4010 mut sources in sources_strategy(),
4011 ) {
4012 sources.push(SourceStatus {
4013 mode: SourceMode::Mirror,
4014 fully_enumerated: false,
4015 });
4016 let plan = reconcile(&manifest, &desired, &local, &sources);
4017 prop_assert_eq!(plan.deletes(), 0);
4018 }
4019
4020 #[test]
4022 fn inv3_all_copy_sources_means_no_deletes(
4023 manifest in manifest_strategy(),
4024 desired in desired_strategy(),
4025 local in local_strategy(),
4026 sources in copy_sources_strategy(),
4027 ) {
4028 let plan = reconcile(&manifest, &desired, &local, &sources);
4029 prop_assert_eq!(plan.deletes(), 0);
4030 }
4031
4032 #[test]
4035 fn inv4_plan_is_deterministic(
4036 manifest in manifest_strategy(),
4037 desired in desired_strategy(),
4038 local in local_strategy(),
4039 sources in sources_strategy(),
4040 ) {
4041 let plan = reconcile(&manifest, &desired, &local, &sources);
4042
4043 let again = reconcile(&manifest, &desired, &local, &sources);
4044 prop_assert_eq!(&plan, &again);
4045
4046 let mut desired_rev = desired.clone();
4047 desired_rev.reverse();
4048 let mut sources_rev = sources.clone();
4049 sources_rev.reverse();
4050 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
4051 prop_assert_eq!(&plan, &shuffled);
4052 }
4053
4054 #[test]
4056 fn inv5_every_delete_is_in_the_manifest(
4057 manifest in manifest_strategy(),
4058 desired in desired_strategy(),
4059 local in local_strategy(),
4060 sources in sources_strategy(),
4061 ) {
4062 let plan = reconcile(&manifest, &desired, &local, &sources);
4063 for id in delete_clip_ids(&plan) {
4064 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4065 }
4066 }
4067
4068 #[test]
4071 fn inv6_never_deletes_protected_clip(
4072 manifest in manifest_strategy(),
4073 desired in desired_strategy(),
4074 local in local_strategy(),
4075 sources in sources_strategy(),
4076 ) {
4077 let plan = reconcile(&manifest, &desired, &local, &sources);
4078 let protected = protected_ids(&desired);
4079 for id in delete_clip_ids(&plan) {
4080 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4081 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4082 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4083 }
4084 }
4085
4086 #[test]
4089 fn inv7_no_delete_unless_deletion_allowed(
4090 manifest in manifest_strategy(),
4091 desired in desired_strategy(),
4092 local in local_strategy(),
4093 sources in sources_strategy(),
4094 ) {
4095 let plan = reconcile(&manifest, &desired, &local, &sources);
4096 if !deletion_allowed(&sources) {
4097 prop_assert_eq!(plan.deletes(), 0);
4098 }
4099 }
4100
4101 #[test]
4103 fn inv8_at_most_one_delete_per_clip(
4104 manifest in manifest_strategy(),
4105 desired in desired_strategy(),
4106 local in local_strategy(),
4107 sources in sources_strategy(),
4108 ) {
4109 let plan = reconcile(&manifest, &desired, &local, &sources);
4110 let ids = delete_clip_ids(&plan);
4111 let unique: BTreeSet<&str> = ids.iter().copied().collect();
4112 prop_assert_eq!(ids.len(), unique.len());
4113 }
4114
4115 #[test]
4117 fn inv9_no_delete_with_empty_path(
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 action in &plan.actions {
4125 if let Action::Delete { path, .. } = action {
4126 prop_assert!(!path.is_empty(), "delete with an empty path");
4127 }
4128 }
4129 }
4130
4131 #[test]
4134 fn inv10_no_delete_aliases_a_write_target(
4135 manifest in manifest_strategy(),
4136 desired in desired_strategy(),
4137 local in local_strategy(),
4138 sources in sources_strategy(),
4139 ) {
4140 let plan = reconcile(&manifest, &desired, &local, &sources);
4141 let targets = write_target_paths(&plan);
4142 for action in &plan.actions {
4143 if let Action::Delete { path, .. } = action {
4144 prop_assert!(
4145 !targets.contains(path.as_str()),
4146 "delete path {path} aliases a write target"
4147 );
4148 }
4149 }
4150 }
4151 }
4152}