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 FolderJpg,
69 FolderWebp,
71 Playlist,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum SourceMode {
79 Mirror,
81 Copy,
83}
84
85#[derive(Debug, Clone, PartialEq)]
92pub struct Desired {
93 pub clip: Clip,
95 pub lineage: LineageContext,
98 pub path: String,
100 pub format: AudioFormat,
102 pub meta_hash: String,
104 pub art_hash: String,
106 pub modes: Vec<SourceMode>,
108 pub trashed: bool,
110 pub private: bool,
112 pub artifacts: Vec<DesiredArtifact>,
120}
121
122#[derive(Debug, Clone, PartialEq)]
127pub struct DesiredArtifact {
128 pub kind: ArtifactKind,
130 pub path: String,
132 pub source_url: String,
135 pub hash: String,
137 pub content: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq)]
154pub struct AlbumDesired {
155 pub root_id: String,
157 pub folder_jpg: Option<DesiredArtifact>,
159 pub folder_webp: Option<DesiredArtifact>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PlaylistDesired {
175 pub id: String,
178 pub name: String,
180 pub path: String,
182 pub content: String,
184 pub hash: String,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
190pub struct LocalFile {
191 pub exists: bool,
193 pub size: u64,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct SourceStatus {
200 pub mode: SourceMode,
202 pub fully_enumerated: bool,
204}
205
206#[derive(Debug, Clone, PartialEq)]
208pub enum Action {
209 Download {
211 clip: Clip,
212 lineage: LineageContext,
213 path: String,
214 format: AudioFormat,
215 },
216 Reformat {
222 clip: Clip,
223 path: String,
224 from_path: String,
225 from: AudioFormat,
226 to: AudioFormat,
227 },
228 Retag {
230 clip: Clip,
231 lineage: LineageContext,
232 path: String,
233 },
234 Rename { from: String, to: String },
236 Delete { path: String, clip_id: String },
238 Skip { clip_id: String },
240 WriteArtifact {
252 kind: ArtifactKind,
253 path: String,
254 source_url: String,
255 hash: String,
256 owner_id: String,
257 content: Option<String>,
258 },
259 DeleteArtifact {
266 kind: ArtifactKind,
267 path: String,
268 owner_id: String,
269 },
270}
271
272#[derive(Debug, Clone, Default, PartialEq)]
277pub struct Plan {
278 pub actions: Vec<Action>,
280}
281
282impl Plan {
283 pub fn len(&self) -> usize {
285 self.actions.len()
286 }
287
288 pub fn is_empty(&self) -> bool {
290 self.actions.is_empty()
291 }
292
293 pub fn downloads(&self) -> usize {
295 self.count(|a| matches!(a, Action::Download { .. }))
296 }
297
298 pub fn reformats(&self) -> usize {
300 self.count(|a| matches!(a, Action::Reformat { .. }))
301 }
302
303 pub fn retags(&self) -> usize {
305 self.count(|a| matches!(a, Action::Retag { .. }))
306 }
307
308 pub fn renames(&self) -> usize {
310 self.count(|a| matches!(a, Action::Rename { .. }))
311 }
312
313 pub fn deletes(&self) -> usize {
315 self.count(|a| matches!(a, Action::Delete { .. }))
316 }
317
318 pub fn skips(&self) -> usize {
320 self.count(|a| matches!(a, Action::Skip { .. }))
321 }
322
323 pub fn artifact_writes(&self) -> usize {
325 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
326 }
327
328 pub fn artifact_deletes(&self) -> usize {
330 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
331 }
332
333 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
334 self.actions.iter().filter(|a| pred(a)).count()
335 }
336}
337
338pub fn reconcile(
353 manifest: &Manifest,
354 desired: &[Desired],
355 local: &HashMap<String, LocalFile>,
356 sources: &[SourceStatus],
357) -> Plan {
358 let mut actions: Vec<Action> = Vec::new();
359
360 let desired = aggregate_desired(desired);
362 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
363
364 let can_delete = deletion_allowed(sources);
365
366 for d in &desired {
367 let before = actions.len();
372 plan_desired(d, manifest, local, can_delete, &mut actions);
373 let audio_deleted = actions[before..]
374 .iter()
375 .any(|a| matches!(a, Action::Delete { .. }));
376 if audio_deleted {
377 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
378 } else {
379 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
380 }
381 }
382
383 for (clip_id, _entry) in manifest.iter() {
385 if desired_ids.contains(clip_id.as_str()) {
386 continue;
387 }
388 match delete_action(clip_id, manifest, can_delete) {
389 Some(action) => {
390 actions.push(action);
391 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
393 }
394 None => actions.push(Action::Skip {
397 clip_id: clip_id.clone(),
398 }),
399 }
400 }
401
402 suppress_path_aliasing(&mut actions);
403 Plan { actions }
404}
405
406pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
417 let mut saw_mirror = false;
418 for status in sources {
419 if !status.fully_enumerated {
420 return false;
421 }
422 if status.mode == SourceMode::Mirror {
423 saw_mirror = true;
424 }
425 }
426 saw_mirror
427}
428
429fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
435 if !can_delete {
436 return None;
437 }
438 let entry = manifest.get(clip_id)?;
439 if entry.path.is_empty() || entry.preserve {
440 return None;
441 }
442 Some(Action::Delete {
443 path: entry.path.clone(),
444 clip_id: clip_id.to_string(),
445 })
446}
447
448fn delete_artifact_action(
458 owner_id: &str,
459 kind: ArtifactKind,
460 path: &str,
461 manifest: &Manifest,
462 can_delete: bool,
463) -> Option<Action> {
464 if !can_delete {
465 return None;
466 }
467 let entry = manifest.get(owner_id)?;
468 if path.is_empty() || entry.preserve {
469 return None;
470 }
471 Some(Action::DeleteArtifact {
472 kind,
473 path: path.to_string(),
474 owner_id: owner_id.to_string(),
475 })
476}
477
478fn is_per_clip_kind(kind: ArtifactKind) -> bool {
484 matches!(
485 kind,
486 ArtifactKind::CoverJpg
487 | ArtifactKind::CoverWebp
488 | ArtifactKind::DetailsTxt
489 | ArtifactKind::LyricsTxt
490 | ArtifactKind::Lrc
491 )
492}
493
494fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
515 match kind {
516 ArtifactKind::CoverJpg
517 | ArtifactKind::CoverWebp
518 | ArtifactKind::LyricsTxt
519 | ArtifactKind::Lrc => false,
520 ArtifactKind::DetailsTxt
521 | ArtifactKind::FolderJpg
522 | ArtifactKind::FolderWebp
523 | ArtifactKind::Playlist => true,
524 }
525}
526
527fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
532 match kind {
533 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
534 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
535 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
536 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
537 ArtifactKind::Lrc => entry.lrc.as_ref(),
538 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
539 }
540}
541
542fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
545 let mut out = Vec::new();
546 if let Some(state) = &entry.cover_jpg {
547 out.push((ArtifactKind::CoverJpg, state));
548 }
549 if let Some(state) = &entry.cover_webp {
550 out.push((ArtifactKind::CoverWebp, state));
551 }
552 if let Some(state) = &entry.details_txt {
553 out.push((ArtifactKind::DetailsTxt, state));
554 }
555 if let Some(state) = &entry.lyrics_txt {
556 out.push((ArtifactKind::LyricsTxt, state));
557 }
558 if let Some(state) = &entry.lrc {
559 out.push((ArtifactKind::Lrc, state));
560 }
561 out
562}
563
564pub(crate) fn set_manifest_artifact(
571 entry: &mut ManifestEntry,
572 kind: ArtifactKind,
573 state: Option<ArtifactState>,
574) {
575 match kind {
576 ArtifactKind::CoverJpg => entry.cover_jpg = state,
577 ArtifactKind::CoverWebp => entry.cover_webp = state,
578 ArtifactKind::DetailsTxt => entry.details_txt = state,
579 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
580 ArtifactKind::Lrc => entry.lrc = state,
581 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
582 }
583}
584
585fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
595 let owner_id = d.clip.id.as_str();
596 let entry = manifest.get(owner_id);
597
598 for artifact in &d.artifacts {
599 if !is_per_clip_kind(artifact.kind) {
603 continue;
604 }
605 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
614 None => true,
615 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
616 };
617 if needs_write {
618 out.push(Action::WriteArtifact {
619 kind: artifact.kind,
620 path: artifact.path.clone(),
621 source_url: artifact.source_url.clone(),
622 hash: artifact.hash.clone(),
623 owner_id: owner_id.to_string(),
624 content: artifact.content.clone(),
625 });
626 }
627 }
628
629 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
634 if !protected_now && let Some(entry) = entry {
635 let desired_kinds: BTreeSet<ArtifactKind> = d
636 .artifacts
637 .iter()
638 .filter(|a| is_per_clip_kind(a.kind))
639 .map(|a| a.kind)
640 .collect();
641 for (kind, state) in manifest_artifacts(entry) {
642 if removed_kind_delete_eligible(kind)
648 && !desired_kinds.contains(&kind)
649 && let Some(action) =
650 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
651 {
652 out.push(action);
653 }
654 }
655 }
656}
657
658fn co_delete_artifacts(
664 owner_id: &str,
665 manifest: &Manifest,
666 can_delete: bool,
667 out: &mut Vec<Action>,
668) {
669 let Some(entry) = manifest.get(owner_id) else {
670 return;
671 };
672 for (kind, state) in manifest_artifacts(entry) {
673 if let Some(action) =
674 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
675 {
676 out.push(action);
677 }
678 }
679}
680
681fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
688 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
689 for d in desired {
690 match by_id.get_mut(d.clip.id.as_str()) {
691 None => {
692 by_id.insert(d.clip.id.as_str(), d.clone());
693 }
694 Some(acc) => {
695 let take = rep_key(d) < rep_key(acc);
696 acc.private = acc.private || d.private;
697 acc.trashed = acc.trashed && d.trashed;
698 for mode in &d.modes {
699 if !acc.modes.contains(mode) {
700 acc.modes.push(*mode);
701 }
702 }
703 if take {
704 acc.clip = d.clip.clone();
705 acc.path = d.path.clone();
706 acc.format = d.format;
707 acc.meta_hash = d.meta_hash.clone();
708 acc.art_hash = d.art_hash.clone();
709 acc.artifacts = d.artifacts.clone();
710 }
711 }
712 }
713 }
714 let mut out: Vec<Desired> = by_id.into_values().collect();
715 for d in &mut out {
716 let has_mirror = d.modes.contains(&SourceMode::Mirror);
718 let has_copy = d.modes.contains(&SourceMode::Copy);
719 d.modes.clear();
720 if has_mirror {
721 d.modes.push(SourceMode::Mirror);
722 }
723 if has_copy {
724 d.modes.push(SourceMode::Copy);
725 }
726 }
727 out
728}
729
730fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
733 let format = match d.format {
734 AudioFormat::Mp3 => 0,
735 AudioFormat::Flac => 1,
736 AudioFormat::Wav => 2,
737 };
738 (
739 d.path.as_str(),
740 d.meta_hash.as_str(),
741 d.art_hash.as_str(),
742 format,
743 )
744}
745
746fn suppress_path_aliasing(actions: &mut [Action]) {
751 let targets: BTreeSet<String> = actions
752 .iter()
753 .filter_map(|a| match a {
754 Action::Download { path, .. }
755 | Action::Reformat { path, .. }
756 | Action::WriteArtifact { path, .. } => Some(path.clone()),
757 Action::Rename { to, .. } => Some(to.clone()),
758 _ => None,
759 })
760 .collect();
761 for a in actions.iter_mut() {
762 if let Action::Delete { path, clip_id } = a
763 && targets.contains(path.as_str())
764 {
765 *a = Action::Skip {
766 clip_id: clip_id.clone(),
767 };
768 }
769 if let Action::DeleteArtifact { path, owner_id, .. } = a
770 && targets.contains(path.as_str())
771 {
772 *a = Action::Skip {
773 clip_id: owner_id.clone(),
774 };
775 }
776 }
777}
778
779fn plan_desired(
781 d: &Desired,
782 manifest: &Manifest,
783 local: &HashMap<String, LocalFile>,
784 can_delete: bool,
785 out: &mut Vec<Action>,
786) {
787 let clip_id = d.clip.id.as_str();
788 let copy_held = d.modes.contains(&SourceMode::Copy);
789
790 if d.trashed && !d.private && !copy_held {
796 match delete_action(clip_id, manifest, can_delete) {
797 Some(action) => out.push(action),
798 None => out.push(Action::Skip {
799 clip_id: clip_id.to_string(),
800 }),
801 }
802 return;
803 }
804
805 let Some(entry) = manifest.get(clip_id) else {
806 out.push(Action::Download {
808 clip: d.clip.clone(),
809 lineage: d.lineage.clone(),
810 path: d.path.clone(),
811 format: d.format,
812 });
813 return;
814 };
815
816 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
819 if missing {
820 out.push(Action::Download {
821 clip: d.clip.clone(),
822 lineage: d.lineage.clone(),
823 path: d.path.clone(),
824 format: d.format,
825 });
826 return;
827 }
828
829 if d.format != entry.format {
830 out.push(Action::Reformat {
833 clip: d.clip.clone(),
834 path: d.path.clone(),
835 from_path: entry.path.clone(),
836 from: entry.format,
837 to: d.format,
838 });
839 return;
840 }
841
842 if d.path != entry.path {
843 out.push(Action::Rename {
844 from: entry.path.clone(),
845 to: d.path.clone(),
846 });
847 if meta_or_art_changed(d, entry) {
849 out.push(Action::Retag {
850 clip: d.clip.clone(),
851 lineage: d.lineage.clone(),
852 path: d.path.clone(),
853 });
854 }
855 return;
856 }
857
858 if meta_or_art_changed(d, entry) {
859 out.push(Action::Retag {
860 clip: d.clip.clone(),
861 lineage: d.lineage.clone(),
862 path: entry.path.clone(),
863 });
864 return;
865 }
866
867 out.push(Action::Skip {
868 clip_id: clip_id.to_string(),
869 });
870}
871
872fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
874 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
875}
876
877pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
897 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
898 for d in desired {
899 groups
900 .entry(d.lineage.root_id.as_str())
901 .or_default()
902 .push(d);
903 }
904
905 groups
906 .into_iter()
907 .map(|(root_id, members)| {
908 let album_dir = album_dir_of(&members);
909 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
910 kind: ArtifactKind::FolderJpg,
911 path: album_child(&album_dir, "folder.jpg"),
912 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
913 hash: art_hash(&source.clip),
914 content: None,
915 });
916 let folder_webp = animated_covers
917 .then(|| folder_webp_source(&members))
918 .flatten()
919 .map(|source| DesiredArtifact {
920 kind: ArtifactKind::FolderWebp,
921 path: album_child(&album_dir, "cover.webp"),
922 source_url: source.clip.video_cover_url.clone(),
923 hash: art_url_hash(&source.clip.video_cover_url),
924 content: None,
925 });
926 AlbumDesired {
927 root_id: root_id.to_owned(),
928 folder_jpg,
929 folder_webp,
930 }
931 })
932 .collect()
933}
934
935fn album_dir_of(members: &[&Desired]) -> String {
940 members
941 .iter()
942 .map(|d| parent_dir(&d.path))
943 .min()
944 .unwrap_or("")
945 .to_owned()
946}
947
948fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
954 members
955 .iter()
956 .copied()
957 .filter(|d| {
958 d.clip
959 .selected_image_url()
960 .is_some_and(|url| !url.is_empty())
961 })
962 .min_by(|a, b| {
963 b.clip
964 .play_count
965 .cmp(&a.clip.play_count)
966 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
967 .then_with(|| a.clip.id.cmp(&b.clip.id))
968 })
969}
970
971fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
976 members
977 .iter()
978 .copied()
979 .filter(|d| !d.clip.video_cover_url.is_empty())
980 .min_by(|a, b| {
981 a.clip
982 .created_at
983 .cmp(&b.clip.created_at)
984 .then_with(|| a.clip.id.cmp(&b.clip.id))
985 })
986}
987
988fn parent_dir(path: &str) -> &str {
990 match path.rsplit_once('/') {
991 Some((dir, _)) => dir,
992 None => "",
993 }
994}
995
996fn album_child(album_dir: &str, name: &str) -> String {
999 if album_dir.is_empty() {
1000 name.to_owned()
1001 } else {
1002 format!("{album_dir}/{name}")
1003 }
1004}
1005
1006pub fn plan_album_artifacts(
1025 desired: &[AlbumDesired],
1026 albums: &BTreeMap<String, AlbumArt>,
1027 can_delete: bool,
1028) -> Vec<Action> {
1029 let mut actions: Vec<Action> = Vec::new();
1030 let by_root: BTreeMap<&str, &AlbumDesired> =
1031 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1032
1033 for d in desired {
1034 let stored = albums.get(&d.root_id);
1035 for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
1036 .into_iter()
1037 .flatten()
1038 {
1039 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1040 None => true,
1041 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1042 };
1043 if needs_write {
1044 actions.push(Action::WriteArtifact {
1045 kind: artifact.kind,
1046 path: artifact.path.clone(),
1047 source_url: artifact.source_url.clone(),
1048 hash: artifact.hash.clone(),
1049 owner_id: d.root_id.clone(),
1050 content: None,
1051 });
1052 }
1053 }
1054 }
1055
1056 if can_delete {
1058 for (root_id, art) in albums {
1059 for (kind, state) in album_artifacts(art) {
1060 let desired_here = by_root
1061 .get(root_id.as_str())
1062 .is_some_and(|d| album_desires_kind(d, kind));
1063 if !desired_here && !state.path.is_empty() {
1064 actions.push(Action::DeleteArtifact {
1065 kind,
1066 path: state.path.clone(),
1067 owner_id: root_id.clone(),
1068 });
1069 }
1070 }
1071 }
1072 }
1073
1074 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1075 actions
1076}
1077
1078fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1081 let mut out = Vec::new();
1082 if let Some(state) = &art.folder_jpg {
1083 out.push((ArtifactKind::FolderJpg, state));
1084 }
1085 if let Some(state) = &art.folder_webp {
1086 out.push((ArtifactKind::FolderWebp, state));
1087 }
1088 out
1089}
1090
1091fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1093 match kind {
1094 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1095 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1096 ArtifactKind::CoverJpg
1097 | ArtifactKind::CoverWebp
1098 | ArtifactKind::DetailsTxt
1099 | ArtifactKind::LyricsTxt
1100 | ArtifactKind::Lrc
1101 | ArtifactKind::Playlist => false,
1102 }
1103}
1104
1105fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1107 match action {
1108 Action::WriteArtifact { owner_id, kind, .. }
1109 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1110 _ => ("", ArtifactKind::CoverJpg),
1111 }
1112}
1113
1114pub fn plan_playlist_artifacts(
1147 desired: &[PlaylistDesired],
1148 stored: &BTreeMap<String, PlaylistState>,
1149 can_delete: bool,
1150 list_fully_enumerated: bool,
1151) -> Vec<Action> {
1152 let mut actions: Vec<Action> = Vec::new();
1153 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1154 let deletes_allowed = can_delete && list_fully_enumerated;
1157
1158 for d in desired {
1159 let stored_here = stored.get(&d.id);
1160 let needs_write = match stored_here {
1161 None => true,
1162 Some(state) => state.hash != d.hash || state.path != d.path,
1163 };
1164 if needs_write {
1165 actions.push(Action::WriteArtifact {
1166 kind: ArtifactKind::Playlist,
1167 path: d.path.clone(),
1168 source_url: String::new(),
1169 hash: d.hash.clone(),
1170 owner_id: d.id.clone(),
1171 content: Some(d.content.clone()),
1172 });
1173 }
1174 if deletes_allowed
1176 && let Some(state) = stored_here
1177 && !state.path.is_empty()
1178 && state.path != d.path
1179 {
1180 actions.push(Action::DeleteArtifact {
1181 kind: ArtifactKind::Playlist,
1182 path: state.path.clone(),
1183 owner_id: d.id.clone(),
1184 });
1185 }
1186 }
1187
1188 if deletes_allowed {
1191 for (id, state) in stored {
1192 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1193 actions.push(Action::DeleteArtifact {
1194 kind: ArtifactKind::Playlist,
1195 path: state.path.clone(),
1196 owner_id: id.clone(),
1197 });
1198 }
1199 }
1200 }
1201
1202 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1203 suppress_path_aliasing(&mut actions);
1206 actions
1207}
1208
1209fn playlist_action_key(action: &Action) -> (&str, u8) {
1212 match action {
1213 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1214 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1215 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1216 _ => ("", 3),
1217 }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use crate::hash::content_hash;
1224
1225 fn clip(id: &str) -> Clip {
1226 Clip {
1227 id: id.to_string(),
1228 title: "Song".to_string(),
1229 ..Default::default()
1230 }
1231 }
1232
1233 fn lineage(id: &str) -> LineageContext {
1234 LineageContext::own_root(&clip(id))
1235 }
1236
1237 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1238 ManifestEntry {
1239 path: path.to_string(),
1240 format,
1241 meta_hash: meta.to_string(),
1242 art_hash: art.to_string(),
1243 size: 100,
1244 preserve: false,
1245 ..Default::default()
1246 }
1247 }
1248
1249 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1250 ManifestEntry {
1251 preserve: true,
1252 ..entry(path, format, meta, art)
1253 }
1254 }
1255
1256 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1257 Desired {
1258 clip: clip(id),
1259 lineage: lineage(id),
1260 path: path.to_string(),
1261 format,
1262 meta_hash: meta.to_string(),
1263 art_hash: art.to_string(),
1264 modes: vec![SourceMode::Mirror],
1265 trashed: false,
1266 private: false,
1267 artifacts: Vec::new(),
1268 }
1269 }
1270
1271 fn present(size: u64) -> LocalFile {
1272 LocalFile { exists: true, size }
1273 }
1274
1275 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1276 [(id.to_string(), present(100))].into_iter().collect()
1277 }
1278
1279 fn mirror_ok() -> Vec<SourceStatus> {
1280 vec![SourceStatus {
1281 mode: SourceMode::Mirror,
1282 fully_enumerated: true,
1283 }]
1284 }
1285
1286 #[test]
1289 fn not_in_manifest_downloads() {
1290 let manifest = Manifest::new();
1291 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1292 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1293 assert_eq!(
1294 plan.actions,
1295 vec![Action::Download {
1296 clip: clip("a"),
1297 lineage: lineage("a"),
1298 path: "a.flac".to_string(),
1299 format: AudioFormat::Flac,
1300 }]
1301 );
1302 }
1303
1304 #[test]
1305 fn unchanged_clip_skips() {
1306 let mut manifest = Manifest::new();
1307 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1308 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1309 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1310 assert_eq!(
1311 plan.actions,
1312 vec![Action::Skip {
1313 clip_id: "a".to_string()
1314 }]
1315 );
1316 }
1317
1318 #[test]
1319 fn meta_change_retags_in_place() {
1320 let mut manifest = Manifest::new();
1321 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1322 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1323 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1324 assert_eq!(
1325 plan.actions,
1326 vec![Action::Retag {
1327 clip: clip("a"),
1328 lineage: lineage("a"),
1329 path: "a.flac".to_string(),
1330 }]
1331 );
1332 }
1333
1334 #[test]
1335 fn art_change_retags_in_place() {
1336 let mut manifest = Manifest::new();
1337 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1338 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "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 rename_when_path_changes() {
1352 let mut manifest = Manifest::new();
1353 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1354 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1355 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1356 assert_eq!(
1357 plan.actions,
1358 vec![Action::Rename {
1359 from: "old/a.flac".to_string(),
1360 to: "new/a.flac".to_string(),
1361 }]
1362 );
1363 }
1364
1365 #[test]
1366 fn rename_with_meta_change_also_retags() {
1367 let mut manifest = Manifest::new();
1368 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1369 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1370 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1371 assert_eq!(
1372 plan.actions,
1373 vec![
1374 Action::Rename {
1375 from: "old/a.flac".to_string(),
1376 to: "new/a.flac".to_string(),
1377 },
1378 Action::Retag {
1379 clip: clip("a"),
1380 lineage: lineage("a"),
1381 path: "new/a.flac".to_string(),
1382 },
1383 ]
1384 );
1385 }
1386
1387 #[test]
1388 fn rename_without_meta_change_does_not_retag() {
1389 let mut manifest = Manifest::new();
1390 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1391 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1392 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1393 assert_eq!(plan.renames(), 1);
1394 assert_eq!(plan.retags(), 0);
1395 }
1396
1397 #[test]
1398 fn format_change_reformats() {
1399 let mut manifest = Manifest::new();
1400 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1401 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1402 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1403 assert_eq!(
1404 plan.actions,
1405 vec![Action::Reformat {
1406 clip: clip("a"),
1407 path: "a.mp3".to_string(),
1408 from_path: "a.flac".to_string(),
1409 from: AudioFormat::Flac,
1410 to: AudioFormat::Mp3,
1411 }]
1412 );
1413 }
1414
1415 #[test]
1416 fn format_change_takes_precedence_over_rename_and_retag() {
1417 let mut manifest = Manifest::new();
1420 manifest.insert(
1421 "a",
1422 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1423 );
1424 let d = vec![desired(
1425 "a",
1426 "new/a.mp3",
1427 AudioFormat::Mp3,
1428 "new",
1429 "new-art",
1430 )];
1431 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1432 assert_eq!(plan.reformats(), 1);
1433 assert_eq!(plan.renames(), 0);
1434 assert_eq!(plan.retags(), 0);
1435 }
1436
1437 #[test]
1440 fn zero_length_file_downloads_even_when_hashes_match() {
1441 let mut manifest = Manifest::new();
1442 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1443 let local: HashMap<String, LocalFile> = [(
1444 "a".to_string(),
1445 LocalFile {
1446 exists: true,
1447 size: 0,
1448 },
1449 )]
1450 .into_iter()
1451 .collect();
1452 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1453 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1454 assert_eq!(plan.downloads(), 1);
1455 assert_eq!(plan.skips(), 0);
1456 }
1457
1458 #[test]
1459 fn missing_file_downloads_even_when_hashes_match() {
1460 let mut manifest = Manifest::new();
1461 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1462 let local: HashMap<String, LocalFile> = [(
1463 "a".to_string(),
1464 LocalFile {
1465 exists: false,
1466 size: 0,
1467 },
1468 )]
1469 .into_iter()
1470 .collect();
1471 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1472 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1473 assert_eq!(plan.downloads(), 1);
1474 }
1475
1476 #[test]
1477 fn absent_local_probe_treated_as_missing() {
1478 let mut manifest = Manifest::new();
1480 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1481 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1482 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1483 assert_eq!(plan.downloads(), 1);
1484 }
1485
1486 #[test]
1487 fn missing_file_download_wins_over_format_difference() {
1488 let mut manifest = Manifest::new();
1491 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1492 let local: HashMap<String, LocalFile> = [(
1493 "a".to_string(),
1494 LocalFile {
1495 exists: false,
1496 size: 0,
1497 },
1498 )]
1499 .into_iter()
1500 .collect();
1501 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1502 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1503 assert_eq!(plan.downloads(), 1);
1504 assert_eq!(plan.reformats(), 0);
1505 }
1506
1507 #[test]
1510 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1511 let mut trashed = clip("a");
1516 trashed.status = "complete".to_string();
1517 trashed.is_trashed = true;
1518 assert!(crate::is_downloadable(&trashed));
1519
1520 let mut manifest = Manifest::new();
1521 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1522 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1523 d.clip = trashed;
1524 d.trashed = true;
1525 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1526 assert_eq!(
1527 plan.actions,
1528 vec![Action::Delete {
1529 path: "a.flac".to_string(),
1530 clip_id: "a".to_string(),
1531 }]
1532 );
1533 }
1534
1535 #[test]
1536 fn trashed_clip_deletes_local_file() {
1537 let mut manifest = Manifest::new();
1538 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1539 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
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_not_in_manifest_skips() {
1553 let manifest = Manifest::new();
1555 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1556 d.trashed = true;
1557 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1558 assert_eq!(
1559 plan.actions,
1560 vec![Action::Skip {
1561 clip_id: "a".to_string()
1562 }]
1563 );
1564 }
1565
1566 #[test]
1567 fn private_clip_is_kept() {
1568 let mut manifest = Manifest::new();
1569 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1570 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1571 d.private = true;
1572 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1573 assert_eq!(
1574 plan.actions,
1575 vec![Action::Skip {
1576 clip_id: "a".to_string()
1577 }]
1578 );
1579 }
1580
1581 #[test]
1582 fn private_beats_trashed_never_deletes() {
1583 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.trashed = true;
1588 d.private = true;
1589 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1590 assert_eq!(plan.deletes(), 0);
1591 assert_eq!(plan.skips(), 1);
1592 }
1593
1594 #[test]
1595 fn copy_held_trashed_clip_is_not_deleted() {
1596 let mut manifest = Manifest::new();
1599 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1600 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1601 d.modes = vec![SourceMode::Copy];
1602 d.trashed = true;
1603 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1604 assert_eq!(plan.deletes(), 0);
1605 assert_eq!(
1606 plan.actions,
1607 vec![Action::Skip {
1608 clip_id: "a".to_string()
1609 }]
1610 );
1611 }
1612
1613 #[test]
1616 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1617 let mut manifest = Manifest::new();
1618 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1619 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1620 assert_eq!(
1621 plan.actions,
1622 vec![Action::Delete {
1623 path: "gone.flac".to_string(),
1624 clip_id: "gone".to_string(),
1625 }]
1626 );
1627 }
1628
1629 #[test]
1630 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1631 let mut manifest = Manifest::new();
1632 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1633 let sources = vec![
1634 SourceStatus {
1635 mode: SourceMode::Mirror,
1636 fully_enumerated: true,
1637 },
1638 SourceStatus {
1639 mode: SourceMode::Mirror,
1640 fully_enumerated: false,
1641 },
1642 ];
1643 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1644 assert_eq!(plan.deletes(), 0);
1645 assert_eq!(
1646 plan.actions,
1647 vec![Action::Skip {
1648 clip_id: "gone".to_string()
1649 }]
1650 );
1651 }
1652
1653 #[test]
1654 fn empty_listing_cannot_cause_deletion() {
1655 let mut manifest = Manifest::new();
1658 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1659 let sources = vec![SourceStatus {
1660 mode: SourceMode::Mirror,
1661 fully_enumerated: false,
1662 }];
1663 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1664 assert_eq!(plan.deletes(), 0);
1665 assert_eq!(plan.skips(), 1);
1666 }
1667
1668 #[test]
1669 fn no_mirror_sources_means_no_deletion() {
1670 let mut manifest = Manifest::new();
1672 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1673 let copy_only = vec![SourceStatus {
1674 mode: SourceMode::Copy,
1675 fully_enumerated: true,
1676 }];
1677 assert_eq!(
1678 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1679 0
1680 );
1681 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1682 }
1683
1684 #[test]
1685 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1686 let mut manifest = Manifest::new();
1687 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1688 let sources = vec![
1689 SourceStatus {
1690 mode: SourceMode::Copy,
1691 fully_enumerated: true,
1692 },
1693 SourceStatus {
1694 mode: SourceMode::Mirror,
1695 fully_enumerated: false,
1696 },
1697 ];
1698 assert_eq!(
1699 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1700 0
1701 );
1702 }
1703
1704 #[test]
1705 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1706 let mut manifest = Manifest::new();
1710 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1711 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1712 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1713 held.modes = vec![SourceMode::Copy];
1714 let local: HashMap<String, LocalFile> = [
1715 ("keep".to_string(), present(100)),
1716 ("gone".to_string(), present(100)),
1717 ]
1718 .into_iter()
1719 .collect();
1720 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1721 assert!(plan.actions.contains(&Action::Skip {
1722 clip_id: "keep".to_string()
1723 }));
1724 assert!(plan.actions.contains(&Action::Delete {
1725 path: "gone.flac".to_string(),
1726 clip_id: "gone".to_string(),
1727 }));
1728 assert!(
1730 !plan
1731 .actions
1732 .iter()
1733 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1734 );
1735 }
1736
1737 #[test]
1740 fn orphan_with_preserve_marker_is_kept() {
1741 let mut manifest = Manifest::new();
1744 manifest.insert(
1745 "gone",
1746 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1747 );
1748 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1749 assert_eq!(plan.deletes(), 0);
1750 assert_eq!(
1751 plan.actions,
1752 vec![Action::Skip {
1753 clip_id: "gone".to_string()
1754 }]
1755 );
1756 }
1757
1758 #[test]
1759 fn trashed_clip_with_preserve_marker_is_kept() {
1760 let mut manifest = Manifest::new();
1763 manifest.insert(
1764 "a",
1765 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1766 );
1767 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1768 d.trashed = true;
1769 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1770 assert_eq!(plan.deletes(), 0);
1771 assert_eq!(plan.skips(), 1);
1772 }
1773
1774 #[test]
1777 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1778 let mut manifest = Manifest::new();
1780 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1781 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1782 d.trashed = true;
1783 let sources = vec![SourceStatus {
1784 mode: SourceMode::Mirror,
1785 fully_enumerated: false,
1786 }];
1787 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1788 assert_eq!(plan.deletes(), 0);
1789 assert_eq!(plan.skips(), 1);
1790 }
1791
1792 #[test]
1793 fn trashed_clip_kept_when_sources_empty() {
1794 let mut manifest = Manifest::new();
1797 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1798 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1799 d.trashed = true;
1800 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1801 assert_eq!(plan.deletes(), 0);
1802 assert_eq!(plan.skips(), 1);
1803 }
1804
1805 #[test]
1806 fn failed_copy_listing_suppresses_orphan_deletion() {
1807 let mut manifest = Manifest::new();
1810 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1811 let sources = vec![
1812 SourceStatus {
1813 mode: SourceMode::Mirror,
1814 fully_enumerated: true,
1815 },
1816 SourceStatus {
1817 mode: SourceMode::Copy,
1818 fully_enumerated: false,
1819 },
1820 ];
1821 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1822 assert_eq!(plan.deletes(), 0);
1823 }
1824
1825 #[test]
1826 fn failed_copy_listing_suppresses_trashed_deletion() {
1827 let mut manifest = Manifest::new();
1828 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1829 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1830 d.trashed = true;
1831 let sources = vec![
1832 SourceStatus {
1833 mode: SourceMode::Mirror,
1834 fully_enumerated: true,
1835 },
1836 SourceStatus {
1837 mode: SourceMode::Copy,
1838 fully_enumerated: false,
1839 },
1840 ];
1841 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1842 assert_eq!(plan.deletes(), 0);
1843 assert_eq!(plan.skips(), 1);
1844 }
1845
1846 #[test]
1847 fn empty_path_entry_never_deletes() {
1848 let mut manifest = Manifest::new();
1851 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1852 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1853 assert_eq!(plan.deletes(), 0);
1854 assert_eq!(
1855 plan.actions,
1856 vec![Action::Skip {
1857 clip_id: "gone".to_string()
1858 }]
1859 );
1860 }
1861
1862 #[test]
1865 fn delete_suppressed_when_path_aliases_rename_target() {
1866 let mut manifest = Manifest::new();
1869 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1870 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1871 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1872 let local: HashMap<String, LocalFile> = [
1873 ("a".to_string(), present(100)),
1874 ("b".to_string(), present(100)),
1875 ]
1876 .into_iter()
1877 .collect();
1878 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1879 assert!(plan.actions.contains(&Action::Rename {
1880 from: "old/a.flac".to_string(),
1881 to: "new/a.flac".to_string(),
1882 }));
1883 assert!(
1885 !plan
1886 .actions
1887 .iter()
1888 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1889 );
1890 assert!(plan.actions.contains(&Action::Skip {
1891 clip_id: "b".to_string()
1892 }));
1893 }
1894
1895 #[test]
1896 fn delete_suppressed_when_path_aliases_download_target() {
1897 let mut manifest = Manifest::new();
1899 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1900 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1901 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1902 assert!(
1903 !plan
1904 .actions
1905 .iter()
1906 .any(|a| matches!(a, Action::Delete { .. }))
1907 );
1908 assert_eq!(plan.downloads(), 1);
1909 }
1910
1911 #[test]
1912 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1913 let mut actions = vec![
1918 Action::Rename {
1919 from: "old/song.flac".to_string(),
1920 to: "new/cover.jpg".to_string(),
1921 },
1922 Action::DeleteArtifact {
1923 kind: ArtifactKind::CoverJpg,
1924 path: "new/cover.jpg".to_string(),
1925 owner_id: "a".to_string(),
1926 },
1927 ];
1928 suppress_path_aliasing(&mut actions);
1929 assert!(
1931 !actions
1932 .iter()
1933 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1934 "a sidecar delete must not alias a rename target"
1935 );
1936 assert!(actions.contains(&Action::Skip {
1937 clip_id: "a".to_string()
1938 }));
1939 assert!(actions.contains(&Action::Rename {
1941 from: "old/song.flac".to_string(),
1942 to: "new/cover.jpg".to_string(),
1943 }));
1944 }
1945
1946 #[test]
1947 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1948 let mut actions = vec![
1951 Action::WriteArtifact {
1952 kind: ArtifactKind::FolderJpg,
1953 path: "creator/album/folder.jpg".to_string(),
1954 source_url: "https://art/large.jpg".to_string(),
1955 hash: "h".to_string(),
1956 owner_id: "root".to_string(),
1957 content: None,
1958 },
1959 Action::DeleteArtifact {
1960 kind: ArtifactKind::FolderJpg,
1961 path: "creator/album/folder.jpg".to_string(),
1962 owner_id: "root-old".to_string(),
1963 },
1964 ];
1965 suppress_path_aliasing(&mut actions);
1966 assert!(
1967 !actions
1968 .iter()
1969 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1970 );
1971 assert!(actions.contains(&Action::Skip {
1972 clip_id: "root-old".to_string()
1973 }));
1974 }
1975
1976 #[test]
1979 fn duplicate_trashed_does_not_defeat_copy_sibling() {
1980 let mut manifest = Manifest::new();
1983 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1984 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1985 copy_entry.modes = vec![SourceMode::Copy];
1986 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1987 trashed_entry.modes = vec![SourceMode::Mirror];
1988 trashed_entry.trashed = true;
1989 let plan = reconcile(
1990 &manifest,
1991 &[copy_entry, trashed_entry],
1992 &local_present("a"),
1993 &mirror_ok(),
1994 );
1995 assert_eq!(plan.deletes(), 0);
1996 assert_eq!(plan.skips(), 1);
1997 }
1998
1999 #[test]
2000 fn duplicate_trashed_does_not_defeat_private_sibling() {
2001 let mut manifest = Manifest::new();
2002 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2003 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2004 private_entry.private = true;
2005 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2006 trashed_entry.trashed = true;
2007 let plan = reconcile(
2008 &manifest,
2009 &[private_entry, trashed_entry],
2010 &local_present("a"),
2011 &mirror_ok(),
2012 );
2013 assert_eq!(plan.deletes(), 0);
2014 assert_eq!(plan.skips(), 1);
2015 }
2016
2017 #[test]
2018 fn duplicate_trashed_deletes_only_when_all_trashed() {
2019 let mut manifest = Manifest::new();
2021 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2022 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2023 first.trashed = true;
2024 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2025 second.trashed = true;
2026 let plan = reconcile(
2027 &manifest,
2028 &[first, second],
2029 &local_present("a"),
2030 &mirror_ok(),
2031 );
2032 assert_eq!(plan.deletes(), 1);
2033 }
2034
2035 #[test]
2036 fn duplicate_desired_unions_modes() {
2037 let mut manifest = Manifest::new();
2039 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2040 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2041 mirror_entry.modes = vec![SourceMode::Mirror];
2042 mirror_entry.trashed = true;
2043 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2044 copy_entry.modes = vec![SourceMode::Copy];
2045 let plan = reconcile(
2046 &manifest,
2047 &[mirror_entry, copy_entry],
2048 &local_present("a"),
2049 &mirror_ok(),
2050 );
2051 assert_eq!(plan.deletes(), 0);
2053 }
2054
2055 #[test]
2058 fn private_new_clip_downloads() {
2059 let manifest = Manifest::new();
2062 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2063 d.private = true;
2064 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2065 assert_eq!(plan.downloads(), 1);
2066 }
2067
2068 #[test]
2069 fn private_zero_length_file_redownloads() {
2070 let mut manifest = Manifest::new();
2071 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2072 let local: HashMap<String, LocalFile> = [(
2073 "a".to_string(),
2074 LocalFile {
2075 exists: true,
2076 size: 0,
2077 },
2078 )]
2079 .into_iter()
2080 .collect();
2081 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2082 d.private = true;
2083 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2084 assert_eq!(plan.downloads(), 1);
2085 }
2086
2087 #[test]
2088 fn private_meta_change_retags() {
2089 let mut manifest = Manifest::new();
2090 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2091 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2092 d.private = true;
2093 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2094 assert_eq!(plan.retags(), 1);
2095 assert_eq!(plan.deletes(), 0);
2096 }
2097
2098 #[test]
2099 fn absent_private_clip_protected_by_preserve_marker() {
2100 let mut manifest = Manifest::new();
2103 manifest.insert(
2104 "a",
2105 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2106 );
2107 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2108 assert_eq!(plan.deletes(), 0);
2109 assert_eq!(plan.skips(), 1);
2110 }
2111
2112 #[test]
2115 fn output_is_deterministic_regardless_of_input_order() {
2116 let mut manifest = Manifest::new();
2117 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2118 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2119 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2120 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2121 .iter()
2122 .map(|id| (id.to_string(), present(100)))
2123 .collect();
2124
2125 let forward = vec![
2126 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2127 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2128 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2129 ];
2130 let mut reversed = forward.clone();
2131 reversed.reverse();
2132
2133 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2134 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2135 assert_eq!(p1.actions, p2.actions);
2136
2137 let ids: Vec<&str> = p1
2140 .actions
2141 .iter()
2142 .map(|a| match a {
2143 Action::Skip { clip_id } => clip_id.as_str(),
2144 Action::Retag { clip, .. } => clip.id.as_str(),
2145 Action::Download { clip, .. } => clip.id.as_str(),
2146 Action::Delete { clip_id, .. } => clip_id.as_str(),
2147 Action::Reformat { clip, .. } => clip.id.as_str(),
2148 Action::Rename { to, .. } => to.as_str(),
2149 Action::WriteArtifact { owner_id, .. }
2150 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2151 })
2152 .collect();
2153 assert_eq!(ids, ["a", "b", "c", "z"]);
2154 }
2155
2156 #[test]
2157 fn empty_inputs_do_not_panic() {
2158 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2159 assert!(plan.is_empty());
2160 assert_eq!(plan.len(), 0);
2161 }
2162
2163 #[test]
2164 fn empty_desired_with_full_manifest_deletes_all() {
2165 let mut manifest = Manifest::new();
2166 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2167 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2168 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2169 assert_eq!(plan.deletes(), 2);
2170 }
2171
2172 #[test]
2173 fn full_desired_with_empty_manifest_downloads_all() {
2174 let d = vec![
2175 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2176 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2177 ];
2178 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2179 assert_eq!(plan.downloads(), 2);
2180 }
2181
2182 #[test]
2183 fn plan_counts_sum_to_len() {
2184 let mut manifest = Manifest::new();
2185 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2186 manifest.insert(
2187 "retag",
2188 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2189 );
2190 manifest.insert(
2191 "reformat",
2192 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2193 );
2194 manifest.insert(
2195 "rename",
2196 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2197 );
2198 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2199 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2200 .iter()
2201 .map(|id| (id.to_string(), present(100)))
2202 .collect();
2203 let d = vec![
2204 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2205 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2206 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2207 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2208 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2209 ];
2210 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2211 let summed = plan.downloads()
2212 + plan.reformats()
2213 + plan.retags()
2214 + plan.renames()
2215 + plan.deletes()
2216 + plan.skips();
2217 assert_eq!(summed, plan.len());
2218 assert_eq!(plan.downloads(), 1);
2219 assert_eq!(plan.reformats(), 1);
2220 assert_eq!(plan.retags(), 1);
2221 assert_eq!(plan.renames(), 1);
2222 assert_eq!(plan.deletes(), 1);
2223 assert_eq!(plan.skips(), 1);
2224 }
2225
2226 fn cover(path: &str, hash: &str) -> ArtifactState {
2229 ArtifactState {
2230 path: path.to_string(),
2231 hash: hash.to_string(),
2232 }
2233 }
2234
2235 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2236 DesiredArtifact {
2237 kind,
2238 path: path.to_string(),
2239 source_url: url.to_string(),
2240 hash: hash.to_string(),
2241 content: None,
2242 }
2243 }
2244
2245 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2247 DesiredArtifact {
2248 kind,
2249 path: path.to_string(),
2250 source_url: String::new(),
2251 hash: content_hash(body),
2252 content: Some(body.to_string()),
2253 }
2254 }
2255
2256 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2258 Desired {
2259 artifacts: arts,
2260 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2261 }
2262 }
2263
2264 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2266 ManifestEntry {
2267 cover_jpg: Some(cover(cover_path, cover_hash)),
2268 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2269 }
2270 }
2271
2272 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2273 plan.actions
2274 .iter()
2275 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2276 .collect()
2277 }
2278
2279 #[test]
2280 fn write_artifact_emitted_when_manifest_lacks_it() {
2281 let mut manifest = Manifest::new();
2284 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2285 let d = vec![desired_arts(
2286 "a",
2287 vec![art(
2288 ArtifactKind::CoverJpg,
2289 "a/cover.jpg",
2290 "https://art/a",
2291 "h1",
2292 )],
2293 )];
2294 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2295 assert_eq!(plan.artifact_writes(), 1);
2296 assert_eq!(plan.artifact_deletes(), 0);
2297 assert_eq!(plan.skips(), 1);
2298 assert_eq!(
2299 write_artifacts(&plan)[0],
2300 &Action::WriteArtifact {
2301 kind: ArtifactKind::CoverJpg,
2302 path: "a/cover.jpg".to_string(),
2303 source_url: "https://art/a".to_string(),
2304 hash: "h1".to_string(),
2305 owner_id: "a".to_string(),
2306 content: None,
2307 }
2308 );
2309 }
2310
2311 #[test]
2312 fn write_artifact_emitted_when_hash_differs() {
2313 let mut manifest = Manifest::new();
2316 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2317 let d = vec![desired_arts(
2318 "a",
2319 vec![art(
2320 ArtifactKind::CoverJpg,
2321 "a/cover.jpg",
2322 "https://art/a",
2323 "new",
2324 )],
2325 )];
2326 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2327 assert_eq!(plan.artifact_writes(), 1);
2328 assert_eq!(plan.artifact_deletes(), 0);
2329 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2330 assert_eq!(hash, "new");
2331 } else {
2332 panic!("expected a WriteArtifact");
2333 }
2334 }
2335
2336 #[test]
2337 fn write_artifact_skipped_when_hash_matches() {
2338 let mut manifest = Manifest::new();
2340 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2341 let d = vec![desired_arts(
2342 "a",
2343 vec![art(
2344 ArtifactKind::CoverJpg,
2345 "a/cover.jpg",
2346 "https://art/a",
2347 "h1",
2348 )],
2349 )];
2350 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2351 assert_eq!(plan.artifact_writes(), 0);
2352 assert_eq!(plan.artifact_deletes(), 0);
2353 assert_eq!(
2354 plan.actions,
2355 vec![Action::Skip {
2356 clip_id: "a".to_string()
2357 }]
2358 );
2359 }
2360
2361 #[test]
2362 fn removed_kind_cover_is_kept_not_deleted() {
2363 let mut manifest = Manifest::new();
2368 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2369 let d = vec![desired_arts("a", vec![])];
2370 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2371 assert_eq!(plan.artifact_deletes(), 0);
2372 assert_eq!(plan.artifact_writes(), 0);
2373 assert_eq!(plan.deletes(), 0);
2375 assert_eq!(
2376 plan.actions,
2377 vec![Action::Skip {
2378 clip_id: "a".to_string()
2379 }]
2380 );
2381 assert!(!plan.actions.iter().any(|a| matches!(
2382 a,
2383 Action::DeleteArtifact {
2384 kind: ArtifactKind::CoverJpg,
2385 ..
2386 }
2387 )));
2388 }
2389
2390 #[test]
2391 fn delete_artifact_never_on_incomplete_listing() {
2392 let mut manifest = Manifest::new();
2397 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2398 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2399 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2400 let sources = vec![SourceStatus {
2401 mode: SourceMode::Mirror,
2402 fully_enumerated: false,
2403 }];
2404 let local: HashMap<String, LocalFile> = [
2405 ("a".to_string(), present(100)),
2406 ("b".to_string(), present(100)),
2407 ]
2408 .into_iter()
2409 .collect();
2410 let plan = reconcile(&manifest, &d, &local, &sources);
2411 assert_eq!(plan.artifact_deletes(), 0);
2412 assert_eq!(plan.deletes(), 0);
2413 }
2414
2415 #[test]
2416 fn delete_artifact_never_when_entry_preserved() {
2417 let mut manifest = Manifest::new();
2420 let preserved = ManifestEntry {
2421 preserve: true,
2422 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2423 };
2424 manifest.insert("a", preserved);
2425 let d = vec![desired_arts("a", vec![])];
2426 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2427 assert_eq!(plan.artifact_deletes(), 0);
2428 }
2429
2430 #[test]
2431 fn co_delete_never_when_path_empty() {
2432 let mut manifest = Manifest::new();
2436 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2437 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2438 assert_eq!(plan.deletes(), 1);
2439 assert_eq!(plan.artifact_deletes(), 0);
2440 }
2441
2442 #[test]
2443 fn co_delete_absent_clip_deletes_audio_and_cover() {
2444 let mut manifest = Manifest::new();
2447 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2448 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2449 assert_eq!(plan.deletes(), 1);
2450 assert_eq!(plan.artifact_deletes(), 1);
2451 assert!(plan.actions.contains(&Action::Delete {
2452 path: "gone.flac".to_string(),
2453 clip_id: "gone".to_string(),
2454 }));
2455 assert!(plan.actions.contains(&Action::DeleteArtifact {
2456 kind: ArtifactKind::CoverJpg,
2457 path: "gone/cover.jpg".to_string(),
2458 owner_id: "gone".to_string(),
2459 }));
2460 }
2461
2462 #[test]
2463 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2464 let mut manifest = Manifest::new();
2466 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2467 let sources = vec![SourceStatus {
2468 mode: SourceMode::Mirror,
2469 fully_enumerated: false,
2470 }];
2471 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2472 assert_eq!(plan.deletes(), 0);
2473 assert_eq!(plan.artifact_deletes(), 0);
2474 }
2475
2476 #[test]
2477 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2478 let mut manifest = Manifest::new();
2480 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2481 let mut d = desired_arts("a", vec![]);
2482 d.trashed = true;
2483 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2484 assert_eq!(plan.deletes(), 1);
2485 assert_eq!(plan.artifact_deletes(), 1);
2486 }
2487
2488 #[test]
2489 fn co_delete_trashed_suppressed_when_not_enumerated() {
2490 let mut manifest = Manifest::new();
2492 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2493 let mut d = desired_arts("a", vec![]);
2494 d.trashed = true;
2495 let sources = vec![SourceStatus {
2496 mode: SourceMode::Mirror,
2497 fully_enumerated: false,
2498 }];
2499 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2500 assert_eq!(plan.deletes(), 0);
2501 assert_eq!(plan.artifact_deletes(), 0);
2502 assert_eq!(plan.skips(), 1);
2503 }
2504
2505 #[test]
2506 fn co_delete_trashed_suppressed_when_preserved() {
2507 let mut manifest = Manifest::new();
2509 let preserved = ManifestEntry {
2510 preserve: true,
2511 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2512 };
2513 manifest.insert("a", preserved);
2514 let mut d = desired_arts("a", vec![]);
2515 d.trashed = true;
2516 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2517 assert_eq!(plan.deletes(), 0);
2518 assert_eq!(plan.artifact_deletes(), 0);
2519 }
2520
2521 #[test]
2524 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2525 let mut manifest = Manifest::new();
2528 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2529 let d = vec![desired_arts(
2530 "a",
2531 vec![text_art(
2532 ArtifactKind::DetailsTxt,
2533 "a.details.txt",
2534 "Title: A\n",
2535 )],
2536 )];
2537 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2538 assert_eq!(plan.artifact_writes(), 1);
2539 assert_eq!(plan.artifact_deletes(), 0);
2540 assert_eq!(
2541 write_artifacts(&plan)[0],
2542 &Action::WriteArtifact {
2543 kind: ArtifactKind::DetailsTxt,
2544 path: "a.details.txt".to_string(),
2545 source_url: String::new(),
2546 hash: content_hash("Title: A\n"),
2547 owner_id: "a".to_string(),
2548 content: Some("Title: A\n".to_string()),
2549 }
2550 );
2551 }
2552
2553 #[test]
2554 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2555 let mut manifest = Manifest::new();
2560 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2561 let body = "[re:rs-suno]\nla la\n";
2562 let d = vec![desired_arts(
2563 "a",
2564 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2565 )];
2566 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2567 assert_eq!(plan.artifact_writes(), 1);
2568 assert_eq!(plan.artifact_deletes(), 0);
2569 assert_eq!(
2570 write_artifacts(&plan)[0],
2571 &Action::WriteArtifact {
2572 kind: ArtifactKind::Lrc,
2573 path: "a.lrc".to_string(),
2574 source_url: String::new(),
2575 hash: content_hash(body),
2576 owner_id: "a".to_string(),
2577 content: Some(body.to_string()),
2578 }
2579 );
2580 }
2581
2582 #[test]
2583 fn text_sidecars_skipped_when_hash_and_path_match() {
2584 let mut manifest = Manifest::new();
2586 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2587 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2588 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2589 manifest.insert("a", e);
2590 let d = vec![desired_arts(
2591 "a",
2592 vec![
2593 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2594 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2595 ],
2596 )];
2597 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2598 assert_eq!(plan.artifact_writes(), 0);
2599 assert_eq!(plan.artifact_deletes(), 0);
2600 }
2601
2602 #[test]
2603 fn details_rewritten_when_content_hash_differs() {
2604 let mut manifest = Manifest::new();
2607 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2608 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2609 manifest.insert("a", e);
2610 let d = vec![desired_arts(
2611 "a",
2612 vec![text_art(
2613 ArtifactKind::DetailsTxt,
2614 "a.details.txt",
2615 "Title: New\n",
2616 )],
2617 )];
2618 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2619 assert_eq!(plan.artifact_writes(), 1);
2620 assert_eq!(plan.artifact_deletes(), 0);
2621 }
2622
2623 #[test]
2624 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2625 let mut manifest = Manifest::new();
2629 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2630 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2631 manifest.insert("a", e);
2632 let d = vec![desired_arts(
2633 "a",
2634 vec![text_art(
2635 ArtifactKind::LyricsTxt,
2636 "a.lyrics.txt",
2637 "new words\n",
2638 )],
2639 )];
2640 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2641 assert_eq!(plan.artifact_writes(), 1);
2643 assert_eq!(plan.retags(), 0);
2644 }
2645
2646 #[test]
2647 fn text_sidecar_relocated_when_path_differs() {
2648 let mut manifest = Manifest::new();
2651 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2652 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2653 manifest.insert("a", e);
2654 let d = vec![desired_arts(
2655 "a",
2656 vec![text_art(
2657 ArtifactKind::DetailsTxt,
2658 "new/a.details.txt",
2659 "Title: A\n",
2660 )],
2661 )];
2662 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2663 assert_eq!(plan.artifact_writes(), 1);
2664 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2665 assert_eq!(path, "new/a.details.txt");
2666 } else {
2667 panic!("expected a WriteArtifact");
2668 }
2669 }
2670
2671 #[test]
2672 fn details_removed_kind_is_deleted_when_feature_off() {
2673 let mut manifest = Manifest::new();
2676 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2677 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2678 manifest.insert("a", e);
2679 let d = vec![desired_arts("a", vec![])];
2680 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2681 assert_eq!(plan.artifact_deletes(), 1);
2682 assert!(plan.actions.contains(&Action::DeleteArtifact {
2683 kind: ArtifactKind::DetailsTxt,
2684 path: "a.details.txt".to_string(),
2685 owner_id: "a".to_string(),
2686 }));
2687 }
2688
2689 #[test]
2690 fn lyrics_removed_kind_is_kept_not_deleted() {
2691 let mut manifest = Manifest::new();
2695 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2696 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2697 manifest.insert("a", e);
2698 let d = vec![desired_arts("a", vec![])];
2699 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2700 assert_eq!(plan.artifact_deletes(), 0);
2701 assert_eq!(plan.deletes(), 0);
2702 }
2703
2704 #[test]
2705 fn lrc_removed_kind_is_kept_not_deleted() {
2706 let mut manifest = Manifest::new();
2709 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2710 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2711 manifest.insert("a", e);
2712 let d = vec![desired_arts("a", vec![])];
2713 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2714 assert_eq!(plan.artifact_deletes(), 0);
2715 assert_eq!(plan.deletes(), 0);
2716 }
2717
2718 #[test]
2719 fn details_removed_kind_not_deleted_on_incomplete_listing() {
2720 let mut manifest = Manifest::new();
2723 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2724 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2725 manifest.insert("a", e);
2726 let d = vec![desired_arts("a", vec![])];
2727 let sources = vec![SourceStatus {
2728 mode: SourceMode::Mirror,
2729 fully_enumerated: false,
2730 }];
2731 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2732 assert_eq!(plan.artifact_deletes(), 0);
2733 }
2734
2735 #[test]
2736 fn details_removed_kind_not_deleted_when_preserved() {
2737 let mut manifest = Manifest::new();
2740 let mut e = ManifestEntry {
2741 preserve: true,
2742 ..entry("a.flac", AudioFormat::Flac, "m", "art")
2743 };
2744 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2745 manifest.insert("a", e);
2746 let d = vec![desired_arts("a", vec![])];
2747 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2748 assert_eq!(plan.artifact_deletes(), 0);
2749 }
2750
2751 #[test]
2752 fn co_delete_orphan_removes_every_text_sidecar() {
2753 let mut manifest = Manifest::new();
2757 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2758 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2759 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2760 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2761 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
2762 manifest.insert("gone", e);
2763 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2764 assert_eq!(plan.deletes(), 1);
2765 assert_eq!(plan.artifact_deletes(), 4);
2766 for (kind, path) in [
2767 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2768 (ArtifactKind::DetailsTxt, "gone.details.txt"),
2769 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2770 (ArtifactKind::Lrc, "gone.lrc"),
2771 ] {
2772 assert!(
2773 plan.actions.contains(&Action::DeleteArtifact {
2774 kind,
2775 path: path.to_string(),
2776 owner_id: "gone".to_string(),
2777 }),
2778 "missing co-delete for {kind:?}"
2779 );
2780 }
2781 }
2782
2783 #[test]
2784 fn co_delete_trashed_removes_every_text_sidecar() {
2785 let mut manifest = Manifest::new();
2787 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2788 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2789 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2790 manifest.insert("a", e);
2791 let mut d = desired_arts("a", vec![]);
2792 d.trashed = true;
2793 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2794 assert_eq!(plan.deletes(), 1);
2795 assert_eq!(plan.artifact_deletes(), 2);
2796 }
2797
2798 #[test]
2799 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2800 let mut manifest = Manifest::new();
2803 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2804 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2805 let d = vec![desired_arts(
2808 "a",
2809 vec![art(
2810 ArtifactKind::CoverJpg,
2811 "shared/cover.jpg",
2812 "https://art/a",
2813 "h2",
2814 )],
2815 )];
2816 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2817 assert_eq!(plan.artifact_writes(), 1);
2818 assert!(!plan.actions.iter().any(
2820 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2821 ));
2822 assert!(plan.actions.contains(&Action::Delete {
2824 path: "b.flac".to_string(),
2825 clip_id: "b".to_string(),
2826 }));
2827 }
2828
2829 #[test]
2830 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2831 let mut manifest = Manifest::new();
2833 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2834 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2835 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2836 assert_eq!(plan.downloads(), 1);
2837 assert!(
2838 !plan
2839 .actions
2840 .iter()
2841 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2842 );
2843 }
2844
2845 #[test]
2846 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2847 let build = |with_art: bool| {
2851 let mut manifest = Manifest::new();
2852 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2853 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2854 manifest.insert(
2855 "trash",
2856 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2857 );
2858 let keep = if with_art {
2859 desired_arts(
2860 "keep",
2861 vec![art(
2862 ArtifactKind::CoverJpg,
2863 "keep/cover.jpg",
2864 "https://art/keep",
2865 "h1",
2866 )],
2867 )
2868 } else {
2869 desired_arts("keep", vec![])
2870 };
2871 let mut trash = desired_arts("trash", vec![]);
2872 trash.trashed = true;
2873 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2874 .iter()
2875 .map(|id| (id.to_string(), present(100)))
2876 .collect();
2877 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2878 };
2879
2880 let with = build(true);
2881 let without = build(false);
2882
2883 let audio = |plan: &Plan| -> Vec<Action> {
2885 plan.actions
2886 .iter()
2887 .filter(|a| {
2888 !matches!(
2889 a,
2890 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2891 )
2892 })
2893 .cloned()
2894 .collect()
2895 };
2896 assert_eq!(audio(&with), audio(&without));
2897 assert_eq!(with.deletes(), without.deletes());
2898 assert_eq!(with.deletes(), 2);
2900 assert_eq!(with.artifact_deletes(), 2);
2904 assert_eq!(with.artifact_writes(), 0);
2905 }
2906
2907 #[test]
2910 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2911 let mut manifest = Manifest::new();
2917 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2918 assert!(!manifest.get("a").unwrap().preserve);
2919
2920 let private = Desired {
2922 private: true,
2923 ..desired_arts("a", vec![])
2924 };
2925 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2926 assert_eq!(plan.artifact_deletes(), 0);
2927
2928 let copy_held = Desired {
2930 modes: vec![SourceMode::Copy],
2931 ..desired_arts("a", vec![])
2932 };
2933 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2934 assert_eq!(plan.artifact_deletes(), 0);
2935 }
2936
2937 #[test]
2938 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2939 let mut manifest = Manifest::new();
2945 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2946 let d = vec![desired_arts(
2947 "a",
2948 vec![art(
2949 ArtifactKind::CoverJpg,
2950 "new/cover.jpg",
2951 "https://art/a",
2952 "h1",
2953 )],
2954 )];
2955 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2956 assert_eq!(plan.artifact_writes(), 1);
2957 assert_eq!(plan.artifact_deletes(), 0);
2958 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2959 assert_eq!(path, "new/cover.jpg");
2960 } else {
2961 panic!("expected a WriteArtifact");
2962 }
2963 }
2964
2965 #[test]
2966 fn per_clip_reconcile_ignores_album_and_library_kinds() {
2967 let mut manifest = Manifest::new();
2971 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2972 let d = vec![desired_arts(
2973 "a",
2974 vec![
2975 art(
2976 ArtifactKind::FolderJpg,
2977 "a/folder.jpg",
2978 "https://art/folder",
2979 "hf",
2980 ),
2981 art(
2982 ArtifactKind::Playlist,
2983 "a/list.m3u",
2984 "https://art/list",
2985 "hp",
2986 ),
2987 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2988 ],
2989 )];
2990 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2991 assert_eq!(plan.artifact_writes(), 1);
2992 let paths: Vec<&str> = plan
2993 .actions
2994 .iter()
2995 .filter_map(|a| match a {
2996 Action::WriteArtifact { path, .. } => Some(path.as_str()),
2997 _ => None,
2998 })
2999 .collect();
3000 assert_eq!(paths, vec!["a/cover.jpg"]);
3001 }
3002
3003 #[test]
3004 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3005 let mut manifest = Manifest::new();
3006 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3007 let d = vec![desired_arts(
3008 "a",
3009 vec![art(
3010 ArtifactKind::FolderWebp,
3011 "a/folder.webp",
3012 "https://art/folder",
3013 "hf",
3014 )],
3015 )];
3016 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3017 assert_eq!(plan.artifact_writes(), 0);
3018 assert_eq!(plan.artifact_deletes(), 0);
3019 }
3020
3021 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3024 Clip {
3025 id: id.to_string(),
3026 title: "Song".to_string(),
3027 image_large_url: image.to_string(),
3028 video_cover_url: video.to_string(),
3029 play_count,
3030 created_at: created_at.to_string(),
3031 ..Default::default()
3032 }
3033 }
3034
3035 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3036 let mut lineage = LineageContext::own_root(&clip);
3037 lineage.root_id = root_id.to_string();
3038 Desired {
3039 clip,
3040 lineage,
3041 path: path.to_string(),
3042 format: AudioFormat::Flac,
3043 meta_hash: "m".to_string(),
3044 art_hash: "a".to_string(),
3045 modes: vec![SourceMode::Mirror],
3046 trashed: false,
3047 private: false,
3048 artifacts: Vec::new(),
3049 }
3050 }
3051
3052 fn stored(path: &str, hash: &str) -> ArtifactState {
3053 ArtifactState {
3054 path: path.to_string(),
3055 hash: hash.to_string(),
3056 }
3057 }
3058
3059 #[test]
3060 fn folder_jpg_source_is_most_played() {
3061 let members = vec![
3062 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3063 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3064 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3065 ];
3066 let albums = album_desired(&members, false);
3067 assert_eq!(albums.len(), 1);
3068 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3069 assert_eq!(jpg.hash, art_url_hash("art-b"));
3071 assert_eq!(jpg.source_url, "art-b");
3072 assert_eq!(jpg.path, "c/al/folder.jpg");
3073 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3074 }
3075
3076 #[test]
3077 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3078 let by_time = vec![
3080 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3081 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3082 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3083 ];
3084 let jpg = album_desired(&by_time, false)[0]
3085 .folder_jpg
3086 .clone()
3087 .unwrap();
3088 assert_eq!(jpg.source_url, "art-y");
3089
3090 let by_id = vec![
3092 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3093 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3094 ];
3095 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3096 assert_eq!(jpg.source_url, "art-g");
3097 }
3098
3099 #[test]
3100 fn folder_webp_source_is_first_created_animated() {
3101 let members = vec![
3102 album_member(
3103 album_clip("a", 9, "t2", "art-a", "vid-a"),
3104 "root",
3105 "c/al/a.flac",
3106 ),
3107 album_member(
3108 album_clip("b", 1, "t0", "art-b", "vid-b"),
3109 "root",
3110 "c/al/b.flac",
3111 ),
3112 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3113 ];
3114 let webp = album_desired(&members, true)[0]
3115 .folder_webp
3116 .clone()
3117 .unwrap();
3118 assert_eq!(webp.source_url, "vid-b");
3120 assert_eq!(webp.hash, art_url_hash("vid-b"));
3121 assert_eq!(webp.path, "c/al/cover.webp");
3122 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3123 }
3124
3125 #[test]
3126 fn animated_covers_off_yields_no_folder_webp() {
3127 let members = vec![album_member(
3128 album_clip("a", 1, "t0", "art-a", "vid-a"),
3129 "root",
3130 "c/al/a.flac",
3131 )];
3132 let off = album_desired(&members, false);
3133 assert!(off[0].folder_webp.is_none());
3134 let on = album_desired(&members, true);
3135 assert!(on[0].folder_webp.is_some());
3136 }
3137
3138 #[test]
3139 fn album_with_no_art_yields_no_folder_jpg() {
3140 let members = vec![album_member(
3141 album_clip("a", 3, "t0", "", ""),
3142 "root",
3143 "c/al/a.flac",
3144 )];
3145 let albums = album_desired(&members, true);
3146 assert!(albums[0].folder_jpg.is_none());
3147 assert!(albums[0].folder_webp.is_none());
3148 }
3149
3150 #[test]
3151 fn album_desired_groups_by_root_id() {
3152 let members = vec![
3153 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3154 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3155 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3156 ];
3157 let albums = album_desired(&members, false);
3158 assert_eq!(albums.len(), 2);
3159 assert_eq!(albums[0].root_id, "r1");
3160 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3161 assert_eq!(
3162 albums[0].folder_jpg.as_ref().unwrap().path,
3163 "c/al1/folder.jpg"
3164 );
3165 assert_eq!(albums[1].root_id, "r2");
3166 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3167 assert_eq!(
3168 albums[1].folder_jpg.as_ref().unwrap().path,
3169 "c/al2/folder.jpg"
3170 );
3171 }
3172
3173 #[test]
3174 fn plan_writes_folder_art_when_store_empty() {
3175 let members = vec![album_member(
3176 album_clip("a", 1, "t0", "art-a", "vid-a"),
3177 "root",
3178 "c/al/a.flac",
3179 )];
3180 let desired = album_desired(&members, true);
3181 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3182 assert_eq!(
3183 actions,
3184 vec![
3185 Action::WriteArtifact {
3186 kind: ArtifactKind::FolderJpg,
3187 path: "c/al/folder.jpg".to_string(),
3188 source_url: "art-a".to_string(),
3189 hash: art_url_hash("art-a"),
3190 owner_id: "root".to_string(),
3191 content: None,
3192 },
3193 Action::WriteArtifact {
3194 kind: ArtifactKind::FolderWebp,
3195 path: "c/al/cover.webp".to_string(),
3196 source_url: "vid-a".to_string(),
3197 hash: art_url_hash("vid-a"),
3198 owner_id: "root".to_string(),
3199 content: None,
3200 },
3201 ]
3202 );
3203 }
3204
3205 #[test]
3206 fn plan_skips_when_hash_and_path_match() {
3207 let members = vec![album_member(
3208 album_clip("a", 1, "t0", "art-a", ""),
3209 "root",
3210 "c/al/a.flac",
3211 )];
3212 let desired = album_desired(&members, false);
3213 let mut albums = BTreeMap::new();
3214 albums.insert(
3215 "root".to_string(),
3216 AlbumArt {
3217 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3218 folder_webp: None,
3219 },
3220 );
3221 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3222 }
3223
3224 #[test]
3225 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3226 let members = vec![album_member(
3227 album_clip("a", 1, "t0", "art-a", ""),
3228 "root",
3229 "c/al/a.flac",
3230 )];
3231 let desired = album_desired(&members, false);
3232 let mut albums = BTreeMap::new();
3233 albums.insert(
3234 "root".to_string(),
3235 AlbumArt {
3236 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3237 folder_webp: None,
3238 },
3239 );
3240 let actions = plan_album_artifacts(&desired, &albums, true);
3241 assert_eq!(actions.len(), 1);
3242 assert!(matches!(
3243 &actions[0],
3244 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3245 ));
3246 }
3247
3248 #[test]
3249 fn h1_most_played_flip_to_same_art_writes_nothing() {
3250 let run1 = vec![
3252 album_member(
3253 album_clip("a", 9, "t0", "same-art", ""),
3254 "root",
3255 "c/al/a.flac",
3256 ),
3257 album_member(
3258 album_clip("b", 1, "t1", "same-art", ""),
3259 "root",
3260 "c/al/b.flac",
3261 ),
3262 ];
3263 let desired1 = album_desired(&run1, false);
3264 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3265 assert_eq!(write1.len(), 1);
3266
3267 let mut albums = BTreeMap::new();
3269 if let Action::WriteArtifact {
3270 path,
3271 hash,
3272 owner_id,
3273 ..
3274 } = &write1[0]
3275 {
3276 albums.insert(
3277 owner_id.clone(),
3278 AlbumArt {
3279 folder_jpg: Some(stored(path, hash)),
3280 folder_webp: None,
3281 },
3282 );
3283 }
3284
3285 let run2 = vec![
3287 album_member(
3288 album_clip("a", 1, "t0", "same-art", ""),
3289 "root",
3290 "c/al/a.flac",
3291 ),
3292 album_member(
3293 album_clip("b", 9, "t1", "same-art", ""),
3294 "root",
3295 "c/al/b.flac",
3296 ),
3297 ];
3298 let desired2 = album_desired(&run2, false);
3299 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3301 }
3302
3303 #[test]
3304 fn h1_flip_to_different_art_writes_exactly_one() {
3305 let mut albums = BTreeMap::new();
3306 albums.insert(
3307 "root".to_string(),
3308 AlbumArt {
3309 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3310 folder_webp: None,
3311 },
3312 );
3313 let members = vec![
3315 album_member(
3316 album_clip("a", 1, "t0", "old-art", ""),
3317 "root",
3318 "c/al/a.flac",
3319 ),
3320 album_member(
3321 album_clip("b", 9, "t1", "new-art", ""),
3322 "root",
3323 "c/al/b.flac",
3324 ),
3325 ];
3326 let desired = album_desired(&members, false);
3327 let actions = plan_album_artifacts(&desired, &albums, true);
3328 assert_eq!(actions.len(), 1);
3329 assert!(matches!(
3330 &actions[0],
3331 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3332 ));
3333 }
3334
3335 #[test]
3336 fn one_write_per_album_regardless_of_clip_count() {
3337 let members: Vec<Desired> = (0..200)
3338 .map(|i| {
3339 album_member(
3340 album_clip(
3341 &format!("clip-{i:03}"),
3342 i as u64,
3343 &format!("t{i:03}"),
3344 &format!("art-{i:03}"),
3345 &format!("vid-{i:03}"),
3346 ),
3347 "root",
3348 &format!("c/al/clip-{i:03}.flac"),
3349 )
3350 })
3351 .collect();
3352 let desired = album_desired(&members, true);
3353 assert_eq!(desired.len(), 1);
3354 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3355 assert_eq!(actions.len(), 2);
3357 assert_eq!(
3358 actions
3359 .iter()
3360 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3361 .count(),
3362 2
3363 );
3364 }
3365
3366 #[test]
3367 fn emptied_album_deletes_only_when_can_delete() {
3368 let mut albums = BTreeMap::new();
3369 albums.insert(
3370 "root".to_string(),
3371 AlbumArt {
3372 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3373 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3374 },
3375 );
3376 let desired: Vec<AlbumDesired> = Vec::new();
3378
3379 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3381
3382 let actions = plan_album_artifacts(&desired, &albums, true);
3384 assert_eq!(
3385 actions,
3386 vec![
3387 Action::DeleteArtifact {
3388 kind: ArtifactKind::FolderJpg,
3389 path: "c/al/folder.jpg".to_string(),
3390 owner_id: "root".to_string(),
3391 },
3392 Action::DeleteArtifact {
3393 kind: ArtifactKind::FolderWebp,
3394 path: "c/al/cover.webp".to_string(),
3395 owner_id: "root".to_string(),
3396 },
3397 ]
3398 );
3399 }
3400
3401 #[test]
3402 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3403 let mut albums = BTreeMap::new();
3404 albums.insert(
3405 "root".to_string(),
3406 AlbumArt {
3407 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3408 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3409 },
3410 );
3411 let members = vec![album_member(
3414 album_clip("a", 1, "t0", "art-a", "vid-a"),
3415 "root",
3416 "c/al/a.flac",
3417 )];
3418 let desired = album_desired(&members, false);
3419
3420 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3421
3422 let actions = plan_album_artifacts(&desired, &albums, true);
3423 assert_eq!(
3424 actions,
3425 vec![Action::DeleteArtifact {
3426 kind: ArtifactKind::FolderWebp,
3427 path: "c/al/cover.webp".to_string(),
3428 owner_id: "root".to_string(),
3429 }]
3430 );
3431 }
3432
3433 #[test]
3434 fn plan_album_artifacts_is_deterministically_ordered() {
3435 let members = vec![
3436 album_member(
3437 album_clip("a", 1, "t0", "art-a", "vid-a"),
3438 "r2",
3439 "c/al2/a.flac",
3440 ),
3441 album_member(
3442 album_clip("b", 1, "t0", "art-b", "vid-b"),
3443 "r1",
3444 "c/al1/b.flac",
3445 ),
3446 ];
3447 let desired = album_desired(&members, true);
3448 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3449 let keys: Vec<(&str, ArtifactKind)> = actions
3450 .iter()
3451 .map(|a| match a {
3452 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3453 _ => unreachable!(),
3454 })
3455 .collect();
3456 assert_eq!(
3457 keys,
3458 vec![
3459 ("r1", ArtifactKind::FolderJpg),
3460 ("r1", ArtifactKind::FolderWebp),
3461 ("r2", ArtifactKind::FolderJpg),
3462 ("r2", ArtifactKind::FolderWebp),
3463 ]
3464 );
3465 }
3466
3467 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3470 PlaylistDesired {
3471 id: id.to_owned(),
3472 name: name.to_owned(),
3473 path: path.to_owned(),
3474 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3475 hash: hash.to_owned(),
3476 }
3477 }
3478
3479 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3480 PlaylistState {
3481 name: name.to_owned(),
3482 path: path.to_owned(),
3483 hash: hash.to_owned(),
3484 }
3485 }
3486
3487 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3488 entries
3489 .iter()
3490 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3491 .collect()
3492 }
3493
3494 #[test]
3495 fn playlist_write_emitted_for_a_new_playlist() {
3496 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3497 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3498 assert_eq!(
3499 actions,
3500 vec![Action::WriteArtifact {
3501 kind: ArtifactKind::Playlist,
3502 path: "Road Trip.m3u8".to_owned(),
3503 source_url: String::new(),
3504 hash: "h1".to_owned(),
3505 owner_id: "pl1".to_owned(),
3506 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3507 }]
3508 );
3509 }
3510
3511 #[test]
3512 fn playlist_write_emitted_when_hash_changes() {
3513 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3516 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3517 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3518 assert_eq!(actions.len(), 1);
3519 assert!(matches!(
3520 &actions[0],
3521 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3522 ));
3523 }
3524
3525 #[test]
3526 fn playlist_unchanged_is_idempotent() {
3527 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3528 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3529 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3530 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3531 }
3532
3533 #[test]
3534 fn playlist_rename_writes_new_and_deletes_old_path() {
3535 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3538 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3539 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3540 assert_eq!(
3541 actions,
3542 vec![
3543 Action::WriteArtifact {
3544 kind: ArtifactKind::Playlist,
3545 path: "Summer.m3u8".to_owned(),
3546 source_url: String::new(),
3547 hash: "h2".to_owned(),
3548 owner_id: "pl1".to_owned(),
3549 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3550 },
3551 Action::DeleteArtifact {
3552 kind: ArtifactKind::Playlist,
3553 path: "Spring.m3u8".to_owned(),
3554 owner_id: "pl1".to_owned(),
3555 },
3556 ]
3557 );
3558 }
3559
3560 #[test]
3561 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3562 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3565 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3566 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3567 assert_eq!(actions.len(), 1);
3568 assert!(matches!(
3569 &actions[0],
3570 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3571 ));
3572 assert!(
3573 !actions
3574 .iter()
3575 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3576 "old path must not be deleted when deletes are disallowed"
3577 );
3578 }
3579
3580 #[test]
3581 fn playlist_stale_removed_only_under_full_gate() {
3582 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3585
3586 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3587 assert_eq!(
3588 deleted,
3589 vec![Action::DeleteArtifact {
3590 kind: ArtifactKind::Playlist,
3591 path: "Gone.m3u8".to_owned(),
3592 owner_id: "gone".to_owned(),
3593 }]
3594 );
3595
3596 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3598 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3599 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3600 }
3601
3602 #[test]
3603 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3604 let stored = pl_store(&[
3609 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3610 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3611 ]);
3612 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3613 assert!(
3614 actions.is_empty(),
3615 "a failed playlist listing must plan zero actions, got {actions:?}"
3616 );
3617 }
3618
3619 #[test]
3620 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3621 let stored = pl_store(&[
3626 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3627 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3628 ]);
3629
3630 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3632
3633 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3636 assert_eq!(
3637 wiped
3638 .iter()
3639 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3640 .count(),
3641 2
3642 );
3643 }
3644
3645 #[test]
3646 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3647 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3652 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3653 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3654 assert_eq!(actions.len(), 1);
3656 assert!(matches!(
3657 &actions[0],
3658 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3659 ));
3660 assert!(
3661 !actions.iter().any(|a| match a {
3662 Action::WriteArtifact { owner_id, .. }
3663 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3664 _ => false,
3665 }),
3666 "a protected (failed-member) playlist must have no action"
3667 );
3668 }
3669
3670 #[test]
3671 fn playlist_rename_collision_downgrades_the_delete() {
3672 let desired = vec![
3678 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3679 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3680 ];
3681 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3682 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3683 let write_paths: BTreeSet<&str> = actions
3685 .iter()
3686 .filter_map(|a| match a {
3687 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3688 _ => None,
3689 })
3690 .collect();
3691 for a in &actions {
3692 if let Action::DeleteArtifact { path, .. } = a {
3693 assert!(
3694 !write_paths.contains(path.as_str()),
3695 "a playlist delete aliases a write target: {path}"
3696 );
3697 }
3698 }
3699 }
3700}
3701
3702#[cfg(test)]
3715mod proptests {
3716 use super::*;
3717 use proptest::collection::{btree_map, hash_map, vec};
3718 use proptest::prelude::*;
3719 use std::collections::BTreeSet;
3720
3721 type DesiredFields = (
3722 String,
3723 AudioFormat,
3724 String,
3725 String,
3726 Vec<SourceMode>,
3727 bool,
3728 bool,
3729 );
3730
3731 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3732 prop_oneof![
3733 Just(AudioFormat::Mp3),
3734 Just(AudioFormat::Flac),
3735 Just(AudioFormat::Wav),
3736 ]
3737 }
3738
3739 fn source_mode() -> impl Strategy<Value = SourceMode> {
3740 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3741 }
3742
3743 fn clip_id() -> impl Strategy<Value = String> {
3746 (0u8..8).prop_map(|n| format!("c{n}"))
3747 }
3748
3749 fn small_path() -> impl Strategy<Value = String> {
3750 (0u8..6).prop_map(|n| format!("path{n}"))
3751 }
3752
3753 fn manifest_path() -> impl Strategy<Value = String> {
3756 prop_oneof![
3757 1 => Just(String::new()),
3758 6 => small_path(),
3759 ]
3760 }
3761
3762 fn small_hash() -> impl Strategy<Value = String> {
3763 (0u8..4).prop_map(|n| format!("h{n}"))
3764 }
3765
3766 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3767 (
3768 manifest_path(),
3769 audio_format(),
3770 small_hash(),
3771 small_hash(),
3772 0u64..4,
3773 any::<bool>(),
3774 )
3775 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3776 ManifestEntry {
3777 path,
3778 format,
3779 meta_hash,
3780 art_hash,
3781 size,
3782 preserve,
3783 ..Default::default()
3784 }
3785 })
3786 }
3787
3788 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3789 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3790 }
3791
3792 fn local_file() -> impl Strategy<Value = LocalFile> {
3793 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3794 }
3795
3796 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3797 hash_map(clip_id(), local_file(), 0..8)
3798 }
3799
3800 fn source_status() -> impl Strategy<Value = SourceStatus> {
3801 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3802 mode,
3803 fully_enumerated,
3804 })
3805 }
3806
3807 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3808 vec(source_status(), 0..5)
3809 }
3810
3811 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3812 vec(
3813 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3814 mode: SourceMode::Copy,
3815 fully_enumerated,
3816 }),
3817 1..5,
3818 )
3819 }
3820
3821 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3822 (
3823 small_path(),
3824 audio_format(),
3825 small_hash(),
3826 small_hash(),
3827 vec(source_mode(), 1..3),
3828 any::<bool>(),
3829 any::<bool>(),
3830 )
3831 }
3832
3833 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3834 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3835 let clip = Clip {
3836 id,
3837 title: "t".to_string(),
3838 ..Default::default()
3839 };
3840 Desired {
3841 lineage: LineageContext::own_root(&clip),
3842 clip,
3843 path,
3844 format,
3845 meta_hash,
3846 art_hash,
3847 modes,
3848 trashed,
3849 private,
3850 artifacts: Vec::new(),
3851 }
3852 }
3853
3854 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3857 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3858 items
3859 .into_iter()
3860 .map(|(id, fields)| build_desired(id, fields))
3861 .collect()
3862 })
3863 }
3864
3865 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3866 desired.iter().map(|d| d.clip.id.as_str()).collect()
3867 }
3868
3869 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3872 desired
3873 .iter()
3874 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3875 .map(|d| d.clip.id.as_str())
3876 .collect()
3877 }
3878
3879 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3882 desired
3883 .iter()
3884 .filter(|d| !d.trashed)
3885 .map(|d| d.clip.id.as_str())
3886 .collect()
3887 }
3888
3889 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3890 plan.actions
3891 .iter()
3892 .filter_map(|a| match a {
3893 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3894 _ => None,
3895 })
3896 .collect()
3897 }
3898
3899 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3900 plan.actions
3901 .iter()
3902 .filter_map(|a| match a {
3903 Action::Download { path, .. } | Action::Reformat { path, .. } => {
3904 Some(path.as_str())
3905 }
3906 Action::Rename { to, .. } => Some(to.as_str()),
3907 _ => None,
3908 })
3909 .collect()
3910 }
3911
3912 proptest! {
3913 #![proptest_config(ProptestConfig {
3914 cases: 256,
3915 failure_persistence: None,
3916 ..ProptestConfig::default()
3917 })]
3918
3919 #[test]
3922 fn inv1_desired_clip_deleted_only_when_fully_trashed(
3923 manifest in manifest_strategy(),
3924 desired in desired_strategy(),
3925 local in local_strategy(),
3926 sources in sources_strategy(),
3927 ) {
3928 let plan = reconcile(&manifest, &desired, &local, &sources);
3929 let present = desired_ids(&desired);
3930 let live = non_trashed_ids(&desired);
3931 for id in delete_clip_ids(&plan) {
3932 prop_assert!(
3933 !(present.contains(id) && live.contains(id)),
3934 "deleted a desired clip with a non-trashed duplicate: {id}"
3935 );
3936 }
3937 }
3938
3939 #[test]
3943 fn inv2_no_delete_when_any_mirror_unenumerated(
3944 manifest in manifest_strategy(),
3945 desired in desired_strategy(),
3946 local in local_strategy(),
3947 mut sources in sources_strategy(),
3948 ) {
3949 sources.push(SourceStatus {
3950 mode: SourceMode::Mirror,
3951 fully_enumerated: false,
3952 });
3953 let plan = reconcile(&manifest, &desired, &local, &sources);
3954 prop_assert_eq!(plan.deletes(), 0);
3955 }
3956
3957 #[test]
3959 fn inv3_all_copy_sources_means_no_deletes(
3960 manifest in manifest_strategy(),
3961 desired in desired_strategy(),
3962 local in local_strategy(),
3963 sources in copy_sources_strategy(),
3964 ) {
3965 let plan = reconcile(&manifest, &desired, &local, &sources);
3966 prop_assert_eq!(plan.deletes(), 0);
3967 }
3968
3969 #[test]
3972 fn inv4_plan_is_deterministic(
3973 manifest in manifest_strategy(),
3974 desired in desired_strategy(),
3975 local in local_strategy(),
3976 sources in sources_strategy(),
3977 ) {
3978 let plan = reconcile(&manifest, &desired, &local, &sources);
3979
3980 let again = reconcile(&manifest, &desired, &local, &sources);
3981 prop_assert_eq!(&plan, &again);
3982
3983 let mut desired_rev = desired.clone();
3984 desired_rev.reverse();
3985 let mut sources_rev = sources.clone();
3986 sources_rev.reverse();
3987 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3988 prop_assert_eq!(&plan, &shuffled);
3989 }
3990
3991 #[test]
3993 fn inv5_every_delete_is_in_the_manifest(
3994 manifest in manifest_strategy(),
3995 desired in desired_strategy(),
3996 local in local_strategy(),
3997 sources in sources_strategy(),
3998 ) {
3999 let plan = reconcile(&manifest, &desired, &local, &sources);
4000 for id in delete_clip_ids(&plan) {
4001 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4002 }
4003 }
4004
4005 #[test]
4008 fn inv6_never_deletes_protected_clip(
4009 manifest in manifest_strategy(),
4010 desired in desired_strategy(),
4011 local in local_strategy(),
4012 sources in sources_strategy(),
4013 ) {
4014 let plan = reconcile(&manifest, &desired, &local, &sources);
4015 let protected = protected_ids(&desired);
4016 for id in delete_clip_ids(&plan) {
4017 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4018 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4019 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4020 }
4021 }
4022
4023 #[test]
4026 fn inv7_no_delete_unless_deletion_allowed(
4027 manifest in manifest_strategy(),
4028 desired in desired_strategy(),
4029 local in local_strategy(),
4030 sources in sources_strategy(),
4031 ) {
4032 let plan = reconcile(&manifest, &desired, &local, &sources);
4033 if !deletion_allowed(&sources) {
4034 prop_assert_eq!(plan.deletes(), 0);
4035 }
4036 }
4037
4038 #[test]
4040 fn inv8_at_most_one_delete_per_clip(
4041 manifest in manifest_strategy(),
4042 desired in desired_strategy(),
4043 local in local_strategy(),
4044 sources in sources_strategy(),
4045 ) {
4046 let plan = reconcile(&manifest, &desired, &local, &sources);
4047 let ids = delete_clip_ids(&plan);
4048 let unique: BTreeSet<&str> = ids.iter().copied().collect();
4049 prop_assert_eq!(ids.len(), unique.len());
4050 }
4051
4052 #[test]
4054 fn inv9_no_delete_with_empty_path(
4055 manifest in manifest_strategy(),
4056 desired in desired_strategy(),
4057 local in local_strategy(),
4058 sources in sources_strategy(),
4059 ) {
4060 let plan = reconcile(&manifest, &desired, &local, &sources);
4061 for action in &plan.actions {
4062 if let Action::Delete { path, .. } = action {
4063 prop_assert!(!path.is_empty(), "delete with an empty path");
4064 }
4065 }
4066 }
4067
4068 #[test]
4071 fn inv10_no_delete_aliases_a_write_target(
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 targets = write_target_paths(&plan);
4079 for action in &plan.actions {
4080 if let Action::Delete { path, .. } = action {
4081 prop_assert!(
4082 !targets.contains(path.as_str()),
4083 "delete path {path} aliases a write target"
4084 );
4085 }
4086 }
4087 }
4088 }
4089}