1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25 Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26 immediate_parent, lineage_edges,
27};
28use crate::manifest::ArtifactState;
29use crate::model::Clip;
30use crate::reconcile::ArtifactKind;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39 pub schema_version: u32,
41 pub nodes: BTreeMap<String, Node>,
43 pub edges: Vec<StoredEdge>,
45 pub resolution_cache: BTreeMap<String, CacheEntry>,
47 pub albums: BTreeMap<String, AlbumArt>,
50 pub playlists: BTreeMap<String, PlaylistState>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub owner: Option<Owner>,
58 #[serde(skip)]
65 pub album_overrides: BTreeMap<String, String>,
66 #[serde(skip)]
80 eligible_root_ids: HashSet<String>,
81}
82
83impl Default for LineageStore {
84 fn default() -> Self {
85 Self {
86 schema_version: 1,
87 nodes: BTreeMap::new(),
88 edges: Vec::new(),
89 resolution_cache: BTreeMap::new(),
90 albums: BTreeMap::new(),
91 playlists: BTreeMap::new(),
92 owner: None,
93 album_overrides: BTreeMap::new(),
94 eligible_root_ids: HashSet::new(),
95 }
96 }
97}
98
99impl PartialEq for LineageStore {
108 fn eq(&self, other: &Self) -> bool {
109 self.schema_version == other.schema_version
110 && self.nodes == other.nodes
111 && self.edges == other.edges
112 && self.resolution_cache == other.resolution_cache
113 && self.albums == other.albums
114 && self.playlists == other.playlists
115 && self.owner == other.owner
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Owner {
126 pub user_id: String,
127 pub display_name: String,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum OwnerCheck {
133 FirstUse,
135 Match,
137 Mismatch,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OwnerGate {
150 AbortConfigMismatch,
153 AbortMismatch,
155 Repin,
158 Proceed,
161 FirstUse,
163}
164
165impl OwnerGate {
166 pub fn is_additive(self) -> bool {
168 matches!(self, OwnerGate::Repin)
169 }
170}
171
172pub fn owner_gate(
179 store_owner: Option<&Owner>,
180 configured_id: Option<&str>,
181 authed_user_id: &str,
182 allow_change: bool,
183) -> OwnerGate {
184 if let Some(configured) = configured_id
185 && configured != authed_user_id
186 {
187 return OwnerGate::AbortConfigMismatch;
188 }
189 match store_owner {
190 None => OwnerGate::FirstUse,
191 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
192 Some(_) if allow_change => OwnerGate::Repin,
193 Some(_) => OwnerGate::AbortMismatch,
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum AdoptDecision {
204 PinFresh,
207 PinAdopt,
210 AdoptForced,
213 Abort,
216 SkipPin,
218}
219
220impl AdoptDecision {
221 pub fn is_additive(self) -> bool {
223 matches!(self, AdoptDecision::AdoptForced)
224 }
225}
226
227pub fn adopt_decision(
237 listed: &[&str],
238 owned: &BTreeSet<&str>,
239 enumerated: bool,
240 allow_change: bool,
241) -> AdoptDecision {
242 if owned.is_empty() {
243 return AdoptDecision::PinFresh;
244 }
245 if !enumerated {
246 return AdoptDecision::SkipPin;
247 }
248 if listed.iter().any(|id| owned.contains(id)) {
249 AdoptDecision::PinAdopt
250 } else if allow_change {
251 AdoptDecision::AdoptForced
252 } else {
253 AdoptDecision::Abort
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct AlbumArt {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub folder_jpg: Option<ArtifactState>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub folder_webp: Option<ArtifactState>,
274}
275
276impl AlbumArt {
277 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
280 match kind {
281 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
282 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
283 ArtifactKind::CoverJpg
284 | ArtifactKind::CoverWebp
285 | ArtifactKind::DetailsTxt
286 | ArtifactKind::LyricsTxt
287 | ArtifactKind::Lrc
288 | ArtifactKind::VideoMp4
289 | ArtifactKind::Playlist => None,
290 }
291 }
292
293 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
299 match kind {
300 ArtifactKind::FolderJpg => self.folder_jpg = state,
301 ArtifactKind::FolderWebp => self.folder_webp = state,
302 ArtifactKind::CoverJpg
303 | ArtifactKind::CoverWebp
304 | ArtifactKind::DetailsTxt
305 | ArtifactKind::LyricsTxt
306 | ArtifactKind::Lrc
307 | ArtifactKind::VideoMp4
308 | ArtifactKind::Playlist => {}
309 }
310 }
311
312 pub fn is_empty(&self) -> bool {
315 self.folder_jpg.is_none() && self.folder_webp.is_none()
316 }
317}
318
319#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(default)]
329pub struct PlaylistState {
330 pub name: String,
332 pub path: String,
334 pub hash: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341#[serde(default)]
342pub struct Node {
343 pub title: String,
344 pub created_at: String,
345 pub clip_type: String,
346 pub task: String,
347 pub is_remix: bool,
348 pub is_trashed: bool,
349 pub status: String,
351 pub first_seen_at: String,
352 pub last_seen_at: String,
353}
354
355impl Default for Node {
356 fn default() -> Self {
357 Self {
358 title: String::new(),
359 created_at: String::new(),
360 clip_type: String::new(),
361 task: String::new(),
362 is_remix: false,
363 is_trashed: false,
364 status: "observed".to_owned(),
365 first_seen_at: String::new(),
366 last_seen_at: String::new(),
367 }
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375#[serde(default)]
376pub struct StoredEdge {
377 pub child_id: String,
378 pub parent_id: String,
379 pub edge_type: String,
381 pub role: String,
383 pub source_field: String,
385 pub ordinal: u32,
387 pub status: String,
389 pub first_seen_at: String,
390 pub last_seen_at: String,
391}
392
393impl Default for StoredEdge {
394 fn default() -> Self {
395 Self {
396 child_id: String::new(),
397 parent_id: String::new(),
398 edge_type: String::new(),
399 role: String::new(),
400 source_field: String::new(),
401 ordinal: 0,
402 status: "active".to_owned(),
403 first_seen_at: String::new(),
404 last_seen_at: String::new(),
405 }
406 }
407}
408
409#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
411#[serde(default)]
412pub struct CacheEntry {
413 pub root_id: String,
414 pub status: String,
416 pub algorithm_version: u32,
417 pub computed_at: String,
418}
419
420impl LineageStore {
421 pub fn new() -> Self {
423 Self::default()
424 }
425
426 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
439 self.album_overrides = overrides;
440 }
441
442 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
466 if !self.eligible_root_ids.contains(root_id) {
467 return root_title;
468 }
469 match self.album_overrides.get(root_id) {
470 Some(name) if !name.trim().is_empty() => name.clone(),
471 _ => root_title,
472 }
473 }
474
475 pub fn refresh_eligible_roots(&mut self) {
485 self.eligible_root_ids = self
486 .resolution_cache
487 .values()
488 .map(|entry| entry.root_id.as_str())
489 .filter(|root_id| !root_id.is_empty())
490 .map(str::to_owned)
491 .collect();
492 }
493
494 #[cfg(test)]
497 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
498 &self.eligible_root_ids
499 }
500
501 pub fn node(&self, id: &str) -> Option<&Node> {
503 self.nodes.get(id)
504 }
505
506 pub fn owner(&self) -> Option<&Owner> {
508 self.owner.as_ref()
509 }
510
511 pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
513 match &self.owner {
514 None => OwnerCheck::FirstUse,
515 Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
516 Some(_) => OwnerCheck::Mismatch,
517 }
518 }
519
520 pub fn pin_owner(&mut self, owner: Owner) {
522 self.owner = Some(owner);
523 }
524
525 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
528 match &mut self.owner {
529 Some(owner) if owner.display_name != display_name => {
530 owner.display_name = display_name.to_owned();
531 true
532 }
533 _ => false,
534 }
535 }
536
537 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
539 self.resolution_cache.get(id)
540 }
541
542 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
544 self.albums.get(root_id)
545 }
546
547 pub fn set_album_artifact(
555 &mut self,
556 root_id: &str,
557 kind: ArtifactKind,
558 state: Option<ArtifactState>,
559 ) {
560 match state {
561 Some(state) => self
562 .albums
563 .entry(root_id.to_owned())
564 .or_default()
565 .set(kind, Some(state)),
566 None => {
567 if let Some(art) = self.albums.get_mut(root_id) {
568 art.set(kind, None);
569 if art.is_empty() {
570 self.albums.remove(root_id);
571 }
572 }
573 }
574 }
575 }
576
577 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
579 self.playlists.get(id)
580 }
581
582 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
590 match state {
591 Some(state) => {
592 self.playlists.insert(id.to_owned(), state);
593 }
594 None => {
595 self.playlists.remove(id);
596 }
597 }
598 }
599
600 pub fn context_for(&self, clip: &Clip) -> LineageContext {
611 let cached = self.get_root(&clip.id);
612 let root_id = cached
613 .map(|entry| entry.root_id.clone())
614 .filter(|id| !id.is_empty())
615 .unwrap_or_else(|| clip.id.clone());
616 let root_title = self
617 .node(&root_id)
618 .map(|node| node.title.clone())
619 .unwrap_or_else(|| clip.title.clone());
620 let root_title = self.effective_root_title(&root_id, root_title);
621 let root_date = self
622 .node(&root_id)
623 .map(|node| node.created_at.clone())
624 .unwrap_or_else(|| clip.created_at.clone());
625 let (parent_id, edge_type) = match immediate_parent(clip) {
626 Some((id, edge)) => (id, Some(edge)),
627 None => (String::new(), None),
628 };
629 let status = cached
630 .map(|entry| status_from_slug(&entry.status))
631 .unwrap_or(ResolveStatus::Resolved);
632 LineageContext {
633 root_id,
634 root_title,
635 root_date,
636 parent_id,
637 edge_type,
638 status,
639 }
640 }
641
642 pub fn album_for_id(&self, id: &str) -> String {
651 let own = self.node(id);
652 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
653 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
654 let root_id = self
655 .get_root(id)
656 .map(|entry| entry.root_id.clone())
657 .filter(|root| !root.is_empty())
658 .unwrap_or_else(|| id.to_owned());
659 let root_title = self
660 .node(&root_id)
661 .map(|node| node.title.clone())
662 .unwrap_or_else(|| own_title.clone());
663 let root_title = self.effective_root_title(&root_id, root_title);
664 let root_date = self
665 .node(&root_id)
666 .map(|node| node.created_at.clone())
667 .unwrap_or(own_created_at);
668 let context = LineageContext {
669 root_id,
670 root_title,
671 root_date,
672 parent_id: String::new(),
673 edge_type: None,
674 status: ResolveStatus::Resolved,
675 };
676 context.album(&own_title)
677 }
678
679 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
705 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
706 for root_id in &self.eligible_root_ids {
707 let node_title = self
708 .nodes
709 .get(root_id)
710 .map(|node| node.title.clone())
711 .unwrap_or_default();
712 let effective = self.effective_root_title(root_id, node_title);
713 let title = effective.trim();
714 if title.is_empty() {
715 continue;
716 }
717 roots_by_title
718 .entry(title.to_owned())
719 .or_default()
720 .insert(root_id.clone());
721 }
722 roots_by_title
723 .into_iter()
724 .filter(|(_, roots)| roots.len() > 1)
725 .map(|(title, _)| title)
726 .collect()
727 }
728
729 pub fn len(&self) -> usize {
731 self.nodes.len()
732 }
733
734 pub fn is_empty(&self) -> bool {
736 self.nodes.is_empty()
737 }
738
739 pub fn iter(&self) -> Iter<'_, String, Node> {
741 self.nodes.iter()
742 }
743
744 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
752 for clip in clips {
753 self.upsert_node(clip, now);
754 }
755 for clip in &resolution.gap_filled {
758 self.upsert_node(clip, now);
759 }
760
761 for clip in clips {
762 for edge in lineage_edges(clip) {
763 self.upsert_edge(&clip.id, &edge, now);
764 }
765 }
766 self.edges.sort_by(|a, b| {
767 a.child_id
768 .cmp(&b.child_id)
769 .then(a.ordinal.cmp(&b.ordinal))
770 .then(a.parent_id.cmp(&b.parent_id))
771 .then(a.edge_type.cmp(&b.edge_type))
772 .then(a.role.cmp(&b.role))
773 });
774
775 for (child_id, info) in &resolution.roots {
776 self.upsert_cache(child_id, info, now);
777 }
778 self.refresh_eligible_roots();
779 }
780
781 fn upsert_node(&mut self, clip: &Clip, now: &str) {
784 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
785 first_seen_at: now.to_owned(),
786 ..Node::default()
787 });
788 node.title = clip.title.clone();
789 node.created_at = clip.created_at.clone();
790 node.clip_type = clip.clip_type.clone();
791 node.task = clip.task.clone();
792 node.is_remix = clip.is_remix;
793 node.is_trashed = clip.is_trashed;
794 node.last_seen_at = now.to_owned();
795 }
796
797 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
800 let edge_type = edge_type_slug(edge.edge_type);
801 let role = edge_role_slug(edge.role);
802 if let Some(existing) = self.edges.iter_mut().find(|stored| {
803 stored.child_id == child_id
804 && stored.parent_id == edge.parent_id
805 && stored.edge_type == edge_type
806 && stored.role == role
807 && stored.ordinal == edge.ordinal
808 }) {
809 existing.source_field = edge.source_field.to_owned();
810 existing.status = "active".to_owned();
811 existing.last_seen_at = now.to_owned();
812 } else {
813 self.edges.push(StoredEdge {
814 child_id: child_id.to_owned(),
815 parent_id: edge.parent_id.clone(),
816 edge_type: edge_type.to_owned(),
817 role: role.to_owned(),
818 source_field: edge.source_field.to_owned(),
819 ordinal: edge.ordinal,
820 status: "active".to_owned(),
821 first_seen_at: now.to_owned(),
822 last_seen_at: now.to_owned(),
823 });
824 }
825 }
826
827 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
834 if info.status != ResolveStatus::Resolved
835 && self
836 .resolution_cache
837 .get(child_id)
838 .is_some_and(|entry| entry.status == "resolved")
839 {
840 return;
841 }
842 self.resolution_cache.insert(
843 child_id.to_owned(),
844 CacheEntry {
845 root_id: info.root_id.clone(),
846 status: resolve_status_slug(info.status).to_owned(),
847 algorithm_version: 1,
848 computed_at: now.to_owned(),
849 },
850 );
851 }
852}
853
854fn edge_type_slug(edge_type: EdgeType) -> &'static str {
856 match edge_type {
857 EdgeType::Cover => "cover",
858 EdgeType::Remaster => "remaster",
859 EdgeType::SpeedEdit => "speed_edit",
860 EdgeType::Edit => "edit",
861 EdgeType::Extend => "extend",
862 EdgeType::SectionReplace => "section_replace",
863 EdgeType::Stitch => "stitch",
864 EdgeType::Derived => "derived",
865 EdgeType::Uploaded => "uploaded",
866 }
867}
868
869fn edge_role_slug(role: EdgeRole) -> &'static str {
871 match role {
872 EdgeRole::Primary => "primary",
873 EdgeRole::Secondary => "secondary",
874 }
875}
876
877fn resolve_status_slug(status: ResolveStatus) -> &'static str {
879 match status {
880 ResolveStatus::Resolved => "resolved",
881 ResolveStatus::External => "external",
882 ResolveStatus::Unresolved => "unresolved",
883 ResolveStatus::Cycle => "cycle",
884 }
885}
886
887fn status_from_slug(slug: &str) -> ResolveStatus {
890 match slug {
891 "external" => ResolveStatus::External,
892 "unresolved" => ResolveStatus::Unresolved,
893 "cycle" => ResolveStatus::Cycle,
894 _ => ResolveStatus::Resolved,
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use std::collections::HashMap;
902
903 fn chain_clips() -> Vec<Clip> {
905 vec![
906 Clip {
907 id: "c".into(),
908 title: "Cover".into(),
909 clip_type: "gen".into(),
910 task: "cover".into(),
911 created_at: "t2".into(),
912 cover_clip_id: "b".into(),
913 edited_clip_id: "b".into(),
914 ..Default::default()
915 },
916 Clip {
917 id: "b".into(),
918 title: "Remaster".into(),
919 clip_type: "upsample".into(),
920 task: "upsample".into(),
921 created_at: "t1".into(),
922 upsample_clip_id: "a".into(),
923 edited_clip_id: "a".into(),
924 ..Default::default()
925 },
926 Clip {
927 id: "a".into(),
928 title: "Root".into(),
929 clip_type: "gen".into(),
930 created_at: "t0".into(),
931 ..Default::default()
932 },
933 ]
934 }
935
936 fn chain_resolution() -> Resolution {
938 let mut roots = HashMap::new();
939 for id in ["a", "b", "c"] {
940 roots.insert(
941 id.to_owned(),
942 RootInfo {
943 root_id: "a".into(),
944 root_title: "Root".into(),
945 status: ResolveStatus::Resolved,
946 },
947 );
948 }
949 Resolution {
950 roots,
951 gap_filled: Vec::new(),
952 }
953 }
954
955 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
956 store
957 .edges
958 .iter()
959 .find(|e| e.child_id == child && e.parent_id == parent)
960 .expect("edge should exist")
961 }
962
963 #[test]
964 fn new_store_is_empty_and_versioned() {
965 let store = LineageStore::new();
966 assert!(store.is_empty());
967 assert_eq!(store.len(), 0);
968 assert_eq!(store.schema_version, 1);
969 }
970
971 #[test]
972 fn update_populates_nodes_edges_and_cache() {
973 let mut store = LineageStore::new();
974 store.update(&chain_clips(), &chain_resolution(), "now");
975
976 assert_eq!(store.len(), 3);
978 let cover = store.node("c").unwrap();
979 assert_eq!(cover.title, "Cover");
980 assert_eq!(cover.clip_type, "gen");
981 assert_eq!(cover.task, "cover");
982 assert_eq!(cover.created_at, "t2");
983 assert_eq!(cover.status, "observed");
984 assert!(!cover.is_trashed);
985 assert_eq!(cover.first_seen_at, "now");
986 assert_eq!(cover.last_seen_at, "now");
987
988 assert_eq!(store.edges.len(), 2);
990 let cb = edge(&store, "c", "b");
991 assert_eq!(cb.edge_type, "cover");
992 assert_eq!(cb.role, "primary");
993 assert_eq!(cb.ordinal, 0);
994 assert_eq!(cb.source_field, "cover_clip_id");
995 assert_eq!(cb.status, "active");
996 let ba = edge(&store, "b", "a");
997 assert_eq!(ba.edge_type, "remaster");
998 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
999
1000 for id in ["a", "b", "c"] {
1002 let cached = store.get_root(id).unwrap();
1003 assert_eq!(cached.root_id, "a");
1004 assert_eq!(cached.status, "resolved");
1005 assert_eq!(cached.algorithm_version, 1);
1006 }
1007 }
1008
1009 #[test]
1010 fn album_for_id_matches_context_for_and_handles_unknown() {
1011 let mut store = LineageStore::new();
1012 store.update(&chain_clips(), &chain_resolution(), "now");
1013
1014 assert_eq!(store.album_for_id("c"), "Root");
1017 let cover = &chain_clips()[0];
1018 assert_eq!(
1019 store.album_for_id("c"),
1020 store.context_for(cover).album(&cover.title)
1021 );
1022 assert_eq!(store.album_for_id("a"), "Root");
1024 assert_eq!(store.album_for_id("missing"), "");
1026 }
1027
1028 #[test]
1029 fn serde_roundtrip_preserves_a_relational_shape() {
1030 let mut store = LineageStore::new();
1031 store.update(&chain_clips(), &chain_resolution(), "now");
1032
1033 let json = serde_json::to_string(&store).unwrap();
1034 let back: LineageStore = serde_json::from_str(&json).unwrap();
1035 assert_eq!(store, back);
1036
1037 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1038 assert_eq!(value.get("schema_version").unwrap(), 1);
1039 assert!(value.get("nodes").unwrap().is_object());
1040 assert!(value.get("edges").unwrap().is_array());
1041 assert!(value.get("resolution_cache").unwrap().is_object());
1042
1043 let node = value.get("nodes").unwrap().get("c").unwrap();
1046 assert!(node.get("edges").is_none());
1047 assert!(node.get("parent_id").is_none());
1048 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1049 assert!(first_edge.get("child_id").is_some());
1050 assert!(first_edge.get("parent_id").is_some());
1051 }
1052
1053 #[test]
1054 fn album_overrides_are_runtime_only_and_never_persist() {
1055 let mut store = LineageStore::new();
1059 store.update(&chain_clips(), &chain_resolution(), "now");
1060 store.set_album_overrides(
1061 [("a".to_owned(), "Preferred".to_owned())]
1062 .into_iter()
1063 .collect(),
1064 );
1065
1066 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1067 assert!(value.get("album_overrides").is_none());
1068
1069 let json = serde_json::to_string(&store).unwrap();
1070 let back: LineageStore = serde_json::from_str(&json).unwrap();
1071 assert!(back.album_overrides.is_empty());
1072 assert_eq!(back.album_for_id("c"), "Root");
1073 }
1074
1075 #[test]
1076 fn update_is_idempotent_bar_last_seen() {
1077 let clips = chain_clips();
1078 let resolution = chain_resolution();
1079 let mut store = LineageStore::new();
1080 store.update(&clips, &resolution, "first");
1081 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1082 let edge_count = store.edges.len();
1083
1084 store.update(&clips, &resolution, "second");
1085
1086 assert_eq!(
1088 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1089 node_ids
1090 );
1091 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1092 assert_eq!(store.resolution_cache.len(), 3);
1093
1094 let cover = store.node("c").unwrap();
1096 assert_eq!(cover.first_seen_at, "first");
1097 assert_eq!(cover.last_seen_at, "second");
1098 let cb = edge(&store, "c", "b");
1099 assert_eq!(cb.first_seen_at, "first");
1100 assert_eq!(cb.last_seen_at, "second");
1101 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1103 }
1104
1105 #[test]
1106 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1107 let mut store = LineageStore::new();
1108 store.update(&chain_clips(), &chain_resolution(), "first");
1109 assert_eq!(store.get_root("c").unwrap().status, "resolved");
1110
1111 let child = Clip {
1114 id: "c".into(),
1115 title: "Cover".into(),
1116 clip_type: "gen".into(),
1117 task: "cover".into(),
1118 cover_clip_id: "b".into(),
1119 edited_clip_id: "b".into(),
1120 ..Default::default()
1121 };
1122 let mut roots = HashMap::new();
1123 roots.insert(
1124 "c".to_owned(),
1125 RootInfo {
1126 root_id: "elsewhere".into(),
1127 root_title: String::new(),
1128 status: ResolveStatus::External,
1129 },
1130 );
1131 roots.insert(
1132 "d".to_owned(),
1133 RootInfo {
1134 root_id: "boundary".into(),
1135 root_title: String::new(),
1136 status: ResolveStatus::External,
1137 },
1138 );
1139 let resolution = Resolution {
1140 roots,
1141 gap_filled: Vec::new(),
1142 };
1143 store.update(&[child], &resolution, "second");
1144
1145 let cached = store.get_root("c").unwrap();
1147 assert_eq!(cached.root_id, "a");
1148 assert_eq!(cached.status, "resolved");
1149 assert_eq!(cached.computed_at, "first");
1150 let d = store.get_root("d").unwrap();
1152 assert_eq!(d.root_id, "boundary");
1153 assert_eq!(d.status, "external");
1154 }
1155
1156 #[test]
1157 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1158 let child = Clip {
1162 id: "c".into(),
1163 title: "Cover".into(),
1164 clip_type: "gen".into(),
1165 task: "cover".into(),
1166 cover_clip_id: "t".into(),
1167 edited_clip_id: "t".into(),
1168 ..Default::default()
1169 };
1170 let trashed = Clip {
1171 id: "t".into(),
1172 title: "Trashed Original".into(),
1173 clip_type: "gen".into(),
1174 is_trashed: true,
1175 ..Default::default()
1176 };
1177 let mut roots = HashMap::new();
1178 roots.insert(
1179 "c".to_owned(),
1180 RootInfo {
1181 root_id: "t".into(),
1182 root_title: "Trashed Original".into(),
1183 status: ResolveStatus::Resolved,
1184 },
1185 );
1186 let resolution = Resolution {
1187 roots,
1188 gap_filled: vec![trashed],
1189 };
1190 store_update_and_assert_trashed(child, resolution);
1191 }
1192
1193 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1194 let mut store = LineageStore::new();
1195 store.update(&[child], &resolution, "now");
1196
1197 let node = store
1198 .node("t")
1199 .expect("trashed ancestor should be archived");
1200 assert!(node.is_trashed);
1201 assert_eq!(node.title, "Trashed Original");
1202 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1204 }
1205
1206 #[test]
1207 fn partial_json_loads_with_defaults() {
1208 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1211 let store: LineageStore = serde_json::from_str(json).unwrap();
1212 assert_eq!(store.schema_version, 1);
1213 let node = store.node("x").unwrap();
1214 assert_eq!(node.title, "Kept");
1215 assert_eq!(node.status, "observed");
1216 assert_eq!(store.edges[0].status, "active");
1217 assert!(store.resolution_cache.is_empty());
1218 assert!(store.albums.is_empty());
1221 assert!(store.album_art("x").is_none());
1222 assert!(store.playlists.is_empty());
1226 assert!(store.playlist("x").is_none());
1227 }
1228
1229 #[test]
1230 fn album_art_roundtrips_and_reads_by_kind() {
1231 let mut store = LineageStore::new();
1232 store.albums.insert(
1233 "root-1".to_owned(),
1234 AlbumArt {
1235 folder_jpg: Some(ArtifactState {
1236 path: "alice/Album/folder.jpg".to_owned(),
1237 hash: "jpg-h".to_owned(),
1238 }),
1239 folder_webp: Some(ArtifactState {
1240 path: "alice/Album/cover.webp".to_owned(),
1241 hash: "webp-h".to_owned(),
1242 }),
1243 },
1244 );
1245
1246 let json = serde_json::to_string(&store).unwrap();
1247 let back: LineageStore = serde_json::from_str(&json).unwrap();
1248 assert_eq!(store, back);
1249
1250 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1252 let album = value.get("albums").unwrap().get("root-1").unwrap();
1253 assert_eq!(
1254 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1255 "jpg-h"
1256 );
1257
1258 let art = back.album_art("root-1").unwrap();
1259 assert_eq!(
1260 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1261 "alice/Album/folder.jpg"
1262 );
1263 assert_eq!(
1264 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1265 "webp-h"
1266 );
1267 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1269 }
1270
1271 #[test]
1272 fn empty_album_art_omits_slots_when_serialised() {
1273 let empty = AlbumArt::default();
1276 assert!(empty.is_empty());
1277 let value = serde_json::to_value(&empty).unwrap();
1278 assert!(value.get("folder_jpg").is_none());
1279 assert!(value.get("folder_webp").is_none());
1280 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1281 assert_eq!(back, empty);
1282 }
1283
1284 #[test]
1285 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1286 let mut store = LineageStore::new();
1287 let jpg = ArtifactState {
1288 path: "a/folder.jpg".to_owned(),
1289 hash: "h1".to_owned(),
1290 };
1291 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1292 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1293
1294 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1296 assert!(store.album_art("root-1").is_none());
1297 assert!(store.albums.is_empty());
1298 }
1299
1300 #[test]
1301 fn playlist_state_roundtrips_by_id() {
1302 let mut store = LineageStore::new();
1303 store.playlists.insert(
1304 "pl1".to_owned(),
1305 PlaylistState {
1306 name: "Road Trip".to_owned(),
1307 path: "Road Trip.m3u8".to_owned(),
1308 hash: "abc123".to_owned(),
1309 },
1310 );
1311
1312 let json = serde_json::to_string(&store).unwrap();
1313 let back: LineageStore = serde_json::from_str(&json).unwrap();
1314 assert_eq!(store, back);
1315
1316 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1318 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1319 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1320 assert_eq!(pl.get("hash").unwrap(), "abc123");
1321
1322 let stored = back.playlist("pl1").unwrap();
1323 assert_eq!(stored.name, "Road Trip");
1324 assert_eq!(stored.hash, "abc123");
1325 }
1326
1327 #[test]
1328 fn set_playlist_upserts_then_clears() {
1329 let mut store = LineageStore::new();
1330 let state = PlaylistState {
1331 name: "Mix".to_owned(),
1332 path: "Mix.m3u8".to_owned(),
1333 hash: "h1".to_owned(),
1334 };
1335 store.set_playlist("pl1", Some(state.clone()));
1336 assert_eq!(store.playlist("pl1"), Some(&state));
1337
1338 let renamed = PlaylistState {
1340 name: "Mix v2".to_owned(),
1341 path: "Mix v2.m3u8".to_owned(),
1342 hash: "h2".to_owned(),
1343 };
1344 store.set_playlist("pl1", Some(renamed.clone()));
1345 assert_eq!(store.playlist("pl1"), Some(&renamed));
1346
1347 store.set_playlist("pl1", None);
1349 assert!(store.playlist("pl1").is_none());
1350 assert!(store.playlists.is_empty());
1351 }
1352
1353 #[test]
1354 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1355 let mut store = LineageStore::new();
1356 store.update(&chain_clips(), &chain_resolution(), "now");
1357
1358 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1360 assert_eq!(ctx.root_id, "a");
1361 assert_eq!(ctx.root_title, "Root");
1362 assert_eq!(ctx.parent_id, "b");
1363 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1364 assert_eq!(ctx.status, ResolveStatus::Resolved);
1365 assert_eq!(ctx.album("Cover"), "Root");
1367 }
1368
1369 #[test]
1370 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1371 let mut store = LineageStore::new();
1372 store.update(&chain_clips(), &chain_resolution(), "now");
1373
1374 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1376 assert_eq!(ctx.root_id, "a");
1377 assert_eq!(ctx.root_title, "Root");
1378 assert_eq!(ctx.parent_id, "");
1379 assert_eq!(ctx.edge_type, None);
1380 assert_eq!(ctx.album("Root"), "Root");
1381 }
1382
1383 #[test]
1384 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1385 let clips = vec![
1388 Clip {
1389 id: "child".into(),
1390 title: "Revision".into(),
1391 clip_type: "gen".into(),
1392 task: "cover".into(),
1393 created_at: "2024-01-02T08:00:00Z".into(),
1394 cover_clip_id: "root".into(),
1395 edited_clip_id: "root".into(),
1396 ..Default::default()
1397 },
1398 Clip {
1399 id: "root".into(),
1400 title: "Origin".into(),
1401 clip_type: "gen".into(),
1402 created_at: "2023-12-30T23:00:00Z".into(),
1403 ..Default::default()
1404 },
1405 ];
1406 let mut roots = HashMap::new();
1407 for id in ["child", "root"] {
1408 roots.insert(
1409 id.to_owned(),
1410 RootInfo {
1411 root_id: "root".into(),
1412 root_title: "Origin".into(),
1413 status: ResolveStatus::Resolved,
1414 },
1415 );
1416 }
1417 let resolution = Resolution {
1418 roots,
1419 gap_filled: Vec::new(),
1420 };
1421 let mut store = LineageStore::new();
1422 store.update(&clips, &resolution, "now");
1423
1424 let child_ctx = store.context_for(&clips[0]);
1425 assert_eq!(child_ctx.root_id, "root");
1426 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1427 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1429
1430 let root_ctx = store.context_for(&clips[1]);
1432 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1433 }
1434
1435 #[test]
1436 fn context_for_an_unknown_clip_is_self_rooted() {
1437 let store = LineageStore::new();
1438 let orphan = Clip {
1439 id: "z".into(),
1440 title: "Lonely".into(),
1441 ..Default::default()
1442 };
1443 let ctx = store.context_for(&orphan);
1444 assert_eq!(ctx.root_id, "z");
1445 assert_eq!(ctx.root_title, "Lonely");
1446 assert_eq!(ctx.parent_id, "");
1447 assert_eq!(ctx.status, ResolveStatus::Resolved);
1448 }
1449
1450 #[test]
1451 fn context_for_retains_a_purged_ancestor_album() {
1452 let child = Clip {
1457 id: "c".into(),
1458 title: "Cover".into(),
1459 clip_type: "gen".into(),
1460 task: "cover".into(),
1461 cover_clip_id: "t".into(),
1462 edited_clip_id: "t".into(),
1463 ..Default::default()
1464 };
1465 let trashed = Clip {
1466 id: "t".into(),
1467 title: "Trashed Original".into(),
1468 clip_type: "gen".into(),
1469 is_trashed: true,
1470 ..Default::default()
1471 };
1472 let mut roots = HashMap::new();
1473 roots.insert(
1474 "c".to_owned(),
1475 RootInfo {
1476 root_id: "t".into(),
1477 root_title: "Trashed Original".into(),
1478 status: ResolveStatus::Resolved,
1479 },
1480 );
1481 let resolution = Resolution {
1482 roots,
1483 gap_filled: vec![trashed],
1484 };
1485 let mut store = LineageStore::new();
1486 store.update(std::slice::from_ref(&child), &resolution, "now");
1487
1488 let ctx = store.context_for(&child);
1489 assert_eq!(ctx.root_id, "t");
1490 assert_eq!(ctx.root_title, "Trashed Original");
1491 assert_eq!(ctx.album("Cover"), "Trashed Original");
1492 }
1493
1494 #[test]
1495 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1496 let clips = vec![
1499 Clip {
1500 id: "r1".into(),
1501 title: "Break Through".into(),
1502 clip_type: "gen".into(),
1503 ..Default::default()
1504 },
1505 Clip {
1506 id: "r2".into(),
1507 title: "Break Through".into(),
1508 clip_type: "gen".into(),
1509 ..Default::default()
1510 },
1511 Clip {
1512 id: "r3".into(),
1513 title: "Solo".into(),
1514 clip_type: "gen".into(),
1515 ..Default::default()
1516 },
1517 Clip {
1518 id: "c1".into(),
1519 title: "Break Through".into(),
1520 clip_type: "gen".into(),
1521 task: "cover".into(),
1522 cover_clip_id: "r1".into(),
1523 edited_clip_id: "r1".into(),
1524 ..Default::default()
1525 },
1526 ];
1527 let mut roots = HashMap::new();
1528 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1529 let title = if root == "r3" {
1530 "Solo"
1531 } else {
1532 "Break Through"
1533 };
1534 roots.insert(
1535 id.to_owned(),
1536 RootInfo {
1537 root_id: root.into(),
1538 root_title: title.into(),
1539 status: ResolveStatus::Resolved,
1540 },
1541 );
1542 }
1543 let resolution = Resolution {
1544 roots,
1545 gap_filled: Vec::new(),
1546 };
1547 let mut store = LineageStore::new();
1548 store.update(&clips, &resolution, "now");
1549
1550 let colliding = store.colliding_root_titles();
1551 assert!(colliding.contains("Break Through"));
1552 assert!(!colliding.contains("Solo"));
1553 assert_eq!(colliding.len(), 1);
1554 }
1555
1556 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1559 let clips = vec![
1560 Clip {
1561 id: "r1".into(),
1562 title: t1.into(),
1563 clip_type: "gen".into(),
1564 ..Default::default()
1565 },
1566 Clip {
1567 id: "r2".into(),
1568 title: t2.into(),
1569 clip_type: "gen".into(),
1570 ..Default::default()
1571 },
1572 ];
1573 let mut roots = HashMap::new();
1574 roots.insert(
1575 "r1".to_owned(),
1576 RootInfo {
1577 root_id: "r1".into(),
1578 root_title: t1.into(),
1579 status: ResolveStatus::Resolved,
1580 },
1581 );
1582 roots.insert(
1583 "r2".to_owned(),
1584 RootInfo {
1585 root_id: "r2".into(),
1586 root_title: t2.into(),
1587 status: ResolveStatus::Resolved,
1588 },
1589 );
1590 let mut store = LineageStore::new();
1591 store.update(
1592 &clips,
1593 &Resolution {
1594 roots,
1595 gap_filled: Vec::new(),
1596 },
1597 "now",
1598 );
1599 store
1600 }
1601
1602 #[test]
1603 fn album_override_flows_into_context_tag_hash_and_index() {
1604 let clips = chain_clips();
1608 let mut store = LineageStore::new();
1609 store.update(&clips, &chain_resolution(), "now");
1610
1611 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1613
1614 store.set_album_overrides(
1615 [("a".to_owned(), "Preferred Name".to_owned())]
1616 .into_iter()
1617 .collect(),
1618 );
1619
1620 for id in ["a", "b", "c"] {
1622 let clip = clips.iter().find(|c| c.id == id).unwrap();
1623 let ctx = store.context_for(clip);
1624 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1625 assert_eq!(store.album_for_id(id), "Preferred Name");
1626 }
1627
1628 let ctx = store.context_for(cover);
1630 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1631 assert_eq!(meta.album, "Preferred Name");
1632
1633 let after_hash = crate::hash::meta_hash(cover, &ctx);
1635 assert_ne!(before_hash, after_hash);
1636 }
1637
1638 #[test]
1639 fn empty_album_override_is_ignored() {
1640 let clips = chain_clips();
1642 let mut store = LineageStore::new();
1643 store.update(&clips, &chain_resolution(), "now");
1644 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1645 assert_eq!(store.album_for_id("c"), "Root");
1646 }
1647
1648 #[test]
1649 fn album_override_creates_a_collision_that_disambiguates() {
1650 let mut store = two_root_store("Alpha", "Beta");
1652 assert!(store.colliding_root_titles().is_empty());
1653
1654 store.set_album_overrides(
1655 [("r2".to_owned(), "Alpha".to_owned())]
1656 .into_iter()
1657 .collect(),
1658 );
1659 let colliding = store.colliding_root_titles();
1660 assert!(colliding.contains("Alpha"));
1661 assert_eq!(colliding.len(), 1);
1662 }
1663
1664 #[test]
1665 fn album_override_resolves_a_natural_collision() {
1666 let mut store = two_root_store("Break Through", "Break Through");
1668 assert!(store.colliding_root_titles().contains("Break Through"));
1669
1670 store.set_album_overrides(
1671 [("r2".to_owned(), "Second Wind".to_owned())]
1672 .into_iter()
1673 .collect(),
1674 );
1675 assert!(store.colliding_root_titles().is_empty());
1676 }
1677
1678 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1683 store.resolution_cache.insert(
1684 root_id.to_owned(),
1685 CacheEntry {
1686 root_id: root_id.to_owned(),
1687 status: "external".to_owned(),
1688 algorithm_version: 1,
1689 computed_at: "now".to_owned(),
1690 },
1691 );
1692 store.refresh_eligible_roots();
1695 }
1696
1697 #[test]
1698 fn override_on_node_less_root_collides_with_a_real_root() {
1699 let mut store = LineageStore::new();
1703 store.update(
1704 std::slice::from_ref(&Clip {
1705 id: "realroot".into(),
1706 title: "Shared".into(),
1707 clip_type: "gen".into(),
1708 ..Default::default()
1709 }),
1710 &Resolution {
1711 roots: [(
1712 "realroot".to_owned(),
1713 RootInfo {
1714 root_id: "realroot".into(),
1715 root_title: "Shared".into(),
1716 status: ResolveStatus::Resolved,
1717 },
1718 )]
1719 .into_iter()
1720 .collect(),
1721 gap_filled: Vec::new(),
1722 },
1723 "now",
1724 );
1725 insert_cache_only_root(&mut store, "extroot");
1726 store.set_album_overrides(
1727 [("extroot".to_owned(), "Shared".to_owned())]
1728 .into_iter()
1729 .collect(),
1730 );
1731
1732 let colliding = store.colliding_root_titles();
1733 assert!(
1734 colliding.contains("Shared"),
1735 "a node-less overridden root must still be seen by collision detection"
1736 );
1737 }
1738
1739 #[test]
1740 fn two_node_less_roots_overridden_to_same_name_collide() {
1741 let mut store = LineageStore::new();
1742 insert_cache_only_root(&mut store, "extone");
1743 insert_cache_only_root(&mut store, "exttwo");
1744 store.set_album_overrides(
1745 [
1746 ("extone".to_owned(), "Shared".to_owned()),
1747 ("exttwo".to_owned(), "Shared".to_owned()),
1748 ]
1749 .into_iter()
1750 .collect(),
1751 );
1752 assert!(store.colliding_root_titles().contains("Shared"));
1753 }
1754
1755 #[test]
1756 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1757 let mut store = LineageStore::new();
1762 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1763 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1764 store.set_album_overrides(
1765 [
1766 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1767 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1768 ]
1769 .into_iter()
1770 .collect(),
1771 );
1772 let colliding = store.colliding_root_titles();
1773
1774 let clip_of = |id: &str| Clip {
1775 id: id.to_owned(),
1776 title: "Track".to_owned(),
1777 display_name: "alice".to_owned(),
1778 image_large_url: "https://art.example/large.jpg".to_owned(),
1779 ..Default::default()
1780 };
1781 let ctx_of = |root_id: &str| LineageContext {
1782 root_id: root_id.to_owned(),
1783 root_title: "Shared".to_owned(),
1784 root_date: String::new(),
1785 parent_id: String::new(),
1786 edge_type: None,
1787 status: ResolveStatus::Resolved,
1788 };
1789 let clip_a = clip_of("clipaaaa-1111");
1790 let clip_b = clip_of("clipbbbb-2222");
1791 let ctx_a = ctx_of("aaaaaaaa-root-one");
1792 let ctx_b = ctx_of("bbbbbbbb-root-two");
1793 let requests = [
1794 crate::naming::NamingRequest {
1795 clip: &clip_a,
1796 lineage: &ctx_a,
1797 },
1798 crate::naming::NamingRequest {
1799 clip: &clip_b,
1800 lineage: &ctx_b,
1801 },
1802 ];
1803 let names = crate::naming::render_clip_names(
1804 &requests,
1805 &crate::naming::NamingConfig::default(),
1806 &colliding,
1807 );
1808
1809 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1810 crate::reconcile::Desired {
1811 clip: clip.clone(),
1812 lineage: ctx.clone(),
1813 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1814 format: crate::AudioFormat::Flac,
1815 meta_hash: String::new(),
1816 art_hash: String::new(),
1817 modes: vec![crate::reconcile::SourceMode::Mirror],
1818 trashed: false,
1819 private: false,
1820 artifacts: Vec::new(),
1821 stems: None,
1822 }
1823 };
1824 let desired = vec![
1825 desired_of(&clip_a, &ctx_a, &names[0]),
1826 desired_of(&clip_b, &ctx_b, &names[1]),
1827 ];
1828
1829 let albums = crate::reconcile::album_desired(&desired, false);
1830 assert_eq!(albums.len(), 2, "each distinct root is its own album");
1831 let jpg_paths: Vec<String> = albums
1832 .iter()
1833 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1834 .collect();
1835 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1836 assert_ne!(
1837 jpg_paths[0], jpg_paths[1],
1838 "colliding roots must not share one folder.jpg path"
1839 );
1840 }
1841
1842 #[test]
1843 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
1844 let mut store = LineageStore::new();
1851 store.update(
1852 std::slice::from_ref(&Clip {
1853 id: "realroot".into(),
1854 title: "Shared".into(),
1855 clip_type: "gen".into(),
1856 ..Default::default()
1857 }),
1858 &Resolution {
1859 roots: [(
1860 "realroot".to_owned(),
1861 RootInfo {
1862 root_id: "realroot".into(),
1863 root_title: "Shared".into(),
1864 status: ResolveStatus::Resolved,
1865 },
1866 )]
1867 .into_iter()
1868 .collect(),
1869 gap_filled: Vec::new(),
1870 },
1871 "now",
1872 );
1873 let new_clip = Clip {
1876 id: "newnewnew-9999".into(),
1877 title: "Solo Track".into(),
1878 display_name: "alice".into(),
1879 image_large_url: "https://art.example/large.jpg".into(),
1880 ..Default::default()
1881 };
1882 store.set_album_overrides(
1883 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
1884 .into_iter()
1885 .collect(),
1886 );
1887
1888 let new_ctx = store.context_for(&new_clip);
1890 assert_eq!(new_ctx.root_id, "newnewnew-9999");
1891 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
1892
1893 assert!(store.colliding_root_titles().is_empty());
1895
1896 let real_clip = Clip {
1898 id: "realroot".into(),
1899 title: "Shared".into(),
1900 display_name: "alice".into(),
1901 image_large_url: "https://art.example/large.jpg".into(),
1902 ..Default::default()
1903 };
1904 let real_ctx = store.context_for(&real_clip);
1905 let colliding = store.colliding_root_titles();
1906 let requests = [
1907 crate::naming::NamingRequest {
1908 clip: &real_clip,
1909 lineage: &real_ctx,
1910 },
1911 crate::naming::NamingRequest {
1912 clip: &new_clip,
1913 lineage: &new_ctx,
1914 },
1915 ];
1916 let names = crate::naming::render_clip_names(
1917 &requests,
1918 &crate::naming::NamingConfig::default(),
1919 &colliding,
1920 );
1921 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1922 crate::reconcile::Desired {
1923 clip: clip.clone(),
1924 lineage: ctx.clone(),
1925 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1926 format: crate::AudioFormat::Flac,
1927 meta_hash: String::new(),
1928 art_hash: String::new(),
1929 modes: vec![crate::reconcile::SourceMode::Mirror],
1930 trashed: false,
1931 private: false,
1932 artifacts: Vec::new(),
1933 stems: None,
1934 }
1935 };
1936 let desired = vec![
1937 desired_of(&real_clip, &real_ctx, &names[0]),
1938 desired_of(&new_clip, &new_ctx, &names[1]),
1939 ];
1940 let albums = crate::reconcile::album_desired(&desired, false);
1941 let jpg_paths: Vec<String> = albums
1942 .iter()
1943 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1944 .collect();
1945 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1946 assert_ne!(
1947 jpg_paths[0], jpg_paths[1],
1948 "an uncached override must not collapse two albums onto one path"
1949 );
1950 }
1951
1952 #[test]
1953 fn override_on_gap_filled_root_applies_to_children_and_collides() {
1954 let child = Clip {
1961 id: "childclip".into(),
1962 title: "Cover".into(),
1963 clip_type: "gen".into(),
1964 task: "cover".into(),
1965 cover_clip_id: "gaproot".into(),
1966 edited_clip_id: "gaproot".into(),
1967 ..Default::default()
1968 };
1969 let other_root = Clip {
1970 id: "otherroot".into(),
1971 title: "Preferred".into(),
1972 clip_type: "gen".into(),
1973 ..Default::default()
1974 };
1975 let gap_ancestor = Clip {
1976 id: "gaproot".into(),
1977 title: "Working Title".into(),
1978 clip_type: "gen".into(),
1979 ..Default::default()
1980 };
1981 let mut roots = HashMap::new();
1982 roots.insert(
1983 "childclip".to_owned(),
1984 RootInfo {
1985 root_id: "gaproot".into(),
1986 root_title: "Working Title".into(),
1987 status: ResolveStatus::Resolved,
1988 },
1989 );
1990 roots.insert(
1991 "otherroot".to_owned(),
1992 RootInfo {
1993 root_id: "otherroot".into(),
1994 root_title: "Preferred".into(),
1995 status: ResolveStatus::Resolved,
1996 },
1997 );
1998 let mut store = LineageStore::new();
1999 store.update(
2000 &[child.clone(), other_root],
2001 &Resolution {
2002 roots,
2003 gap_filled: vec![gap_ancestor],
2004 },
2005 "now",
2006 );
2007 assert!(store.node("gaproot").is_some());
2009 assert!(!store.resolution_cache.contains_key("gaproot"));
2010
2011 store.set_album_overrides(
2012 [("gaproot".to_owned(), "Preferred".to_owned())]
2013 .into_iter()
2014 .collect(),
2015 );
2016
2017 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2020 assert_eq!(store.album_for_id("childclip"), "Preferred");
2021
2022 assert!(store.colliding_root_titles().contains("Preferred"));
2025 }
2026
2027 #[test]
2028 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2029 let child = Clip {
2035 id: "childclip".into(),
2036 title: "Cover".into(),
2037 clip_type: "gen".into(),
2038 task: "cover".into(),
2039 cover_clip_id: "gaproot".into(),
2040 edited_clip_id: "gaproot".into(),
2041 ..Default::default()
2042 };
2043 let mut roots = HashMap::new();
2044 roots.insert(
2045 "childclip".to_owned(),
2046 RootInfo {
2047 root_id: "gaproot".into(),
2048 root_title: "Working Title".into(),
2049 status: ResolveStatus::Resolved,
2050 },
2051 );
2052 let mut store = LineageStore::new();
2053 store.update(
2054 std::slice::from_ref(&child),
2055 &Resolution {
2056 roots,
2057 gap_filled: vec![Clip {
2058 id: "gaproot".into(),
2059 title: "Working Title".into(),
2060 clip_type: "gen".into(),
2061 ..Default::default()
2062 }],
2063 },
2064 "now",
2065 );
2066
2067 let expected: std::collections::HashSet<String> = store
2068 .resolution_cache
2069 .values()
2070 .map(|entry| entry.root_id.clone())
2071 .filter(|root_id| !root_id.is_empty())
2072 .collect();
2073 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2074 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2076 assert!(!store.resolution_cache.contains_key("gaproot"));
2077 }
2078
2079 fn owner(id: &str, name: &str) -> Owner {
2080 Owner {
2081 user_id: id.to_owned(),
2082 display_name: name.to_owned(),
2083 }
2084 }
2085
2086 #[test]
2087 fn owner_check_covers_first_use_match_and_mismatch() {
2088 let mut store = LineageStore::new();
2089 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
2090
2091 store.pin_owner(owner("user_a", "Alice"));
2092 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
2093 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
2094 assert_eq!(store.owner().unwrap().display_name, "Alice");
2095 }
2096
2097 #[test]
2098 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2099 let mut store = LineageStore::new();
2100 assert!(!store.refresh_display_name("Alice"));
2102 assert!(store.owner().is_none());
2103
2104 store.pin_owner(owner("user_a", "Alice"));
2105 assert!(!store.refresh_display_name("Alice"));
2107 assert!(store.refresh_display_name("Alice Cooper"));
2109 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2110 assert_eq!(store.owner().unwrap().user_id, "user_a");
2112 }
2113
2114 #[test]
2115 fn owner_gate_covers_the_full_matrix() {
2116 let alice = owner("user_a", "Alice");
2117
2118 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2120 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2121
2122 assert_eq!(
2124 owner_gate(Some(&alice), None, "user_a", false),
2125 OwnerGate::Proceed
2126 );
2127
2128 assert_eq!(
2130 owner_gate(Some(&alice), None, "user_b", false),
2131 OwnerGate::AbortMismatch
2132 );
2133 assert_eq!(
2134 owner_gate(Some(&alice), None, "user_b", true),
2135 OwnerGate::Repin
2136 );
2137
2138 assert_eq!(
2141 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2142 OwnerGate::AbortConfigMismatch
2143 );
2144 assert_eq!(
2145 owner_gate(None, Some("user_c"), "user_a", true),
2146 OwnerGate::AbortConfigMismatch
2147 );
2148 assert_eq!(
2150 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2151 OwnerGate::Proceed
2152 );
2153
2154 assert!(OwnerGate::Repin.is_additive());
2156 for gate in [
2157 OwnerGate::AbortConfigMismatch,
2158 OwnerGate::AbortMismatch,
2159 OwnerGate::Proceed,
2160 OwnerGate::FirstUse,
2161 ] {
2162 assert!(!gate.is_additive());
2163 }
2164 }
2165
2166 #[test]
2167 fn adopt_decision_covers_every_branch() {
2168 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2169 let empty: BTreeSet<&str> = BTreeSet::new();
2170
2171 assert_eq!(
2173 adopt_decision(&["x", "y"], &empty, true, false),
2174 AdoptDecision::PinFresh
2175 );
2176 assert_eq!(
2178 adopt_decision(&["c1"], &owned, false, false),
2179 AdoptDecision::SkipPin
2180 );
2181 assert_eq!(
2182 adopt_decision(&["c1"], &owned, false, true),
2183 AdoptDecision::SkipPin
2184 );
2185 assert_eq!(
2187 adopt_decision(&["c1", "z"], &owned, true, false),
2188 AdoptDecision::PinAdopt
2189 );
2190 assert_eq!(
2192 adopt_decision(&["z1", "z2"], &owned, true, false),
2193 AdoptDecision::Abort
2194 );
2195 assert_eq!(
2196 adopt_decision(&["z1", "z2"], &owned, true, true),
2197 AdoptDecision::AdoptForced
2198 );
2199
2200 assert!(AdoptDecision::AdoptForced.is_additive());
2202 for decision in [
2203 AdoptDecision::PinFresh,
2204 AdoptDecision::PinAdopt,
2205 AdoptDecision::Abort,
2206 AdoptDecision::SkipPin,
2207 ] {
2208 assert!(!decision.is_additive());
2209 }
2210 }
2211
2212 #[test]
2213 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2214 let json = r#"{"nodes":{},"edges":[]}"#;
2216 let store: LineageStore = serde_json::from_str(json).unwrap();
2217 assert!(store.owner().is_none());
2218 let value = serde_json::to_value(&store).unwrap();
2220 assert!(value.get("owner").is_none());
2221
2222 let mut pinned = LineageStore::new();
2224 pinned.pin_owner(owner("user_a", "Alice"));
2225 let back: LineageStore =
2226 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2227 assert_eq!(back, pinned);
2228 assert_eq!(back.owner().unwrap().user_id, "user_a");
2229 }
2230}