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_clip_deletes_local_file() {
1498 let mut manifest = Manifest::new();
1499 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1500 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1501 d.trashed = true;
1502 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1503 assert_eq!(
1504 plan.actions,
1505 vec![Action::Delete {
1506 path: "a.flac".to_string(),
1507 clip_id: "a".to_string(),
1508 }]
1509 );
1510 }
1511
1512 #[test]
1513 fn trashed_clip_not_in_manifest_skips() {
1514 let manifest = Manifest::new();
1516 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1517 d.trashed = true;
1518 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1519 assert_eq!(
1520 plan.actions,
1521 vec![Action::Skip {
1522 clip_id: "a".to_string()
1523 }]
1524 );
1525 }
1526
1527 #[test]
1528 fn private_clip_is_kept() {
1529 let mut manifest = Manifest::new();
1530 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1531 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1532 d.private = true;
1533 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1534 assert_eq!(
1535 plan.actions,
1536 vec![Action::Skip {
1537 clip_id: "a".to_string()
1538 }]
1539 );
1540 }
1541
1542 #[test]
1543 fn private_beats_trashed_never_deletes() {
1544 let mut manifest = Manifest::new();
1546 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1547 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1548 d.trashed = true;
1549 d.private = true;
1550 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1551 assert_eq!(plan.deletes(), 0);
1552 assert_eq!(plan.skips(), 1);
1553 }
1554
1555 #[test]
1556 fn copy_held_trashed_clip_is_not_deleted() {
1557 let mut manifest = Manifest::new();
1560 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1561 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1562 d.modes = vec![SourceMode::Copy];
1563 d.trashed = true;
1564 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1565 assert_eq!(plan.deletes(), 0);
1566 assert_eq!(
1567 plan.actions,
1568 vec![Action::Skip {
1569 clip_id: "a".to_string()
1570 }]
1571 );
1572 }
1573
1574 #[test]
1577 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1578 let mut manifest = Manifest::new();
1579 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1580 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1581 assert_eq!(
1582 plan.actions,
1583 vec![Action::Delete {
1584 path: "gone.flac".to_string(),
1585 clip_id: "gone".to_string(),
1586 }]
1587 );
1588 }
1589
1590 #[test]
1591 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1592 let mut manifest = Manifest::new();
1593 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1594 let sources = vec![
1595 SourceStatus {
1596 mode: SourceMode::Mirror,
1597 fully_enumerated: true,
1598 },
1599 SourceStatus {
1600 mode: SourceMode::Mirror,
1601 fully_enumerated: false,
1602 },
1603 ];
1604 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1605 assert_eq!(plan.deletes(), 0);
1606 assert_eq!(
1607 plan.actions,
1608 vec![Action::Skip {
1609 clip_id: "gone".to_string()
1610 }]
1611 );
1612 }
1613
1614 #[test]
1615 fn empty_listing_cannot_cause_deletion() {
1616 let mut manifest = Manifest::new();
1619 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1620 let sources = vec![SourceStatus {
1621 mode: SourceMode::Mirror,
1622 fully_enumerated: false,
1623 }];
1624 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1625 assert_eq!(plan.deletes(), 0);
1626 assert_eq!(plan.skips(), 1);
1627 }
1628
1629 #[test]
1630 fn no_mirror_sources_means_no_deletion() {
1631 let mut manifest = Manifest::new();
1633 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1634 let copy_only = vec![SourceStatus {
1635 mode: SourceMode::Copy,
1636 fully_enumerated: true,
1637 }];
1638 assert_eq!(
1639 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1640 0
1641 );
1642 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1643 }
1644
1645 #[test]
1646 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1647 let mut manifest = Manifest::new();
1648 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1649 let sources = vec![
1650 SourceStatus {
1651 mode: SourceMode::Copy,
1652 fully_enumerated: true,
1653 },
1654 SourceStatus {
1655 mode: SourceMode::Mirror,
1656 fully_enumerated: false,
1657 },
1658 ];
1659 assert_eq!(
1660 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1661 0
1662 );
1663 }
1664
1665 #[test]
1666 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1667 let mut manifest = Manifest::new();
1671 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1672 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1673 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1674 held.modes = vec![SourceMode::Copy];
1675 let local: HashMap<String, LocalFile> = [
1676 ("keep".to_string(), present(100)),
1677 ("gone".to_string(), present(100)),
1678 ]
1679 .into_iter()
1680 .collect();
1681 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1682 assert!(plan.actions.contains(&Action::Skip {
1683 clip_id: "keep".to_string()
1684 }));
1685 assert!(plan.actions.contains(&Action::Delete {
1686 path: "gone.flac".to_string(),
1687 clip_id: "gone".to_string(),
1688 }));
1689 assert!(
1691 !plan
1692 .actions
1693 .iter()
1694 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1695 );
1696 }
1697
1698 #[test]
1701 fn orphan_with_preserve_marker_is_kept() {
1702 let mut manifest = Manifest::new();
1705 manifest.insert(
1706 "gone",
1707 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1708 );
1709 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1710 assert_eq!(plan.deletes(), 0);
1711 assert_eq!(
1712 plan.actions,
1713 vec![Action::Skip {
1714 clip_id: "gone".to_string()
1715 }]
1716 );
1717 }
1718
1719 #[test]
1720 fn trashed_clip_with_preserve_marker_is_kept() {
1721 let mut manifest = Manifest::new();
1724 manifest.insert(
1725 "a",
1726 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1727 );
1728 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1729 d.trashed = true;
1730 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1731 assert_eq!(plan.deletes(), 0);
1732 assert_eq!(plan.skips(), 1);
1733 }
1734
1735 #[test]
1738 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1739 let mut manifest = Manifest::new();
1741 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1742 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1743 d.trashed = true;
1744 let sources = vec![SourceStatus {
1745 mode: SourceMode::Mirror,
1746 fully_enumerated: false,
1747 }];
1748 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1749 assert_eq!(plan.deletes(), 0);
1750 assert_eq!(plan.skips(), 1);
1751 }
1752
1753 #[test]
1754 fn trashed_clip_kept_when_sources_empty() {
1755 let mut manifest = Manifest::new();
1758 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1759 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1760 d.trashed = true;
1761 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1762 assert_eq!(plan.deletes(), 0);
1763 assert_eq!(plan.skips(), 1);
1764 }
1765
1766 #[test]
1767 fn failed_copy_listing_suppresses_orphan_deletion() {
1768 let mut manifest = Manifest::new();
1771 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1772 let sources = vec![
1773 SourceStatus {
1774 mode: SourceMode::Mirror,
1775 fully_enumerated: true,
1776 },
1777 SourceStatus {
1778 mode: SourceMode::Copy,
1779 fully_enumerated: false,
1780 },
1781 ];
1782 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1783 assert_eq!(plan.deletes(), 0);
1784 }
1785
1786 #[test]
1787 fn failed_copy_listing_suppresses_trashed_deletion() {
1788 let mut manifest = Manifest::new();
1789 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1790 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1791 d.trashed = true;
1792 let sources = vec![
1793 SourceStatus {
1794 mode: SourceMode::Mirror,
1795 fully_enumerated: true,
1796 },
1797 SourceStatus {
1798 mode: SourceMode::Copy,
1799 fully_enumerated: false,
1800 },
1801 ];
1802 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1803 assert_eq!(plan.deletes(), 0);
1804 assert_eq!(plan.skips(), 1);
1805 }
1806
1807 #[test]
1808 fn empty_path_entry_never_deletes() {
1809 let mut manifest = Manifest::new();
1812 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1813 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1814 assert_eq!(plan.deletes(), 0);
1815 assert_eq!(
1816 plan.actions,
1817 vec![Action::Skip {
1818 clip_id: "gone".to_string()
1819 }]
1820 );
1821 }
1822
1823 #[test]
1826 fn delete_suppressed_when_path_aliases_rename_target() {
1827 let mut manifest = Manifest::new();
1830 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1831 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1832 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1833 let local: HashMap<String, LocalFile> = [
1834 ("a".to_string(), present(100)),
1835 ("b".to_string(), present(100)),
1836 ]
1837 .into_iter()
1838 .collect();
1839 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1840 assert!(plan.actions.contains(&Action::Rename {
1841 from: "old/a.flac".to_string(),
1842 to: "new/a.flac".to_string(),
1843 }));
1844 assert!(
1846 !plan
1847 .actions
1848 .iter()
1849 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1850 );
1851 assert!(plan.actions.contains(&Action::Skip {
1852 clip_id: "b".to_string()
1853 }));
1854 }
1855
1856 #[test]
1857 fn delete_suppressed_when_path_aliases_download_target() {
1858 let mut manifest = Manifest::new();
1860 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1861 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1862 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1863 assert!(
1864 !plan
1865 .actions
1866 .iter()
1867 .any(|a| matches!(a, Action::Delete { .. }))
1868 );
1869 assert_eq!(plan.downloads(), 1);
1870 }
1871
1872 #[test]
1873 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1874 let mut actions = vec![
1879 Action::Rename {
1880 from: "old/song.flac".to_string(),
1881 to: "new/cover.jpg".to_string(),
1882 },
1883 Action::DeleteArtifact {
1884 kind: ArtifactKind::CoverJpg,
1885 path: "new/cover.jpg".to_string(),
1886 owner_id: "a".to_string(),
1887 },
1888 ];
1889 suppress_path_aliasing(&mut actions);
1890 assert!(
1892 !actions
1893 .iter()
1894 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1895 "a sidecar delete must not alias a rename target"
1896 );
1897 assert!(actions.contains(&Action::Skip {
1898 clip_id: "a".to_string()
1899 }));
1900 assert!(actions.contains(&Action::Rename {
1902 from: "old/song.flac".to_string(),
1903 to: "new/cover.jpg".to_string(),
1904 }));
1905 }
1906
1907 #[test]
1908 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1909 let mut actions = vec![
1912 Action::WriteArtifact {
1913 kind: ArtifactKind::FolderJpg,
1914 path: "creator/album/folder.jpg".to_string(),
1915 source_url: "https://art/large.jpg".to_string(),
1916 hash: "h".to_string(),
1917 owner_id: "root".to_string(),
1918 content: None,
1919 },
1920 Action::DeleteArtifact {
1921 kind: ArtifactKind::FolderJpg,
1922 path: "creator/album/folder.jpg".to_string(),
1923 owner_id: "root-old".to_string(),
1924 },
1925 ];
1926 suppress_path_aliasing(&mut actions);
1927 assert!(
1928 !actions
1929 .iter()
1930 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1931 );
1932 assert!(actions.contains(&Action::Skip {
1933 clip_id: "root-old".to_string()
1934 }));
1935 }
1936
1937 #[test]
1940 fn duplicate_trashed_does_not_defeat_copy_sibling() {
1941 let mut manifest = Manifest::new();
1944 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1945 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1946 copy_entry.modes = vec![SourceMode::Copy];
1947 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1948 trashed_entry.modes = vec![SourceMode::Mirror];
1949 trashed_entry.trashed = true;
1950 let plan = reconcile(
1951 &manifest,
1952 &[copy_entry, trashed_entry],
1953 &local_present("a"),
1954 &mirror_ok(),
1955 );
1956 assert_eq!(plan.deletes(), 0);
1957 assert_eq!(plan.skips(), 1);
1958 }
1959
1960 #[test]
1961 fn duplicate_trashed_does_not_defeat_private_sibling() {
1962 let mut manifest = Manifest::new();
1963 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1964 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1965 private_entry.private = true;
1966 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1967 trashed_entry.trashed = true;
1968 let plan = reconcile(
1969 &manifest,
1970 &[private_entry, trashed_entry],
1971 &local_present("a"),
1972 &mirror_ok(),
1973 );
1974 assert_eq!(plan.deletes(), 0);
1975 assert_eq!(plan.skips(), 1);
1976 }
1977
1978 #[test]
1979 fn duplicate_trashed_deletes_only_when_all_trashed() {
1980 let mut manifest = Manifest::new();
1982 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1983 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1984 first.trashed = true;
1985 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1986 second.trashed = true;
1987 let plan = reconcile(
1988 &manifest,
1989 &[first, second],
1990 &local_present("a"),
1991 &mirror_ok(),
1992 );
1993 assert_eq!(plan.deletes(), 1);
1994 }
1995
1996 #[test]
1997 fn duplicate_desired_unions_modes() {
1998 let mut manifest = Manifest::new();
2000 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2001 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2002 mirror_entry.modes = vec![SourceMode::Mirror];
2003 mirror_entry.trashed = true;
2004 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2005 copy_entry.modes = vec![SourceMode::Copy];
2006 let plan = reconcile(
2007 &manifest,
2008 &[mirror_entry, copy_entry],
2009 &local_present("a"),
2010 &mirror_ok(),
2011 );
2012 assert_eq!(plan.deletes(), 0);
2014 }
2015
2016 #[test]
2019 fn private_new_clip_downloads() {
2020 let manifest = Manifest::new();
2023 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2024 d.private = true;
2025 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2026 assert_eq!(plan.downloads(), 1);
2027 }
2028
2029 #[test]
2030 fn private_zero_length_file_redownloads() {
2031 let mut manifest = Manifest::new();
2032 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2033 let local: HashMap<String, LocalFile> = [(
2034 "a".to_string(),
2035 LocalFile {
2036 exists: true,
2037 size: 0,
2038 },
2039 )]
2040 .into_iter()
2041 .collect();
2042 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2043 d.private = true;
2044 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2045 assert_eq!(plan.downloads(), 1);
2046 }
2047
2048 #[test]
2049 fn private_meta_change_retags() {
2050 let mut manifest = Manifest::new();
2051 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2052 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2053 d.private = true;
2054 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2055 assert_eq!(plan.retags(), 1);
2056 assert_eq!(plan.deletes(), 0);
2057 }
2058
2059 #[test]
2060 fn absent_private_clip_protected_by_preserve_marker() {
2061 let mut manifest = Manifest::new();
2064 manifest.insert(
2065 "a",
2066 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2067 );
2068 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2069 assert_eq!(plan.deletes(), 0);
2070 assert_eq!(plan.skips(), 1);
2071 }
2072
2073 #[test]
2076 fn output_is_deterministic_regardless_of_input_order() {
2077 let mut manifest = Manifest::new();
2078 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2079 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2080 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2081 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2082 .iter()
2083 .map(|id| (id.to_string(), present(100)))
2084 .collect();
2085
2086 let forward = vec![
2087 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2088 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2089 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2090 ];
2091 let mut reversed = forward.clone();
2092 reversed.reverse();
2093
2094 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2095 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2096 assert_eq!(p1.actions, p2.actions);
2097
2098 let ids: Vec<&str> = p1
2101 .actions
2102 .iter()
2103 .map(|a| match a {
2104 Action::Skip { clip_id } => clip_id.as_str(),
2105 Action::Retag { clip, .. } => clip.id.as_str(),
2106 Action::Download { clip, .. } => clip.id.as_str(),
2107 Action::Delete { clip_id, .. } => clip_id.as_str(),
2108 Action::Reformat { clip, .. } => clip.id.as_str(),
2109 Action::Rename { to, .. } => to.as_str(),
2110 Action::WriteArtifact { owner_id, .. }
2111 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2112 })
2113 .collect();
2114 assert_eq!(ids, ["a", "b", "c", "z"]);
2115 }
2116
2117 #[test]
2118 fn empty_inputs_do_not_panic() {
2119 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2120 assert!(plan.is_empty());
2121 assert_eq!(plan.len(), 0);
2122 }
2123
2124 #[test]
2125 fn empty_desired_with_full_manifest_deletes_all() {
2126 let mut manifest = Manifest::new();
2127 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2128 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2129 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2130 assert_eq!(plan.deletes(), 2);
2131 }
2132
2133 #[test]
2134 fn full_desired_with_empty_manifest_downloads_all() {
2135 let d = vec![
2136 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2137 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2138 ];
2139 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2140 assert_eq!(plan.downloads(), 2);
2141 }
2142
2143 #[test]
2144 fn plan_counts_sum_to_len() {
2145 let mut manifest = Manifest::new();
2146 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2147 manifest.insert(
2148 "retag",
2149 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2150 );
2151 manifest.insert(
2152 "reformat",
2153 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2154 );
2155 manifest.insert(
2156 "rename",
2157 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2158 );
2159 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2160 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2161 .iter()
2162 .map(|id| (id.to_string(), present(100)))
2163 .collect();
2164 let d = vec![
2165 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2166 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2167 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2168 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2169 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2170 ];
2171 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2172 let summed = plan.downloads()
2173 + plan.reformats()
2174 + plan.retags()
2175 + plan.renames()
2176 + plan.deletes()
2177 + plan.skips();
2178 assert_eq!(summed, plan.len());
2179 assert_eq!(plan.downloads(), 1);
2180 assert_eq!(plan.reformats(), 1);
2181 assert_eq!(plan.retags(), 1);
2182 assert_eq!(plan.renames(), 1);
2183 assert_eq!(plan.deletes(), 1);
2184 assert_eq!(plan.skips(), 1);
2185 }
2186
2187 fn cover(path: &str, hash: &str) -> ArtifactState {
2190 ArtifactState {
2191 path: path.to_string(),
2192 hash: hash.to_string(),
2193 }
2194 }
2195
2196 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2197 DesiredArtifact {
2198 kind,
2199 path: path.to_string(),
2200 source_url: url.to_string(),
2201 hash: hash.to_string(),
2202 content: None,
2203 }
2204 }
2205
2206 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2208 DesiredArtifact {
2209 kind,
2210 path: path.to_string(),
2211 source_url: String::new(),
2212 hash: content_hash(body),
2213 content: Some(body.to_string()),
2214 }
2215 }
2216
2217 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2219 Desired {
2220 artifacts: arts,
2221 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2222 }
2223 }
2224
2225 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2227 ManifestEntry {
2228 cover_jpg: Some(cover(cover_path, cover_hash)),
2229 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2230 }
2231 }
2232
2233 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2234 plan.actions
2235 .iter()
2236 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2237 .collect()
2238 }
2239
2240 #[test]
2241 fn write_artifact_emitted_when_manifest_lacks_it() {
2242 let mut manifest = Manifest::new();
2245 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2246 let d = vec![desired_arts(
2247 "a",
2248 vec![art(
2249 ArtifactKind::CoverJpg,
2250 "a/cover.jpg",
2251 "https://art/a",
2252 "h1",
2253 )],
2254 )];
2255 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2256 assert_eq!(plan.artifact_writes(), 1);
2257 assert_eq!(plan.artifact_deletes(), 0);
2258 assert_eq!(plan.skips(), 1);
2259 assert_eq!(
2260 write_artifacts(&plan)[0],
2261 &Action::WriteArtifact {
2262 kind: ArtifactKind::CoverJpg,
2263 path: "a/cover.jpg".to_string(),
2264 source_url: "https://art/a".to_string(),
2265 hash: "h1".to_string(),
2266 owner_id: "a".to_string(),
2267 content: None,
2268 }
2269 );
2270 }
2271
2272 #[test]
2273 fn write_artifact_emitted_when_hash_differs() {
2274 let mut manifest = Manifest::new();
2277 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2278 let d = vec![desired_arts(
2279 "a",
2280 vec![art(
2281 ArtifactKind::CoverJpg,
2282 "a/cover.jpg",
2283 "https://art/a",
2284 "new",
2285 )],
2286 )];
2287 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2288 assert_eq!(plan.artifact_writes(), 1);
2289 assert_eq!(plan.artifact_deletes(), 0);
2290 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2291 assert_eq!(hash, "new");
2292 } else {
2293 panic!("expected a WriteArtifact");
2294 }
2295 }
2296
2297 #[test]
2298 fn write_artifact_skipped_when_hash_matches() {
2299 let mut manifest = Manifest::new();
2301 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2302 let d = vec![desired_arts(
2303 "a",
2304 vec![art(
2305 ArtifactKind::CoverJpg,
2306 "a/cover.jpg",
2307 "https://art/a",
2308 "h1",
2309 )],
2310 )];
2311 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2312 assert_eq!(plan.artifact_writes(), 0);
2313 assert_eq!(plan.artifact_deletes(), 0);
2314 assert_eq!(
2315 plan.actions,
2316 vec![Action::Skip {
2317 clip_id: "a".to_string()
2318 }]
2319 );
2320 }
2321
2322 #[test]
2323 fn removed_kind_cover_is_kept_not_deleted() {
2324 let mut manifest = Manifest::new();
2329 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2330 let d = vec![desired_arts("a", vec![])];
2331 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2332 assert_eq!(plan.artifact_deletes(), 0);
2333 assert_eq!(plan.artifact_writes(), 0);
2334 assert_eq!(plan.deletes(), 0);
2336 assert_eq!(
2337 plan.actions,
2338 vec![Action::Skip {
2339 clip_id: "a".to_string()
2340 }]
2341 );
2342 assert!(!plan.actions.iter().any(|a| matches!(
2343 a,
2344 Action::DeleteArtifact {
2345 kind: ArtifactKind::CoverJpg,
2346 ..
2347 }
2348 )));
2349 }
2350
2351 #[test]
2352 fn delete_artifact_never_on_incomplete_listing() {
2353 let mut manifest = Manifest::new();
2358 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2359 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2360 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2361 let sources = vec![SourceStatus {
2362 mode: SourceMode::Mirror,
2363 fully_enumerated: false,
2364 }];
2365 let local: HashMap<String, LocalFile> = [
2366 ("a".to_string(), present(100)),
2367 ("b".to_string(), present(100)),
2368 ]
2369 .into_iter()
2370 .collect();
2371 let plan = reconcile(&manifest, &d, &local, &sources);
2372 assert_eq!(plan.artifact_deletes(), 0);
2373 assert_eq!(plan.deletes(), 0);
2374 }
2375
2376 #[test]
2377 fn delete_artifact_never_when_entry_preserved() {
2378 let mut manifest = Manifest::new();
2381 let preserved = ManifestEntry {
2382 preserve: true,
2383 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2384 };
2385 manifest.insert("a", preserved);
2386 let d = vec![desired_arts("a", vec![])];
2387 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2388 assert_eq!(plan.artifact_deletes(), 0);
2389 }
2390
2391 #[test]
2392 fn co_delete_never_when_path_empty() {
2393 let mut manifest = Manifest::new();
2397 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2398 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2399 assert_eq!(plan.deletes(), 1);
2400 assert_eq!(plan.artifact_deletes(), 0);
2401 }
2402
2403 #[test]
2404 fn co_delete_absent_clip_deletes_audio_and_cover() {
2405 let mut manifest = Manifest::new();
2408 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2409 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2410 assert_eq!(plan.deletes(), 1);
2411 assert_eq!(plan.artifact_deletes(), 1);
2412 assert!(plan.actions.contains(&Action::Delete {
2413 path: "gone.flac".to_string(),
2414 clip_id: "gone".to_string(),
2415 }));
2416 assert!(plan.actions.contains(&Action::DeleteArtifact {
2417 kind: ArtifactKind::CoverJpg,
2418 path: "gone/cover.jpg".to_string(),
2419 owner_id: "gone".to_string(),
2420 }));
2421 }
2422
2423 #[test]
2424 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2425 let mut manifest = Manifest::new();
2427 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2428 let sources = vec![SourceStatus {
2429 mode: SourceMode::Mirror,
2430 fully_enumerated: false,
2431 }];
2432 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2433 assert_eq!(plan.deletes(), 0);
2434 assert_eq!(plan.artifact_deletes(), 0);
2435 }
2436
2437 #[test]
2438 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2439 let mut manifest = Manifest::new();
2441 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2442 let mut d = desired_arts("a", vec![]);
2443 d.trashed = true;
2444 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2445 assert_eq!(plan.deletes(), 1);
2446 assert_eq!(plan.artifact_deletes(), 1);
2447 }
2448
2449 #[test]
2450 fn co_delete_trashed_suppressed_when_not_enumerated() {
2451 let mut manifest = Manifest::new();
2453 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2454 let mut d = desired_arts("a", vec![]);
2455 d.trashed = true;
2456 let sources = vec![SourceStatus {
2457 mode: SourceMode::Mirror,
2458 fully_enumerated: false,
2459 }];
2460 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2461 assert_eq!(plan.deletes(), 0);
2462 assert_eq!(plan.artifact_deletes(), 0);
2463 assert_eq!(plan.skips(), 1);
2464 }
2465
2466 #[test]
2467 fn co_delete_trashed_suppressed_when_preserved() {
2468 let mut manifest = Manifest::new();
2470 let preserved = ManifestEntry {
2471 preserve: true,
2472 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2473 };
2474 manifest.insert("a", preserved);
2475 let mut d = desired_arts("a", vec![]);
2476 d.trashed = true;
2477 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2478 assert_eq!(plan.deletes(), 0);
2479 assert_eq!(plan.artifact_deletes(), 0);
2480 }
2481
2482 #[test]
2485 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2486 let mut manifest = Manifest::new();
2489 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2490 let d = vec![desired_arts(
2491 "a",
2492 vec![text_art(
2493 ArtifactKind::DetailsTxt,
2494 "a.details.txt",
2495 "Title: A\n",
2496 )],
2497 )];
2498 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2499 assert_eq!(plan.artifact_writes(), 1);
2500 assert_eq!(plan.artifact_deletes(), 0);
2501 assert_eq!(
2502 write_artifacts(&plan)[0],
2503 &Action::WriteArtifact {
2504 kind: ArtifactKind::DetailsTxt,
2505 path: "a.details.txt".to_string(),
2506 source_url: String::new(),
2507 hash: content_hash("Title: A\n"),
2508 owner_id: "a".to_string(),
2509 content: Some("Title: A\n".to_string()),
2510 }
2511 );
2512 }
2513
2514 #[test]
2515 fn text_sidecars_skipped_when_hash_and_path_match() {
2516 let mut manifest = Manifest::new();
2518 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2519 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2520 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2521 manifest.insert("a", e);
2522 let d = vec![desired_arts(
2523 "a",
2524 vec![
2525 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2526 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2527 ],
2528 )];
2529 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2530 assert_eq!(plan.artifact_writes(), 0);
2531 assert_eq!(plan.artifact_deletes(), 0);
2532 }
2533
2534 #[test]
2535 fn details_rewritten_when_content_hash_differs() {
2536 let mut manifest = Manifest::new();
2539 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2540 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2541 manifest.insert("a", e);
2542 let d = vec![desired_arts(
2543 "a",
2544 vec![text_art(
2545 ArtifactKind::DetailsTxt,
2546 "a.details.txt",
2547 "Title: New\n",
2548 )],
2549 )];
2550 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2551 assert_eq!(plan.artifact_writes(), 1);
2552 assert_eq!(plan.artifact_deletes(), 0);
2553 }
2554
2555 #[test]
2556 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2557 let mut manifest = Manifest::new();
2560 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2561 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2562 manifest.insert("a", e);
2563 let d = vec![desired_arts(
2564 "a",
2565 vec![text_art(
2566 ArtifactKind::LyricsTxt,
2567 "a.lyrics.txt",
2568 "new words\n",
2569 )],
2570 )];
2571 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2572 assert_eq!(plan.artifact_writes(), 1);
2574 assert_eq!(plan.retags(), 0);
2575 }
2576
2577 #[test]
2578 fn text_sidecar_relocated_when_path_differs() {
2579 let mut manifest = Manifest::new();
2582 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2583 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2584 manifest.insert("a", e);
2585 let d = vec![desired_arts(
2586 "a",
2587 vec![text_art(
2588 ArtifactKind::DetailsTxt,
2589 "new/a.details.txt",
2590 "Title: A\n",
2591 )],
2592 )];
2593 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2594 assert_eq!(plan.artifact_writes(), 1);
2595 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2596 assert_eq!(path, "new/a.details.txt");
2597 } else {
2598 panic!("expected a WriteArtifact");
2599 }
2600 }
2601
2602 #[test]
2603 fn details_removed_kind_is_deleted_when_feature_off() {
2604 let mut manifest = Manifest::new();
2607 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2608 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2609 manifest.insert("a", e);
2610 let d = vec![desired_arts("a", vec![])];
2611 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2612 assert_eq!(plan.artifact_deletes(), 1);
2613 assert!(plan.actions.contains(&Action::DeleteArtifact {
2614 kind: ArtifactKind::DetailsTxt,
2615 path: "a.details.txt".to_string(),
2616 owner_id: "a".to_string(),
2617 }));
2618 }
2619
2620 #[test]
2621 fn lyrics_removed_kind_is_kept_not_deleted() {
2622 let mut manifest = Manifest::new();
2626 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2627 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2628 manifest.insert("a", e);
2629 let d = vec![desired_arts("a", vec![])];
2630 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2631 assert_eq!(plan.artifact_deletes(), 0);
2632 assert_eq!(plan.deletes(), 0);
2633 }
2634
2635 #[test]
2636 fn details_removed_kind_not_deleted_on_incomplete_listing() {
2637 let mut manifest = Manifest::new();
2640 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2641 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2642 manifest.insert("a", e);
2643 let d = vec![desired_arts("a", vec![])];
2644 let sources = vec![SourceStatus {
2645 mode: SourceMode::Mirror,
2646 fully_enumerated: false,
2647 }];
2648 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
2649 assert_eq!(plan.artifact_deletes(), 0);
2650 }
2651
2652 #[test]
2653 fn details_removed_kind_not_deleted_when_preserved() {
2654 let mut manifest = Manifest::new();
2657 let mut e = ManifestEntry {
2658 preserve: true,
2659 ..entry("a.flac", AudioFormat::Flac, "m", "art")
2660 };
2661 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2662 manifest.insert("a", e);
2663 let d = vec![desired_arts("a", vec![])];
2664 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2665 assert_eq!(plan.artifact_deletes(), 0);
2666 }
2667
2668 #[test]
2669 fn co_delete_orphan_removes_every_text_sidecar() {
2670 let mut manifest = Manifest::new();
2674 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
2675 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
2676 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
2677 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
2678 manifest.insert("gone", e);
2679 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2680 assert_eq!(plan.deletes(), 1);
2681 assert_eq!(plan.artifact_deletes(), 3);
2682 for (kind, path) in [
2683 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
2684 (ArtifactKind::DetailsTxt, "gone.details.txt"),
2685 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
2686 ] {
2687 assert!(
2688 plan.actions.contains(&Action::DeleteArtifact {
2689 kind,
2690 path: path.to_string(),
2691 owner_id: "gone".to_string(),
2692 }),
2693 "missing co-delete for {kind:?}"
2694 );
2695 }
2696 }
2697
2698 #[test]
2699 fn co_delete_trashed_removes_every_text_sidecar() {
2700 let mut manifest = Manifest::new();
2702 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2703 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2704 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
2705 manifest.insert("a", e);
2706 let mut d = desired_arts("a", vec![]);
2707 d.trashed = true;
2708 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2709 assert_eq!(plan.deletes(), 1);
2710 assert_eq!(plan.artifact_deletes(), 2);
2711 }
2712
2713 #[test]
2714 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2715 let mut manifest = Manifest::new();
2718 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2719 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2720 let d = vec![desired_arts(
2723 "a",
2724 vec![art(
2725 ArtifactKind::CoverJpg,
2726 "shared/cover.jpg",
2727 "https://art/a",
2728 "h2",
2729 )],
2730 )];
2731 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2732 assert_eq!(plan.artifact_writes(), 1);
2733 assert!(!plan.actions.iter().any(
2735 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2736 ));
2737 assert!(plan.actions.contains(&Action::Delete {
2739 path: "b.flac".to_string(),
2740 clip_id: "b".to_string(),
2741 }));
2742 }
2743
2744 #[test]
2745 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2746 let mut manifest = Manifest::new();
2748 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2749 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2750 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2751 assert_eq!(plan.downloads(), 1);
2752 assert!(
2753 !plan
2754 .actions
2755 .iter()
2756 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2757 );
2758 }
2759
2760 #[test]
2761 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2762 let build = |with_art: bool| {
2766 let mut manifest = Manifest::new();
2767 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2768 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2769 manifest.insert(
2770 "trash",
2771 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2772 );
2773 let keep = if with_art {
2774 desired_arts(
2775 "keep",
2776 vec![art(
2777 ArtifactKind::CoverJpg,
2778 "keep/cover.jpg",
2779 "https://art/keep",
2780 "h1",
2781 )],
2782 )
2783 } else {
2784 desired_arts("keep", vec![])
2785 };
2786 let mut trash = desired_arts("trash", vec![]);
2787 trash.trashed = true;
2788 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2789 .iter()
2790 .map(|id| (id.to_string(), present(100)))
2791 .collect();
2792 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2793 };
2794
2795 let with = build(true);
2796 let without = build(false);
2797
2798 let audio = |plan: &Plan| -> Vec<Action> {
2800 plan.actions
2801 .iter()
2802 .filter(|a| {
2803 !matches!(
2804 a,
2805 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2806 )
2807 })
2808 .cloned()
2809 .collect()
2810 };
2811 assert_eq!(audio(&with), audio(&without));
2812 assert_eq!(with.deletes(), without.deletes());
2813 assert_eq!(with.deletes(), 2);
2815 assert_eq!(with.artifact_deletes(), 2);
2819 assert_eq!(with.artifact_writes(), 0);
2820 }
2821
2822 #[test]
2825 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2826 let mut manifest = Manifest::new();
2832 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2833 assert!(!manifest.get("a").unwrap().preserve);
2834
2835 let private = Desired {
2837 private: true,
2838 ..desired_arts("a", vec![])
2839 };
2840 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2841 assert_eq!(plan.artifact_deletes(), 0);
2842
2843 let copy_held = Desired {
2845 modes: vec![SourceMode::Copy],
2846 ..desired_arts("a", vec![])
2847 };
2848 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2849 assert_eq!(plan.artifact_deletes(), 0);
2850 }
2851
2852 #[test]
2853 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2854 let mut manifest = Manifest::new();
2860 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2861 let d = vec![desired_arts(
2862 "a",
2863 vec![art(
2864 ArtifactKind::CoverJpg,
2865 "new/cover.jpg",
2866 "https://art/a",
2867 "h1",
2868 )],
2869 )];
2870 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2871 assert_eq!(plan.artifact_writes(), 1);
2872 assert_eq!(plan.artifact_deletes(), 0);
2873 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2874 assert_eq!(path, "new/cover.jpg");
2875 } else {
2876 panic!("expected a WriteArtifact");
2877 }
2878 }
2879
2880 #[test]
2881 fn per_clip_reconcile_ignores_album_and_library_kinds() {
2882 let mut manifest = Manifest::new();
2886 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2887 let d = vec![desired_arts(
2888 "a",
2889 vec![
2890 art(
2891 ArtifactKind::FolderJpg,
2892 "a/folder.jpg",
2893 "https://art/folder",
2894 "hf",
2895 ),
2896 art(
2897 ArtifactKind::Playlist,
2898 "a/list.m3u",
2899 "https://art/list",
2900 "hp",
2901 ),
2902 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2903 ],
2904 )];
2905 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2906 assert_eq!(plan.artifact_writes(), 1);
2907 let paths: Vec<&str> = plan
2908 .actions
2909 .iter()
2910 .filter_map(|a| match a {
2911 Action::WriteArtifact { path, .. } => Some(path.as_str()),
2912 _ => None,
2913 })
2914 .collect();
2915 assert_eq!(paths, vec!["a/cover.jpg"]);
2916 }
2917
2918 #[test]
2919 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
2920 let mut manifest = Manifest::new();
2921 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2922 let d = vec![desired_arts(
2923 "a",
2924 vec![art(
2925 ArtifactKind::FolderWebp,
2926 "a/folder.webp",
2927 "https://art/folder",
2928 "hf",
2929 )],
2930 )];
2931 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2932 assert_eq!(plan.artifact_writes(), 0);
2933 assert_eq!(plan.artifact_deletes(), 0);
2934 }
2935
2936 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
2939 Clip {
2940 id: id.to_string(),
2941 title: "Song".to_string(),
2942 image_large_url: image.to_string(),
2943 video_cover_url: video.to_string(),
2944 play_count,
2945 created_at: created_at.to_string(),
2946 ..Default::default()
2947 }
2948 }
2949
2950 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
2951 let mut lineage = LineageContext::own_root(&clip);
2952 lineage.root_id = root_id.to_string();
2953 Desired {
2954 clip,
2955 lineage,
2956 path: path.to_string(),
2957 format: AudioFormat::Flac,
2958 meta_hash: "m".to_string(),
2959 art_hash: "a".to_string(),
2960 modes: vec![SourceMode::Mirror],
2961 trashed: false,
2962 private: false,
2963 artifacts: Vec::new(),
2964 }
2965 }
2966
2967 fn stored(path: &str, hash: &str) -> ArtifactState {
2968 ArtifactState {
2969 path: path.to_string(),
2970 hash: hash.to_string(),
2971 }
2972 }
2973
2974 #[test]
2975 fn folder_jpg_source_is_most_played() {
2976 let members = vec![
2977 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
2978 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
2979 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
2980 ];
2981 let albums = album_desired(&members, false);
2982 assert_eq!(albums.len(), 1);
2983 let jpg = albums[0].folder_jpg.as_ref().unwrap();
2984 assert_eq!(jpg.hash, art_url_hash("art-b"));
2986 assert_eq!(jpg.source_url, "art-b");
2987 assert_eq!(jpg.path, "c/al/folder.jpg");
2988 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
2989 }
2990
2991 #[test]
2992 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
2993 let by_time = vec![
2995 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
2996 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
2997 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
2998 ];
2999 let jpg = album_desired(&by_time, false)[0]
3000 .folder_jpg
3001 .clone()
3002 .unwrap();
3003 assert_eq!(jpg.source_url, "art-y");
3004
3005 let by_id = vec![
3007 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3008 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3009 ];
3010 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
3011 assert_eq!(jpg.source_url, "art-g");
3012 }
3013
3014 #[test]
3015 fn folder_webp_source_is_first_created_animated() {
3016 let members = vec![
3017 album_member(
3018 album_clip("a", 9, "t2", "art-a", "vid-a"),
3019 "root",
3020 "c/al/a.flac",
3021 ),
3022 album_member(
3023 album_clip("b", 1, "t0", "art-b", "vid-b"),
3024 "root",
3025 "c/al/b.flac",
3026 ),
3027 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3028 ];
3029 let webp = album_desired(&members, true)[0]
3030 .folder_webp
3031 .clone()
3032 .unwrap();
3033 assert_eq!(webp.source_url, "vid-b");
3035 assert_eq!(webp.hash, art_url_hash("vid-b"));
3036 assert_eq!(webp.path, "c/al/cover.webp");
3037 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3038 }
3039
3040 #[test]
3041 fn animated_covers_off_yields_no_folder_webp() {
3042 let members = vec![album_member(
3043 album_clip("a", 1, "t0", "art-a", "vid-a"),
3044 "root",
3045 "c/al/a.flac",
3046 )];
3047 let off = album_desired(&members, false);
3048 assert!(off[0].folder_webp.is_none());
3049 let on = album_desired(&members, true);
3050 assert!(on[0].folder_webp.is_some());
3051 }
3052
3053 #[test]
3054 fn album_with_no_art_yields_no_folder_jpg() {
3055 let members = vec![album_member(
3056 album_clip("a", 3, "t0", "", ""),
3057 "root",
3058 "c/al/a.flac",
3059 )];
3060 let albums = album_desired(&members, true);
3061 assert!(albums[0].folder_jpg.is_none());
3062 assert!(albums[0].folder_webp.is_none());
3063 }
3064
3065 #[test]
3066 fn album_desired_groups_by_root_id() {
3067 let members = vec![
3068 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3069 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3070 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3071 ];
3072 let albums = album_desired(&members, false);
3073 assert_eq!(albums.len(), 2);
3074 assert_eq!(albums[0].root_id, "r1");
3075 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3076 assert_eq!(
3077 albums[0].folder_jpg.as_ref().unwrap().path,
3078 "c/al1/folder.jpg"
3079 );
3080 assert_eq!(albums[1].root_id, "r2");
3081 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3082 assert_eq!(
3083 albums[1].folder_jpg.as_ref().unwrap().path,
3084 "c/al2/folder.jpg"
3085 );
3086 }
3087
3088 #[test]
3089 fn plan_writes_folder_art_when_store_empty() {
3090 let members = vec![album_member(
3091 album_clip("a", 1, "t0", "art-a", "vid-a"),
3092 "root",
3093 "c/al/a.flac",
3094 )];
3095 let desired = album_desired(&members, true);
3096 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3097 assert_eq!(
3098 actions,
3099 vec![
3100 Action::WriteArtifact {
3101 kind: ArtifactKind::FolderJpg,
3102 path: "c/al/folder.jpg".to_string(),
3103 source_url: "art-a".to_string(),
3104 hash: art_url_hash("art-a"),
3105 owner_id: "root".to_string(),
3106 content: None,
3107 },
3108 Action::WriteArtifact {
3109 kind: ArtifactKind::FolderWebp,
3110 path: "c/al/cover.webp".to_string(),
3111 source_url: "vid-a".to_string(),
3112 hash: art_url_hash("vid-a"),
3113 owner_id: "root".to_string(),
3114 content: None,
3115 },
3116 ]
3117 );
3118 }
3119
3120 #[test]
3121 fn plan_skips_when_hash_and_path_match() {
3122 let members = vec![album_member(
3123 album_clip("a", 1, "t0", "art-a", ""),
3124 "root",
3125 "c/al/a.flac",
3126 )];
3127 let desired = album_desired(&members, false);
3128 let mut albums = BTreeMap::new();
3129 albums.insert(
3130 "root".to_string(),
3131 AlbumArt {
3132 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3133 folder_webp: None,
3134 },
3135 );
3136 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3137 }
3138
3139 #[test]
3140 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3141 let members = vec![album_member(
3142 album_clip("a", 1, "t0", "art-a", ""),
3143 "root",
3144 "c/al/a.flac",
3145 )];
3146 let desired = album_desired(&members, false);
3147 let mut albums = BTreeMap::new();
3148 albums.insert(
3149 "root".to_string(),
3150 AlbumArt {
3151 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3152 folder_webp: None,
3153 },
3154 );
3155 let actions = plan_album_artifacts(&desired, &albums, true);
3156 assert_eq!(actions.len(), 1);
3157 assert!(matches!(
3158 &actions[0],
3159 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3160 ));
3161 }
3162
3163 #[test]
3164 fn h1_most_played_flip_to_same_art_writes_nothing() {
3165 let run1 = vec![
3167 album_member(
3168 album_clip("a", 9, "t0", "same-art", ""),
3169 "root",
3170 "c/al/a.flac",
3171 ),
3172 album_member(
3173 album_clip("b", 1, "t1", "same-art", ""),
3174 "root",
3175 "c/al/b.flac",
3176 ),
3177 ];
3178 let desired1 = album_desired(&run1, false);
3179 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3180 assert_eq!(write1.len(), 1);
3181
3182 let mut albums = BTreeMap::new();
3184 if let Action::WriteArtifact {
3185 path,
3186 hash,
3187 owner_id,
3188 ..
3189 } = &write1[0]
3190 {
3191 albums.insert(
3192 owner_id.clone(),
3193 AlbumArt {
3194 folder_jpg: Some(stored(path, hash)),
3195 folder_webp: None,
3196 },
3197 );
3198 }
3199
3200 let run2 = vec![
3202 album_member(
3203 album_clip("a", 1, "t0", "same-art", ""),
3204 "root",
3205 "c/al/a.flac",
3206 ),
3207 album_member(
3208 album_clip("b", 9, "t1", "same-art", ""),
3209 "root",
3210 "c/al/b.flac",
3211 ),
3212 ];
3213 let desired2 = album_desired(&run2, false);
3214 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3216 }
3217
3218 #[test]
3219 fn h1_flip_to_different_art_writes_exactly_one() {
3220 let mut albums = BTreeMap::new();
3221 albums.insert(
3222 "root".to_string(),
3223 AlbumArt {
3224 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3225 folder_webp: None,
3226 },
3227 );
3228 let members = vec![
3230 album_member(
3231 album_clip("a", 1, "t0", "old-art", ""),
3232 "root",
3233 "c/al/a.flac",
3234 ),
3235 album_member(
3236 album_clip("b", 9, "t1", "new-art", ""),
3237 "root",
3238 "c/al/b.flac",
3239 ),
3240 ];
3241 let desired = album_desired(&members, false);
3242 let actions = plan_album_artifacts(&desired, &albums, true);
3243 assert_eq!(actions.len(), 1);
3244 assert!(matches!(
3245 &actions[0],
3246 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3247 ));
3248 }
3249
3250 #[test]
3251 fn one_write_per_album_regardless_of_clip_count() {
3252 let members: Vec<Desired> = (0..200)
3253 .map(|i| {
3254 album_member(
3255 album_clip(
3256 &format!("clip-{i:03}"),
3257 i as u64,
3258 &format!("t{i:03}"),
3259 &format!("art-{i:03}"),
3260 &format!("vid-{i:03}"),
3261 ),
3262 "root",
3263 &format!("c/al/clip-{i:03}.flac"),
3264 )
3265 })
3266 .collect();
3267 let desired = album_desired(&members, true);
3268 assert_eq!(desired.len(), 1);
3269 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3270 assert_eq!(actions.len(), 2);
3272 assert_eq!(
3273 actions
3274 .iter()
3275 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3276 .count(),
3277 2
3278 );
3279 }
3280
3281 #[test]
3282 fn emptied_album_deletes_only_when_can_delete() {
3283 let mut albums = BTreeMap::new();
3284 albums.insert(
3285 "root".to_string(),
3286 AlbumArt {
3287 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3288 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3289 },
3290 );
3291 let desired: Vec<AlbumDesired> = Vec::new();
3293
3294 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3296
3297 let actions = plan_album_artifacts(&desired, &albums, true);
3299 assert_eq!(
3300 actions,
3301 vec![
3302 Action::DeleteArtifact {
3303 kind: ArtifactKind::FolderJpg,
3304 path: "c/al/folder.jpg".to_string(),
3305 owner_id: "root".to_string(),
3306 },
3307 Action::DeleteArtifact {
3308 kind: ArtifactKind::FolderWebp,
3309 path: "c/al/cover.webp".to_string(),
3310 owner_id: "root".to_string(),
3311 },
3312 ]
3313 );
3314 }
3315
3316 #[test]
3317 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3318 let mut albums = BTreeMap::new();
3319 albums.insert(
3320 "root".to_string(),
3321 AlbumArt {
3322 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3323 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3324 },
3325 );
3326 let members = vec![album_member(
3329 album_clip("a", 1, "t0", "art-a", "vid-a"),
3330 "root",
3331 "c/al/a.flac",
3332 )];
3333 let desired = album_desired(&members, false);
3334
3335 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3336
3337 let actions = plan_album_artifacts(&desired, &albums, true);
3338 assert_eq!(
3339 actions,
3340 vec![Action::DeleteArtifact {
3341 kind: ArtifactKind::FolderWebp,
3342 path: "c/al/cover.webp".to_string(),
3343 owner_id: "root".to_string(),
3344 }]
3345 );
3346 }
3347
3348 #[test]
3349 fn plan_album_artifacts_is_deterministically_ordered() {
3350 let members = vec![
3351 album_member(
3352 album_clip("a", 1, "t0", "art-a", "vid-a"),
3353 "r2",
3354 "c/al2/a.flac",
3355 ),
3356 album_member(
3357 album_clip("b", 1, "t0", "art-b", "vid-b"),
3358 "r1",
3359 "c/al1/b.flac",
3360 ),
3361 ];
3362 let desired = album_desired(&members, true);
3363 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3364 let keys: Vec<(&str, ArtifactKind)> = actions
3365 .iter()
3366 .map(|a| match a {
3367 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3368 _ => unreachable!(),
3369 })
3370 .collect();
3371 assert_eq!(
3372 keys,
3373 vec![
3374 ("r1", ArtifactKind::FolderJpg),
3375 ("r1", ArtifactKind::FolderWebp),
3376 ("r2", ArtifactKind::FolderJpg),
3377 ("r2", ArtifactKind::FolderWebp),
3378 ]
3379 );
3380 }
3381
3382 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3385 PlaylistDesired {
3386 id: id.to_owned(),
3387 name: name.to_owned(),
3388 path: path.to_owned(),
3389 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3390 hash: hash.to_owned(),
3391 }
3392 }
3393
3394 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3395 PlaylistState {
3396 name: name.to_owned(),
3397 path: path.to_owned(),
3398 hash: hash.to_owned(),
3399 }
3400 }
3401
3402 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3403 entries
3404 .iter()
3405 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3406 .collect()
3407 }
3408
3409 #[test]
3410 fn playlist_write_emitted_for_a_new_playlist() {
3411 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3412 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3413 assert_eq!(
3414 actions,
3415 vec![Action::WriteArtifact {
3416 kind: ArtifactKind::Playlist,
3417 path: "Road Trip.m3u8".to_owned(),
3418 source_url: String::new(),
3419 hash: "h1".to_owned(),
3420 owner_id: "pl1".to_owned(),
3421 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3422 }]
3423 );
3424 }
3425
3426 #[test]
3427 fn playlist_write_emitted_when_hash_changes() {
3428 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3431 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3432 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3433 assert_eq!(actions.len(), 1);
3434 assert!(matches!(
3435 &actions[0],
3436 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3437 ));
3438 }
3439
3440 #[test]
3441 fn playlist_unchanged_is_idempotent() {
3442 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3443 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3444 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3445 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3446 }
3447
3448 #[test]
3449 fn playlist_rename_writes_new_and_deletes_old_path() {
3450 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3453 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3454 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3455 assert_eq!(
3456 actions,
3457 vec![
3458 Action::WriteArtifact {
3459 kind: ArtifactKind::Playlist,
3460 path: "Summer.m3u8".to_owned(),
3461 source_url: String::new(),
3462 hash: "h2".to_owned(),
3463 owner_id: "pl1".to_owned(),
3464 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3465 },
3466 Action::DeleteArtifact {
3467 kind: ArtifactKind::Playlist,
3468 path: "Spring.m3u8".to_owned(),
3469 owner_id: "pl1".to_owned(),
3470 },
3471 ]
3472 );
3473 }
3474
3475 #[test]
3476 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3477 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3480 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3481 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3482 assert_eq!(actions.len(), 1);
3483 assert!(matches!(
3484 &actions[0],
3485 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3486 ));
3487 assert!(
3488 !actions
3489 .iter()
3490 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3491 "old path must not be deleted when deletes are disallowed"
3492 );
3493 }
3494
3495 #[test]
3496 fn playlist_stale_removed_only_under_full_gate() {
3497 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3500
3501 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3502 assert_eq!(
3503 deleted,
3504 vec![Action::DeleteArtifact {
3505 kind: ArtifactKind::Playlist,
3506 path: "Gone.m3u8".to_owned(),
3507 owner_id: "gone".to_owned(),
3508 }]
3509 );
3510
3511 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3513 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3514 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3515 }
3516
3517 #[test]
3518 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3519 let stored = pl_store(&[
3524 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3525 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3526 ]);
3527 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3528 assert!(
3529 actions.is_empty(),
3530 "a failed playlist listing must plan zero actions, got {actions:?}"
3531 );
3532 }
3533
3534 #[test]
3535 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3536 let stored = pl_store(&[
3541 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3542 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3543 ]);
3544
3545 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3547
3548 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3551 assert_eq!(
3552 wiped
3553 .iter()
3554 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3555 .count(),
3556 2
3557 );
3558 }
3559
3560 #[test]
3561 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3562 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3567 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3568 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3569 assert_eq!(actions.len(), 1);
3571 assert!(matches!(
3572 &actions[0],
3573 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3574 ));
3575 assert!(
3576 !actions.iter().any(|a| match a {
3577 Action::WriteArtifact { owner_id, .. }
3578 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3579 _ => false,
3580 }),
3581 "a protected (failed-member) playlist must have no action"
3582 );
3583 }
3584
3585 #[test]
3586 fn playlist_rename_collision_downgrades_the_delete() {
3587 let desired = vec![
3593 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3594 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3595 ];
3596 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3597 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3598 let write_paths: BTreeSet<&str> = actions
3600 .iter()
3601 .filter_map(|a| match a {
3602 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3603 _ => None,
3604 })
3605 .collect();
3606 for a in &actions {
3607 if let Action::DeleteArtifact { path, .. } = a {
3608 assert!(
3609 !write_paths.contains(path.as_str()),
3610 "a playlist delete aliases a write target: {path}"
3611 );
3612 }
3613 }
3614 }
3615}
3616
3617#[cfg(test)]
3630mod proptests {
3631 use super::*;
3632 use proptest::collection::{btree_map, hash_map, vec};
3633 use proptest::prelude::*;
3634 use std::collections::BTreeSet;
3635
3636 type DesiredFields = (
3637 String,
3638 AudioFormat,
3639 String,
3640 String,
3641 Vec<SourceMode>,
3642 bool,
3643 bool,
3644 );
3645
3646 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3647 prop_oneof![
3648 Just(AudioFormat::Mp3),
3649 Just(AudioFormat::Flac),
3650 Just(AudioFormat::Wav),
3651 ]
3652 }
3653
3654 fn source_mode() -> impl Strategy<Value = SourceMode> {
3655 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3656 }
3657
3658 fn clip_id() -> impl Strategy<Value = String> {
3661 (0u8..8).prop_map(|n| format!("c{n}"))
3662 }
3663
3664 fn small_path() -> impl Strategy<Value = String> {
3665 (0u8..6).prop_map(|n| format!("path{n}"))
3666 }
3667
3668 fn manifest_path() -> impl Strategy<Value = String> {
3671 prop_oneof![
3672 1 => Just(String::new()),
3673 6 => small_path(),
3674 ]
3675 }
3676
3677 fn small_hash() -> impl Strategy<Value = String> {
3678 (0u8..4).prop_map(|n| format!("h{n}"))
3679 }
3680
3681 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3682 (
3683 manifest_path(),
3684 audio_format(),
3685 small_hash(),
3686 small_hash(),
3687 0u64..4,
3688 any::<bool>(),
3689 )
3690 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3691 ManifestEntry {
3692 path,
3693 format,
3694 meta_hash,
3695 art_hash,
3696 size,
3697 preserve,
3698 ..Default::default()
3699 }
3700 })
3701 }
3702
3703 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3704 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3705 }
3706
3707 fn local_file() -> impl Strategy<Value = LocalFile> {
3708 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3709 }
3710
3711 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3712 hash_map(clip_id(), local_file(), 0..8)
3713 }
3714
3715 fn source_status() -> impl Strategy<Value = SourceStatus> {
3716 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3717 mode,
3718 fully_enumerated,
3719 })
3720 }
3721
3722 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3723 vec(source_status(), 0..5)
3724 }
3725
3726 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3727 vec(
3728 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3729 mode: SourceMode::Copy,
3730 fully_enumerated,
3731 }),
3732 1..5,
3733 )
3734 }
3735
3736 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3737 (
3738 small_path(),
3739 audio_format(),
3740 small_hash(),
3741 small_hash(),
3742 vec(source_mode(), 1..3),
3743 any::<bool>(),
3744 any::<bool>(),
3745 )
3746 }
3747
3748 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3749 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3750 let clip = Clip {
3751 id,
3752 title: "t".to_string(),
3753 ..Default::default()
3754 };
3755 Desired {
3756 lineage: LineageContext::own_root(&clip),
3757 clip,
3758 path,
3759 format,
3760 meta_hash,
3761 art_hash,
3762 modes,
3763 trashed,
3764 private,
3765 artifacts: Vec::new(),
3766 }
3767 }
3768
3769 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3772 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3773 items
3774 .into_iter()
3775 .map(|(id, fields)| build_desired(id, fields))
3776 .collect()
3777 })
3778 }
3779
3780 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3781 desired.iter().map(|d| d.clip.id.as_str()).collect()
3782 }
3783
3784 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3787 desired
3788 .iter()
3789 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3790 .map(|d| d.clip.id.as_str())
3791 .collect()
3792 }
3793
3794 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3797 desired
3798 .iter()
3799 .filter(|d| !d.trashed)
3800 .map(|d| d.clip.id.as_str())
3801 .collect()
3802 }
3803
3804 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3805 plan.actions
3806 .iter()
3807 .filter_map(|a| match a {
3808 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3809 _ => None,
3810 })
3811 .collect()
3812 }
3813
3814 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3815 plan.actions
3816 .iter()
3817 .filter_map(|a| match a {
3818 Action::Download { path, .. } | Action::Reformat { path, .. } => {
3819 Some(path.as_str())
3820 }
3821 Action::Rename { to, .. } => Some(to.as_str()),
3822 _ => None,
3823 })
3824 .collect()
3825 }
3826
3827 proptest! {
3828 #![proptest_config(ProptestConfig {
3829 cases: 256,
3830 failure_persistence: None,
3831 ..ProptestConfig::default()
3832 })]
3833
3834 #[test]
3837 fn inv1_desired_clip_deleted_only_when_fully_trashed(
3838 manifest in manifest_strategy(),
3839 desired in desired_strategy(),
3840 local in local_strategy(),
3841 sources in sources_strategy(),
3842 ) {
3843 let plan = reconcile(&manifest, &desired, &local, &sources);
3844 let present = desired_ids(&desired);
3845 let live = non_trashed_ids(&desired);
3846 for id in delete_clip_ids(&plan) {
3847 prop_assert!(
3848 !(present.contains(id) && live.contains(id)),
3849 "deleted a desired clip with a non-trashed duplicate: {id}"
3850 );
3851 }
3852 }
3853
3854 #[test]
3858 fn inv2_no_delete_when_any_mirror_unenumerated(
3859 manifest in manifest_strategy(),
3860 desired in desired_strategy(),
3861 local in local_strategy(),
3862 mut sources in sources_strategy(),
3863 ) {
3864 sources.push(SourceStatus {
3865 mode: SourceMode::Mirror,
3866 fully_enumerated: false,
3867 });
3868 let plan = reconcile(&manifest, &desired, &local, &sources);
3869 prop_assert_eq!(plan.deletes(), 0);
3870 }
3871
3872 #[test]
3874 fn inv3_all_copy_sources_means_no_deletes(
3875 manifest in manifest_strategy(),
3876 desired in desired_strategy(),
3877 local in local_strategy(),
3878 sources in copy_sources_strategy(),
3879 ) {
3880 let plan = reconcile(&manifest, &desired, &local, &sources);
3881 prop_assert_eq!(plan.deletes(), 0);
3882 }
3883
3884 #[test]
3887 fn inv4_plan_is_deterministic(
3888 manifest in manifest_strategy(),
3889 desired in desired_strategy(),
3890 local in local_strategy(),
3891 sources in sources_strategy(),
3892 ) {
3893 let plan = reconcile(&manifest, &desired, &local, &sources);
3894
3895 let again = reconcile(&manifest, &desired, &local, &sources);
3896 prop_assert_eq!(&plan, &again);
3897
3898 let mut desired_rev = desired.clone();
3899 desired_rev.reverse();
3900 let mut sources_rev = sources.clone();
3901 sources_rev.reverse();
3902 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3903 prop_assert_eq!(&plan, &shuffled);
3904 }
3905
3906 #[test]
3908 fn inv5_every_delete_is_in_the_manifest(
3909 manifest in manifest_strategy(),
3910 desired in desired_strategy(),
3911 local in local_strategy(),
3912 sources in sources_strategy(),
3913 ) {
3914 let plan = reconcile(&manifest, &desired, &local, &sources);
3915 for id in delete_clip_ids(&plan) {
3916 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
3917 }
3918 }
3919
3920 #[test]
3923 fn inv6_never_deletes_protected_clip(
3924 manifest in manifest_strategy(),
3925 desired in desired_strategy(),
3926 local in local_strategy(),
3927 sources in sources_strategy(),
3928 ) {
3929 let plan = reconcile(&manifest, &desired, &local, &sources);
3930 let protected = protected_ids(&desired);
3931 for id in delete_clip_ids(&plan) {
3932 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
3933 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
3934 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
3935 }
3936 }
3937
3938 #[test]
3941 fn inv7_no_delete_unless_deletion_allowed(
3942 manifest in manifest_strategy(),
3943 desired in desired_strategy(),
3944 local in local_strategy(),
3945 sources in sources_strategy(),
3946 ) {
3947 let plan = reconcile(&manifest, &desired, &local, &sources);
3948 if !deletion_allowed(&sources) {
3949 prop_assert_eq!(plan.deletes(), 0);
3950 }
3951 }
3952
3953 #[test]
3955 fn inv8_at_most_one_delete_per_clip(
3956 manifest in manifest_strategy(),
3957 desired in desired_strategy(),
3958 local in local_strategy(),
3959 sources in sources_strategy(),
3960 ) {
3961 let plan = reconcile(&manifest, &desired, &local, &sources);
3962 let ids = delete_clip_ids(&plan);
3963 let unique: BTreeSet<&str> = ids.iter().copied().collect();
3964 prop_assert_eq!(ids.len(), unique.len());
3965 }
3966
3967 #[test]
3969 fn inv9_no_delete_with_empty_path(
3970 manifest in manifest_strategy(),
3971 desired in desired_strategy(),
3972 local in local_strategy(),
3973 sources in sources_strategy(),
3974 ) {
3975 let plan = reconcile(&manifest, &desired, &local, &sources);
3976 for action in &plan.actions {
3977 if let Action::Delete { path, .. } = action {
3978 prop_assert!(!path.is_empty(), "delete with an empty path");
3979 }
3980 }
3981 }
3982
3983 #[test]
3986 fn inv10_no_delete_aliases_a_write_target(
3987 manifest in manifest_strategy(),
3988 desired in desired_strategy(),
3989 local in local_strategy(),
3990 sources in sources_strategy(),
3991 ) {
3992 let plan = reconcile(&manifest, &desired, &local, &sources);
3993 let targets = write_target_paths(&plan);
3994 for action in &plan.actions {
3995 if let Action::Delete { path, .. } = action {
3996 prop_assert!(
3997 !targets.contains(path.as_str()),
3998 "delete path {path} aliases a write target"
3999 );
4000 }
4001 }
4002 }
4003 }
4004}