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)]
55pub enum ArtifactKind {
56 CoverJpg,
58 CoverWebp,
60 FolderJpg,
62 FolderWebp,
64 Playlist,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum SourceMode {
72 Mirror,
74 Copy,
76}
77
78#[derive(Debug, Clone, PartialEq)]
85pub struct Desired {
86 pub clip: Clip,
88 pub lineage: LineageContext,
91 pub path: String,
93 pub format: AudioFormat,
95 pub meta_hash: String,
97 pub art_hash: String,
99 pub modes: Vec<SourceMode>,
101 pub trashed: bool,
103 pub private: bool,
105 pub artifacts: Vec<DesiredArtifact>,
113}
114
115#[derive(Debug, Clone, PartialEq)]
120pub struct DesiredArtifact {
121 pub kind: ArtifactKind,
123 pub path: String,
125 pub source_url: String,
127 pub hash: String,
129}
130
131#[derive(Debug, Clone, PartialEq)]
142pub struct AlbumDesired {
143 pub root_id: String,
145 pub folder_jpg: Option<DesiredArtifact>,
147 pub folder_webp: Option<DesiredArtifact>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct PlaylistDesired {
163 pub id: String,
166 pub name: String,
168 pub path: String,
170 pub content: String,
172 pub hash: String,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub struct LocalFile {
179 pub exists: bool,
181 pub size: u64,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct SourceStatus {
188 pub mode: SourceMode,
190 pub fully_enumerated: bool,
192}
193
194#[derive(Debug, Clone, PartialEq)]
196pub enum Action {
197 Download {
199 clip: Clip,
200 lineage: LineageContext,
201 path: String,
202 format: AudioFormat,
203 },
204 Reformat {
210 clip: Clip,
211 path: String,
212 from_path: String,
213 from: AudioFormat,
214 to: AudioFormat,
215 },
216 Retag {
218 clip: Clip,
219 lineage: LineageContext,
220 path: String,
221 },
222 Rename { from: String, to: String },
224 Delete { path: String, clip_id: String },
226 Skip { clip_id: String },
228 WriteArtifact {
240 kind: ArtifactKind,
241 path: String,
242 source_url: String,
243 hash: String,
244 owner_id: String,
245 content: Option<String>,
246 },
247 DeleteArtifact {
254 kind: ArtifactKind,
255 path: String,
256 owner_id: String,
257 },
258}
259
260#[derive(Debug, Clone, Default, PartialEq)]
265pub struct Plan {
266 pub actions: Vec<Action>,
268}
269
270impl Plan {
271 pub fn len(&self) -> usize {
273 self.actions.len()
274 }
275
276 pub fn is_empty(&self) -> bool {
278 self.actions.is_empty()
279 }
280
281 pub fn downloads(&self) -> usize {
283 self.count(|a| matches!(a, Action::Download { .. }))
284 }
285
286 pub fn reformats(&self) -> usize {
288 self.count(|a| matches!(a, Action::Reformat { .. }))
289 }
290
291 pub fn retags(&self) -> usize {
293 self.count(|a| matches!(a, Action::Retag { .. }))
294 }
295
296 pub fn renames(&self) -> usize {
298 self.count(|a| matches!(a, Action::Rename { .. }))
299 }
300
301 pub fn deletes(&self) -> usize {
303 self.count(|a| matches!(a, Action::Delete { .. }))
304 }
305
306 pub fn skips(&self) -> usize {
308 self.count(|a| matches!(a, Action::Skip { .. }))
309 }
310
311 pub fn artifact_writes(&self) -> usize {
313 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
314 }
315
316 pub fn artifact_deletes(&self) -> usize {
318 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
319 }
320
321 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
322 self.actions.iter().filter(|a| pred(a)).count()
323 }
324}
325
326pub fn reconcile(
341 manifest: &Manifest,
342 desired: &[Desired],
343 local: &HashMap<String, LocalFile>,
344 sources: &[SourceStatus],
345) -> Plan {
346 let mut actions: Vec<Action> = Vec::new();
347
348 let desired = aggregate_desired(desired);
350 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
351
352 let can_delete = deletion_allowed(sources);
353
354 for d in &desired {
355 let before = actions.len();
360 plan_desired(d, manifest, local, can_delete, &mut actions);
361 let audio_deleted = actions[before..]
362 .iter()
363 .any(|a| matches!(a, Action::Delete { .. }));
364 if audio_deleted {
365 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
366 } else {
367 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
368 }
369 }
370
371 for (clip_id, _entry) in manifest.iter() {
373 if desired_ids.contains(clip_id.as_str()) {
374 continue;
375 }
376 match delete_action(clip_id, manifest, can_delete) {
377 Some(action) => {
378 actions.push(action);
379 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
381 }
382 None => actions.push(Action::Skip {
385 clip_id: clip_id.clone(),
386 }),
387 }
388 }
389
390 suppress_path_aliasing(&mut actions);
391 Plan { actions }
392}
393
394pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
405 let mut saw_mirror = false;
406 for status in sources {
407 if !status.fully_enumerated {
408 return false;
409 }
410 if status.mode == SourceMode::Mirror {
411 saw_mirror = true;
412 }
413 }
414 saw_mirror
415}
416
417fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
423 if !can_delete {
424 return None;
425 }
426 let entry = manifest.get(clip_id)?;
427 if entry.path.is_empty() || entry.preserve {
428 return None;
429 }
430 Some(Action::Delete {
431 path: entry.path.clone(),
432 clip_id: clip_id.to_string(),
433 })
434}
435
436fn delete_artifact_action(
446 owner_id: &str,
447 kind: ArtifactKind,
448 path: &str,
449 manifest: &Manifest,
450 can_delete: bool,
451) -> Option<Action> {
452 if !can_delete {
453 return None;
454 }
455 let entry = manifest.get(owner_id)?;
456 if path.is_empty() || entry.preserve {
457 return None;
458 }
459 Some(Action::DeleteArtifact {
460 kind,
461 path: path.to_string(),
462 owner_id: owner_id.to_string(),
463 })
464}
465
466fn is_per_clip_kind(kind: ArtifactKind) -> bool {
472 matches!(kind, ArtifactKind::CoverJpg | ArtifactKind::CoverWebp)
473}
474
475fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
487 match kind {
488 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp => false,
489 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => true,
490 }
491}
492
493fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
498 match kind {
499 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
500 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
501 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
502 }
503}
504
505fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
508 let mut out = Vec::new();
509 if let Some(state) = &entry.cover_jpg {
510 out.push((ArtifactKind::CoverJpg, state));
511 }
512 if let Some(state) = &entry.cover_webp {
513 out.push((ArtifactKind::CoverWebp, state));
514 }
515 out
516}
517
518pub(crate) fn set_manifest_artifact(
525 entry: &mut ManifestEntry,
526 kind: ArtifactKind,
527 state: Option<ArtifactState>,
528) {
529 match kind {
530 ArtifactKind::CoverJpg => entry.cover_jpg = state,
531 ArtifactKind::CoverWebp => entry.cover_webp = state,
532 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
533 }
534}
535
536fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
546 let owner_id = d.clip.id.as_str();
547 let entry = manifest.get(owner_id);
548
549 for artifact in &d.artifacts {
550 if !is_per_clip_kind(artifact.kind) {
554 continue;
555 }
556 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
565 None => true,
566 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
567 };
568 if needs_write {
569 out.push(Action::WriteArtifact {
570 kind: artifact.kind,
571 path: artifact.path.clone(),
572 source_url: artifact.source_url.clone(),
573 hash: artifact.hash.clone(),
574 owner_id: owner_id.to_string(),
575 content: None,
576 });
577 }
578 }
579
580 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
585 if !protected_now && let Some(entry) = entry {
586 let desired_kinds: BTreeSet<ArtifactKind> = d
587 .artifacts
588 .iter()
589 .filter(|a| is_per_clip_kind(a.kind))
590 .map(|a| a.kind)
591 .collect();
592 for (kind, state) in manifest_artifacts(entry) {
593 if removed_kind_delete_eligible(kind)
599 && !desired_kinds.contains(&kind)
600 && let Some(action) =
601 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
602 {
603 out.push(action);
604 }
605 }
606 }
607}
608
609fn co_delete_artifacts(
615 owner_id: &str,
616 manifest: &Manifest,
617 can_delete: bool,
618 out: &mut Vec<Action>,
619) {
620 let Some(entry) = manifest.get(owner_id) else {
621 return;
622 };
623 for (kind, state) in manifest_artifacts(entry) {
624 if let Some(action) =
625 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
626 {
627 out.push(action);
628 }
629 }
630}
631
632fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
639 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
640 for d in desired {
641 match by_id.get_mut(d.clip.id.as_str()) {
642 None => {
643 by_id.insert(d.clip.id.as_str(), d.clone());
644 }
645 Some(acc) => {
646 let take = rep_key(d) < rep_key(acc);
647 acc.private = acc.private || d.private;
648 acc.trashed = acc.trashed && d.trashed;
649 for mode in &d.modes {
650 if !acc.modes.contains(mode) {
651 acc.modes.push(*mode);
652 }
653 }
654 if take {
655 acc.clip = d.clip.clone();
656 acc.path = d.path.clone();
657 acc.format = d.format;
658 acc.meta_hash = d.meta_hash.clone();
659 acc.art_hash = d.art_hash.clone();
660 acc.artifacts = d.artifacts.clone();
661 }
662 }
663 }
664 }
665 let mut out: Vec<Desired> = by_id.into_values().collect();
666 for d in &mut out {
667 let has_mirror = d.modes.contains(&SourceMode::Mirror);
669 let has_copy = d.modes.contains(&SourceMode::Copy);
670 d.modes.clear();
671 if has_mirror {
672 d.modes.push(SourceMode::Mirror);
673 }
674 if has_copy {
675 d.modes.push(SourceMode::Copy);
676 }
677 }
678 out
679}
680
681fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
684 let format = match d.format {
685 AudioFormat::Mp3 => 0,
686 AudioFormat::Flac => 1,
687 AudioFormat::Wav => 2,
688 };
689 (
690 d.path.as_str(),
691 d.meta_hash.as_str(),
692 d.art_hash.as_str(),
693 format,
694 )
695}
696
697fn suppress_path_aliasing(actions: &mut [Action]) {
702 let targets: BTreeSet<String> = actions
703 .iter()
704 .filter_map(|a| match a {
705 Action::Download { path, .. }
706 | Action::Reformat { path, .. }
707 | Action::WriteArtifact { path, .. } => Some(path.clone()),
708 Action::Rename { to, .. } => Some(to.clone()),
709 _ => None,
710 })
711 .collect();
712 for a in actions.iter_mut() {
713 if let Action::Delete { path, clip_id } = a
714 && targets.contains(path.as_str())
715 {
716 *a = Action::Skip {
717 clip_id: clip_id.clone(),
718 };
719 }
720 if let Action::DeleteArtifact { path, owner_id, .. } = a
721 && targets.contains(path.as_str())
722 {
723 *a = Action::Skip {
724 clip_id: owner_id.clone(),
725 };
726 }
727 }
728}
729
730fn plan_desired(
732 d: &Desired,
733 manifest: &Manifest,
734 local: &HashMap<String, LocalFile>,
735 can_delete: bool,
736 out: &mut Vec<Action>,
737) {
738 let clip_id = d.clip.id.as_str();
739 let copy_held = d.modes.contains(&SourceMode::Copy);
740
741 if d.trashed && !d.private && !copy_held {
747 match delete_action(clip_id, manifest, can_delete) {
748 Some(action) => out.push(action),
749 None => out.push(Action::Skip {
750 clip_id: clip_id.to_string(),
751 }),
752 }
753 return;
754 }
755
756 let Some(entry) = manifest.get(clip_id) else {
757 out.push(Action::Download {
759 clip: d.clip.clone(),
760 lineage: d.lineage.clone(),
761 path: d.path.clone(),
762 format: d.format,
763 });
764 return;
765 };
766
767 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
770 if missing {
771 out.push(Action::Download {
772 clip: d.clip.clone(),
773 lineage: d.lineage.clone(),
774 path: d.path.clone(),
775 format: d.format,
776 });
777 return;
778 }
779
780 if d.format != entry.format {
781 out.push(Action::Reformat {
784 clip: d.clip.clone(),
785 path: d.path.clone(),
786 from_path: entry.path.clone(),
787 from: entry.format,
788 to: d.format,
789 });
790 return;
791 }
792
793 if d.path != entry.path {
794 out.push(Action::Rename {
795 from: entry.path.clone(),
796 to: d.path.clone(),
797 });
798 if meta_or_art_changed(d, entry) {
800 out.push(Action::Retag {
801 clip: d.clip.clone(),
802 lineage: d.lineage.clone(),
803 path: d.path.clone(),
804 });
805 }
806 return;
807 }
808
809 if meta_or_art_changed(d, entry) {
810 out.push(Action::Retag {
811 clip: d.clip.clone(),
812 lineage: d.lineage.clone(),
813 path: entry.path.clone(),
814 });
815 return;
816 }
817
818 out.push(Action::Skip {
819 clip_id: clip_id.to_string(),
820 });
821}
822
823fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
825 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
826}
827
828pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
848 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
849 for d in desired {
850 groups
851 .entry(d.lineage.root_id.as_str())
852 .or_default()
853 .push(d);
854 }
855
856 groups
857 .into_iter()
858 .map(|(root_id, members)| {
859 let album_dir = album_dir_of(&members);
860 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
861 kind: ArtifactKind::FolderJpg,
862 path: album_child(&album_dir, "folder.jpg"),
863 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
864 hash: art_hash(&source.clip),
865 });
866 let folder_webp = animated_covers
867 .then(|| folder_webp_source(&members))
868 .flatten()
869 .map(|source| DesiredArtifact {
870 kind: ArtifactKind::FolderWebp,
871 path: album_child(&album_dir, "cover.webp"),
872 source_url: source.clip.video_cover_url.clone(),
873 hash: art_url_hash(&source.clip.video_cover_url),
874 });
875 AlbumDesired {
876 root_id: root_id.to_owned(),
877 folder_jpg,
878 folder_webp,
879 }
880 })
881 .collect()
882}
883
884fn album_dir_of(members: &[&Desired]) -> String {
889 members
890 .iter()
891 .map(|d| parent_dir(&d.path))
892 .min()
893 .unwrap_or("")
894 .to_owned()
895}
896
897fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
903 members
904 .iter()
905 .copied()
906 .filter(|d| {
907 d.clip
908 .selected_image_url()
909 .is_some_and(|url| !url.is_empty())
910 })
911 .min_by(|a, b| {
912 b.clip
913 .play_count
914 .cmp(&a.clip.play_count)
915 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
916 .then_with(|| a.clip.id.cmp(&b.clip.id))
917 })
918}
919
920fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
925 members
926 .iter()
927 .copied()
928 .filter(|d| !d.clip.video_cover_url.is_empty())
929 .min_by(|a, b| {
930 a.clip
931 .created_at
932 .cmp(&b.clip.created_at)
933 .then_with(|| a.clip.id.cmp(&b.clip.id))
934 })
935}
936
937fn parent_dir(path: &str) -> &str {
939 match path.rsplit_once('/') {
940 Some((dir, _)) => dir,
941 None => "",
942 }
943}
944
945fn album_child(album_dir: &str, name: &str) -> String {
948 if album_dir.is_empty() {
949 name.to_owned()
950 } else {
951 format!("{album_dir}/{name}")
952 }
953}
954
955pub fn plan_album_artifacts(
974 desired: &[AlbumDesired],
975 albums: &BTreeMap<String, AlbumArt>,
976 can_delete: bool,
977) -> Vec<Action> {
978 let mut actions: Vec<Action> = Vec::new();
979 let by_root: BTreeMap<&str, &AlbumDesired> =
980 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
981
982 for d in desired {
983 let stored = albums.get(&d.root_id);
984 for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
985 .into_iter()
986 .flatten()
987 {
988 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
989 None => true,
990 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
991 };
992 if needs_write {
993 actions.push(Action::WriteArtifact {
994 kind: artifact.kind,
995 path: artifact.path.clone(),
996 source_url: artifact.source_url.clone(),
997 hash: artifact.hash.clone(),
998 owner_id: d.root_id.clone(),
999 content: None,
1000 });
1001 }
1002 }
1003 }
1004
1005 if can_delete {
1007 for (root_id, art) in albums {
1008 for (kind, state) in album_artifacts(art) {
1009 let desired_here = by_root
1010 .get(root_id.as_str())
1011 .is_some_and(|d| album_desires_kind(d, kind));
1012 if !desired_here && !state.path.is_empty() {
1013 actions.push(Action::DeleteArtifact {
1014 kind,
1015 path: state.path.clone(),
1016 owner_id: root_id.clone(),
1017 });
1018 }
1019 }
1020 }
1021 }
1022
1023 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1024 actions
1025}
1026
1027fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1030 let mut out = Vec::new();
1031 if let Some(state) = &art.folder_jpg {
1032 out.push((ArtifactKind::FolderJpg, state));
1033 }
1034 if let Some(state) = &art.folder_webp {
1035 out.push((ArtifactKind::FolderWebp, state));
1036 }
1037 out
1038}
1039
1040fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1042 match kind {
1043 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1044 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1045 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => false,
1046 }
1047}
1048
1049fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1051 match action {
1052 Action::WriteArtifact { owner_id, kind, .. }
1053 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1054 _ => ("", ArtifactKind::CoverJpg),
1055 }
1056}
1057
1058pub fn plan_playlist_artifacts(
1091 desired: &[PlaylistDesired],
1092 stored: &BTreeMap<String, PlaylistState>,
1093 can_delete: bool,
1094 list_fully_enumerated: bool,
1095) -> Vec<Action> {
1096 let mut actions: Vec<Action> = Vec::new();
1097 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1098 let deletes_allowed = can_delete && list_fully_enumerated;
1101
1102 for d in desired {
1103 let stored_here = stored.get(&d.id);
1104 let needs_write = match stored_here {
1105 None => true,
1106 Some(state) => state.hash != d.hash || state.path != d.path,
1107 };
1108 if needs_write {
1109 actions.push(Action::WriteArtifact {
1110 kind: ArtifactKind::Playlist,
1111 path: d.path.clone(),
1112 source_url: String::new(),
1113 hash: d.hash.clone(),
1114 owner_id: d.id.clone(),
1115 content: Some(d.content.clone()),
1116 });
1117 }
1118 if deletes_allowed
1120 && let Some(state) = stored_here
1121 && !state.path.is_empty()
1122 && state.path != d.path
1123 {
1124 actions.push(Action::DeleteArtifact {
1125 kind: ArtifactKind::Playlist,
1126 path: state.path.clone(),
1127 owner_id: d.id.clone(),
1128 });
1129 }
1130 }
1131
1132 if deletes_allowed {
1135 for (id, state) in stored {
1136 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1137 actions.push(Action::DeleteArtifact {
1138 kind: ArtifactKind::Playlist,
1139 path: state.path.clone(),
1140 owner_id: id.clone(),
1141 });
1142 }
1143 }
1144 }
1145
1146 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1147 suppress_path_aliasing(&mut actions);
1150 actions
1151}
1152
1153fn playlist_action_key(action: &Action) -> (&str, u8) {
1156 match action {
1157 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1158 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1159 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1160 _ => ("", 3),
1161 }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167
1168 fn clip(id: &str) -> Clip {
1169 Clip {
1170 id: id.to_string(),
1171 title: "Song".to_string(),
1172 ..Default::default()
1173 }
1174 }
1175
1176 fn lineage(id: &str) -> LineageContext {
1177 LineageContext::own_root(&clip(id))
1178 }
1179
1180 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1181 ManifestEntry {
1182 path: path.to_string(),
1183 format,
1184 meta_hash: meta.to_string(),
1185 art_hash: art.to_string(),
1186 size: 100,
1187 preserve: false,
1188 ..Default::default()
1189 }
1190 }
1191
1192 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1193 ManifestEntry {
1194 preserve: true,
1195 ..entry(path, format, meta, art)
1196 }
1197 }
1198
1199 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1200 Desired {
1201 clip: clip(id),
1202 lineage: lineage(id),
1203 path: path.to_string(),
1204 format,
1205 meta_hash: meta.to_string(),
1206 art_hash: art.to_string(),
1207 modes: vec![SourceMode::Mirror],
1208 trashed: false,
1209 private: false,
1210 artifacts: Vec::new(),
1211 }
1212 }
1213
1214 fn present(size: u64) -> LocalFile {
1215 LocalFile { exists: true, size }
1216 }
1217
1218 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1219 [(id.to_string(), present(100))].into_iter().collect()
1220 }
1221
1222 fn mirror_ok() -> Vec<SourceStatus> {
1223 vec![SourceStatus {
1224 mode: SourceMode::Mirror,
1225 fully_enumerated: true,
1226 }]
1227 }
1228
1229 #[test]
1232 fn not_in_manifest_downloads() {
1233 let manifest = Manifest::new();
1234 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1235 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1236 assert_eq!(
1237 plan.actions,
1238 vec![Action::Download {
1239 clip: clip("a"),
1240 lineage: lineage("a"),
1241 path: "a.flac".to_string(),
1242 format: AudioFormat::Flac,
1243 }]
1244 );
1245 }
1246
1247 #[test]
1248 fn unchanged_clip_skips() {
1249 let mut manifest = Manifest::new();
1250 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1251 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1252 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1253 assert_eq!(
1254 plan.actions,
1255 vec![Action::Skip {
1256 clip_id: "a".to_string()
1257 }]
1258 );
1259 }
1260
1261 #[test]
1262 fn meta_change_retags_in_place() {
1263 let mut manifest = Manifest::new();
1264 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1265 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1266 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1267 assert_eq!(
1268 plan.actions,
1269 vec![Action::Retag {
1270 clip: clip("a"),
1271 lineage: lineage("a"),
1272 path: "a.flac".to_string(),
1273 }]
1274 );
1275 }
1276
1277 #[test]
1278 fn art_change_retags_in_place() {
1279 let mut manifest = Manifest::new();
1280 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1281 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1282 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1283 assert_eq!(
1284 plan.actions,
1285 vec![Action::Retag {
1286 clip: clip("a"),
1287 lineage: lineage("a"),
1288 path: "a.flac".to_string(),
1289 }]
1290 );
1291 }
1292
1293 #[test]
1294 fn rename_when_path_changes() {
1295 let mut manifest = Manifest::new();
1296 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1297 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1298 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1299 assert_eq!(
1300 plan.actions,
1301 vec![Action::Rename {
1302 from: "old/a.flac".to_string(),
1303 to: "new/a.flac".to_string(),
1304 }]
1305 );
1306 }
1307
1308 #[test]
1309 fn rename_with_meta_change_also_retags() {
1310 let mut manifest = Manifest::new();
1311 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1312 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1313 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1314 assert_eq!(
1315 plan.actions,
1316 vec![
1317 Action::Rename {
1318 from: "old/a.flac".to_string(),
1319 to: "new/a.flac".to_string(),
1320 },
1321 Action::Retag {
1322 clip: clip("a"),
1323 lineage: lineage("a"),
1324 path: "new/a.flac".to_string(),
1325 },
1326 ]
1327 );
1328 }
1329
1330 #[test]
1331 fn rename_without_meta_change_does_not_retag() {
1332 let mut manifest = Manifest::new();
1333 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1334 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1335 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1336 assert_eq!(plan.renames(), 1);
1337 assert_eq!(plan.retags(), 0);
1338 }
1339
1340 #[test]
1341 fn format_change_reformats() {
1342 let mut manifest = Manifest::new();
1343 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1344 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1345 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1346 assert_eq!(
1347 plan.actions,
1348 vec![Action::Reformat {
1349 clip: clip("a"),
1350 path: "a.mp3".to_string(),
1351 from_path: "a.flac".to_string(),
1352 from: AudioFormat::Flac,
1353 to: AudioFormat::Mp3,
1354 }]
1355 );
1356 }
1357
1358 #[test]
1359 fn format_change_takes_precedence_over_rename_and_retag() {
1360 let mut manifest = Manifest::new();
1363 manifest.insert(
1364 "a",
1365 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1366 );
1367 let d = vec![desired(
1368 "a",
1369 "new/a.mp3",
1370 AudioFormat::Mp3,
1371 "new",
1372 "new-art",
1373 )];
1374 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1375 assert_eq!(plan.reformats(), 1);
1376 assert_eq!(plan.renames(), 0);
1377 assert_eq!(plan.retags(), 0);
1378 }
1379
1380 #[test]
1383 fn zero_length_file_downloads_even_when_hashes_match() {
1384 let mut manifest = Manifest::new();
1385 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1386 let local: HashMap<String, LocalFile> = [(
1387 "a".to_string(),
1388 LocalFile {
1389 exists: true,
1390 size: 0,
1391 },
1392 )]
1393 .into_iter()
1394 .collect();
1395 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1396 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1397 assert_eq!(plan.downloads(), 1);
1398 assert_eq!(plan.skips(), 0);
1399 }
1400
1401 #[test]
1402 fn missing_file_downloads_even_when_hashes_match() {
1403 let mut manifest = Manifest::new();
1404 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1405 let local: HashMap<String, LocalFile> = [(
1406 "a".to_string(),
1407 LocalFile {
1408 exists: false,
1409 size: 0,
1410 },
1411 )]
1412 .into_iter()
1413 .collect();
1414 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1415 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1416 assert_eq!(plan.downloads(), 1);
1417 }
1418
1419 #[test]
1420 fn absent_local_probe_treated_as_missing() {
1421 let mut manifest = Manifest::new();
1423 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1424 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1425 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1426 assert_eq!(plan.downloads(), 1);
1427 }
1428
1429 #[test]
1430 fn missing_file_download_wins_over_format_difference() {
1431 let mut manifest = Manifest::new();
1434 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1435 let local: HashMap<String, LocalFile> = [(
1436 "a".to_string(),
1437 LocalFile {
1438 exists: false,
1439 size: 0,
1440 },
1441 )]
1442 .into_iter()
1443 .collect();
1444 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1445 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1446 assert_eq!(plan.downloads(), 1);
1447 assert_eq!(plan.reformats(), 0);
1448 }
1449
1450 #[test]
1453 fn trashed_clip_deletes_local_file() {
1454 let mut manifest = Manifest::new();
1455 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1456 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1457 d.trashed = true;
1458 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1459 assert_eq!(
1460 plan.actions,
1461 vec![Action::Delete {
1462 path: "a.flac".to_string(),
1463 clip_id: "a".to_string(),
1464 }]
1465 );
1466 }
1467
1468 #[test]
1469 fn trashed_clip_not_in_manifest_skips() {
1470 let manifest = Manifest::new();
1472 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1473 d.trashed = true;
1474 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1475 assert_eq!(
1476 plan.actions,
1477 vec![Action::Skip {
1478 clip_id: "a".to_string()
1479 }]
1480 );
1481 }
1482
1483 #[test]
1484 fn private_clip_is_kept() {
1485 let mut manifest = Manifest::new();
1486 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1487 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1488 d.private = true;
1489 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1490 assert_eq!(
1491 plan.actions,
1492 vec![Action::Skip {
1493 clip_id: "a".to_string()
1494 }]
1495 );
1496 }
1497
1498 #[test]
1499 fn private_beats_trashed_never_deletes() {
1500 let mut manifest = Manifest::new();
1502 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1503 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1504 d.trashed = true;
1505 d.private = true;
1506 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1507 assert_eq!(plan.deletes(), 0);
1508 assert_eq!(plan.skips(), 1);
1509 }
1510
1511 #[test]
1512 fn copy_held_trashed_clip_is_not_deleted() {
1513 let mut manifest = Manifest::new();
1516 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1517 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1518 d.modes = vec![SourceMode::Copy];
1519 d.trashed = true;
1520 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1521 assert_eq!(plan.deletes(), 0);
1522 assert_eq!(
1523 plan.actions,
1524 vec![Action::Skip {
1525 clip_id: "a".to_string()
1526 }]
1527 );
1528 }
1529
1530 #[test]
1533 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1534 let mut manifest = Manifest::new();
1535 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1536 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1537 assert_eq!(
1538 plan.actions,
1539 vec![Action::Delete {
1540 path: "gone.flac".to_string(),
1541 clip_id: "gone".to_string(),
1542 }]
1543 );
1544 }
1545
1546 #[test]
1547 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1548 let mut manifest = Manifest::new();
1549 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1550 let sources = vec![
1551 SourceStatus {
1552 mode: SourceMode::Mirror,
1553 fully_enumerated: true,
1554 },
1555 SourceStatus {
1556 mode: SourceMode::Mirror,
1557 fully_enumerated: false,
1558 },
1559 ];
1560 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1561 assert_eq!(plan.deletes(), 0);
1562 assert_eq!(
1563 plan.actions,
1564 vec![Action::Skip {
1565 clip_id: "gone".to_string()
1566 }]
1567 );
1568 }
1569
1570 #[test]
1571 fn empty_listing_cannot_cause_deletion() {
1572 let mut manifest = Manifest::new();
1575 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1576 let sources = vec![SourceStatus {
1577 mode: SourceMode::Mirror,
1578 fully_enumerated: false,
1579 }];
1580 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1581 assert_eq!(plan.deletes(), 0);
1582 assert_eq!(plan.skips(), 1);
1583 }
1584
1585 #[test]
1586 fn no_mirror_sources_means_no_deletion() {
1587 let mut manifest = Manifest::new();
1589 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1590 let copy_only = vec![SourceStatus {
1591 mode: SourceMode::Copy,
1592 fully_enumerated: true,
1593 }];
1594 assert_eq!(
1595 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
1596 0
1597 );
1598 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
1599 }
1600
1601 #[test]
1602 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
1603 let mut manifest = Manifest::new();
1604 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1605 let sources = vec![
1606 SourceStatus {
1607 mode: SourceMode::Copy,
1608 fully_enumerated: true,
1609 },
1610 SourceStatus {
1611 mode: SourceMode::Mirror,
1612 fully_enumerated: false,
1613 },
1614 ];
1615 assert_eq!(
1616 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
1617 0
1618 );
1619 }
1620
1621 #[test]
1622 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
1623 let mut manifest = Manifest::new();
1627 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
1628 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1629 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
1630 held.modes = vec![SourceMode::Copy];
1631 let local: HashMap<String, LocalFile> = [
1632 ("keep".to_string(), present(100)),
1633 ("gone".to_string(), present(100)),
1634 ]
1635 .into_iter()
1636 .collect();
1637 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
1638 assert!(plan.actions.contains(&Action::Skip {
1639 clip_id: "keep".to_string()
1640 }));
1641 assert!(plan.actions.contains(&Action::Delete {
1642 path: "gone.flac".to_string(),
1643 clip_id: "gone".to_string(),
1644 }));
1645 assert!(
1647 !plan
1648 .actions
1649 .iter()
1650 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
1651 );
1652 }
1653
1654 #[test]
1657 fn orphan_with_preserve_marker_is_kept() {
1658 let mut manifest = Manifest::new();
1661 manifest.insert(
1662 "gone",
1663 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
1664 );
1665 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1666 assert_eq!(plan.deletes(), 0);
1667 assert_eq!(
1668 plan.actions,
1669 vec![Action::Skip {
1670 clip_id: "gone".to_string()
1671 }]
1672 );
1673 }
1674
1675 #[test]
1676 fn trashed_clip_with_preserve_marker_is_kept() {
1677 let mut manifest = Manifest::new();
1680 manifest.insert(
1681 "a",
1682 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
1683 );
1684 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1685 d.trashed = true;
1686 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1687 assert_eq!(plan.deletes(), 0);
1688 assert_eq!(plan.skips(), 1);
1689 }
1690
1691 #[test]
1694 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
1695 let mut manifest = Manifest::new();
1697 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1698 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1699 d.trashed = true;
1700 let sources = vec![SourceStatus {
1701 mode: SourceMode::Mirror,
1702 fully_enumerated: false,
1703 }];
1704 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1705 assert_eq!(plan.deletes(), 0);
1706 assert_eq!(plan.skips(), 1);
1707 }
1708
1709 #[test]
1710 fn trashed_clip_kept_when_sources_empty() {
1711 let mut manifest = Manifest::new();
1714 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1715 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1716 d.trashed = true;
1717 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
1718 assert_eq!(plan.deletes(), 0);
1719 assert_eq!(plan.skips(), 1);
1720 }
1721
1722 #[test]
1723 fn failed_copy_listing_suppresses_orphan_deletion() {
1724 let mut manifest = Manifest::new();
1727 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1728 let sources = vec![
1729 SourceStatus {
1730 mode: SourceMode::Mirror,
1731 fully_enumerated: true,
1732 },
1733 SourceStatus {
1734 mode: SourceMode::Copy,
1735 fully_enumerated: false,
1736 },
1737 ];
1738 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1739 assert_eq!(plan.deletes(), 0);
1740 }
1741
1742 #[test]
1743 fn failed_copy_listing_suppresses_trashed_deletion() {
1744 let mut manifest = Manifest::new();
1745 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1746 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1747 d.trashed = true;
1748 let sources = vec![
1749 SourceStatus {
1750 mode: SourceMode::Mirror,
1751 fully_enumerated: true,
1752 },
1753 SourceStatus {
1754 mode: SourceMode::Copy,
1755 fully_enumerated: false,
1756 },
1757 ];
1758 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
1759 assert_eq!(plan.deletes(), 0);
1760 assert_eq!(plan.skips(), 1);
1761 }
1762
1763 #[test]
1764 fn empty_path_entry_never_deletes() {
1765 let mut manifest = Manifest::new();
1768 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
1769 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1770 assert_eq!(plan.deletes(), 0);
1771 assert_eq!(
1772 plan.actions,
1773 vec![Action::Skip {
1774 clip_id: "gone".to_string()
1775 }]
1776 );
1777 }
1778
1779 #[test]
1782 fn delete_suppressed_when_path_aliases_rename_target() {
1783 let mut manifest = Manifest::new();
1786 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1787 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
1788 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1789 let local: HashMap<String, LocalFile> = [
1790 ("a".to_string(), present(100)),
1791 ("b".to_string(), present(100)),
1792 ]
1793 .into_iter()
1794 .collect();
1795 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1796 assert!(plan.actions.contains(&Action::Rename {
1797 from: "old/a.flac".to_string(),
1798 to: "new/a.flac".to_string(),
1799 }));
1800 assert!(
1802 !plan
1803 .actions
1804 .iter()
1805 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
1806 );
1807 assert!(plan.actions.contains(&Action::Skip {
1808 clip_id: "b".to_string()
1809 }));
1810 }
1811
1812 #[test]
1813 fn delete_suppressed_when_path_aliases_download_target() {
1814 let mut manifest = Manifest::new();
1816 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
1817 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
1818 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1819 assert!(
1820 !plan
1821 .actions
1822 .iter()
1823 .any(|a| matches!(a, Action::Delete { .. }))
1824 );
1825 assert_eq!(plan.downloads(), 1);
1826 }
1827
1828 #[test]
1829 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
1830 let mut actions = vec![
1835 Action::Rename {
1836 from: "old/song.flac".to_string(),
1837 to: "new/cover.jpg".to_string(),
1838 },
1839 Action::DeleteArtifact {
1840 kind: ArtifactKind::CoverJpg,
1841 path: "new/cover.jpg".to_string(),
1842 owner_id: "a".to_string(),
1843 },
1844 ];
1845 suppress_path_aliasing(&mut actions);
1846 assert!(
1848 !actions
1849 .iter()
1850 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
1851 "a sidecar delete must not alias a rename target"
1852 );
1853 assert!(actions.contains(&Action::Skip {
1854 clip_id: "a".to_string()
1855 }));
1856 assert!(actions.contains(&Action::Rename {
1858 from: "old/song.flac".to_string(),
1859 to: "new/cover.jpg".to_string(),
1860 }));
1861 }
1862
1863 #[test]
1864 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
1865 let mut actions = vec![
1868 Action::WriteArtifact {
1869 kind: ArtifactKind::FolderJpg,
1870 path: "creator/album/folder.jpg".to_string(),
1871 source_url: "https://art/large.jpg".to_string(),
1872 hash: "h".to_string(),
1873 owner_id: "root".to_string(),
1874 content: None,
1875 },
1876 Action::DeleteArtifact {
1877 kind: ArtifactKind::FolderJpg,
1878 path: "creator/album/folder.jpg".to_string(),
1879 owner_id: "root-old".to_string(),
1880 },
1881 ];
1882 suppress_path_aliasing(&mut actions);
1883 assert!(
1884 !actions
1885 .iter()
1886 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
1887 );
1888 assert!(actions.contains(&Action::Skip {
1889 clip_id: "root-old".to_string()
1890 }));
1891 }
1892
1893 #[test]
1896 fn duplicate_trashed_does_not_defeat_copy_sibling() {
1897 let mut manifest = Manifest::new();
1900 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1901 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1902 copy_entry.modes = vec![SourceMode::Copy];
1903 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1904 trashed_entry.modes = vec![SourceMode::Mirror];
1905 trashed_entry.trashed = true;
1906 let plan = reconcile(
1907 &manifest,
1908 &[copy_entry, trashed_entry],
1909 &local_present("a"),
1910 &mirror_ok(),
1911 );
1912 assert_eq!(plan.deletes(), 0);
1913 assert_eq!(plan.skips(), 1);
1914 }
1915
1916 #[test]
1917 fn duplicate_trashed_does_not_defeat_private_sibling() {
1918 let mut manifest = Manifest::new();
1919 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1920 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1921 private_entry.private = true;
1922 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1923 trashed_entry.trashed = true;
1924 let plan = reconcile(
1925 &manifest,
1926 &[private_entry, trashed_entry],
1927 &local_present("a"),
1928 &mirror_ok(),
1929 );
1930 assert_eq!(plan.deletes(), 0);
1931 assert_eq!(plan.skips(), 1);
1932 }
1933
1934 #[test]
1935 fn duplicate_trashed_deletes_only_when_all_trashed() {
1936 let mut manifest = Manifest::new();
1938 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1939 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1940 first.trashed = true;
1941 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1942 second.trashed = true;
1943 let plan = reconcile(
1944 &manifest,
1945 &[first, second],
1946 &local_present("a"),
1947 &mirror_ok(),
1948 );
1949 assert_eq!(plan.deletes(), 1);
1950 }
1951
1952 #[test]
1953 fn duplicate_desired_unions_modes() {
1954 let mut manifest = Manifest::new();
1956 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1957 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1958 mirror_entry.modes = vec![SourceMode::Mirror];
1959 mirror_entry.trashed = true;
1960 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1961 copy_entry.modes = vec![SourceMode::Copy];
1962 let plan = reconcile(
1963 &manifest,
1964 &[mirror_entry, copy_entry],
1965 &local_present("a"),
1966 &mirror_ok(),
1967 );
1968 assert_eq!(plan.deletes(), 0);
1970 }
1971
1972 #[test]
1975 fn private_new_clip_downloads() {
1976 let manifest = Manifest::new();
1979 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1980 d.private = true;
1981 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1982 assert_eq!(plan.downloads(), 1);
1983 }
1984
1985 #[test]
1986 fn private_zero_length_file_redownloads() {
1987 let mut manifest = Manifest::new();
1988 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1989 let local: HashMap<String, LocalFile> = [(
1990 "a".to_string(),
1991 LocalFile {
1992 exists: true,
1993 size: 0,
1994 },
1995 )]
1996 .into_iter()
1997 .collect();
1998 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1999 d.private = true;
2000 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2001 assert_eq!(plan.downloads(), 1);
2002 }
2003
2004 #[test]
2005 fn private_meta_change_retags() {
2006 let mut manifest = Manifest::new();
2007 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2008 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2009 d.private = true;
2010 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2011 assert_eq!(plan.retags(), 1);
2012 assert_eq!(plan.deletes(), 0);
2013 }
2014
2015 #[test]
2016 fn absent_private_clip_protected_by_preserve_marker() {
2017 let mut manifest = Manifest::new();
2020 manifest.insert(
2021 "a",
2022 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2023 );
2024 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2025 assert_eq!(plan.deletes(), 0);
2026 assert_eq!(plan.skips(), 1);
2027 }
2028
2029 #[test]
2032 fn output_is_deterministic_regardless_of_input_order() {
2033 let mut manifest = Manifest::new();
2034 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2035 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2036 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2037 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2038 .iter()
2039 .map(|id| (id.to_string(), present(100)))
2040 .collect();
2041
2042 let forward = vec![
2043 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2044 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2045 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2046 ];
2047 let mut reversed = forward.clone();
2048 reversed.reverse();
2049
2050 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2051 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2052 assert_eq!(p1.actions, p2.actions);
2053
2054 let ids: Vec<&str> = p1
2057 .actions
2058 .iter()
2059 .map(|a| match a {
2060 Action::Skip { clip_id } => clip_id.as_str(),
2061 Action::Retag { clip, .. } => clip.id.as_str(),
2062 Action::Download { clip, .. } => clip.id.as_str(),
2063 Action::Delete { clip_id, .. } => clip_id.as_str(),
2064 Action::Reformat { clip, .. } => clip.id.as_str(),
2065 Action::Rename { to, .. } => to.as_str(),
2066 Action::WriteArtifact { owner_id, .. }
2067 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2068 })
2069 .collect();
2070 assert_eq!(ids, ["a", "b", "c", "z"]);
2071 }
2072
2073 #[test]
2074 fn empty_inputs_do_not_panic() {
2075 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2076 assert!(plan.is_empty());
2077 assert_eq!(plan.len(), 0);
2078 }
2079
2080 #[test]
2081 fn empty_desired_with_full_manifest_deletes_all() {
2082 let mut manifest = Manifest::new();
2083 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2084 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2085 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2086 assert_eq!(plan.deletes(), 2);
2087 }
2088
2089 #[test]
2090 fn full_desired_with_empty_manifest_downloads_all() {
2091 let d = vec![
2092 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2093 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2094 ];
2095 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2096 assert_eq!(plan.downloads(), 2);
2097 }
2098
2099 #[test]
2100 fn plan_counts_sum_to_len() {
2101 let mut manifest = Manifest::new();
2102 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2103 manifest.insert(
2104 "retag",
2105 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2106 );
2107 manifest.insert(
2108 "reformat",
2109 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2110 );
2111 manifest.insert(
2112 "rename",
2113 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2114 );
2115 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2116 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2117 .iter()
2118 .map(|id| (id.to_string(), present(100)))
2119 .collect();
2120 let d = vec![
2121 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2122 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2123 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2124 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2125 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2126 ];
2127 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2128 let summed = plan.downloads()
2129 + plan.reformats()
2130 + plan.retags()
2131 + plan.renames()
2132 + plan.deletes()
2133 + plan.skips();
2134 assert_eq!(summed, plan.len());
2135 assert_eq!(plan.downloads(), 1);
2136 assert_eq!(plan.reformats(), 1);
2137 assert_eq!(plan.retags(), 1);
2138 assert_eq!(plan.renames(), 1);
2139 assert_eq!(plan.deletes(), 1);
2140 assert_eq!(plan.skips(), 1);
2141 }
2142
2143 fn cover(path: &str, hash: &str) -> ArtifactState {
2146 ArtifactState {
2147 path: path.to_string(),
2148 hash: hash.to_string(),
2149 }
2150 }
2151
2152 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2153 DesiredArtifact {
2154 kind,
2155 path: path.to_string(),
2156 source_url: url.to_string(),
2157 hash: hash.to_string(),
2158 }
2159 }
2160
2161 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2163 Desired {
2164 artifacts: arts,
2165 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2166 }
2167 }
2168
2169 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2171 ManifestEntry {
2172 cover_jpg: Some(cover(cover_path, cover_hash)),
2173 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2174 }
2175 }
2176
2177 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2178 plan.actions
2179 .iter()
2180 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2181 .collect()
2182 }
2183
2184 #[test]
2185 fn write_artifact_emitted_when_manifest_lacks_it() {
2186 let mut manifest = Manifest::new();
2189 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2190 let d = vec![desired_arts(
2191 "a",
2192 vec![art(
2193 ArtifactKind::CoverJpg,
2194 "a/cover.jpg",
2195 "https://art/a",
2196 "h1",
2197 )],
2198 )];
2199 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2200 assert_eq!(plan.artifact_writes(), 1);
2201 assert_eq!(plan.artifact_deletes(), 0);
2202 assert_eq!(plan.skips(), 1);
2203 assert_eq!(
2204 write_artifacts(&plan)[0],
2205 &Action::WriteArtifact {
2206 kind: ArtifactKind::CoverJpg,
2207 path: "a/cover.jpg".to_string(),
2208 source_url: "https://art/a".to_string(),
2209 hash: "h1".to_string(),
2210 owner_id: "a".to_string(),
2211 content: None,
2212 }
2213 );
2214 }
2215
2216 #[test]
2217 fn write_artifact_emitted_when_hash_differs() {
2218 let mut manifest = Manifest::new();
2221 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2222 let d = vec![desired_arts(
2223 "a",
2224 vec![art(
2225 ArtifactKind::CoverJpg,
2226 "a/cover.jpg",
2227 "https://art/a",
2228 "new",
2229 )],
2230 )];
2231 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2232 assert_eq!(plan.artifact_writes(), 1);
2233 assert_eq!(plan.artifact_deletes(), 0);
2234 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2235 assert_eq!(hash, "new");
2236 } else {
2237 panic!("expected a WriteArtifact");
2238 }
2239 }
2240
2241 #[test]
2242 fn write_artifact_skipped_when_hash_matches() {
2243 let mut manifest = Manifest::new();
2245 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
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(), 0);
2257 assert_eq!(plan.artifact_deletes(), 0);
2258 assert_eq!(
2259 plan.actions,
2260 vec![Action::Skip {
2261 clip_id: "a".to_string()
2262 }]
2263 );
2264 }
2265
2266 #[test]
2267 fn removed_kind_cover_is_kept_not_deleted() {
2268 let mut manifest = Manifest::new();
2273 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2274 let d = vec![desired_arts("a", vec![])];
2275 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2276 assert_eq!(plan.artifact_deletes(), 0);
2277 assert_eq!(plan.artifact_writes(), 0);
2278 assert_eq!(plan.deletes(), 0);
2280 assert_eq!(
2281 plan.actions,
2282 vec![Action::Skip {
2283 clip_id: "a".to_string()
2284 }]
2285 );
2286 assert!(!plan.actions.iter().any(|a| matches!(
2287 a,
2288 Action::DeleteArtifact {
2289 kind: ArtifactKind::CoverJpg,
2290 ..
2291 }
2292 )));
2293 }
2294
2295 #[test]
2296 fn delete_artifact_never_on_incomplete_listing() {
2297 let mut manifest = Manifest::new();
2302 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2303 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2304 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2305 let sources = vec![SourceStatus {
2306 mode: SourceMode::Mirror,
2307 fully_enumerated: false,
2308 }];
2309 let local: HashMap<String, LocalFile> = [
2310 ("a".to_string(), present(100)),
2311 ("b".to_string(), present(100)),
2312 ]
2313 .into_iter()
2314 .collect();
2315 let plan = reconcile(&manifest, &d, &local, &sources);
2316 assert_eq!(plan.artifact_deletes(), 0);
2317 assert_eq!(plan.deletes(), 0);
2318 }
2319
2320 #[test]
2321 fn delete_artifact_never_when_entry_preserved() {
2322 let mut manifest = Manifest::new();
2325 let preserved = ManifestEntry {
2326 preserve: true,
2327 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2328 };
2329 manifest.insert("a", preserved);
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 }
2334
2335 #[test]
2336 fn co_delete_never_when_path_empty() {
2337 let mut manifest = Manifest::new();
2341 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2342 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2343 assert_eq!(plan.deletes(), 1);
2344 assert_eq!(plan.artifact_deletes(), 0);
2345 }
2346
2347 #[test]
2348 fn co_delete_absent_clip_deletes_audio_and_cover() {
2349 let mut manifest = Manifest::new();
2352 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2353 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2354 assert_eq!(plan.deletes(), 1);
2355 assert_eq!(plan.artifact_deletes(), 1);
2356 assert!(plan.actions.contains(&Action::Delete {
2357 path: "gone.flac".to_string(),
2358 clip_id: "gone".to_string(),
2359 }));
2360 assert!(plan.actions.contains(&Action::DeleteArtifact {
2361 kind: ArtifactKind::CoverJpg,
2362 path: "gone/cover.jpg".to_string(),
2363 owner_id: "gone".to_string(),
2364 }));
2365 }
2366
2367 #[test]
2368 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2369 let mut manifest = Manifest::new();
2371 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2372 let sources = vec![SourceStatus {
2373 mode: SourceMode::Mirror,
2374 fully_enumerated: false,
2375 }];
2376 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2377 assert_eq!(plan.deletes(), 0);
2378 assert_eq!(plan.artifact_deletes(), 0);
2379 }
2380
2381 #[test]
2382 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2383 let mut manifest = Manifest::new();
2385 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2386 let mut d = desired_arts("a", vec![]);
2387 d.trashed = true;
2388 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2389 assert_eq!(plan.deletes(), 1);
2390 assert_eq!(plan.artifact_deletes(), 1);
2391 }
2392
2393 #[test]
2394 fn co_delete_trashed_suppressed_when_not_enumerated() {
2395 let mut manifest = Manifest::new();
2397 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2398 let mut d = desired_arts("a", vec![]);
2399 d.trashed = true;
2400 let sources = vec![SourceStatus {
2401 mode: SourceMode::Mirror,
2402 fully_enumerated: false,
2403 }];
2404 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2405 assert_eq!(plan.deletes(), 0);
2406 assert_eq!(plan.artifact_deletes(), 0);
2407 assert_eq!(plan.skips(), 1);
2408 }
2409
2410 #[test]
2411 fn co_delete_trashed_suppressed_when_preserved() {
2412 let mut manifest = Manifest::new();
2414 let preserved = ManifestEntry {
2415 preserve: true,
2416 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2417 };
2418 manifest.insert("a", preserved);
2419 let mut d = desired_arts("a", vec![]);
2420 d.trashed = true;
2421 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2422 assert_eq!(plan.deletes(), 0);
2423 assert_eq!(plan.artifact_deletes(), 0);
2424 }
2425
2426 #[test]
2427 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
2428 let mut manifest = Manifest::new();
2431 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2432 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
2433 let d = vec![desired_arts(
2436 "a",
2437 vec![art(
2438 ArtifactKind::CoverJpg,
2439 "shared/cover.jpg",
2440 "https://art/a",
2441 "h2",
2442 )],
2443 )];
2444 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2445 assert_eq!(plan.artifact_writes(), 1);
2446 assert!(!plan.actions.iter().any(
2448 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
2449 ));
2450 assert!(plan.actions.contains(&Action::Delete {
2452 path: "b.flac".to_string(),
2453 clip_id: "b".to_string(),
2454 }));
2455 }
2456
2457 #[test]
2458 fn suppress_downgrades_delete_artifact_colliding_with_download() {
2459 let mut manifest = Manifest::new();
2461 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
2462 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
2463 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2464 assert_eq!(plan.downloads(), 1);
2465 assert!(
2466 !plan
2467 .actions
2468 .iter()
2469 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
2470 );
2471 }
2472
2473 #[test]
2474 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
2475 let build = |with_art: bool| {
2479 let mut manifest = Manifest::new();
2480 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
2481 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2482 manifest.insert(
2483 "trash",
2484 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
2485 );
2486 let keep = if with_art {
2487 desired_arts(
2488 "keep",
2489 vec![art(
2490 ArtifactKind::CoverJpg,
2491 "keep/cover.jpg",
2492 "https://art/keep",
2493 "h1",
2494 )],
2495 )
2496 } else {
2497 desired_arts("keep", vec![])
2498 };
2499 let mut trash = desired_arts("trash", vec![]);
2500 trash.trashed = true;
2501 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
2502 .iter()
2503 .map(|id| (id.to_string(), present(100)))
2504 .collect();
2505 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
2506 };
2507
2508 let with = build(true);
2509 let without = build(false);
2510
2511 let audio = |plan: &Plan| -> Vec<Action> {
2513 plan.actions
2514 .iter()
2515 .filter(|a| {
2516 !matches!(
2517 a,
2518 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
2519 )
2520 })
2521 .cloned()
2522 .collect()
2523 };
2524 assert_eq!(audio(&with), audio(&without));
2525 assert_eq!(with.deletes(), without.deletes());
2526 assert_eq!(with.deletes(), 2);
2528 assert_eq!(with.artifact_deletes(), 2);
2532 assert_eq!(with.artifact_writes(), 0);
2533 }
2534
2535 #[test]
2538 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
2539 let mut manifest = Manifest::new();
2545 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2546 assert!(!manifest.get("a").unwrap().preserve);
2547
2548 let private = Desired {
2550 private: true,
2551 ..desired_arts("a", vec![])
2552 };
2553 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
2554 assert_eq!(plan.artifact_deletes(), 0);
2555
2556 let copy_held = Desired {
2558 modes: vec![SourceMode::Copy],
2559 ..desired_arts("a", vec![])
2560 };
2561 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
2562 assert_eq!(plan.artifact_deletes(), 0);
2563 }
2564
2565 #[test]
2566 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
2567 let mut manifest = Manifest::new();
2573 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
2574 let d = vec![desired_arts(
2575 "a",
2576 vec![art(
2577 ArtifactKind::CoverJpg,
2578 "new/cover.jpg",
2579 "https://art/a",
2580 "h1",
2581 )],
2582 )];
2583 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2584 assert_eq!(plan.artifact_writes(), 1);
2585 assert_eq!(plan.artifact_deletes(), 0);
2586 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
2587 assert_eq!(path, "new/cover.jpg");
2588 } else {
2589 panic!("expected a WriteArtifact");
2590 }
2591 }
2592
2593 #[test]
2594 fn per_clip_reconcile_ignores_album_and_library_kinds() {
2595 let mut manifest = Manifest::new();
2599 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2600 let d = vec![desired_arts(
2601 "a",
2602 vec![
2603 art(
2604 ArtifactKind::FolderJpg,
2605 "a/folder.jpg",
2606 "https://art/folder",
2607 "hf",
2608 ),
2609 art(
2610 ArtifactKind::Playlist,
2611 "a/list.m3u",
2612 "https://art/list",
2613 "hp",
2614 ),
2615 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
2616 ],
2617 )];
2618 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2619 assert_eq!(plan.artifact_writes(), 1);
2620 let paths: Vec<&str> = plan
2621 .actions
2622 .iter()
2623 .filter_map(|a| match a {
2624 Action::WriteArtifact { path, .. } => Some(path.as_str()),
2625 _ => None,
2626 })
2627 .collect();
2628 assert_eq!(paths, vec!["a/cover.jpg"]);
2629 }
2630
2631 #[test]
2632 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
2633 let mut manifest = Manifest::new();
2634 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2635 let d = vec![desired_arts(
2636 "a",
2637 vec![art(
2638 ArtifactKind::FolderWebp,
2639 "a/folder.webp",
2640 "https://art/folder",
2641 "hf",
2642 )],
2643 )];
2644 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2645 assert_eq!(plan.artifact_writes(), 0);
2646 assert_eq!(plan.artifact_deletes(), 0);
2647 }
2648
2649 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
2652 Clip {
2653 id: id.to_string(),
2654 title: "Song".to_string(),
2655 image_large_url: image.to_string(),
2656 video_cover_url: video.to_string(),
2657 play_count,
2658 created_at: created_at.to_string(),
2659 ..Default::default()
2660 }
2661 }
2662
2663 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
2664 let mut lineage = LineageContext::own_root(&clip);
2665 lineage.root_id = root_id.to_string();
2666 Desired {
2667 clip,
2668 lineage,
2669 path: path.to_string(),
2670 format: AudioFormat::Flac,
2671 meta_hash: "m".to_string(),
2672 art_hash: "a".to_string(),
2673 modes: vec![SourceMode::Mirror],
2674 trashed: false,
2675 private: false,
2676 artifacts: Vec::new(),
2677 }
2678 }
2679
2680 fn stored(path: &str, hash: &str) -> ArtifactState {
2681 ArtifactState {
2682 path: path.to_string(),
2683 hash: hash.to_string(),
2684 }
2685 }
2686
2687 #[test]
2688 fn folder_jpg_source_is_most_played() {
2689 let members = vec![
2690 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
2691 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
2692 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
2693 ];
2694 let albums = album_desired(&members, false);
2695 assert_eq!(albums.len(), 1);
2696 let jpg = albums[0].folder_jpg.as_ref().unwrap();
2697 assert_eq!(jpg.hash, art_url_hash("art-b"));
2699 assert_eq!(jpg.source_url, "art-b");
2700 assert_eq!(jpg.path, "c/al/folder.jpg");
2701 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
2702 }
2703
2704 #[test]
2705 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
2706 let by_time = vec![
2708 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
2709 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
2710 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
2711 ];
2712 let jpg = album_desired(&by_time, false)[0]
2713 .folder_jpg
2714 .clone()
2715 .unwrap();
2716 assert_eq!(jpg.source_url, "art-y");
2717
2718 let by_id = vec![
2720 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
2721 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
2722 ];
2723 let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
2724 assert_eq!(jpg.source_url, "art-g");
2725 }
2726
2727 #[test]
2728 fn folder_webp_source_is_first_created_animated() {
2729 let members = vec![
2730 album_member(
2731 album_clip("a", 9, "t2", "art-a", "vid-a"),
2732 "root",
2733 "c/al/a.flac",
2734 ),
2735 album_member(
2736 album_clip("b", 1, "t0", "art-b", "vid-b"),
2737 "root",
2738 "c/al/b.flac",
2739 ),
2740 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
2741 ];
2742 let webp = album_desired(&members, true)[0]
2743 .folder_webp
2744 .clone()
2745 .unwrap();
2746 assert_eq!(webp.source_url, "vid-b");
2748 assert_eq!(webp.hash, art_url_hash("vid-b"));
2749 assert_eq!(webp.path, "c/al/cover.webp");
2750 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
2751 }
2752
2753 #[test]
2754 fn animated_covers_off_yields_no_folder_webp() {
2755 let members = vec![album_member(
2756 album_clip("a", 1, "t0", "art-a", "vid-a"),
2757 "root",
2758 "c/al/a.flac",
2759 )];
2760 let off = album_desired(&members, false);
2761 assert!(off[0].folder_webp.is_none());
2762 let on = album_desired(&members, true);
2763 assert!(on[0].folder_webp.is_some());
2764 }
2765
2766 #[test]
2767 fn album_with_no_art_yields_no_folder_jpg() {
2768 let members = vec![album_member(
2769 album_clip("a", 3, "t0", "", ""),
2770 "root",
2771 "c/al/a.flac",
2772 )];
2773 let albums = album_desired(&members, true);
2774 assert!(albums[0].folder_jpg.is_none());
2775 assert!(albums[0].folder_webp.is_none());
2776 }
2777
2778 #[test]
2779 fn album_desired_groups_by_root_id() {
2780 let members = vec![
2781 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
2782 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
2783 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
2784 ];
2785 let albums = album_desired(&members, false);
2786 assert_eq!(albums.len(), 2);
2787 assert_eq!(albums[0].root_id, "r1");
2788 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
2789 assert_eq!(
2790 albums[0].folder_jpg.as_ref().unwrap().path,
2791 "c/al1/folder.jpg"
2792 );
2793 assert_eq!(albums[1].root_id, "r2");
2794 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
2795 assert_eq!(
2796 albums[1].folder_jpg.as_ref().unwrap().path,
2797 "c/al2/folder.jpg"
2798 );
2799 }
2800
2801 #[test]
2802 fn plan_writes_folder_art_when_store_empty() {
2803 let members = vec![album_member(
2804 album_clip("a", 1, "t0", "art-a", "vid-a"),
2805 "root",
2806 "c/al/a.flac",
2807 )];
2808 let desired = album_desired(&members, true);
2809 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
2810 assert_eq!(
2811 actions,
2812 vec![
2813 Action::WriteArtifact {
2814 kind: ArtifactKind::FolderJpg,
2815 path: "c/al/folder.jpg".to_string(),
2816 source_url: "art-a".to_string(),
2817 hash: art_url_hash("art-a"),
2818 owner_id: "root".to_string(),
2819 content: None,
2820 },
2821 Action::WriteArtifact {
2822 kind: ArtifactKind::FolderWebp,
2823 path: "c/al/cover.webp".to_string(),
2824 source_url: "vid-a".to_string(),
2825 hash: art_url_hash("vid-a"),
2826 owner_id: "root".to_string(),
2827 content: None,
2828 },
2829 ]
2830 );
2831 }
2832
2833 #[test]
2834 fn plan_skips_when_hash_and_path_match() {
2835 let members = vec![album_member(
2836 album_clip("a", 1, "t0", "art-a", ""),
2837 "root",
2838 "c/al/a.flac",
2839 )];
2840 let desired = album_desired(&members, false);
2841 let mut albums = BTreeMap::new();
2842 albums.insert(
2843 "root".to_string(),
2844 AlbumArt {
2845 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
2846 folder_webp: None,
2847 },
2848 );
2849 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
2850 }
2851
2852 #[test]
2853 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
2854 let members = vec![album_member(
2855 album_clip("a", 1, "t0", "art-a", ""),
2856 "root",
2857 "c/al/a.flac",
2858 )];
2859 let desired = album_desired(&members, false);
2860 let mut albums = BTreeMap::new();
2861 albums.insert(
2862 "root".to_string(),
2863 AlbumArt {
2864 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
2865 folder_webp: None,
2866 },
2867 );
2868 let actions = plan_album_artifacts(&desired, &albums, true);
2869 assert_eq!(actions.len(), 1);
2870 assert!(matches!(
2871 &actions[0],
2872 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
2873 ));
2874 }
2875
2876 #[test]
2877 fn h1_most_played_flip_to_same_art_writes_nothing() {
2878 let run1 = vec![
2880 album_member(
2881 album_clip("a", 9, "t0", "same-art", ""),
2882 "root",
2883 "c/al/a.flac",
2884 ),
2885 album_member(
2886 album_clip("b", 1, "t1", "same-art", ""),
2887 "root",
2888 "c/al/b.flac",
2889 ),
2890 ];
2891 let desired1 = album_desired(&run1, false);
2892 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
2893 assert_eq!(write1.len(), 1);
2894
2895 let mut albums = BTreeMap::new();
2897 if let Action::WriteArtifact {
2898 path,
2899 hash,
2900 owner_id,
2901 ..
2902 } = &write1[0]
2903 {
2904 albums.insert(
2905 owner_id.clone(),
2906 AlbumArt {
2907 folder_jpg: Some(stored(path, hash)),
2908 folder_webp: None,
2909 },
2910 );
2911 }
2912
2913 let run2 = vec![
2915 album_member(
2916 album_clip("a", 1, "t0", "same-art", ""),
2917 "root",
2918 "c/al/a.flac",
2919 ),
2920 album_member(
2921 album_clip("b", 9, "t1", "same-art", ""),
2922 "root",
2923 "c/al/b.flac",
2924 ),
2925 ];
2926 let desired2 = album_desired(&run2, false);
2927 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
2929 }
2930
2931 #[test]
2932 fn h1_flip_to_different_art_writes_exactly_one() {
2933 let mut albums = BTreeMap::new();
2934 albums.insert(
2935 "root".to_string(),
2936 AlbumArt {
2937 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
2938 folder_webp: None,
2939 },
2940 );
2941 let members = vec![
2943 album_member(
2944 album_clip("a", 1, "t0", "old-art", ""),
2945 "root",
2946 "c/al/a.flac",
2947 ),
2948 album_member(
2949 album_clip("b", 9, "t1", "new-art", ""),
2950 "root",
2951 "c/al/b.flac",
2952 ),
2953 ];
2954 let desired = album_desired(&members, false);
2955 let actions = plan_album_artifacts(&desired, &albums, true);
2956 assert_eq!(actions.len(), 1);
2957 assert!(matches!(
2958 &actions[0],
2959 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
2960 ));
2961 }
2962
2963 #[test]
2964 fn one_write_per_album_regardless_of_clip_count() {
2965 let members: Vec<Desired> = (0..200)
2966 .map(|i| {
2967 album_member(
2968 album_clip(
2969 &format!("clip-{i:03}"),
2970 i as u64,
2971 &format!("t{i:03}"),
2972 &format!("art-{i:03}"),
2973 &format!("vid-{i:03}"),
2974 ),
2975 "root",
2976 &format!("c/al/clip-{i:03}.flac"),
2977 )
2978 })
2979 .collect();
2980 let desired = album_desired(&members, true);
2981 assert_eq!(desired.len(), 1);
2982 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
2983 assert_eq!(actions.len(), 2);
2985 assert_eq!(
2986 actions
2987 .iter()
2988 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2989 .count(),
2990 2
2991 );
2992 }
2993
2994 #[test]
2995 fn emptied_album_deletes_only_when_can_delete() {
2996 let mut albums = BTreeMap::new();
2997 albums.insert(
2998 "root".to_string(),
2999 AlbumArt {
3000 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3001 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3002 },
3003 );
3004 let desired: Vec<AlbumDesired> = Vec::new();
3006
3007 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3009
3010 let actions = plan_album_artifacts(&desired, &albums, true);
3012 assert_eq!(
3013 actions,
3014 vec![
3015 Action::DeleteArtifact {
3016 kind: ArtifactKind::FolderJpg,
3017 path: "c/al/folder.jpg".to_string(),
3018 owner_id: "root".to_string(),
3019 },
3020 Action::DeleteArtifact {
3021 kind: ArtifactKind::FolderWebp,
3022 path: "c/al/cover.webp".to_string(),
3023 owner_id: "root".to_string(),
3024 },
3025 ]
3026 );
3027 }
3028
3029 #[test]
3030 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3031 let mut albums = BTreeMap::new();
3032 albums.insert(
3033 "root".to_string(),
3034 AlbumArt {
3035 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3036 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3037 },
3038 );
3039 let members = vec![album_member(
3042 album_clip("a", 1, "t0", "art-a", "vid-a"),
3043 "root",
3044 "c/al/a.flac",
3045 )];
3046 let desired = album_desired(&members, false);
3047
3048 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3049
3050 let actions = plan_album_artifacts(&desired, &albums, true);
3051 assert_eq!(
3052 actions,
3053 vec![Action::DeleteArtifact {
3054 kind: ArtifactKind::FolderWebp,
3055 path: "c/al/cover.webp".to_string(),
3056 owner_id: "root".to_string(),
3057 }]
3058 );
3059 }
3060
3061 #[test]
3062 fn plan_album_artifacts_is_deterministically_ordered() {
3063 let members = vec![
3064 album_member(
3065 album_clip("a", 1, "t0", "art-a", "vid-a"),
3066 "r2",
3067 "c/al2/a.flac",
3068 ),
3069 album_member(
3070 album_clip("b", 1, "t0", "art-b", "vid-b"),
3071 "r1",
3072 "c/al1/b.flac",
3073 ),
3074 ];
3075 let desired = album_desired(&members, true);
3076 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3077 let keys: Vec<(&str, ArtifactKind)> = actions
3078 .iter()
3079 .map(|a| match a {
3080 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3081 _ => unreachable!(),
3082 })
3083 .collect();
3084 assert_eq!(
3085 keys,
3086 vec![
3087 ("r1", ArtifactKind::FolderJpg),
3088 ("r1", ArtifactKind::FolderWebp),
3089 ("r2", ArtifactKind::FolderJpg),
3090 ("r2", ArtifactKind::FolderWebp),
3091 ]
3092 );
3093 }
3094
3095 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3098 PlaylistDesired {
3099 id: id.to_owned(),
3100 name: name.to_owned(),
3101 path: path.to_owned(),
3102 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3103 hash: hash.to_owned(),
3104 }
3105 }
3106
3107 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3108 PlaylistState {
3109 name: name.to_owned(),
3110 path: path.to_owned(),
3111 hash: hash.to_owned(),
3112 }
3113 }
3114
3115 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3116 entries
3117 .iter()
3118 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3119 .collect()
3120 }
3121
3122 #[test]
3123 fn playlist_write_emitted_for_a_new_playlist() {
3124 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3125 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
3126 assert_eq!(
3127 actions,
3128 vec![Action::WriteArtifact {
3129 kind: ArtifactKind::Playlist,
3130 path: "Road Trip.m3u8".to_owned(),
3131 source_url: String::new(),
3132 hash: "h1".to_owned(),
3133 owner_id: "pl1".to_owned(),
3134 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
3135 }]
3136 );
3137 }
3138
3139 #[test]
3140 fn playlist_write_emitted_when_hash_changes() {
3141 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
3144 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3145 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3146 assert_eq!(actions.len(), 1);
3147 assert!(matches!(
3148 &actions[0],
3149 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
3150 ));
3151 }
3152
3153 #[test]
3154 fn playlist_unchanged_is_idempotent() {
3155 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3156 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3157 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3158 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
3159 }
3160
3161 #[test]
3162 fn playlist_rename_writes_new_and_deletes_old_path() {
3163 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3166 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3167 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3168 assert_eq!(
3169 actions,
3170 vec![
3171 Action::WriteArtifact {
3172 kind: ArtifactKind::Playlist,
3173 path: "Summer.m3u8".to_owned(),
3174 source_url: String::new(),
3175 hash: "h2".to_owned(),
3176 owner_id: "pl1".to_owned(),
3177 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
3178 },
3179 Action::DeleteArtifact {
3180 kind: ArtifactKind::Playlist,
3181 path: "Spring.m3u8".to_owned(),
3182 owner_id: "pl1".to_owned(),
3183 },
3184 ]
3185 );
3186 }
3187
3188 #[test]
3189 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
3190 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
3193 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
3194 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
3195 assert_eq!(actions.len(), 1);
3196 assert!(matches!(
3197 &actions[0],
3198 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
3199 ));
3200 assert!(
3201 !actions
3202 .iter()
3203 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
3204 "old path must not be deleted when deletes are disallowed"
3205 );
3206 }
3207
3208 #[test]
3209 fn playlist_stale_removed_only_under_full_gate() {
3210 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
3213
3214 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
3215 assert_eq!(
3216 deleted,
3217 vec![Action::DeleteArtifact {
3218 kind: ArtifactKind::Playlist,
3219 path: "Gone.m3u8".to_owned(),
3220 owner_id: "gone".to_owned(),
3221 }]
3222 );
3223
3224 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
3226 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3227 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
3228 }
3229
3230 #[test]
3231 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
3232 let stored = pl_store(&[
3237 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3238 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3239 ]);
3240 let actions = plan_playlist_artifacts(&[], &stored, true, false);
3241 assert!(
3242 actions.is_empty(),
3243 "a failed playlist listing must plan zero actions, got {actions:?}"
3244 );
3245 }
3246
3247 #[test]
3248 fn b2_empty_list_deletes_only_when_fully_enumerated() {
3249 let stored = pl_store(&[
3254 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
3255 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
3256 ]);
3257
3258 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
3260
3261 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
3264 assert_eq!(
3265 wiped
3266 .iter()
3267 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
3268 .count(),
3269 2
3270 );
3271 }
3272
3273 #[test]
3274 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
3275 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
3280 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
3281 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3282 assert_eq!(actions.len(), 1);
3284 assert!(matches!(
3285 &actions[0],
3286 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
3287 ));
3288 assert!(
3289 !actions.iter().any(|a| match a {
3290 Action::WriteArtifact { owner_id, .. }
3291 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
3292 _ => false,
3293 }),
3294 "a protected (failed-member) playlist must have no action"
3295 );
3296 }
3297
3298 #[test]
3299 fn playlist_rename_collision_downgrades_the_delete() {
3300 let desired = vec![
3306 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
3307 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
3308 ];
3309 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
3310 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
3311 let write_paths: BTreeSet<&str> = actions
3313 .iter()
3314 .filter_map(|a| match a {
3315 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3316 _ => None,
3317 })
3318 .collect();
3319 for a in &actions {
3320 if let Action::DeleteArtifact { path, .. } = a {
3321 assert!(
3322 !write_paths.contains(path.as_str()),
3323 "a playlist delete aliases a write target: {path}"
3324 );
3325 }
3326 }
3327 }
3328}
3329
3330#[cfg(test)]
3343mod proptests {
3344 use super::*;
3345 use proptest::collection::{btree_map, hash_map, vec};
3346 use proptest::prelude::*;
3347 use std::collections::BTreeSet;
3348
3349 type DesiredFields = (
3350 String,
3351 AudioFormat,
3352 String,
3353 String,
3354 Vec<SourceMode>,
3355 bool,
3356 bool,
3357 );
3358
3359 fn audio_format() -> impl Strategy<Value = AudioFormat> {
3360 prop_oneof![
3361 Just(AudioFormat::Mp3),
3362 Just(AudioFormat::Flac),
3363 Just(AudioFormat::Wav),
3364 ]
3365 }
3366
3367 fn source_mode() -> impl Strategy<Value = SourceMode> {
3368 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
3369 }
3370
3371 fn clip_id() -> impl Strategy<Value = String> {
3374 (0u8..8).prop_map(|n| format!("c{n}"))
3375 }
3376
3377 fn small_path() -> impl Strategy<Value = String> {
3378 (0u8..6).prop_map(|n| format!("path{n}"))
3379 }
3380
3381 fn manifest_path() -> impl Strategy<Value = String> {
3384 prop_oneof![
3385 1 => Just(String::new()),
3386 6 => small_path(),
3387 ]
3388 }
3389
3390 fn small_hash() -> impl Strategy<Value = String> {
3391 (0u8..4).prop_map(|n| format!("h{n}"))
3392 }
3393
3394 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
3395 (
3396 manifest_path(),
3397 audio_format(),
3398 small_hash(),
3399 small_hash(),
3400 0u64..4,
3401 any::<bool>(),
3402 )
3403 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
3404 ManifestEntry {
3405 path,
3406 format,
3407 meta_hash,
3408 art_hash,
3409 size,
3410 preserve,
3411 ..Default::default()
3412 }
3413 })
3414 }
3415
3416 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
3417 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
3418 }
3419
3420 fn local_file() -> impl Strategy<Value = LocalFile> {
3421 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
3422 }
3423
3424 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
3425 hash_map(clip_id(), local_file(), 0..8)
3426 }
3427
3428 fn source_status() -> impl Strategy<Value = SourceStatus> {
3429 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
3430 mode,
3431 fully_enumerated,
3432 })
3433 }
3434
3435 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3436 vec(source_status(), 0..5)
3437 }
3438
3439 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
3440 vec(
3441 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
3442 mode: SourceMode::Copy,
3443 fully_enumerated,
3444 }),
3445 1..5,
3446 )
3447 }
3448
3449 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
3450 (
3451 small_path(),
3452 audio_format(),
3453 small_hash(),
3454 small_hash(),
3455 vec(source_mode(), 1..3),
3456 any::<bool>(),
3457 any::<bool>(),
3458 )
3459 }
3460
3461 fn build_desired(id: String, fields: DesiredFields) -> Desired {
3462 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
3463 let clip = Clip {
3464 id,
3465 title: "t".to_string(),
3466 ..Default::default()
3467 };
3468 Desired {
3469 lineage: LineageContext::own_root(&clip),
3470 clip,
3471 path,
3472 format,
3473 meta_hash,
3474 art_hash,
3475 modes,
3476 trashed,
3477 private,
3478 artifacts: Vec::new(),
3479 }
3480 }
3481
3482 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
3485 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
3486 items
3487 .into_iter()
3488 .map(|(id, fields)| build_desired(id, fields))
3489 .collect()
3490 })
3491 }
3492
3493 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
3494 desired.iter().map(|d| d.clip.id.as_str()).collect()
3495 }
3496
3497 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
3500 desired
3501 .iter()
3502 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
3503 .map(|d| d.clip.id.as_str())
3504 .collect()
3505 }
3506
3507 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
3510 desired
3511 .iter()
3512 .filter(|d| !d.trashed)
3513 .map(|d| d.clip.id.as_str())
3514 .collect()
3515 }
3516
3517 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
3518 plan.actions
3519 .iter()
3520 .filter_map(|a| match a {
3521 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
3522 _ => None,
3523 })
3524 .collect()
3525 }
3526
3527 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
3528 plan.actions
3529 .iter()
3530 .filter_map(|a| match a {
3531 Action::Download { path, .. } | Action::Reformat { path, .. } => {
3532 Some(path.as_str())
3533 }
3534 Action::Rename { to, .. } => Some(to.as_str()),
3535 _ => None,
3536 })
3537 .collect()
3538 }
3539
3540 proptest! {
3541 #![proptest_config(ProptestConfig {
3542 cases: 256,
3543 failure_persistence: None,
3544 ..ProptestConfig::default()
3545 })]
3546
3547 #[test]
3550 fn inv1_desired_clip_deleted_only_when_fully_trashed(
3551 manifest in manifest_strategy(),
3552 desired in desired_strategy(),
3553 local in local_strategy(),
3554 sources in sources_strategy(),
3555 ) {
3556 let plan = reconcile(&manifest, &desired, &local, &sources);
3557 let present = desired_ids(&desired);
3558 let live = non_trashed_ids(&desired);
3559 for id in delete_clip_ids(&plan) {
3560 prop_assert!(
3561 !(present.contains(id) && live.contains(id)),
3562 "deleted a desired clip with a non-trashed duplicate: {id}"
3563 );
3564 }
3565 }
3566
3567 #[test]
3571 fn inv2_no_delete_when_any_mirror_unenumerated(
3572 manifest in manifest_strategy(),
3573 desired in desired_strategy(),
3574 local in local_strategy(),
3575 mut sources in sources_strategy(),
3576 ) {
3577 sources.push(SourceStatus {
3578 mode: SourceMode::Mirror,
3579 fully_enumerated: false,
3580 });
3581 let plan = reconcile(&manifest, &desired, &local, &sources);
3582 prop_assert_eq!(plan.deletes(), 0);
3583 }
3584
3585 #[test]
3587 fn inv3_all_copy_sources_means_no_deletes(
3588 manifest in manifest_strategy(),
3589 desired in desired_strategy(),
3590 local in local_strategy(),
3591 sources in copy_sources_strategy(),
3592 ) {
3593 let plan = reconcile(&manifest, &desired, &local, &sources);
3594 prop_assert_eq!(plan.deletes(), 0);
3595 }
3596
3597 #[test]
3600 fn inv4_plan_is_deterministic(
3601 manifest in manifest_strategy(),
3602 desired in desired_strategy(),
3603 local in local_strategy(),
3604 sources in sources_strategy(),
3605 ) {
3606 let plan = reconcile(&manifest, &desired, &local, &sources);
3607
3608 let again = reconcile(&manifest, &desired, &local, &sources);
3609 prop_assert_eq!(&plan, &again);
3610
3611 let mut desired_rev = desired.clone();
3612 desired_rev.reverse();
3613 let mut sources_rev = sources.clone();
3614 sources_rev.reverse();
3615 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
3616 prop_assert_eq!(&plan, &shuffled);
3617 }
3618
3619 #[test]
3621 fn inv5_every_delete_is_in_the_manifest(
3622 manifest in manifest_strategy(),
3623 desired in desired_strategy(),
3624 local in local_strategy(),
3625 sources in sources_strategy(),
3626 ) {
3627 let plan = reconcile(&manifest, &desired, &local, &sources);
3628 for id in delete_clip_ids(&plan) {
3629 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
3630 }
3631 }
3632
3633 #[test]
3636 fn inv6_never_deletes_protected_clip(
3637 manifest in manifest_strategy(),
3638 desired in desired_strategy(),
3639 local in local_strategy(),
3640 sources in sources_strategy(),
3641 ) {
3642 let plan = reconcile(&manifest, &desired, &local, &sources);
3643 let protected = protected_ids(&desired);
3644 for id in delete_clip_ids(&plan) {
3645 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
3646 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
3647 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
3648 }
3649 }
3650
3651 #[test]
3654 fn inv7_no_delete_unless_deletion_allowed(
3655 manifest in manifest_strategy(),
3656 desired in desired_strategy(),
3657 local in local_strategy(),
3658 sources in sources_strategy(),
3659 ) {
3660 let plan = reconcile(&manifest, &desired, &local, &sources);
3661 if !deletion_allowed(&sources) {
3662 prop_assert_eq!(plan.deletes(), 0);
3663 }
3664 }
3665
3666 #[test]
3668 fn inv8_at_most_one_delete_per_clip(
3669 manifest in manifest_strategy(),
3670 desired in desired_strategy(),
3671 local in local_strategy(),
3672 sources in sources_strategy(),
3673 ) {
3674 let plan = reconcile(&manifest, &desired, &local, &sources);
3675 let ids = delete_clip_ids(&plan);
3676 let unique: BTreeSet<&str> = ids.iter().copied().collect();
3677 prop_assert_eq!(ids.len(), unique.len());
3678 }
3679
3680 #[test]
3682 fn inv9_no_delete_with_empty_path(
3683 manifest in manifest_strategy(),
3684 desired in desired_strategy(),
3685 local in local_strategy(),
3686 sources in sources_strategy(),
3687 ) {
3688 let plan = reconcile(&manifest, &desired, &local, &sources);
3689 for action in &plan.actions {
3690 if let Action::Delete { path, .. } = action {
3691 prop_assert!(!path.is_empty(), "delete with an empty path");
3692 }
3693 }
3694 }
3695
3696 #[test]
3699 fn inv10_no_delete_aliases_a_write_target(
3700 manifest in manifest_strategy(),
3701 desired in desired_strategy(),
3702 local in local_strategy(),
3703 sources in sources_strategy(),
3704 ) {
3705 let plan = reconcile(&manifest, &desired, &local, &sources);
3706 let targets = write_target_paths(&plan);
3707 for action in &plan.actions {
3708 if let Action::Delete { path, .. } = action {
3709 prop_assert!(
3710 !targets.contains(path.as_str()),
3711 "delete path {path} aliases a write target"
3712 );
3713 }
3714 }
3715 }
3716 }
3717}