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 FolderJpg,
67 FolderWebp,
69 Playlist,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum SourceMode {
77 Mirror,
79 Copy,
81}
82
83#[derive(Debug, Clone, PartialEq)]
90pub struct Desired {
91 pub clip: Clip,
93 pub lineage: LineageContext,
96 pub path: String,
98 pub format: AudioFormat,
100 pub meta_hash: String,
102 pub art_hash: String,
104 pub modes: Vec<SourceMode>,
106 pub trashed: bool,
108 pub private: bool,
110 pub artifacts: Vec<DesiredArtifact>,
118}
119
120#[derive(Debug, Clone, PartialEq)]
125pub struct DesiredArtifact {
126 pub kind: ArtifactKind,
128 pub path: String,
130 pub source_url: String,
133 pub hash: String,
135 pub content: Option<String>,
139}
140
141#[derive(Debug, Clone, PartialEq)]
152pub struct AlbumDesired {
153 pub root_id: String,
155 pub folder_jpg: Option<DesiredArtifact>,
157 pub folder_webp: Option<DesiredArtifact>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct PlaylistDesired {
173 pub id: String,
176 pub name: String,
178 pub path: String,
180 pub content: String,
182 pub hash: String,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub struct LocalFile {
189 pub exists: bool,
191 pub size: u64,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct SourceStatus {
198 pub mode: SourceMode,
200 pub fully_enumerated: bool,
202}
203
204#[derive(Debug, Clone, PartialEq)]
206pub enum Action {
207 Download {
209 clip: Clip,
210 lineage: LineageContext,
211 path: String,
212 format: AudioFormat,
213 },
214 Reformat {
220 clip: Clip,
221 path: String,
222 from_path: String,
223 from: AudioFormat,
224 to: AudioFormat,
225 },
226 Retag {
228 clip: Clip,
229 lineage: LineageContext,
230 path: String,
231 },
232 Rename { from: String, to: String },
234 Delete { path: String, clip_id: String },
236 Skip { clip_id: String },
238 WriteArtifact {
250 kind: ArtifactKind,
251 path: String,
252 source_url: String,
253 hash: String,
254 owner_id: String,
255 content: Option<String>,
256 },
257 DeleteArtifact {
264 kind: ArtifactKind,
265 path: String,
266 owner_id: String,
267 },
268}
269
270#[derive(Debug, Clone, Default, PartialEq)]
275pub struct Plan {
276 pub actions: Vec<Action>,
278}
279
280impl Plan {
281 pub fn len(&self) -> usize {
283 self.actions.len()
284 }
285
286 pub fn is_empty(&self) -> bool {
288 self.actions.is_empty()
289 }
290
291 pub fn downloads(&self) -> usize {
293 self.count(|a| matches!(a, Action::Download { .. }))
294 }
295
296 pub fn reformats(&self) -> usize {
298 self.count(|a| matches!(a, Action::Reformat { .. }))
299 }
300
301 pub fn retags(&self) -> usize {
303 self.count(|a| matches!(a, Action::Retag { .. }))
304 }
305
306 pub fn renames(&self) -> usize {
308 self.count(|a| matches!(a, Action::Rename { .. }))
309 }
310
311 pub fn deletes(&self) -> usize {
313 self.count(|a| matches!(a, Action::Delete { .. }))
314 }
315
316 pub fn skips(&self) -> usize {
318 self.count(|a| matches!(a, Action::Skip { .. }))
319 }
320
321 pub fn artifact_writes(&self) -> usize {
323 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
324 }
325
326 pub fn artifact_deletes(&self) -> usize {
328 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
329 }
330
331 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
332 self.actions.iter().filter(|a| pred(a)).count()
333 }
334}
335
336pub fn reconcile(
351 manifest: &Manifest,
352 desired: &[Desired],
353 local: &HashMap<String, LocalFile>,
354 sources: &[SourceStatus],
355) -> Plan {
356 let mut actions: Vec<Action> = Vec::new();
357
358 let desired = aggregate_desired(desired);
360 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
361
362 let can_delete = deletion_allowed(sources);
363
364 for d in &desired {
365 let before = actions.len();
370 plan_desired(d, manifest, local, can_delete, &mut actions);
371 let audio_deleted = actions[before..]
372 .iter()
373 .any(|a| matches!(a, Action::Delete { .. }));
374 if audio_deleted {
375 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
376 } else {
377 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
378 }
379 }
380
381 for (clip_id, _entry) in manifest.iter() {
383 if desired_ids.contains(clip_id.as_str()) {
384 continue;
385 }
386 match delete_action(clip_id, manifest, can_delete) {
387 Some(action) => {
388 actions.push(action);
389 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
391 }
392 None => actions.push(Action::Skip {
395 clip_id: clip_id.clone(),
396 }),
397 }
398 }
399
400 suppress_path_aliasing(&mut actions);
401 Plan { actions }
402}
403
404pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
415 let mut saw_mirror = false;
416 for status in sources {
417 if !status.fully_enumerated {
418 return false;
419 }
420 if status.mode == SourceMode::Mirror {
421 saw_mirror = true;
422 }
423 }
424 saw_mirror
425}
426
427fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
433 if !can_delete {
434 return None;
435 }
436 let entry = manifest.get(clip_id)?;
437 if entry.path.is_empty() || entry.preserve {
438 return None;
439 }
440 Some(Action::Delete {
441 path: entry.path.clone(),
442 clip_id: clip_id.to_string(),
443 })
444}
445
446fn delete_artifact_action(
456 owner_id: &str,
457 kind: ArtifactKind,
458 path: &str,
459 manifest: &Manifest,
460 can_delete: bool,
461) -> Option<Action> {
462 if !can_delete {
463 return None;
464 }
465 let entry = manifest.get(owner_id)?;
466 if path.is_empty() || entry.preserve {
467 return None;
468 }
469 Some(Action::DeleteArtifact {
470 kind,
471 path: path.to_string(),
472 owner_id: owner_id.to_string(),
473 })
474}
475
476fn is_per_clip_kind(kind: ArtifactKind) -> bool {
482 matches!(
483 kind,
484 ArtifactKind::CoverJpg
485 | ArtifactKind::CoverWebp
486 | ArtifactKind::DetailsTxt
487 | ArtifactKind::LyricsTxt
488 )
489}
490
491fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
511 match kind {
512 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::LyricsTxt => false,
513 ArtifactKind::DetailsTxt
514 | ArtifactKind::FolderJpg
515 | ArtifactKind::FolderWebp
516 | ArtifactKind::Playlist => true,
517 }
518}
519
520fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
525 match kind {
526 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
527 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
528 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
529 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
530 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
531 }
532}
533
534fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
537 let mut out = Vec::new();
538 if let Some(state) = &entry.cover_jpg {
539 out.push((ArtifactKind::CoverJpg, state));
540 }
541 if let Some(state) = &entry.cover_webp {
542 out.push((ArtifactKind::CoverWebp, state));
543 }
544 if let Some(state) = &entry.details_txt {
545 out.push((ArtifactKind::DetailsTxt, state));
546 }
547 if let Some(state) = &entry.lyrics_txt {
548 out.push((ArtifactKind::LyricsTxt, state));
549 }
550 out
551}
552
553pub(crate) fn set_manifest_artifact(
560 entry: &mut ManifestEntry,
561 kind: ArtifactKind,
562 state: Option<ArtifactState>,
563) {
564 match kind {
565 ArtifactKind::CoverJpg => entry.cover_jpg = state,
566 ArtifactKind::CoverWebp => entry.cover_webp = state,
567 ArtifactKind::DetailsTxt => entry.details_txt = state,
568 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
569 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
570 }
571}
572
573fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
583 let owner_id = d.clip.id.as_str();
584 let entry = manifest.get(owner_id);
585
586 for artifact in &d.artifacts {
587 if !is_per_clip_kind(artifact.kind) {
591 continue;
592 }
593 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
602 None => true,
603 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
604 };
605 if needs_write {
606 out.push(Action::WriteArtifact {
607 kind: artifact.kind,
608 path: artifact.path.clone(),
609 source_url: artifact.source_url.clone(),
610 hash: artifact.hash.clone(),
611 owner_id: owner_id.to_string(),
612 content: artifact.content.clone(),
613 });
614 }
615 }
616
617 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
622 if !protected_now && let Some(entry) = entry {
623 let desired_kinds: BTreeSet<ArtifactKind> = d
624 .artifacts
625 .iter()
626 .filter(|a| is_per_clip_kind(a.kind))
627 .map(|a| a.kind)
628 .collect();
629 for (kind, state) in manifest_artifacts(entry) {
630 if removed_kind_delete_eligible(kind)
636 && !desired_kinds.contains(&kind)
637 && let Some(action) =
638 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
639 {
640 out.push(action);
641 }
642 }
643 }
644}
645
646fn co_delete_artifacts(
652 owner_id: &str,
653 manifest: &Manifest,
654 can_delete: bool,
655 out: &mut Vec<Action>,
656) {
657 let Some(entry) = manifest.get(owner_id) else {
658 return;
659 };
660 for (kind, state) in manifest_artifacts(entry) {
661 if let Some(action) =
662 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
663 {
664 out.push(action);
665 }
666 }
667}
668
669fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
676 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
677 for d in desired {
678 match by_id.get_mut(d.clip.id.as_str()) {
679 None => {
680 by_id.insert(d.clip.id.as_str(), d.clone());
681 }
682 Some(acc) => {
683 let take = rep_key(d) < rep_key(acc);
684 acc.private = acc.private || d.private;
685 acc.trashed = acc.trashed && d.trashed;
686 for mode in &d.modes {
687 if !acc.modes.contains(mode) {
688 acc.modes.push(*mode);
689 }
690 }
691 if take {
692 acc.clip = d.clip.clone();
693 acc.path = d.path.clone();
694 acc.format = d.format;
695 acc.meta_hash = d.meta_hash.clone();
696 acc.art_hash = d.art_hash.clone();
697 acc.artifacts = d.artifacts.clone();
698 }
699 }
700 }
701 }
702 let mut out: Vec<Desired> = by_id.into_values().collect();
703 for d in &mut out {
704 let has_mirror = d.modes.contains(&SourceMode::Mirror);
706 let has_copy = d.modes.contains(&SourceMode::Copy);
707 d.modes.clear();
708 if has_mirror {
709 d.modes.push(SourceMode::Mirror);
710 }
711 if has_copy {
712 d.modes.push(SourceMode::Copy);
713 }
714 }
715 out
716}
717
718fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
721 let format = match d.format {
722 AudioFormat::Mp3 => 0,
723 AudioFormat::Flac => 1,
724 AudioFormat::Wav => 2,
725 };
726 (
727 d.path.as_str(),
728 d.meta_hash.as_str(),
729 d.art_hash.as_str(),
730 format,
731 )
732}
733
734fn suppress_path_aliasing(actions: &mut [Action]) {
739 let targets: BTreeSet<String> = actions
740 .iter()
741 .filter_map(|a| match a {
742 Action::Download { path, .. }
743 | Action::Reformat { path, .. }
744 | Action::WriteArtifact { path, .. } => Some(path.clone()),
745 Action::Rename { to, .. } => Some(to.clone()),
746 _ => None,
747 })
748 .collect();
749 for a in actions.iter_mut() {
750 if let Action::Delete { path, clip_id } = a
751 && targets.contains(path.as_str())
752 {
753 *a = Action::Skip {
754 clip_id: clip_id.clone(),
755 };
756 }
757 if let Action::DeleteArtifact { path, owner_id, .. } = a
758 && targets.contains(path.as_str())
759 {
760 *a = Action::Skip {
761 clip_id: owner_id.clone(),
762 };
763 }
764 }
765}
766
767fn plan_desired(
769 d: &Desired,
770 manifest: &Manifest,
771 local: &HashMap<String, LocalFile>,
772 can_delete: bool,
773 out: &mut Vec<Action>,
774) {
775 let clip_id = d.clip.id.as_str();
776 let copy_held = d.modes.contains(&SourceMode::Copy);
777
778 if d.trashed && !d.private && !copy_held {
784 match delete_action(clip_id, manifest, can_delete) {
785 Some(action) => out.push(action),
786 None => out.push(Action::Skip {
787 clip_id: clip_id.to_string(),
788 }),
789 }
790 return;
791 }
792
793 let Some(entry) = manifest.get(clip_id) else {
794 out.push(Action::Download {
796 clip: d.clip.clone(),
797 lineage: d.lineage.clone(),
798 path: d.path.clone(),
799 format: d.format,
800 });
801 return;
802 };
803
804 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
807 if missing {
808 out.push(Action::Download {
809 clip: d.clip.clone(),
810 lineage: d.lineage.clone(),
811 path: d.path.clone(),
812 format: d.format,
813 });
814 return;
815 }
816
817 if d.format != entry.format {
818 out.push(Action::Reformat {
821 clip: d.clip.clone(),
822 path: d.path.clone(),
823 from_path: entry.path.clone(),
824 from: entry.format,
825 to: d.format,
826 });
827 return;
828 }
829
830 if d.path != entry.path {
831 out.push(Action::Rename {
832 from: entry.path.clone(),
833 to: d.path.clone(),
834 });
835 if meta_or_art_changed(d, entry) {
837 out.push(Action::Retag {
838 clip: d.clip.clone(),
839 lineage: d.lineage.clone(),
840 path: d.path.clone(),
841 });
842 }
843 return;
844 }
845
846 if meta_or_art_changed(d, entry) {
847 out.push(Action::Retag {
848 clip: d.clip.clone(),
849 lineage: d.lineage.clone(),
850 path: entry.path.clone(),
851 });
852 return;
853 }
854
855 out.push(Action::Skip {
856 clip_id: clip_id.to_string(),
857 });
858}
859
860fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
862 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
863}
864
865pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
885 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
886 for d in desired {
887 groups
888 .entry(d.lineage.root_id.as_str())
889 .or_default()
890 .push(d);
891 }
892
893 groups
894 .into_iter()
895 .map(|(root_id, members)| {
896 let album_dir = album_dir_of(&members);
897 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
898 kind: ArtifactKind::FolderJpg,
899 path: album_child(&album_dir, "folder.jpg"),
900 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
901 hash: art_hash(&source.clip),
902 content: None,
903 });
904 let folder_webp = animated_covers
905 .then(|| folder_webp_source(&members))
906 .flatten()
907 .map(|source| DesiredArtifact {
908 kind: ArtifactKind::FolderWebp,
909 path: album_child(&album_dir, "cover.webp"),
910 source_url: source.clip.video_cover_url.clone(),
911 hash: art_url_hash(&source.clip.video_cover_url),
912 content: None,
913 });
914 AlbumDesired {
915 root_id: root_id.to_owned(),
916 folder_jpg,
917 folder_webp,
918 }
919 })
920 .collect()
921}
922
923fn album_dir_of(members: &[&Desired]) -> String {
928 members
929 .iter()
930 .map(|d| parent_dir(&d.path))
931 .min()
932 .unwrap_or("")
933 .to_owned()
934}
935
936fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
942 members
943 .iter()
944 .copied()
945 .filter(|d| {
946 d.clip
947 .selected_image_url()
948 .is_some_and(|url| !url.is_empty())
949 })
950 .min_by(|a, b| {
951 b.clip
952 .play_count
953 .cmp(&a.clip.play_count)
954 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
955 .then_with(|| a.clip.id.cmp(&b.clip.id))
956 })
957}
958
959fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
964 members
965 .iter()
966 .copied()
967 .filter(|d| !d.clip.video_cover_url.is_empty())
968 .min_by(|a, b| {
969 a.clip
970 .created_at
971 .cmp(&b.clip.created_at)
972 .then_with(|| a.clip.id.cmp(&b.clip.id))
973 })
974}
975
976fn parent_dir(path: &str) -> &str {
978 match path.rsplit_once('/') {
979 Some((dir, _)) => dir,
980 None => "",
981 }
982}
983
984fn album_child(album_dir: &str, name: &str) -> String {
987 if album_dir.is_empty() {
988 name.to_owned()
989 } else {
990 format!("{album_dir}/{name}")
991 }
992}
993
994pub fn plan_album_artifacts(
1013 desired: &[AlbumDesired],
1014 albums: &BTreeMap<String, AlbumArt>,
1015 can_delete: bool,
1016) -> Vec<Action> {
1017 let mut actions: Vec<Action> = Vec::new();
1018 let by_root: BTreeMap<&str, &AlbumDesired> =
1019 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1020
1021 for d in desired {
1022 let stored = albums.get(&d.root_id);
1023 for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
1024 .into_iter()
1025 .flatten()
1026 {
1027 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1028 None => true,
1029 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1030 };
1031 if needs_write {
1032 actions.push(Action::WriteArtifact {
1033 kind: artifact.kind,
1034 path: artifact.path.clone(),
1035 source_url: artifact.source_url.clone(),
1036 hash: artifact.hash.clone(),
1037 owner_id: d.root_id.clone(),
1038 content: None,
1039 });
1040 }
1041 }
1042 }
1043
1044 if can_delete {
1046 for (root_id, art) in albums {
1047 for (kind, state) in album_artifacts(art) {
1048 let desired_here = by_root
1049 .get(root_id.as_str())
1050 .is_some_and(|d| album_desires_kind(d, kind));
1051 if !desired_here && !state.path.is_empty() {
1052 actions.push(Action::DeleteArtifact {
1053 kind,
1054 path: state.path.clone(),
1055 owner_id: root_id.clone(),
1056 });
1057 }
1058 }
1059 }
1060 }
1061
1062 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1063 actions
1064}
1065
1066fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1069 let mut out = Vec::new();
1070 if let Some(state) = &art.folder_jpg {
1071 out.push((ArtifactKind::FolderJpg, state));
1072 }
1073 if let Some(state) = &art.folder_webp {
1074 out.push((ArtifactKind::FolderWebp, state));
1075 }
1076 out
1077}
1078
1079fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1081 match kind {
1082 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1083 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1084 ArtifactKind::CoverJpg
1085 | ArtifactKind::CoverWebp
1086 | ArtifactKind::DetailsTxt
1087 | ArtifactKind::LyricsTxt
1088 | ArtifactKind::Playlist => false,
1089 }
1090}
1091
1092fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1094 match action {
1095 Action::WriteArtifact { owner_id, kind, .. }
1096 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1097 _ => ("", ArtifactKind::CoverJpg),
1098 }
1099}
1100
1101pub fn plan_playlist_artifacts(
1134 desired: &[PlaylistDesired],
1135 stored: &BTreeMap<String, PlaylistState>,
1136 can_delete: bool,
1137 list_fully_enumerated: bool,
1138) -> Vec<Action> {
1139 let mut actions: Vec<Action> = Vec::new();
1140 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1141 let deletes_allowed = can_delete && list_fully_enumerated;
1144
1145 for d in desired {
1146 let stored_here = stored.get(&d.id);
1147 let needs_write = match stored_here {
1148 None => true,
1149 Some(state) => state.hash != d.hash || state.path != d.path,
1150 };
1151 if needs_write {
1152 actions.push(Action::WriteArtifact {
1153 kind: ArtifactKind::Playlist,
1154 path: d.path.clone(),
1155 source_url: String::new(),
1156 hash: d.hash.clone(),
1157 owner_id: d.id.clone(),
1158 content: Some(d.content.clone()),
1159 });
1160 }
1161 if deletes_allowed
1163 && let Some(state) = stored_here
1164 && !state.path.is_empty()
1165 && state.path != d.path
1166 {
1167 actions.push(Action::DeleteArtifact {
1168 kind: ArtifactKind::Playlist,
1169 path: state.path.clone(),
1170 owner_id: d.id.clone(),
1171 });
1172 }
1173 }
1174
1175 if deletes_allowed {
1178 for (id, state) in stored {
1179 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1180 actions.push(Action::DeleteArtifact {
1181 kind: ArtifactKind::Playlist,
1182 path: state.path.clone(),
1183 owner_id: id.clone(),
1184 });
1185 }
1186 }
1187 }
1188
1189 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1190 suppress_path_aliasing(&mut actions);
1193 actions
1194}
1195
1196fn playlist_action_key(action: &Action) -> (&str, u8) {
1199 match action {
1200 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1201 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1202 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1203 _ => ("", 3),
1204 }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use crate::hash::content_hash;
1211
1212 fn clip(id: &str) -> Clip {
1213 Clip {
1214 id: id.to_string(),
1215 title: "Song".to_string(),
1216 ..Default::default()
1217 }
1218 }
1219
1220 fn lineage(id: &str) -> LineageContext {
1221 LineageContext::own_root(&clip(id))
1222 }
1223
1224 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1225 ManifestEntry {
1226 path: path.to_string(),
1227 format,
1228 meta_hash: meta.to_string(),
1229 art_hash: art.to_string(),
1230 size: 100,
1231 preserve: false,
1232 ..Default::default()
1233 }
1234 }
1235
1236 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1237 ManifestEntry {
1238 preserve: true,
1239 ..entry(path, format, meta, art)
1240 }
1241 }
1242
1243 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1244 Desired {
1245 clip: clip(id),
1246 lineage: lineage(id),
1247 path: path.to_string(),
1248 format,
1249 meta_hash: meta.to_string(),
1250 art_hash: art.to_string(),
1251 modes: vec![SourceMode::Mirror],
1252 trashed: false,
1253 private: false,
1254 artifacts: Vec::new(),
1255 }
1256 }
1257
1258 fn present(size: u64) -> LocalFile {
1259 LocalFile { exists: true, size }
1260 }
1261
1262 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1263 [(id.to_string(), present(100))].into_iter().collect()
1264 }
1265
1266 fn mirror_ok() -> Vec<SourceStatus> {
1267 vec![SourceStatus {
1268 mode: SourceMode::Mirror,
1269 fully_enumerated: true,
1270 }]
1271 }
1272
1273 #[test]
1276 fn not_in_manifest_downloads() {
1277 let manifest = Manifest::new();
1278 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1279 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1280 assert_eq!(
1281 plan.actions,
1282 vec![Action::Download {
1283 clip: clip("a"),
1284 lineage: lineage("a"),
1285 path: "a.flac".to_string(),
1286 format: AudioFormat::Flac,
1287 }]
1288 );
1289 }
1290
1291 #[test]
1292 fn unchanged_clip_skips() {
1293 let mut manifest = Manifest::new();
1294 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1295 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1296 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1297 assert_eq!(
1298 plan.actions,
1299 vec![Action::Skip {
1300 clip_id: "a".to_string()
1301 }]
1302 );
1303 }
1304
1305 #[test]
1306 fn meta_change_retags_in_place() {
1307 let mut manifest = Manifest::new();
1308 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1309 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1310 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1311 assert_eq!(
1312 plan.actions,
1313 vec![Action::Retag {
1314 clip: clip("a"),
1315 lineage: lineage("a"),
1316 path: "a.flac".to_string(),
1317 }]
1318 );
1319 }
1320
1321 #[test]
1322 fn art_change_retags_in_place() {
1323 let mut manifest = Manifest::new();
1324 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1325 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1326 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1327 assert_eq!(
1328 plan.actions,
1329 vec![Action::Retag {
1330 clip: clip("a"),
1331 lineage: lineage("a"),
1332 path: "a.flac".to_string(),
1333 }]
1334 );
1335 }
1336
1337 #[test]
1338 fn rename_when_path_changes() {
1339 let mut manifest = Manifest::new();
1340 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1341 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1342 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1343 assert_eq!(
1344 plan.actions,
1345 vec![Action::Rename {
1346 from: "old/a.flac".to_string(),
1347 to: "new/a.flac".to_string(),
1348 }]
1349 );
1350 }
1351
1352 #[test]
1353 fn rename_with_meta_change_also_retags() {
1354 let mut manifest = Manifest::new();
1355 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1356 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1357 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1358 assert_eq!(
1359 plan.actions,
1360 vec![
1361 Action::Rename {
1362 from: "old/a.flac".to_string(),
1363 to: "new/a.flac".to_string(),
1364 },
1365 Action::Retag {
1366 clip: clip("a"),
1367 lineage: lineage("a"),
1368 path: "new/a.flac".to_string(),
1369 },
1370 ]
1371 );
1372 }
1373
1374 #[test]
1375 fn rename_without_meta_change_does_not_retag() {
1376 let mut manifest = Manifest::new();
1377 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1378 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1379 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1380 assert_eq!(plan.renames(), 1);
1381 assert_eq!(plan.retags(), 0);
1382 }
1383
1384 #[test]
1385 fn format_change_reformats() {
1386 let mut manifest = Manifest::new();
1387 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1388 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1389 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1390 assert_eq!(
1391 plan.actions,
1392 vec![Action::Reformat {
1393 clip: clip("a"),
1394 path: "a.mp3".to_string(),
1395 from_path: "a.flac".to_string(),
1396 from: AudioFormat::Flac,
1397 to: AudioFormat::Mp3,
1398 }]
1399 );
1400 }
1401
1402 #[test]
1403 fn format_change_takes_precedence_over_rename_and_retag() {
1404 let mut manifest = Manifest::new();
1407 manifest.insert(
1408 "a",
1409 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1410 );
1411 let d = vec![desired(
1412 "a",
1413 "new/a.mp3",
1414 AudioFormat::Mp3,
1415 "new",
1416 "new-art",
1417 )];
1418 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1419 assert_eq!(plan.reformats(), 1);
1420 assert_eq!(plan.renames(), 0);
1421 assert_eq!(plan.retags(), 0);
1422 }
1423
1424 #[test]
1427 fn zero_length_file_downloads_even_when_hashes_match() {
1428 let mut manifest = Manifest::new();
1429 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1430 let local: HashMap<String, LocalFile> = [(
1431 "a".to_string(),
1432 LocalFile {
1433 exists: true,
1434 size: 0,
1435 },
1436 )]
1437 .into_iter()
1438 .collect();
1439 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1440 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1441 assert_eq!(plan.downloads(), 1);
1442 assert_eq!(plan.skips(), 0);
1443 }
1444
1445 #[test]
1446 fn missing_file_downloads_even_when_hashes_match() {
1447 let mut manifest = Manifest::new();
1448 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1449 let local: HashMap<String, LocalFile> = [(
1450 "a".to_string(),
1451 LocalFile {
1452 exists: false,
1453 size: 0,
1454 },
1455 )]
1456 .into_iter()
1457 .collect();
1458 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1459 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1460 assert_eq!(plan.downloads(), 1);
1461 }
1462
1463 #[test]
1464 fn absent_local_probe_treated_as_missing() {
1465 let mut manifest = Manifest::new();
1467 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1468 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1469 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1470 assert_eq!(plan.downloads(), 1);
1471 }
1472
1473 #[test]
1474 fn missing_file_download_wins_over_format_difference() {
1475 let mut manifest = Manifest::new();
1478 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1479 let local: HashMap<String, LocalFile> = [(
1480 "a".to_string(),
1481 LocalFile {
1482 exists: false,
1483 size: 0,
1484 },
1485 )]
1486 .into_iter()
1487 .collect();
1488 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1489 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1490 assert_eq!(plan.downloads(), 1);
1491 assert_eq!(plan.reformats(), 0);
1492 }
1493
1494 #[test]
1497 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1498 let mut trashed = clip("a");
1503 trashed.status = "complete".to_string();
1504 trashed.is_trashed = true;
1505 assert!(crate::is_downloadable(&trashed));
1506
1507 let mut manifest = Manifest::new();
1508 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1509 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1510 d.clip = trashed;
1511 d.trashed = true;
1512 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1513 assert_eq!(
1514 plan.actions,
1515 vec![Action::Delete {
1516 path: "a.flac".to_string(),
1517 clip_id: "a".to_string(),
1518 }]
1519 );
1520 }
1521
1522 #[test]
1523 fn trashed_clip_deletes_local_file() {
1524 let mut manifest = Manifest::new();
1525 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1526 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1527 d.trashed = true;
1528 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1529 assert_eq!(
1530 plan.actions,
1531 vec![Action::Delete {
1532 path: "a.flac".to_string(),
1533 clip_id: "a".to_string(),
1534 }]
1535 );
1536 }
1537
1538 #[test]
1539 fn trashed_clip_not_in_manifest_skips() {
1540 let manifest = Manifest::new();
1542 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1543 d.trashed = true;
1544 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1545 assert_eq!(
1546 plan.actions,
1547 vec![Action::Skip {
1548 clip_id: "a".to_string()
1549 }]
1550 );
1551 }
1552
1553 #[test]
1554 fn private_clip_is_kept() {
1555 let mut manifest = Manifest::new();
1556 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1557 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1558 d.private = true;
1559 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1560 assert_eq!(
1561 plan.actions,
1562 vec![Action::Skip {
1563 clip_id: "a".to_string()
1564 }]
1565 );
1566 }
1567
1568 #[test]
1569 fn private_beats_trashed_never_deletes() {
1570 let mut manifest = Manifest::new();
1572 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1573 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1574 d.trashed = true;
1575 d.private = true;
1576 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1577 assert_eq!(plan.deletes(), 0);
1578 assert_eq!(plan.skips(), 1);
1579 }
1580
1581 #[test]
1582 fn copy_held_trashed_clip_is_not_deleted() {
1583 let mut manifest = Manifest::new();
1586 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1587 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1588 d.modes = vec![SourceMode::Copy];
1589 d.trashed = true;
1590 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1591 assert_eq!(plan.deletes(), 0);
1592 assert_eq!(
1593 plan.actions,
1594 vec![Action::Skip {
1595 clip_id: "a".to_string()
1596 }]
1597 );
1598 }
1599
1600 #[test]
1603 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1604 let mut manifest = Manifest::new();
1605 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1606 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1607 assert_eq!(
1608 plan.actions,
1609 vec![Action::Delete {
1610 path: "gone.flac".to_string(),
1611 clip_id: "gone".to_string(),
1612 }]
1613 );
1614 }
1615
1616 #[test]
1617 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1618 let mut manifest = Manifest::new();
1619 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1620 let sources = vec![
1621 SourceStatus {
1622 mode: SourceMode::Mirror,
1623 fully_enumerated: true,
1624 },
1625 SourceStatus {
1626 mode: SourceMode::Mirror,
1627 fully_enumerated: false,
1628 },
1629 ];
1630 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1631 assert_eq!(plan.deletes(), 0);
1632 assert_eq!(
1633 plan.actions,
1634 vec![Action::Skip {
1635 clip_id: "gone".to_string()
1636 }]
1637 );
1638 }
1639
1640 #[test]
1641 fn empty_listing_cannot_cause_deletion() {
1642 let mut manifest = Manifest::new();
1645 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1646 let sources = vec![SourceStatus {
1647 mode: SourceMode::Mirror,
1648 fully_enumerated: false,
1649 }];
1650 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1651 assert_eq!(plan.deletes(), 0);
1652 assert_eq!(plan.skips(), 1);
1653 }
1654
1655 #[test]
1656 fn no_mirror_sources_means_no_deletion() {
1657 let mut manifest = Manifest::new();
1659 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1660 let copy_only = vec![SourceStatus {
1661 mode: SourceMode::Copy,
1662 fully_enumerated: true,
1663 }];
1664 assert_eq!(
1665 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1666 0
1667 );
1668 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1669 }
1670
1671 #[test]
1672 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1673 let mut manifest = Manifest::new();
1674 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1675 let sources = vec![
1676 SourceStatus {
1677 mode: SourceMode::Copy,
1678 fully_enumerated: true,
1679 },
1680 SourceStatus {
1681 mode: SourceMode::Mirror,
1682 fully_enumerated: false,
1683 },
1684 ];
1685 assert_eq!(
1686 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1687 0
1688 );
1689 }
1690
1691 #[test]
1692 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1693 let mut manifest = Manifest::new();
1697 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1698 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1699 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1700 held.modes = vec![SourceMode::Copy];
1701 let local: HashMap<String, LocalFile> = [
1702 ("keep".to_string(), present(100)),
1703 ("gone".to_string(), present(100)),
1704 ]
1705 .into_iter()
1706 .collect();
1707 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1708 assert!(plan.actions.contains(&Action::Skip {
1709 clip_id: "keep".to_string()
1710 }));
1711 assert!(plan.actions.contains(&Action::Delete {
1712 path: "gone.flac".to_string(),
1713 clip_id: "gone".to_string(),
1714 }));
1715 assert!(
1717 !plan
1718 .actions
1719 .iter()
1720 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1721 );
1722 }
1723
1724 #[test]
1727 fn orphan_with_preserve_marker_is_kept() {
1728 let mut manifest = Manifest::new();
1731 manifest.insert(
1732 "gone",
1733 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1734 );
1735 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1736 assert_eq!(plan.deletes(), 0);
1737 assert_eq!(
1738 plan.actions,
1739 vec![Action::Skip {
1740 clip_id: "gone".to_string()
1741 }]
1742 );
1743 }
1744
1745 #[test]
1746 fn trashed_clip_with_preserve_marker_is_kept() {
1747 let mut manifest = Manifest::new();
1750 manifest.insert(
1751 "a",
1752 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1753 );
1754 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1755 d.trashed = true;
1756 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1757 assert_eq!(plan.deletes(), 0);
1758 assert_eq!(plan.skips(), 1);
1759 }
1760
1761 #[test]
1764 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1765 let mut manifest = Manifest::new();
1767 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1768 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1769 d.trashed = true;
1770 let sources = vec![SourceStatus {
1771 mode: SourceMode::Mirror,
1772 fully_enumerated: false,
1773 }];
1774 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1775 assert_eq!(plan.deletes(), 0);
1776 assert_eq!(plan.skips(), 1);
1777 }
1778
1779 #[test]
1780 fn trashed_clip_kept_when_sources_empty() {
1781 let mut manifest = Manifest::new();
1784 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1785 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1786 d.trashed = true;
1787 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1788 assert_eq!(plan.deletes(), 0);
1789 assert_eq!(plan.skips(), 1);
1790 }
1791
1792 #[test]
1793 fn failed_copy_listing_suppresses_orphan_deletion() {
1794 let mut manifest = Manifest::new();
1797 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1798 let sources = vec![
1799 SourceStatus {
1800 mode: SourceMode::Mirror,
1801 fully_enumerated: true,
1802 },
1803 SourceStatus {
1804 mode: SourceMode::Copy,
1805 fully_enumerated: false,
1806 },
1807 ];
1808 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1809 assert_eq!(plan.deletes(), 0);
1810 }
1811
1812 #[test]
1813 fn failed_copy_listing_suppresses_trashed_deletion() {
1814 let mut manifest = Manifest::new();
1815 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1816 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1817 d.trashed = true;
1818 let sources = vec![
1819 SourceStatus {
1820 mode: SourceMode::Mirror,
1821 fully_enumerated: true,
1822 },
1823 SourceStatus {
1824 mode: SourceMode::Copy,
1825 fully_enumerated: false,
1826 },
1827 ];
1828 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1829 assert_eq!(plan.deletes(), 0);
1830 assert_eq!(plan.skips(), 1);
1831 }
1832
1833 #[test]
1834 fn empty_path_entry_never_deletes() {
1835 let mut manifest = Manifest::new();
1838 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1839 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1840 assert_eq!(plan.deletes(), 0);
1841 assert_eq!(
1842 plan.actions,
1843 vec![Action::Skip {
1844 clip_id: "gone".to_string()
1845 }]
1846 );
1847 }
1848
1849 #[test]
1852 fn delete_suppressed_when_path_aliases_rename_target() {
1853 let mut manifest = Manifest::new();
1856 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1857 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1858 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1859 let local: HashMap<String, LocalFile> = [
1860 ("a".to_string(), present(100)),
1861 ("b".to_string(), present(100)),
1862 ]
1863 .into_iter()
1864 .collect();
1865 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1866 assert!(plan.actions.contains(&Action::Rename {
1867 from: "old/a.flac".to_string(),
1868 to: "new/a.flac".to_string(),
1869 }));
1870 assert!(
1872 !plan
1873 .actions
1874 .iter()
1875 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1876 );
1877 assert!(plan.actions.contains(&Action::Skip {
1878 clip_id: "b".to_string()
1879 }));
1880 }
1881
1882 #[test]
1883 fn delete_suppressed_when_path_aliases_download_target() {
1884 let mut manifest = Manifest::new();
1886 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1887 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1888 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1889 assert!(
1890 !plan
1891 .actions
1892 .iter()
1893 .any(|a| matches!(a, Action::Delete { .. }))
1894 );
1895 assert_eq!(plan.downloads(), 1);
1896 }
1897
1898 #[test]
1899 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1900 let mut actions = vec![
1905 Action::Rename {
1906 from: "old/song.flac".to_string(),
1907 to: "new/cover.jpg".to_string(),
1908 },
1909 Action::DeleteArtifact {
1910 kind: ArtifactKind::CoverJpg,
1911 path: "new/cover.jpg".to_string(),
1912 owner_id: "a".to_string(),
1913 },
1914 ];
1915 suppress_path_aliasing(&mut actions);
1916 assert!(
1918 !actions
1919 .iter()
1920 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1921 "a sidecar delete must not alias a rename target"
1922 );
1923 assert!(actions.contains(&Action::Skip {
1924 clip_id: "a".to_string()
1925 }));
1926 assert!(actions.contains(&Action::Rename {
1928 from: "old/song.flac".to_string(),
1929 to: "new/cover.jpg".to_string(),
1930 }));
1931 }
1932
1933 #[test]
1934 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1935 let mut actions = vec![
1938 Action::WriteArtifact {
1939 kind: ArtifactKind::FolderJpg,
1940 path: "creator/album/folder.jpg".to_string(),
1941 source_url: "https://art/large.jpg".to_string(),
1942 hash: "h".to_string(),
1943 owner_id: "root".to_string(),
1944 content: None,
1945 },
1946 Action::DeleteArtifact {
1947 kind: ArtifactKind::FolderJpg,
1948 path: "creator/album/folder.jpg".to_string(),
1949 owner_id: "root-old".to_string(),
1950 },
1951 ];
1952 suppress_path_aliasing(&mut actions);
1953 assert!(
1954 !actions
1955 .iter()
1956 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1957 );
1958 assert!(actions.contains(&Action::Skip {
1959 clip_id: "root-old".to_string()
1960 }));
1961 }
1962
1963 #[test]
1966 fn duplicate_trashed_does_not_defeat_copy_sibling() {
1967 let mut manifest = Manifest::new();
1970 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1971 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1972 copy_entry.modes = vec![SourceMode::Copy];
1973 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1974 trashed_entry.modes = vec![SourceMode::Mirror];
1975 trashed_entry.trashed = true;
1976 let plan = reconcile(
1977 &manifest,
1978 &[copy_entry, trashed_entry],
1979 &local_present("a"),
1980 &mirror_ok(),
1981 );
1982 assert_eq!(plan.deletes(), 0);
1983 assert_eq!(plan.skips(), 1);
1984 }
1985
1986 #[test]
1987 fn duplicate_trashed_does_not_defeat_private_sibling() {
1988 let mut manifest = Manifest::new();
1989 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1990 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1991 private_entry.private = true;
1992 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1993 trashed_entry.trashed = true;
1994 let plan = reconcile(
1995 &manifest,
1996 &[private_entry, trashed_entry],
1997 &local_present("a"),
1998 &mirror_ok(),
1999 );
2000 assert_eq!(plan.deletes(), 0);
2001 assert_eq!(plan.skips(), 1);
2002 }
2003
2004 #[test]
2005 fn duplicate_trashed_deletes_only_when_all_trashed() {
2006 let mut manifest = Manifest::new();
2008 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2009 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2010 first.trashed = true;
2011 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2012 second.trashed = true;
2013 let plan = reconcile(
2014 &manifest,
2015 &[first, second],
2016 &local_present("a"),
2017 &mirror_ok(),
2018 );
2019 assert_eq!(plan.deletes(), 1);
2020 }
2021
2022 #[test]
2023 fn duplicate_desired_unions_modes() {
2024 let mut manifest = Manifest::new();
2026 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2027 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2028 mirror_entry.modes = vec![SourceMode::Mirror];
2029 mirror_entry.trashed = true;
2030 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2031 copy_entry.modes = vec![SourceMode::Copy];
2032 let plan = reconcile(
2033 &manifest,
2034 &[mirror_entry, copy_entry],
2035 &local_present("a"),
2036 &mirror_ok(),
2037 );
2038 assert_eq!(plan.deletes(), 0);
2040 }
2041
2042 #[test]
2045 fn private_new_clip_downloads() {
2046 let manifest = Manifest::new();
2049 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2050 d.private = true;
2051 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2052 assert_eq!(plan.downloads(), 1);
2053 }
2054
2055 #[test]
2056 fn private_zero_length_file_redownloads() {
2057 let mut manifest = Manifest::new();
2058 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2059 let local: HashMap<String, LocalFile> = [(
2060 "a".to_string(),
2061 LocalFile {
2062 exists: true,
2063 size: 0,
2064 },
2065 )]
2066 .into_iter()
2067 .collect();
2068 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2069 d.private = true;
2070 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2071 assert_eq!(plan.downloads(), 1);
2072 }
2073
2074 #[test]
2075 fn private_meta_change_retags() {
2076 let mut manifest = Manifest::new();
2077 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2078 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2079 d.private = true;
2080 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2081 assert_eq!(plan.retags(), 1);
2082 assert_eq!(plan.deletes(), 0);
2083 }
2084
2085 #[test]
2086 fn absent_private_clip_protected_by_preserve_marker() {
2087 let mut manifest = Manifest::new();
2090 manifest.insert(
2091 "a",
2092 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2093 );
2094 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2095 assert_eq!(plan.deletes(), 0);
2096 assert_eq!(plan.skips(), 1);
2097 }
2098
2099 #[test]
2102 fn output_is_deterministic_regardless_of_input_order() {
2103 let mut manifest = Manifest::new();
2104 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2105 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2106 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2107 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2108 .iter()
2109 .map(|id| (id.to_string(), present(100)))
2110 .collect();
2111
2112 let forward = vec![
2113 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2114 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2115 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2116 ];
2117 let mut reversed = forward.clone();
2118 reversed.reverse();
2119
2120 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2121 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2122 assert_eq!(p1.actions, p2.actions);
2123
2124 let ids: Vec<&str> = p1
2127 .actions
2128 .iter()
2129 .map(|a| match a {
2130 Action::Skip { clip_id } => clip_id.as_str(),
2131 Action::Retag { clip, .. } => clip.id.as_str(),
2132 Action::Download { clip, .. } => clip.id.as_str(),
2133 Action::Delete { clip_id, .. } => clip_id.as_str(),
2134 Action::Reformat { clip, .. } => clip.id.as_str(),
2135 Action::Rename { to, .. } => to.as_str(),
2136 Action::WriteArtifact { owner_id, .. }
2137 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2138 })
2139 .collect();
2140 assert_eq!(ids, ["a", "b", "c", "z"]);
2141 }
2142
2143 #[test]
2144 fn empty_inputs_do_not_panic() {
2145 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2146 assert!(plan.is_empty());
2147 assert_eq!(plan.len(), 0);
2148 }
2149
2150 #[test]
2151 fn empty_desired_with_full_manifest_deletes_all() {
2152 let mut manifest = Manifest::new();
2153 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2154 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2155 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2156 assert_eq!(plan.deletes(), 2);
2157 }
2158
2159 #[test]
2160 fn full_desired_with_empty_manifest_downloads_all() {
2161 let d = vec![
2162 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2163 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2164 ];
2165 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2166 assert_eq!(plan.downloads(), 2);
2167 }
2168
2169 #[test]
2170 fn plan_counts_sum_to_len() {
2171 let mut manifest = Manifest::new();
2172 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2173 manifest.insert(
2174 "retag",
2175 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2176 );
2177 manifest.insert(
2178 "reformat",
2179 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2180 );
2181 manifest.insert(
2182 "rename",
2183 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2184 );
2185 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2186 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2187 .iter()
2188 .map(|id| (id.to_string(), present(100)))
2189 .collect();
2190 let d = vec![
2191 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2192 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2193 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2194 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2195 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2196 ];
2197 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2198 let summed = plan.downloads()
2199 + plan.reformats()
2200 + plan.retags()
2201 + plan.renames()
2202 + plan.deletes()
2203 + plan.skips();
2204 assert_eq!(summed, plan.len());
2205 assert_eq!(plan.downloads(), 1);
2206 assert_eq!(plan.reformats(), 1);
2207 assert_eq!(plan.retags(), 1);
2208 assert_eq!(plan.renames(), 1);
2209 assert_eq!(plan.deletes(), 1);
2210 assert_eq!(plan.skips(), 1);
2211 }
2212
2213 fn cover(path: &str, hash: &str) -> ArtifactState {
2216 ArtifactState {
2217 path: path.to_string(),
2218 hash: hash.to_string(),
2219 }
2220 }
2221
2222 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2223 DesiredArtifact {
2224 kind,
2225 path: path.to_string(),
2226 source_url: url.to_string(),
2227 hash: hash.to_string(),
2228 content: None,
2229 }
2230 }
2231
2232 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2234 DesiredArtifact {
2235 kind,
2236 path: path.to_string(),
2237 source_url: String::new(),
2238 hash: content_hash(body),
2239 content: Some(body.to_string()),
2240 }
2241 }
2242
2243 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2245 Desired {
2246 artifacts: arts,
2247 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2248 }
2249 }
2250
2251 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2253 ManifestEntry {
2254 cover_jpg: Some(cover(cover_path, cover_hash)),
2255 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2256 }
2257 }
2258
2259 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2260 plan.actions
2261 .iter()
2262 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2263 .collect()
2264 }
2265
2266 #[test]
2267 fn write_artifact_emitted_when_manifest_lacks_it() {
2268 let mut manifest = Manifest::new();
2271 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2272 let d = vec![desired_arts(
2273 "a",
2274 vec![art(
2275 ArtifactKind::CoverJpg,
2276 "a/cover.jpg",
2277 "https://art/a",
2278 "h1",
2279 )],
2280 )];
2281 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2282 assert_eq!(plan.artifact_writes(), 1);
2283 assert_eq!(plan.artifact_deletes(), 0);
2284 assert_eq!(plan.skips(), 1);
2285 assert_eq!(
2286 write_artifacts(&plan)[0],
2287 &Action::WriteArtifact {
2288 kind: ArtifactKind::CoverJpg,
2289 path: "a/cover.jpg".to_string(),
2290 source_url: "https://art/a".to_string(),
2291 hash: "h1".to_string(),
2292 owner_id: "a".to_string(),
2293 content: None,
2294 }
2295 );
2296 }
2297
2298 #[test]
2299 fn write_artifact_emitted_when_hash_differs() {
2300 let mut manifest = Manifest::new();
2303 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2304 let d = vec![desired_arts(
2305 "a",
2306 vec![art(
2307 ArtifactKind::CoverJpg,
2308 "a/cover.jpg",
2309 "https://art/a",
2310 "new",
2311 )],
2312 )];
2313 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2314 assert_eq!(plan.artifact_writes(), 1);
2315 assert_eq!(plan.artifact_deletes(), 0);
2316 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2317 assert_eq!(hash, "new");
2318 } else {
2319 panic!("expected a WriteArtifact");
2320 }
2321 }
2322
2323 #[test]
2324 fn write_artifact_skipped_when_hash_matches() {
2325 let mut manifest = Manifest::new();
2327 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2328 let d = vec![desired_arts(
2329 "a",
2330 vec![art(
2331 ArtifactKind::CoverJpg,
2332 "a/cover.jpg",
2333 "https://art/a",
2334 "h1",
2335 )],
2336 )];
2337 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2338 assert_eq!(plan.artifact_writes(), 0);
2339 assert_eq!(plan.artifact_deletes(), 0);
2340 assert_eq!(
2341 plan.actions,
2342 vec![Action::Skip {
2343 clip_id: "a".to_string()
2344 }]
2345 );
2346 }
2347
2348 #[test]
2349 fn removed_kind_cover_is_kept_not_deleted() {
2350 let mut manifest = Manifest::new();
2355 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2356 let d = vec![desired_arts("a", vec![])];
2357 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2358 assert_eq!(plan.artifact_deletes(), 0);
2359 assert_eq!(plan.artifact_writes(), 0);
2360 assert_eq!(plan.deletes(), 0);
2362 assert_eq!(
2363 plan.actions,
2364 vec![Action::Skip {
2365 clip_id: "a".to_string()
2366 }]
2367 );
2368 assert!(!plan.actions.iter().any(|a| matches!(
2369 a,
2370 Action::DeleteArtifact {
2371 kind: ArtifactKind::CoverJpg,
2372 ..
2373 }
2374 )));
2375 }
2376
2377 #[test]
2378 fn delete_artifact_never_on_incomplete_listing() {
2379 let mut manifest = Manifest::new();
2384 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2385 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2386 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2387 let sources = vec![SourceStatus {
2388 mode: SourceMode::Mirror,
2389 fully_enumerated: false,
2390 }];
2391 let local: HashMap<String, LocalFile> = [
2392 ("a".to_string(), present(100)),
2393 ("b".to_string(), present(100)),
2394 ]
2395 .into_iter()
2396 .collect();
2397 let plan = reconcile(&manifest, &d, &local, &sources);
2398 assert_eq!(plan.artifact_deletes(), 0);
2399 assert_eq!(plan.deletes(), 0);
2400 }
2401
2402 #[test]
2403 fn delete_artifact_never_when_entry_preserved() {
2404 let mut manifest = Manifest::new();
2407 let preserved = ManifestEntry {
2408 preserve: true,
2409 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2410 };
2411 manifest.insert("a", preserved);
2412 let d = vec![desired_arts("a", vec![])];
2413 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2414 assert_eq!(plan.artifact_deletes(), 0);
2415 }
2416
2417 #[test]
2418 fn co_delete_never_when_path_empty() {
2419 let mut manifest = Manifest::new();
2423 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2424 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2425 assert_eq!(plan.deletes(), 1);
2426 assert_eq!(plan.artifact_deletes(), 0);
2427 }
2428
2429 #[test]
2430 fn co_delete_absent_clip_deletes_audio_and_cover() {
2431 let mut manifest = Manifest::new();
2434 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2435 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2436 assert_eq!(plan.deletes(), 1);
2437 assert_eq!(plan.artifact_deletes(), 1);
2438 assert!(plan.actions.contains(&Action::Delete {
2439 path: "gone.flac".to_string(),
2440 clip_id: "gone".to_string(),
2441 }));
2442 assert!(plan.actions.contains(&Action::DeleteArtifact {
2443 kind: ArtifactKind::CoverJpg,
2444 path: "gone/cover.jpg".to_string(),
2445 owner_id: "gone".to_string(),
2446 }));
2447 }
2448
2449 #[test]
2450 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2451 let mut manifest = Manifest::new();
2453 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2454 let sources = vec![SourceStatus {
2455 mode: SourceMode::Mirror,
2456 fully_enumerated: false,
2457 }];
2458 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2459 assert_eq!(plan.deletes(), 0);
2460 assert_eq!(plan.artifact_deletes(), 0);
2461 }
2462
2463 #[test]
2464 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2465 let mut manifest = Manifest::new();
2467 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2468 let mut d = desired_arts("a", vec![]);
2469 d.trashed = true;
2470 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2471 assert_eq!(plan.deletes(), 1);
2472 assert_eq!(plan.artifact_deletes(), 1);
2473 }
2474
2475 #[test]
2476 fn co_delete_trashed_suppressed_when_not_enumerated() {
2477 let mut manifest = Manifest::new();
2479 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2480 let mut d = desired_arts("a", vec![]);
2481 d.trashed = true;
2482 let sources = vec![SourceStatus {
2483 mode: SourceMode::Mirror,
2484 fully_enumerated: false,
2485 }];
2486 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2487 assert_eq!(plan.deletes(), 0);
2488 assert_eq!(plan.artifact_deletes(), 0);
2489 assert_eq!(plan.skips(), 1);
2490 }
2491
2492 #[test]
2493 fn co_delete_trashed_suppressed_when_preserved() {
2494 let mut manifest = Manifest::new();
2496 let preserved = ManifestEntry {
2497 preserve: true,
2498 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2499 };
2500 manifest.insert("a", preserved);
2501 let mut d = desired_arts("a", vec![]);
2502 d.trashed = true;
2503 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2504 assert_eq!(plan.deletes(), 0);
2505 assert_eq!(plan.artifact_deletes(), 0);
2506 }
2507
2508 #[test]
2511 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2512 let mut manifest = Manifest::new();
2515 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2516 let d = vec![desired_arts(
2517 "a",
2518 vec![text_art(
2519 ArtifactKind::DetailsTxt,
2520 "a.details.txt",
2521 "Title: A\n",
2522 )],
2523 )];
2524 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2525 assert_eq!(plan.artifact_writes(), 1);
2526 assert_eq!(plan.artifact_deletes(), 0);
2527 assert_eq!(
2528 write_artifacts(&plan)[0],
2529 &Action::WriteArtifact {
2530 kind: ArtifactKind::DetailsTxt,
2531 path: "a.details.txt".to_string(),
2532 source_url: String::new(),
2533 hash: content_hash("Title: A\n"),
2534 owner_id: "a".to_string(),
2535 content: Some("Title: A\n".to_string()),
2536 }
2537 );
2538 }
2539
2540 #[test]
2541 fn text_sidecars_skipped_when_hash_and_path_match() {
2542 let mut manifest = Manifest::new();
2544 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2545 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2546 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2547 manifest.insert("a", e);
2548 let d = vec![desired_arts(
2549 "a",
2550 vec![
2551 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2552 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2553 ],
2554 )];
2555 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2556 assert_eq!(plan.artifact_writes(), 0);
2557 assert_eq!(plan.artifact_deletes(), 0);
2558 }
2559
2560 #[test]
2561 fn details_rewritten_when_content_hash_differs() {
2562 let mut manifest = Manifest::new();
2565 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2566 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2567 manifest.insert("a", e);
2568 let d = vec![desired_arts(
2569 "a",
2570 vec![text_art(
2571 ArtifactKind::DetailsTxt,
2572 "a.details.txt",
2573 "Title: New\n",
2574 )],
2575 )];
2576 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2577 assert_eq!(plan.artifact_writes(), 1);
2578 assert_eq!(plan.artifact_deletes(), 0);
2579 }
2580
2581 #[test]
2582 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2583 let mut manifest = Manifest::new();
2586 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2587 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2588 manifest.insert("a", e);
2589 let d = vec![desired_arts(
2590 "a",
2591 vec![text_art(
2592 ArtifactKind::LyricsTxt,
2593 "a.lyrics.txt",
2594 "new words\n",
2595 )],
2596 )];
2597 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2598 assert_eq!(plan.artifact_writes(), 1);
2600 assert_eq!(plan.retags(), 0);
2601 }
2602
2603 #[test]
2604 fn text_sidecar_relocated_when_path_differs() {
2605 let mut manifest = Manifest::new();
2608 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2609 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2610 manifest.insert("a", e);
2611 let d = vec![desired_arts(
2612 "a",
2613 vec![text_art(
2614 ArtifactKind::DetailsTxt,
2615 "new/a.details.txt",
2616 "Title: A\n",
2617 )],
2618 )];
2619 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2620 assert_eq!(plan.artifact_writes(), 1);
2621 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2622 assert_eq!(path, "new/a.details.txt");
2623 } else {
2624 panic!("expected a WriteArtifact");
2625 }
2626 }
2627
2628 #[test]
2629 fn details_removed_kind_is_deleted_when_feature_off() {
2630 let mut manifest = Manifest::new();
2633 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2634 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2635 manifest.insert("a", e);
2636 let d = vec![desired_arts("a", vec![])];
2637 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2638 assert_eq!(plan.artifact_deletes(), 1);
2639 assert!(plan.actions.contains(&Action::DeleteArtifact {
2640 kind: ArtifactKind::DetailsTxt,
2641 path: "a.details.txt".to_string(),
2642 owner_id: "a".to_string(),
2643 }));
2644 }
2645
2646 #[test]
2647 fn lyrics_removed_kind_is_kept_not_deleted() {
2648 let mut manifest = Manifest::new();
2652 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2653 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2654 manifest.insert("a", e);
2655 let d = vec![desired_arts("a", vec![])];
2656 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2657 assert_eq!(plan.artifact_deletes(), 0);
2658 assert_eq!(plan.deletes(), 0);
2659 }
2660
2661 #[test]
2662 fn details_removed_kind_not_deleted_on_incomplete_listing() {
2663 let mut manifest = Manifest::new();
2666 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2667 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2668 manifest.insert("a", e);
2669 let d = vec![desired_arts("a", vec![])];
2670 let sources = vec![SourceStatus {
2671 mode: SourceMode::Mirror,
2672 fully_enumerated: false,
2673 }];
2674 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2675 assert_eq!(plan.artifact_deletes(), 0);
2676 }
2677
2678 #[test]
2679 fn details_removed_kind_not_deleted_when_preserved() {
2680 let mut manifest = Manifest::new();
2683 let mut e = ManifestEntry {
2684 preserve: true,
2685 ..entry("a.flac", AudioFormat::Flac, "m", "art")
2686 };
2687 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2688 manifest.insert("a", e);
2689 let d = vec![desired_arts("a", vec![])];
2690 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2691 assert_eq!(plan.artifact_deletes(), 0);
2692 }
2693
2694 #[test]
2695 fn co_delete_orphan_removes_every_text_sidecar() {
2696 let mut manifest = Manifest::new();
2700 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2701 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2702 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2703 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2704 manifest.insert("gone", e);
2705 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2706 assert_eq!(plan.deletes(), 1);
2707 assert_eq!(plan.artifact_deletes(), 3);
2708 for (kind, path) in [
2709 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2710 (ArtifactKind::DetailsTxt, "gone.details.txt"),
2711 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2712 ] {
2713 assert!(
2714 plan.actions.contains(&Action::DeleteArtifact {
2715 kind,
2716 path: path.to_string(),
2717 owner_id: "gone".to_string(),
2718 }),
2719 "missing co-delete for {kind:?}"
2720 );
2721 }
2722 }
2723
2724 #[test]
2725 fn co_delete_trashed_removes_every_text_sidecar() {
2726 let mut manifest = Manifest::new();
2728 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2729 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2730 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2731 manifest.insert("a", e);
2732 let mut d = desired_arts("a", vec![]);
2733 d.trashed = true;
2734 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2735 assert_eq!(plan.deletes(), 1);
2736 assert_eq!(plan.artifact_deletes(), 2);
2737 }
2738
2739 #[test]
2740 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2741 let mut manifest = Manifest::new();
2744 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2745 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2746 let d = vec![desired_arts(
2749 "a",
2750 vec![art(
2751 ArtifactKind::CoverJpg,
2752 "shared/cover.jpg",
2753 "https://art/a",
2754 "h2",
2755 )],
2756 )];
2757 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2758 assert_eq!(plan.artifact_writes(), 1);
2759 assert!(!plan.actions.iter().any(
2761 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2762 ));
2763 assert!(plan.actions.contains(&Action::Delete {
2765 path: "b.flac".to_string(),
2766 clip_id: "b".to_string(),
2767 }));
2768 }
2769
2770 #[test]
2771 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2772 let mut manifest = Manifest::new();
2774 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2775 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2776 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2777 assert_eq!(plan.downloads(), 1);
2778 assert!(
2779 !plan
2780 .actions
2781 .iter()
2782 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2783 );
2784 }
2785
2786 #[test]
2787 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2788 let build = |with_art: bool| {
2792 let mut manifest = Manifest::new();
2793 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2794 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2795 manifest.insert(
2796 "trash",
2797 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2798 );
2799 let keep = if with_art {
2800 desired_arts(
2801 "keep",
2802 vec![art(
2803 ArtifactKind::CoverJpg,
2804 "keep/cover.jpg",
2805 "https://art/keep",
2806 "h1",
2807 )],
2808 )
2809 } else {
2810 desired_arts("keep", vec![])
2811 };
2812 let mut trash = desired_arts("trash", vec![]);
2813 trash.trashed = true;
2814 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2815 .iter()
2816 .map(|id| (id.to_string(), present(100)))
2817 .collect();
2818 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2819 };
2820
2821 let with = build(true);
2822 let without = build(false);
2823
2824 let audio = |plan: &Plan| -> Vec<Action> {
2826 plan.actions
2827 .iter()
2828 .filter(|a| {
2829 !matches!(
2830 a,
2831 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2832 )
2833 })
2834 .cloned()
2835 .collect()
2836 };
2837 assert_eq!(audio(&with), audio(&without));
2838 assert_eq!(with.deletes(), without.deletes());
2839 assert_eq!(with.deletes(), 2);
2841 assert_eq!(with.artifact_deletes(), 2);
2845 assert_eq!(with.artifact_writes(), 0);
2846 }
2847
2848 #[test]
2851 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2852 let mut manifest = Manifest::new();
2858 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2859 assert!(!manifest.get("a").unwrap().preserve);
2860
2861 let private = Desired {
2863 private: true,
2864 ..desired_arts("a", vec![])
2865 };
2866 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2867 assert_eq!(plan.artifact_deletes(), 0);
2868
2869 let copy_held = Desired {
2871 modes: vec![SourceMode::Copy],
2872 ..desired_arts("a", vec![])
2873 };
2874 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2875 assert_eq!(plan.artifact_deletes(), 0);
2876 }
2877
2878 #[test]
2879 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2880 let mut manifest = Manifest::new();
2886 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2887 let d = vec![desired_arts(
2888 "a",
2889 vec![art(
2890 ArtifactKind::CoverJpg,
2891 "new/cover.jpg",
2892 "https://art/a",
2893 "h1",
2894 )],
2895 )];
2896 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2897 assert_eq!(plan.artifact_writes(), 1);
2898 assert_eq!(plan.artifact_deletes(), 0);
2899 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2900 assert_eq!(path, "new/cover.jpg");
2901 } else {
2902 panic!("expected a WriteArtifact");
2903 }
2904 }
2905
2906 #[test]
2907 fn per_clip_reconcile_ignores_album_and_library_kinds() {
2908 let mut manifest = Manifest::new();
2912 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2913 let d = vec![desired_arts(
2914 "a",
2915 vec![
2916 art(
2917 ArtifactKind::FolderJpg,
2918 "a/folder.jpg",
2919 "https://art/folder",
2920 "hf",
2921 ),
2922 art(
2923 ArtifactKind::Playlist,
2924 "a/list.m3u",
2925 "https://art/list",
2926 "hp",
2927 ),
2928 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2929 ],
2930 )];
2931 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2932 assert_eq!(plan.artifact_writes(), 1);
2933 let paths: Vec<&str> = plan
2934 .actions
2935 .iter()
2936 .filter_map(|a| match a {
2937 Action::WriteArtifact { path, .. } => Some(path.as_str()),
2938 _ => None,
2939 })
2940 .collect();
2941 assert_eq!(paths, vec!["a/cover.jpg"]);
2942 }
2943
2944 #[test]
2945 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
2946 let mut manifest = Manifest::new();
2947 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2948 let d = vec![desired_arts(
2949 "a",
2950 vec![art(
2951 ArtifactKind::FolderWebp,
2952 "a/folder.webp",
2953 "https://art/folder",
2954 "hf",
2955 )],
2956 )];
2957 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2958 assert_eq!(plan.artifact_writes(), 0);
2959 assert_eq!(plan.artifact_deletes(), 0);
2960 }
2961
2962 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
2965 Clip {
2966 id: id.to_string(),
2967 title: "Song".to_string(),
2968 image_large_url: image.to_string(),
2969 video_cover_url: video.to_string(),
2970 play_count,
2971 created_at: created_at.to_string(),
2972 ..Default::default()
2973 }
2974 }
2975
2976 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
2977 let mut lineage = LineageContext::own_root(&clip);
2978 lineage.root_id = root_id.to_string();
2979 Desired {
2980 clip,
2981 lineage,
2982 path: path.to_string(),
2983 format: AudioFormat::Flac,
2984 meta_hash: "m".to_string(),
2985 art_hash: "a".to_string(),
2986 modes: vec![SourceMode::Mirror],
2987 trashed: false,
2988 private: false,
2989 artifacts: Vec::new(),
2990 }
2991 }
2992
2993 fn stored(path: &str, hash: &str) -> ArtifactState {
2994 ArtifactState {
2995 path: path.to_string(),
2996 hash: hash.to_string(),
2997 }
2998 }
2999
3000 #[test]
3001 fn folder_jpg_source_is_most_played() {
3002 let members = vec![
3003 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3004 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3005 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3006 ];
3007 let albums = album_desired(&members, false);
3008 assert_eq!(albums.len(), 1);
3009 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3010 assert_eq!(jpg.hash, art_url_hash("art-b"));
3012 assert_eq!(jpg.source_url, "art-b");
3013 assert_eq!(jpg.path, "c/al/folder.jpg");
3014 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3015 }
3016
3017 #[test]
3018 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3019 let by_time = vec![
3021 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3022 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3023 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3024 ];
3025 let jpg = album_desired(&by_time, false)[0]
3026 .folder_jpg
3027 .clone()
3028 .unwrap();
3029 assert_eq!(jpg.source_url, "art-y");
3030
3031 let by_id = vec![
3033 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3034 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3035 ];
3036 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3037 assert_eq!(jpg.source_url, "art-g");
3038 }
3039
3040 #[test]
3041 fn folder_webp_source_is_first_created_animated() {
3042 let members = vec![
3043 album_member(
3044 album_clip("a", 9, "t2", "art-a", "vid-a"),
3045 "root",
3046 "c/al/a.flac",
3047 ),
3048 album_member(
3049 album_clip("b", 1, "t0", "art-b", "vid-b"),
3050 "root",
3051 "c/al/b.flac",
3052 ),
3053 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3054 ];
3055 let webp = album_desired(&members, true)[0]
3056 .folder_webp
3057 .clone()
3058 .unwrap();
3059 assert_eq!(webp.source_url, "vid-b");
3061 assert_eq!(webp.hash, art_url_hash("vid-b"));
3062 assert_eq!(webp.path, "c/al/cover.webp");
3063 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3064 }
3065
3066 #[test]
3067 fn animated_covers_off_yields_no_folder_webp() {
3068 let members = vec![album_member(
3069 album_clip("a", 1, "t0", "art-a", "vid-a"),
3070 "root",
3071 "c/al/a.flac",
3072 )];
3073 let off = album_desired(&members, false);
3074 assert!(off[0].folder_webp.is_none());
3075 let on = album_desired(&members, true);
3076 assert!(on[0].folder_webp.is_some());
3077 }
3078
3079 #[test]
3080 fn album_with_no_art_yields_no_folder_jpg() {
3081 let members = vec![album_member(
3082 album_clip("a", 3, "t0", "", ""),
3083 "root",
3084 "c/al/a.flac",
3085 )];
3086 let albums = album_desired(&members, true);
3087 assert!(albums[0].folder_jpg.is_none());
3088 assert!(albums[0].folder_webp.is_none());
3089 }
3090
3091 #[test]
3092 fn album_desired_groups_by_root_id() {
3093 let members = vec![
3094 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3095 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3096 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3097 ];
3098 let albums = album_desired(&members, false);
3099 assert_eq!(albums.len(), 2);
3100 assert_eq!(albums[0].root_id, "r1");
3101 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3102 assert_eq!(
3103 albums[0].folder_jpg.as_ref().unwrap().path,
3104 "c/al1/folder.jpg"
3105 );
3106 assert_eq!(albums[1].root_id, "r2");
3107 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3108 assert_eq!(
3109 albums[1].folder_jpg.as_ref().unwrap().path,
3110 "c/al2/folder.jpg"
3111 );
3112 }
3113
3114 #[test]
3115 fn plan_writes_folder_art_when_store_empty() {
3116 let members = vec![album_member(
3117 album_clip("a", 1, "t0", "art-a", "vid-a"),
3118 "root",
3119 "c/al/a.flac",
3120 )];
3121 let desired = album_desired(&members, true);
3122 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3123 assert_eq!(
3124 actions,
3125 vec![
3126 Action::WriteArtifact {
3127 kind: ArtifactKind::FolderJpg,
3128 path: "c/al/folder.jpg".to_string(),
3129 source_url: "art-a".to_string(),
3130 hash: art_url_hash("art-a"),
3131 owner_id: "root".to_string(),
3132 content: None,
3133 },
3134 Action::WriteArtifact {
3135 kind: ArtifactKind::FolderWebp,
3136 path: "c/al/cover.webp".to_string(),
3137 source_url: "vid-a".to_string(),
3138 hash: art_url_hash("vid-a"),
3139 owner_id: "root".to_string(),
3140 content: None,
3141 },
3142 ]
3143 );
3144 }
3145
3146 #[test]
3147 fn plan_skips_when_hash_and_path_match() {
3148 let members = vec![album_member(
3149 album_clip("a", 1, "t0", "art-a", ""),
3150 "root",
3151 "c/al/a.flac",
3152 )];
3153 let desired = album_desired(&members, false);
3154 let mut albums = BTreeMap::new();
3155 albums.insert(
3156 "root".to_string(),
3157 AlbumArt {
3158 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3159 folder_webp: None,
3160 },
3161 );
3162 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3163 }
3164
3165 #[test]
3166 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3167 let members = vec![album_member(
3168 album_clip("a", 1, "t0", "art-a", ""),
3169 "root",
3170 "c/al/a.flac",
3171 )];
3172 let desired = album_desired(&members, false);
3173 let mut albums = BTreeMap::new();
3174 albums.insert(
3175 "root".to_string(),
3176 AlbumArt {
3177 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3178 folder_webp: None,
3179 },
3180 );
3181 let actions = plan_album_artifacts(&desired, &albums, true);
3182 assert_eq!(actions.len(), 1);
3183 assert!(matches!(
3184 &actions[0],
3185 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3186 ));
3187 }
3188
3189 #[test]
3190 fn h1_most_played_flip_to_same_art_writes_nothing() {
3191 let run1 = vec![
3193 album_member(
3194 album_clip("a", 9, "t0", "same-art", ""),
3195 "root",
3196 "c/al/a.flac",
3197 ),
3198 album_member(
3199 album_clip("b", 1, "t1", "same-art", ""),
3200 "root",
3201 "c/al/b.flac",
3202 ),
3203 ];
3204 let desired1 = album_desired(&run1, false);
3205 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3206 assert_eq!(write1.len(), 1);
3207
3208 let mut albums = BTreeMap::new();
3210 if let Action::WriteArtifact {
3211 path,
3212 hash,
3213 owner_id,
3214 ..
3215 } = &write1[0]
3216 {
3217 albums.insert(
3218 owner_id.clone(),
3219 AlbumArt {
3220 folder_jpg: Some(stored(path, hash)),
3221 folder_webp: None,
3222 },
3223 );
3224 }
3225
3226 let run2 = vec![
3228 album_member(
3229 album_clip("a", 1, "t0", "same-art", ""),
3230 "root",
3231 "c/al/a.flac",
3232 ),
3233 album_member(
3234 album_clip("b", 9, "t1", "same-art", ""),
3235 "root",
3236 "c/al/b.flac",
3237 ),
3238 ];
3239 let desired2 = album_desired(&run2, false);
3240 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3242 }
3243
3244 #[test]
3245 fn h1_flip_to_different_art_writes_exactly_one() {
3246 let mut albums = BTreeMap::new();
3247 albums.insert(
3248 "root".to_string(),
3249 AlbumArt {
3250 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3251 folder_webp: None,
3252 },
3253 );
3254 let members = vec![
3256 album_member(
3257 album_clip("a", 1, "t0", "old-art", ""),
3258 "root",
3259 "c/al/a.flac",
3260 ),
3261 album_member(
3262 album_clip("b", 9, "t1", "new-art", ""),
3263 "root",
3264 "c/al/b.flac",
3265 ),
3266 ];
3267 let desired = album_desired(&members, false);
3268 let actions = plan_album_artifacts(&desired, &albums, true);
3269 assert_eq!(actions.len(), 1);
3270 assert!(matches!(
3271 &actions[0],
3272 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3273 ));
3274 }
3275
3276 #[test]
3277 fn one_write_per_album_regardless_of_clip_count() {
3278 let members: Vec<Desired> = (0..200)
3279 .map(|i| {
3280 album_member(
3281 album_clip(
3282 &format!("clip-{i:03}"),
3283 i as u64,
3284 &format!("t{i:03}"),
3285 &format!("art-{i:03}"),
3286 &format!("vid-{i:03}"),
3287 ),
3288 "root",
3289 &format!("c/al/clip-{i:03}.flac"),
3290 )
3291 })
3292 .collect();
3293 let desired = album_desired(&members, true);
3294 assert_eq!(desired.len(), 1);
3295 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3296 assert_eq!(actions.len(), 2);
3298 assert_eq!(
3299 actions
3300 .iter()
3301 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3302 .count(),
3303 2
3304 );
3305 }
3306
3307 #[test]
3308 fn emptied_album_deletes_only_when_can_delete() {
3309 let mut albums = BTreeMap::new();
3310 albums.insert(
3311 "root".to_string(),
3312 AlbumArt {
3313 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3314 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3315 },
3316 );
3317 let desired: Vec<AlbumDesired> = Vec::new();
3319
3320 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3322
3323 let actions = plan_album_artifacts(&desired, &albums, true);
3325 assert_eq!(
3326 actions,
3327 vec![
3328 Action::DeleteArtifact {
3329 kind: ArtifactKind::FolderJpg,
3330 path: "c/al/folder.jpg".to_string(),
3331 owner_id: "root".to_string(),
3332 },
3333 Action::DeleteArtifact {
3334 kind: ArtifactKind::FolderWebp,
3335 path: "c/al/cover.webp".to_string(),
3336 owner_id: "root".to_string(),
3337 },
3338 ]
3339 );
3340 }
3341
3342 #[test]
3343 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3344 let mut albums = BTreeMap::new();
3345 albums.insert(
3346 "root".to_string(),
3347 AlbumArt {
3348 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3349 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3350 },
3351 );
3352 let members = vec![album_member(
3355 album_clip("a", 1, "t0", "art-a", "vid-a"),
3356 "root",
3357 "c/al/a.flac",
3358 )];
3359 let desired = album_desired(&members, false);
3360
3361 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3362
3363 let actions = plan_album_artifacts(&desired, &albums, true);
3364 assert_eq!(
3365 actions,
3366 vec![Action::DeleteArtifact {
3367 kind: ArtifactKind::FolderWebp,
3368 path: "c/al/cover.webp".to_string(),
3369 owner_id: "root".to_string(),
3370 }]
3371 );
3372 }
3373
3374 #[test]
3375 fn plan_album_artifacts_is_deterministically_ordered() {
3376 let members = vec![
3377 album_member(
3378 album_clip("a", 1, "t0", "art-a", "vid-a"),
3379 "r2",
3380 "c/al2/a.flac",
3381 ),
3382 album_member(
3383 album_clip("b", 1, "t0", "art-b", "vid-b"),
3384 "r1",
3385 "c/al1/b.flac",
3386 ),
3387 ];
3388 let desired = album_desired(&members, true);
3389 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3390 let keys: Vec<(&str, ArtifactKind)> = actions
3391 .iter()
3392 .map(|a| match a {
3393 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3394 _ => unreachable!(),
3395 })
3396 .collect();
3397 assert_eq!(
3398 keys,
3399 vec![
3400 ("r1", ArtifactKind::FolderJpg),
3401 ("r1", ArtifactKind::FolderWebp),
3402 ("r2", ArtifactKind::FolderJpg),
3403 ("r2", ArtifactKind::FolderWebp),
3404 ]
3405 );
3406 }
3407
3408 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3411 PlaylistDesired {
3412 id: id.to_owned(),
3413 name: name.to_owned(),
3414 path: path.to_owned(),
3415 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3416 hash: hash.to_owned(),
3417 }
3418 }
3419
3420 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3421 PlaylistState {
3422 name: name.to_owned(),
3423 path: path.to_owned(),
3424 hash: hash.to_owned(),
3425 }
3426 }
3427
3428 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3429 entries
3430 .iter()
3431 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3432 .collect()
3433 }
3434
3435 #[test]
3436 fn playlist_write_emitted_for_a_new_playlist() {
3437 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3438 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3439 assert_eq!(
3440 actions,
3441 vec![Action::WriteArtifact {
3442 kind: ArtifactKind::Playlist,
3443 path: "Road Trip.m3u8".to_owned(),
3444 source_url: String::new(),
3445 hash: "h1".to_owned(),
3446 owner_id: "pl1".to_owned(),
3447 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3448 }]
3449 );
3450 }
3451
3452 #[test]
3453 fn playlist_write_emitted_when_hash_changes() {
3454 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3457 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3458 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3459 assert_eq!(actions.len(), 1);
3460 assert!(matches!(
3461 &actions[0],
3462 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3463 ));
3464 }
3465
3466 #[test]
3467 fn playlist_unchanged_is_idempotent() {
3468 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3469 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3470 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3471 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3472 }
3473
3474 #[test]
3475 fn playlist_rename_writes_new_and_deletes_old_path() {
3476 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3479 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3480 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3481 assert_eq!(
3482 actions,
3483 vec![
3484 Action::WriteArtifact {
3485 kind: ArtifactKind::Playlist,
3486 path: "Summer.m3u8".to_owned(),
3487 source_url: String::new(),
3488 hash: "h2".to_owned(),
3489 owner_id: "pl1".to_owned(),
3490 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3491 },
3492 Action::DeleteArtifact {
3493 kind: ArtifactKind::Playlist,
3494 path: "Spring.m3u8".to_owned(),
3495 owner_id: "pl1".to_owned(),
3496 },
3497 ]
3498 );
3499 }
3500
3501 #[test]
3502 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3503 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3506 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3507 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3508 assert_eq!(actions.len(), 1);
3509 assert!(matches!(
3510 &actions[0],
3511 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3512 ));
3513 assert!(
3514 !actions
3515 .iter()
3516 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3517 "old path must not be deleted when deletes are disallowed"
3518 );
3519 }
3520
3521 #[test]
3522 fn playlist_stale_removed_only_under_full_gate() {
3523 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3526
3527 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3528 assert_eq!(
3529 deleted,
3530 vec![Action::DeleteArtifact {
3531 kind: ArtifactKind::Playlist,
3532 path: "Gone.m3u8".to_owned(),
3533 owner_id: "gone".to_owned(),
3534 }]
3535 );
3536
3537 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3539 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3540 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3541 }
3542
3543 #[test]
3544 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3545 let stored = pl_store(&[
3550 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3551 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3552 ]);
3553 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3554 assert!(
3555 actions.is_empty(),
3556 "a failed playlist listing must plan zero actions, got {actions:?}"
3557 );
3558 }
3559
3560 #[test]
3561 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3562 let stored = pl_store(&[
3567 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3568 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3569 ]);
3570
3571 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3573
3574 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3577 assert_eq!(
3578 wiped
3579 .iter()
3580 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3581 .count(),
3582 2
3583 );
3584 }
3585
3586 #[test]
3587 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3588 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3593 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3594 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3595 assert_eq!(actions.len(), 1);
3597 assert!(matches!(
3598 &actions[0],
3599 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3600 ));
3601 assert!(
3602 !actions.iter().any(|a| match a {
3603 Action::WriteArtifact { owner_id, .. }
3604 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3605 _ => false,
3606 }),
3607 "a protected (failed-member) playlist must have no action"
3608 );
3609 }
3610
3611 #[test]
3612 fn playlist_rename_collision_downgrades_the_delete() {
3613 let desired = vec![
3619 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3620 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3621 ];
3622 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3623 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3624 let write_paths: BTreeSet<&str> = actions
3626 .iter()
3627 .filter_map(|a| match a {
3628 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3629 _ => None,
3630 })
3631 .collect();
3632 for a in &actions {
3633 if let Action::DeleteArtifact { path, .. } = a {
3634 assert!(
3635 !write_paths.contains(path.as_str()),
3636 "a playlist delete aliases a write target: {path}"
3637 );
3638 }
3639 }
3640 }
3641}
3642
3643#[cfg(test)]
3656mod proptests {
3657 use super::*;
3658 use proptest::collection::{btree_map, hash_map, vec};
3659 use proptest::prelude::*;
3660 use std::collections::BTreeSet;
3661
3662 type DesiredFields = (
3663 String,
3664 AudioFormat,
3665 String,
3666 String,
3667 Vec<SourceMode>,
3668 bool,
3669 bool,
3670 );
3671
3672 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3673 prop_oneof![
3674 Just(AudioFormat::Mp3),
3675 Just(AudioFormat::Flac),
3676 Just(AudioFormat::Wav),
3677 ]
3678 }
3679
3680 fn source_mode() -> impl Strategy<Value = SourceMode> {
3681 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3682 }
3683
3684 fn clip_id() -> impl Strategy<Value = String> {
3687 (0u8..8).prop_map(|n| format!("c{n}"))
3688 }
3689
3690 fn small_path() -> impl Strategy<Value = String> {
3691 (0u8..6).prop_map(|n| format!("path{n}"))
3692 }
3693
3694 fn manifest_path() -> impl Strategy<Value = String> {
3697 prop_oneof![
3698 1 => Just(String::new()),
3699 6 => small_path(),
3700 ]
3701 }
3702
3703 fn small_hash() -> impl Strategy<Value = String> {
3704 (0u8..4).prop_map(|n| format!("h{n}"))
3705 }
3706
3707 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3708 (
3709 manifest_path(),
3710 audio_format(),
3711 small_hash(),
3712 small_hash(),
3713 0u64..4,
3714 any::<bool>(),
3715 )
3716 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3717 ManifestEntry {
3718 path,
3719 format,
3720 meta_hash,
3721 art_hash,
3722 size,
3723 preserve,
3724 ..Default::default()
3725 }
3726 })
3727 }
3728
3729 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3730 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3731 }
3732
3733 fn local_file() -> impl Strategy<Value = LocalFile> {
3734 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3735 }
3736
3737 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3738 hash_map(clip_id(), local_file(), 0..8)
3739 }
3740
3741 fn source_status() -> impl Strategy<Value = SourceStatus> {
3742 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3743 mode,
3744 fully_enumerated,
3745 })
3746 }
3747
3748 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3749 vec(source_status(), 0..5)
3750 }
3751
3752 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3753 vec(
3754 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3755 mode: SourceMode::Copy,
3756 fully_enumerated,
3757 }),
3758 1..5,
3759 )
3760 }
3761
3762 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3763 (
3764 small_path(),
3765 audio_format(),
3766 small_hash(),
3767 small_hash(),
3768 vec(source_mode(), 1..3),
3769 any::<bool>(),
3770 any::<bool>(),
3771 )
3772 }
3773
3774 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3775 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3776 let clip = Clip {
3777 id,
3778 title: "t".to_string(),
3779 ..Default::default()
3780 };
3781 Desired {
3782 lineage: LineageContext::own_root(&clip),
3783 clip,
3784 path,
3785 format,
3786 meta_hash,
3787 art_hash,
3788 modes,
3789 trashed,
3790 private,
3791 artifacts: Vec::new(),
3792 }
3793 }
3794
3795 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3798 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3799 items
3800 .into_iter()
3801 .map(|(id, fields)| build_desired(id, fields))
3802 .collect()
3803 })
3804 }
3805
3806 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3807 desired.iter().map(|d| d.clip.id.as_str()).collect()
3808 }
3809
3810 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3813 desired
3814 .iter()
3815 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3816 .map(|d| d.clip.id.as_str())
3817 .collect()
3818 }
3819
3820 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3823 desired
3824 .iter()
3825 .filter(|d| !d.trashed)
3826 .map(|d| d.clip.id.as_str())
3827 .collect()
3828 }
3829
3830 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3831 plan.actions
3832 .iter()
3833 .filter_map(|a| match a {
3834 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3835 _ => None,
3836 })
3837 .collect()
3838 }
3839
3840 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3841 plan.actions
3842 .iter()
3843 .filter_map(|a| match a {
3844 Action::Download { path, .. } | Action::Reformat { path, .. } => {
3845 Some(path.as_str())
3846 }
3847 Action::Rename { to, .. } => Some(to.as_str()),
3848 _ => None,
3849 })
3850 .collect()
3851 }
3852
3853 proptest! {
3854 #![proptest_config(ProptestConfig {
3855 cases: 256,
3856 failure_persistence: None,
3857 ..ProptestConfig::default()
3858 })]
3859
3860 #[test]
3863 fn inv1_desired_clip_deleted_only_when_fully_trashed(
3864 manifest in manifest_strategy(),
3865 desired in desired_strategy(),
3866 local in local_strategy(),
3867 sources in sources_strategy(),
3868 ) {
3869 let plan = reconcile(&manifest, &desired, &local, &sources);
3870 let present = desired_ids(&desired);
3871 let live = non_trashed_ids(&desired);
3872 for id in delete_clip_ids(&plan) {
3873 prop_assert!(
3874 !(present.contains(id) && live.contains(id)),
3875 "deleted a desired clip with a non-trashed duplicate: {id}"
3876 );
3877 }
3878 }
3879
3880 #[test]
3884 fn inv2_no_delete_when_any_mirror_unenumerated(
3885 manifest in manifest_strategy(),
3886 desired in desired_strategy(),
3887 local in local_strategy(),
3888 mut sources in sources_strategy(),
3889 ) {
3890 sources.push(SourceStatus {
3891 mode: SourceMode::Mirror,
3892 fully_enumerated: false,
3893 });
3894 let plan = reconcile(&manifest, &desired, &local, &sources);
3895 prop_assert_eq!(plan.deletes(), 0);
3896 }
3897
3898 #[test]
3900 fn inv3_all_copy_sources_means_no_deletes(
3901 manifest in manifest_strategy(),
3902 desired in desired_strategy(),
3903 local in local_strategy(),
3904 sources in copy_sources_strategy(),
3905 ) {
3906 let plan = reconcile(&manifest, &desired, &local, &sources);
3907 prop_assert_eq!(plan.deletes(), 0);
3908 }
3909
3910 #[test]
3913 fn inv4_plan_is_deterministic(
3914 manifest in manifest_strategy(),
3915 desired in desired_strategy(),
3916 local in local_strategy(),
3917 sources in sources_strategy(),
3918 ) {
3919 let plan = reconcile(&manifest, &desired, &local, &sources);
3920
3921 let again = reconcile(&manifest, &desired, &local, &sources);
3922 prop_assert_eq!(&plan, &again);
3923
3924 let mut desired_rev = desired.clone();
3925 desired_rev.reverse();
3926 let mut sources_rev = sources.clone();
3927 sources_rev.reverse();
3928 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3929 prop_assert_eq!(&plan, &shuffled);
3930 }
3931
3932 #[test]
3934 fn inv5_every_delete_is_in_the_manifest(
3935 manifest in manifest_strategy(),
3936 desired in desired_strategy(),
3937 local in local_strategy(),
3938 sources in sources_strategy(),
3939 ) {
3940 let plan = reconcile(&manifest, &desired, &local, &sources);
3941 for id in delete_clip_ids(&plan) {
3942 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
3943 }
3944 }
3945
3946 #[test]
3949 fn inv6_never_deletes_protected_clip(
3950 manifest in manifest_strategy(),
3951 desired in desired_strategy(),
3952 local in local_strategy(),
3953 sources in sources_strategy(),
3954 ) {
3955 let plan = reconcile(&manifest, &desired, &local, &sources);
3956 let protected = protected_ids(&desired);
3957 for id in delete_clip_ids(&plan) {
3958 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
3959 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
3960 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
3961 }
3962 }
3963
3964 #[test]
3967 fn inv7_no_delete_unless_deletion_allowed(
3968 manifest in manifest_strategy(),
3969 desired in desired_strategy(),
3970 local in local_strategy(),
3971 sources in sources_strategy(),
3972 ) {
3973 let plan = reconcile(&manifest, &desired, &local, &sources);
3974 if !deletion_allowed(&sources) {
3975 prop_assert_eq!(plan.deletes(), 0);
3976 }
3977 }
3978
3979 #[test]
3981 fn inv8_at_most_one_delete_per_clip(
3982 manifest in manifest_strategy(),
3983 desired in desired_strategy(),
3984 local in local_strategy(),
3985 sources in sources_strategy(),
3986 ) {
3987 let plan = reconcile(&manifest, &desired, &local, &sources);
3988 let ids = delete_clip_ids(&plan);
3989 let unique: BTreeSet<&str> = ids.iter().copied().collect();
3990 prop_assert_eq!(ids.len(), unique.len());
3991 }
3992
3993 #[test]
3995 fn inv9_no_delete_with_empty_path(
3996 manifest in manifest_strategy(),
3997 desired in desired_strategy(),
3998 local in local_strategy(),
3999 sources in sources_strategy(),
4000 ) {
4001 let plan = reconcile(&manifest, &desired, &local, &sources);
4002 for action in &plan.actions {
4003 if let Action::Delete { path, .. } = action {
4004 prop_assert!(!path.is_empty(), "delete with an empty path");
4005 }
4006 }
4007 }
4008
4009 #[test]
4012 fn inv10_no_delete_aliases_a_write_target(
4013 manifest in manifest_strategy(),
4014 desired in desired_strategy(),
4015 local in local_strategy(),
4016 sources in sources_strategy(),
4017 ) {
4018 let plan = reconcile(&manifest, &desired, &local, &sources);
4019 let targets = write_target_paths(&plan);
4020 for action in &plan.actions {
4021 if let Action::Delete { path, .. } = action {
4022 prop_assert!(
4023 !targets.contains(path.as_str()),
4024 "delete path {path} aliases a write target"
4025 );
4026 }
4027 }
4028 }
4029 }
4030}