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 {
610 let cached = self.get_root(&clip.id);
611 let root_id = cached
612 .map(|entry| entry.root_id.clone())
613 .filter(|id| !id.is_empty())
614 .unwrap_or_else(|| clip.id.clone());
615 let root_title = self
616 .node(&root_id)
617 .map(|node| node.title.clone())
618 .unwrap_or_else(|| clip.title.clone());
619 let root_title = self.effective_root_title(&root_id, root_title);
620 let (parent_id, edge_type) = match immediate_parent(clip) {
621 Some((id, edge)) => (id, Some(edge)),
622 None => (String::new(), None),
623 };
624 let status = cached
625 .map(|entry| status_from_slug(&entry.status))
626 .unwrap_or(ResolveStatus::Resolved);
627 LineageContext {
628 root_id,
629 root_title,
630 parent_id,
631 edge_type,
632 status,
633 }
634 }
635
636 pub fn album_for_id(&self, id: &str) -> String {
645 let own_title = self
646 .node(id)
647 .map(|node| node.title.clone())
648 .unwrap_or_default();
649 let root_id = self
650 .get_root(id)
651 .map(|entry| entry.root_id.clone())
652 .filter(|root| !root.is_empty())
653 .unwrap_or_else(|| id.to_owned());
654 let root_title = self
655 .node(&root_id)
656 .map(|node| node.title.clone())
657 .unwrap_or_else(|| own_title.clone());
658 let root_title = self.effective_root_title(&root_id, root_title);
659 let context = LineageContext {
660 root_id,
661 root_title,
662 parent_id: String::new(),
663 edge_type: None,
664 status: ResolveStatus::Resolved,
665 };
666 context.album(&own_title)
667 }
668
669 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
695 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
696 for root_id in &self.eligible_root_ids {
697 let node_title = self
698 .nodes
699 .get(root_id)
700 .map(|node| node.title.clone())
701 .unwrap_or_default();
702 let effective = self.effective_root_title(root_id, node_title);
703 let title = effective.trim();
704 if title.is_empty() {
705 continue;
706 }
707 roots_by_title
708 .entry(title.to_owned())
709 .or_default()
710 .insert(root_id.clone());
711 }
712 roots_by_title
713 .into_iter()
714 .filter(|(_, roots)| roots.len() > 1)
715 .map(|(title, _)| title)
716 .collect()
717 }
718
719 pub fn len(&self) -> usize {
721 self.nodes.len()
722 }
723
724 pub fn is_empty(&self) -> bool {
726 self.nodes.is_empty()
727 }
728
729 pub fn iter(&self) -> Iter<'_, String, Node> {
731 self.nodes.iter()
732 }
733
734 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
742 for clip in clips {
743 self.upsert_node(clip, now);
744 }
745 for clip in &resolution.gap_filled {
748 self.upsert_node(clip, now);
749 }
750
751 for clip in clips {
752 for edge in lineage_edges(clip) {
753 self.upsert_edge(&clip.id, &edge, now);
754 }
755 }
756 self.edges.sort_by(|a, b| {
757 a.child_id
758 .cmp(&b.child_id)
759 .then(a.ordinal.cmp(&b.ordinal))
760 .then(a.parent_id.cmp(&b.parent_id))
761 .then(a.edge_type.cmp(&b.edge_type))
762 .then(a.role.cmp(&b.role))
763 });
764
765 for (child_id, info) in &resolution.roots {
766 self.upsert_cache(child_id, info, now);
767 }
768 self.refresh_eligible_roots();
769 }
770
771 fn upsert_node(&mut self, clip: &Clip, now: &str) {
774 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
775 first_seen_at: now.to_owned(),
776 ..Node::default()
777 });
778 node.title = clip.title.clone();
779 node.created_at = clip.created_at.clone();
780 node.clip_type = clip.clip_type.clone();
781 node.task = clip.task.clone();
782 node.is_remix = clip.is_remix;
783 node.is_trashed = clip.is_trashed;
784 node.last_seen_at = now.to_owned();
785 }
786
787 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
790 let edge_type = edge_type_slug(edge.edge_type);
791 let role = edge_role_slug(edge.role);
792 if let Some(existing) = self.edges.iter_mut().find(|stored| {
793 stored.child_id == child_id
794 && stored.parent_id == edge.parent_id
795 && stored.edge_type == edge_type
796 && stored.role == role
797 && stored.ordinal == edge.ordinal
798 }) {
799 existing.source_field = edge.source_field.to_owned();
800 existing.status = "active".to_owned();
801 existing.last_seen_at = now.to_owned();
802 } else {
803 self.edges.push(StoredEdge {
804 child_id: child_id.to_owned(),
805 parent_id: edge.parent_id.clone(),
806 edge_type: edge_type.to_owned(),
807 role: role.to_owned(),
808 source_field: edge.source_field.to_owned(),
809 ordinal: edge.ordinal,
810 status: "active".to_owned(),
811 first_seen_at: now.to_owned(),
812 last_seen_at: now.to_owned(),
813 });
814 }
815 }
816
817 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
824 if info.status != ResolveStatus::Resolved
825 && self
826 .resolution_cache
827 .get(child_id)
828 .is_some_and(|entry| entry.status == "resolved")
829 {
830 return;
831 }
832 self.resolution_cache.insert(
833 child_id.to_owned(),
834 CacheEntry {
835 root_id: info.root_id.clone(),
836 status: resolve_status_slug(info.status).to_owned(),
837 algorithm_version: 1,
838 computed_at: now.to_owned(),
839 },
840 );
841 }
842}
843
844fn edge_type_slug(edge_type: EdgeType) -> &'static str {
846 match edge_type {
847 EdgeType::Cover => "cover",
848 EdgeType::Remaster => "remaster",
849 EdgeType::SpeedEdit => "speed_edit",
850 EdgeType::Edit => "edit",
851 EdgeType::Extend => "extend",
852 EdgeType::SectionReplace => "section_replace",
853 EdgeType::Stitch => "stitch",
854 EdgeType::Derived => "derived",
855 EdgeType::Uploaded => "uploaded",
856 }
857}
858
859fn edge_role_slug(role: EdgeRole) -> &'static str {
861 match role {
862 EdgeRole::Primary => "primary",
863 EdgeRole::Secondary => "secondary",
864 }
865}
866
867fn resolve_status_slug(status: ResolveStatus) -> &'static str {
869 match status {
870 ResolveStatus::Resolved => "resolved",
871 ResolveStatus::External => "external",
872 ResolveStatus::Unresolved => "unresolved",
873 ResolveStatus::Cycle => "cycle",
874 }
875}
876
877fn status_from_slug(slug: &str) -> ResolveStatus {
880 match slug {
881 "external" => ResolveStatus::External,
882 "unresolved" => ResolveStatus::Unresolved,
883 "cycle" => ResolveStatus::Cycle,
884 _ => ResolveStatus::Resolved,
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use std::collections::HashMap;
892
893 fn chain_clips() -> Vec<Clip> {
895 vec![
896 Clip {
897 id: "c".into(),
898 title: "Cover".into(),
899 clip_type: "gen".into(),
900 task: "cover".into(),
901 created_at: "t2".into(),
902 cover_clip_id: "b".into(),
903 edited_clip_id: "b".into(),
904 ..Default::default()
905 },
906 Clip {
907 id: "b".into(),
908 title: "Remaster".into(),
909 clip_type: "upsample".into(),
910 task: "upsample".into(),
911 created_at: "t1".into(),
912 upsample_clip_id: "a".into(),
913 edited_clip_id: "a".into(),
914 ..Default::default()
915 },
916 Clip {
917 id: "a".into(),
918 title: "Root".into(),
919 clip_type: "gen".into(),
920 created_at: "t0".into(),
921 ..Default::default()
922 },
923 ]
924 }
925
926 fn chain_resolution() -> Resolution {
928 let mut roots = HashMap::new();
929 for id in ["a", "b", "c"] {
930 roots.insert(
931 id.to_owned(),
932 RootInfo {
933 root_id: "a".into(),
934 root_title: "Root".into(),
935 status: ResolveStatus::Resolved,
936 },
937 );
938 }
939 Resolution {
940 roots,
941 gap_filled: Vec::new(),
942 }
943 }
944
945 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
946 store
947 .edges
948 .iter()
949 .find(|e| e.child_id == child && e.parent_id == parent)
950 .expect("edge should exist")
951 }
952
953 #[test]
954 fn new_store_is_empty_and_versioned() {
955 let store = LineageStore::new();
956 assert!(store.is_empty());
957 assert_eq!(store.len(), 0);
958 assert_eq!(store.schema_version, 1);
959 }
960
961 #[test]
962 fn update_populates_nodes_edges_and_cache() {
963 let mut store = LineageStore::new();
964 store.update(&chain_clips(), &chain_resolution(), "now");
965
966 assert_eq!(store.len(), 3);
968 let cover = store.node("c").unwrap();
969 assert_eq!(cover.title, "Cover");
970 assert_eq!(cover.clip_type, "gen");
971 assert_eq!(cover.task, "cover");
972 assert_eq!(cover.created_at, "t2");
973 assert_eq!(cover.status, "observed");
974 assert!(!cover.is_trashed);
975 assert_eq!(cover.first_seen_at, "now");
976 assert_eq!(cover.last_seen_at, "now");
977
978 assert_eq!(store.edges.len(), 2);
980 let cb = edge(&store, "c", "b");
981 assert_eq!(cb.edge_type, "cover");
982 assert_eq!(cb.role, "primary");
983 assert_eq!(cb.ordinal, 0);
984 assert_eq!(cb.source_field, "cover_clip_id");
985 assert_eq!(cb.status, "active");
986 let ba = edge(&store, "b", "a");
987 assert_eq!(ba.edge_type, "remaster");
988 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
989
990 for id in ["a", "b", "c"] {
992 let cached = store.get_root(id).unwrap();
993 assert_eq!(cached.root_id, "a");
994 assert_eq!(cached.status, "resolved");
995 assert_eq!(cached.algorithm_version, 1);
996 }
997 }
998
999 #[test]
1000 fn album_for_id_matches_context_for_and_handles_unknown() {
1001 let mut store = LineageStore::new();
1002 store.update(&chain_clips(), &chain_resolution(), "now");
1003
1004 assert_eq!(store.album_for_id("c"), "Root");
1007 let cover = &chain_clips()[0];
1008 assert_eq!(
1009 store.album_for_id("c"),
1010 store.context_for(cover).album(&cover.title)
1011 );
1012 assert_eq!(store.album_for_id("a"), "Root");
1014 assert_eq!(store.album_for_id("missing"), "");
1016 }
1017
1018 #[test]
1019 fn serde_roundtrip_preserves_a_relational_shape() {
1020 let mut store = LineageStore::new();
1021 store.update(&chain_clips(), &chain_resolution(), "now");
1022
1023 let json = serde_json::to_string(&store).unwrap();
1024 let back: LineageStore = serde_json::from_str(&json).unwrap();
1025 assert_eq!(store, back);
1026
1027 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1028 assert_eq!(value.get("schema_version").unwrap(), 1);
1029 assert!(value.get("nodes").unwrap().is_object());
1030 assert!(value.get("edges").unwrap().is_array());
1031 assert!(value.get("resolution_cache").unwrap().is_object());
1032
1033 let node = value.get("nodes").unwrap().get("c").unwrap();
1036 assert!(node.get("edges").is_none());
1037 assert!(node.get("parent_id").is_none());
1038 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1039 assert!(first_edge.get("child_id").is_some());
1040 assert!(first_edge.get("parent_id").is_some());
1041 }
1042
1043 #[test]
1044 fn album_overrides_are_runtime_only_and_never_persist() {
1045 let mut store = LineageStore::new();
1049 store.update(&chain_clips(), &chain_resolution(), "now");
1050 store.set_album_overrides(
1051 [("a".to_owned(), "Preferred".to_owned())]
1052 .into_iter()
1053 .collect(),
1054 );
1055
1056 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1057 assert!(value.get("album_overrides").is_none());
1058
1059 let json = serde_json::to_string(&store).unwrap();
1060 let back: LineageStore = serde_json::from_str(&json).unwrap();
1061 assert!(back.album_overrides.is_empty());
1062 assert_eq!(back.album_for_id("c"), "Root");
1063 }
1064
1065 #[test]
1066 fn update_is_idempotent_bar_last_seen() {
1067 let clips = chain_clips();
1068 let resolution = chain_resolution();
1069 let mut store = LineageStore::new();
1070 store.update(&clips, &resolution, "first");
1071 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1072 let edge_count = store.edges.len();
1073
1074 store.update(&clips, &resolution, "second");
1075
1076 assert_eq!(
1078 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1079 node_ids
1080 );
1081 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1082 assert_eq!(store.resolution_cache.len(), 3);
1083
1084 let cover = store.node("c").unwrap();
1086 assert_eq!(cover.first_seen_at, "first");
1087 assert_eq!(cover.last_seen_at, "second");
1088 let cb = edge(&store, "c", "b");
1089 assert_eq!(cb.first_seen_at, "first");
1090 assert_eq!(cb.last_seen_at, "second");
1091 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1093 }
1094
1095 #[test]
1096 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1097 let mut store = LineageStore::new();
1098 store.update(&chain_clips(), &chain_resolution(), "first");
1099 assert_eq!(store.get_root("c").unwrap().status, "resolved");
1100
1101 let child = Clip {
1104 id: "c".into(),
1105 title: "Cover".into(),
1106 clip_type: "gen".into(),
1107 task: "cover".into(),
1108 cover_clip_id: "b".into(),
1109 edited_clip_id: "b".into(),
1110 ..Default::default()
1111 };
1112 let mut roots = HashMap::new();
1113 roots.insert(
1114 "c".to_owned(),
1115 RootInfo {
1116 root_id: "elsewhere".into(),
1117 root_title: String::new(),
1118 status: ResolveStatus::External,
1119 },
1120 );
1121 roots.insert(
1122 "d".to_owned(),
1123 RootInfo {
1124 root_id: "boundary".into(),
1125 root_title: String::new(),
1126 status: ResolveStatus::External,
1127 },
1128 );
1129 let resolution = Resolution {
1130 roots,
1131 gap_filled: Vec::new(),
1132 };
1133 store.update(&[child], &resolution, "second");
1134
1135 let cached = store.get_root("c").unwrap();
1137 assert_eq!(cached.root_id, "a");
1138 assert_eq!(cached.status, "resolved");
1139 assert_eq!(cached.computed_at, "first");
1140 let d = store.get_root("d").unwrap();
1142 assert_eq!(d.root_id, "boundary");
1143 assert_eq!(d.status, "external");
1144 }
1145
1146 #[test]
1147 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1148 let child = Clip {
1152 id: "c".into(),
1153 title: "Cover".into(),
1154 clip_type: "gen".into(),
1155 task: "cover".into(),
1156 cover_clip_id: "t".into(),
1157 edited_clip_id: "t".into(),
1158 ..Default::default()
1159 };
1160 let trashed = Clip {
1161 id: "t".into(),
1162 title: "Trashed Original".into(),
1163 clip_type: "gen".into(),
1164 is_trashed: true,
1165 ..Default::default()
1166 };
1167 let mut roots = HashMap::new();
1168 roots.insert(
1169 "c".to_owned(),
1170 RootInfo {
1171 root_id: "t".into(),
1172 root_title: "Trashed Original".into(),
1173 status: ResolveStatus::Resolved,
1174 },
1175 );
1176 let resolution = Resolution {
1177 roots,
1178 gap_filled: vec![trashed],
1179 };
1180 store_update_and_assert_trashed(child, resolution);
1181 }
1182
1183 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1184 let mut store = LineageStore::new();
1185 store.update(&[child], &resolution, "now");
1186
1187 let node = store
1188 .node("t")
1189 .expect("trashed ancestor should be archived");
1190 assert!(node.is_trashed);
1191 assert_eq!(node.title, "Trashed Original");
1192 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1194 }
1195
1196 #[test]
1197 fn partial_json_loads_with_defaults() {
1198 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1201 let store: LineageStore = serde_json::from_str(json).unwrap();
1202 assert_eq!(store.schema_version, 1);
1203 let node = store.node("x").unwrap();
1204 assert_eq!(node.title, "Kept");
1205 assert_eq!(node.status, "observed");
1206 assert_eq!(store.edges[0].status, "active");
1207 assert!(store.resolution_cache.is_empty());
1208 assert!(store.albums.is_empty());
1211 assert!(store.album_art("x").is_none());
1212 assert!(store.playlists.is_empty());
1216 assert!(store.playlist("x").is_none());
1217 }
1218
1219 #[test]
1220 fn album_art_roundtrips_and_reads_by_kind() {
1221 let mut store = LineageStore::new();
1222 store.albums.insert(
1223 "root-1".to_owned(),
1224 AlbumArt {
1225 folder_jpg: Some(ArtifactState {
1226 path: "alice/Album/folder.jpg".to_owned(),
1227 hash: "jpg-h".to_owned(),
1228 }),
1229 folder_webp: Some(ArtifactState {
1230 path: "alice/Album/cover.webp".to_owned(),
1231 hash: "webp-h".to_owned(),
1232 }),
1233 },
1234 );
1235
1236 let json = serde_json::to_string(&store).unwrap();
1237 let back: LineageStore = serde_json::from_str(&json).unwrap();
1238 assert_eq!(store, back);
1239
1240 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1242 let album = value.get("albums").unwrap().get("root-1").unwrap();
1243 assert_eq!(
1244 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1245 "jpg-h"
1246 );
1247
1248 let art = back.album_art("root-1").unwrap();
1249 assert_eq!(
1250 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1251 "alice/Album/folder.jpg"
1252 );
1253 assert_eq!(
1254 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1255 "webp-h"
1256 );
1257 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1259 }
1260
1261 #[test]
1262 fn empty_album_art_omits_slots_when_serialised() {
1263 let empty = AlbumArt::default();
1266 assert!(empty.is_empty());
1267 let value = serde_json::to_value(&empty).unwrap();
1268 assert!(value.get("folder_jpg").is_none());
1269 assert!(value.get("folder_webp").is_none());
1270 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1271 assert_eq!(back, empty);
1272 }
1273
1274 #[test]
1275 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1276 let mut store = LineageStore::new();
1277 let jpg = ArtifactState {
1278 path: "a/folder.jpg".to_owned(),
1279 hash: "h1".to_owned(),
1280 };
1281 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1282 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1283
1284 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1286 assert!(store.album_art("root-1").is_none());
1287 assert!(store.albums.is_empty());
1288 }
1289
1290 #[test]
1291 fn playlist_state_roundtrips_by_id() {
1292 let mut store = LineageStore::new();
1293 store.playlists.insert(
1294 "pl1".to_owned(),
1295 PlaylistState {
1296 name: "Road Trip".to_owned(),
1297 path: "Road Trip.m3u8".to_owned(),
1298 hash: "abc123".to_owned(),
1299 },
1300 );
1301
1302 let json = serde_json::to_string(&store).unwrap();
1303 let back: LineageStore = serde_json::from_str(&json).unwrap();
1304 assert_eq!(store, back);
1305
1306 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1308 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1309 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1310 assert_eq!(pl.get("hash").unwrap(), "abc123");
1311
1312 let stored = back.playlist("pl1").unwrap();
1313 assert_eq!(stored.name, "Road Trip");
1314 assert_eq!(stored.hash, "abc123");
1315 }
1316
1317 #[test]
1318 fn set_playlist_upserts_then_clears() {
1319 let mut store = LineageStore::new();
1320 let state = PlaylistState {
1321 name: "Mix".to_owned(),
1322 path: "Mix.m3u8".to_owned(),
1323 hash: "h1".to_owned(),
1324 };
1325 store.set_playlist("pl1", Some(state.clone()));
1326 assert_eq!(store.playlist("pl1"), Some(&state));
1327
1328 let renamed = PlaylistState {
1330 name: "Mix v2".to_owned(),
1331 path: "Mix v2.m3u8".to_owned(),
1332 hash: "h2".to_owned(),
1333 };
1334 store.set_playlist("pl1", Some(renamed.clone()));
1335 assert_eq!(store.playlist("pl1"), Some(&renamed));
1336
1337 store.set_playlist("pl1", None);
1339 assert!(store.playlist("pl1").is_none());
1340 assert!(store.playlists.is_empty());
1341 }
1342
1343 #[test]
1344 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1345 let mut store = LineageStore::new();
1346 store.update(&chain_clips(), &chain_resolution(), "now");
1347
1348 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1350 assert_eq!(ctx.root_id, "a");
1351 assert_eq!(ctx.root_title, "Root");
1352 assert_eq!(ctx.parent_id, "b");
1353 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1354 assert_eq!(ctx.status, ResolveStatus::Resolved);
1355 assert_eq!(ctx.album("Cover"), "Root");
1357 }
1358
1359 #[test]
1360 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1361 let mut store = LineageStore::new();
1362 store.update(&chain_clips(), &chain_resolution(), "now");
1363
1364 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1366 assert_eq!(ctx.root_id, "a");
1367 assert_eq!(ctx.root_title, "Root");
1368 assert_eq!(ctx.parent_id, "");
1369 assert_eq!(ctx.edge_type, None);
1370 assert_eq!(ctx.album("Root"), "Root");
1371 }
1372
1373 #[test]
1374 fn context_for_an_unknown_clip_is_self_rooted() {
1375 let store = LineageStore::new();
1376 let orphan = Clip {
1377 id: "z".into(),
1378 title: "Lonely".into(),
1379 ..Default::default()
1380 };
1381 let ctx = store.context_for(&orphan);
1382 assert_eq!(ctx.root_id, "z");
1383 assert_eq!(ctx.root_title, "Lonely");
1384 assert_eq!(ctx.parent_id, "");
1385 assert_eq!(ctx.status, ResolveStatus::Resolved);
1386 }
1387
1388 #[test]
1389 fn context_for_retains_a_purged_ancestor_album() {
1390 let child = Clip {
1395 id: "c".into(),
1396 title: "Cover".into(),
1397 clip_type: "gen".into(),
1398 task: "cover".into(),
1399 cover_clip_id: "t".into(),
1400 edited_clip_id: "t".into(),
1401 ..Default::default()
1402 };
1403 let trashed = Clip {
1404 id: "t".into(),
1405 title: "Trashed Original".into(),
1406 clip_type: "gen".into(),
1407 is_trashed: true,
1408 ..Default::default()
1409 };
1410 let mut roots = HashMap::new();
1411 roots.insert(
1412 "c".to_owned(),
1413 RootInfo {
1414 root_id: "t".into(),
1415 root_title: "Trashed Original".into(),
1416 status: ResolveStatus::Resolved,
1417 },
1418 );
1419 let resolution = Resolution {
1420 roots,
1421 gap_filled: vec![trashed],
1422 };
1423 let mut store = LineageStore::new();
1424 store.update(std::slice::from_ref(&child), &resolution, "now");
1425
1426 let ctx = store.context_for(&child);
1427 assert_eq!(ctx.root_id, "t");
1428 assert_eq!(ctx.root_title, "Trashed Original");
1429 assert_eq!(ctx.album("Cover"), "Trashed Original");
1430 }
1431
1432 #[test]
1433 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1434 let clips = vec![
1437 Clip {
1438 id: "r1".into(),
1439 title: "Break Through".into(),
1440 clip_type: "gen".into(),
1441 ..Default::default()
1442 },
1443 Clip {
1444 id: "r2".into(),
1445 title: "Break Through".into(),
1446 clip_type: "gen".into(),
1447 ..Default::default()
1448 },
1449 Clip {
1450 id: "r3".into(),
1451 title: "Solo".into(),
1452 clip_type: "gen".into(),
1453 ..Default::default()
1454 },
1455 Clip {
1456 id: "c1".into(),
1457 title: "Break Through".into(),
1458 clip_type: "gen".into(),
1459 task: "cover".into(),
1460 cover_clip_id: "r1".into(),
1461 edited_clip_id: "r1".into(),
1462 ..Default::default()
1463 },
1464 ];
1465 let mut roots = HashMap::new();
1466 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1467 let title = if root == "r3" {
1468 "Solo"
1469 } else {
1470 "Break Through"
1471 };
1472 roots.insert(
1473 id.to_owned(),
1474 RootInfo {
1475 root_id: root.into(),
1476 root_title: title.into(),
1477 status: ResolveStatus::Resolved,
1478 },
1479 );
1480 }
1481 let resolution = Resolution {
1482 roots,
1483 gap_filled: Vec::new(),
1484 };
1485 let mut store = LineageStore::new();
1486 store.update(&clips, &resolution, "now");
1487
1488 let colliding = store.colliding_root_titles();
1489 assert!(colliding.contains("Break Through"));
1490 assert!(!colliding.contains("Solo"));
1491 assert_eq!(colliding.len(), 1);
1492 }
1493
1494 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1497 let clips = vec![
1498 Clip {
1499 id: "r1".into(),
1500 title: t1.into(),
1501 clip_type: "gen".into(),
1502 ..Default::default()
1503 },
1504 Clip {
1505 id: "r2".into(),
1506 title: t2.into(),
1507 clip_type: "gen".into(),
1508 ..Default::default()
1509 },
1510 ];
1511 let mut roots = HashMap::new();
1512 roots.insert(
1513 "r1".to_owned(),
1514 RootInfo {
1515 root_id: "r1".into(),
1516 root_title: t1.into(),
1517 status: ResolveStatus::Resolved,
1518 },
1519 );
1520 roots.insert(
1521 "r2".to_owned(),
1522 RootInfo {
1523 root_id: "r2".into(),
1524 root_title: t2.into(),
1525 status: ResolveStatus::Resolved,
1526 },
1527 );
1528 let mut store = LineageStore::new();
1529 store.update(
1530 &clips,
1531 &Resolution {
1532 roots,
1533 gap_filled: Vec::new(),
1534 },
1535 "now",
1536 );
1537 store
1538 }
1539
1540 #[test]
1541 fn album_override_flows_into_context_tag_hash_and_index() {
1542 let clips = chain_clips();
1546 let mut store = LineageStore::new();
1547 store.update(&clips, &chain_resolution(), "now");
1548
1549 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1551
1552 store.set_album_overrides(
1553 [("a".to_owned(), "Preferred Name".to_owned())]
1554 .into_iter()
1555 .collect(),
1556 );
1557
1558 for id in ["a", "b", "c"] {
1560 let clip = clips.iter().find(|c| c.id == id).unwrap();
1561 let ctx = store.context_for(clip);
1562 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1563 assert_eq!(store.album_for_id(id), "Preferred Name");
1564 }
1565
1566 let ctx = store.context_for(cover);
1568 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1569 assert_eq!(meta.album, "Preferred Name");
1570
1571 let after_hash = crate::hash::meta_hash(cover, &ctx);
1573 assert_ne!(before_hash, after_hash);
1574 }
1575
1576 #[test]
1577 fn empty_album_override_is_ignored() {
1578 let clips = chain_clips();
1580 let mut store = LineageStore::new();
1581 store.update(&clips, &chain_resolution(), "now");
1582 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1583 assert_eq!(store.album_for_id("c"), "Root");
1584 }
1585
1586 #[test]
1587 fn album_override_creates_a_collision_that_disambiguates() {
1588 let mut store = two_root_store("Alpha", "Beta");
1590 assert!(store.colliding_root_titles().is_empty());
1591
1592 store.set_album_overrides(
1593 [("r2".to_owned(), "Alpha".to_owned())]
1594 .into_iter()
1595 .collect(),
1596 );
1597 let colliding = store.colliding_root_titles();
1598 assert!(colliding.contains("Alpha"));
1599 assert_eq!(colliding.len(), 1);
1600 }
1601
1602 #[test]
1603 fn album_override_resolves_a_natural_collision() {
1604 let mut store = two_root_store("Break Through", "Break Through");
1606 assert!(store.colliding_root_titles().contains("Break Through"));
1607
1608 store.set_album_overrides(
1609 [("r2".to_owned(), "Second Wind".to_owned())]
1610 .into_iter()
1611 .collect(),
1612 );
1613 assert!(store.colliding_root_titles().is_empty());
1614 }
1615
1616 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1621 store.resolution_cache.insert(
1622 root_id.to_owned(),
1623 CacheEntry {
1624 root_id: root_id.to_owned(),
1625 status: "external".to_owned(),
1626 algorithm_version: 1,
1627 computed_at: "now".to_owned(),
1628 },
1629 );
1630 store.refresh_eligible_roots();
1633 }
1634
1635 #[test]
1636 fn override_on_node_less_root_collides_with_a_real_root() {
1637 let mut store = LineageStore::new();
1641 store.update(
1642 std::slice::from_ref(&Clip {
1643 id: "realroot".into(),
1644 title: "Shared".into(),
1645 clip_type: "gen".into(),
1646 ..Default::default()
1647 }),
1648 &Resolution {
1649 roots: [(
1650 "realroot".to_owned(),
1651 RootInfo {
1652 root_id: "realroot".into(),
1653 root_title: "Shared".into(),
1654 status: ResolveStatus::Resolved,
1655 },
1656 )]
1657 .into_iter()
1658 .collect(),
1659 gap_filled: Vec::new(),
1660 },
1661 "now",
1662 );
1663 insert_cache_only_root(&mut store, "extroot");
1664 store.set_album_overrides(
1665 [("extroot".to_owned(), "Shared".to_owned())]
1666 .into_iter()
1667 .collect(),
1668 );
1669
1670 let colliding = store.colliding_root_titles();
1671 assert!(
1672 colliding.contains("Shared"),
1673 "a node-less overridden root must still be seen by collision detection"
1674 );
1675 }
1676
1677 #[test]
1678 fn two_node_less_roots_overridden_to_same_name_collide() {
1679 let mut store = LineageStore::new();
1680 insert_cache_only_root(&mut store, "extone");
1681 insert_cache_only_root(&mut store, "exttwo");
1682 store.set_album_overrides(
1683 [
1684 ("extone".to_owned(), "Shared".to_owned()),
1685 ("exttwo".to_owned(), "Shared".to_owned()),
1686 ]
1687 .into_iter()
1688 .collect(),
1689 );
1690 assert!(store.colliding_root_titles().contains("Shared"));
1691 }
1692
1693 #[test]
1694 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1695 let mut store = LineageStore::new();
1700 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1701 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1702 store.set_album_overrides(
1703 [
1704 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1705 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1706 ]
1707 .into_iter()
1708 .collect(),
1709 );
1710 let colliding = store.colliding_root_titles();
1711
1712 let clip_of = |id: &str| Clip {
1713 id: id.to_owned(),
1714 title: "Track".to_owned(),
1715 display_name: "alice".to_owned(),
1716 image_large_url: "https://art.example/large.jpg".to_owned(),
1717 ..Default::default()
1718 };
1719 let ctx_of = |root_id: &str| LineageContext {
1720 root_id: root_id.to_owned(),
1721 root_title: "Shared".to_owned(),
1722 parent_id: String::new(),
1723 edge_type: None,
1724 status: ResolveStatus::Resolved,
1725 };
1726 let clip_a = clip_of("clipaaaa-1111");
1727 let clip_b = clip_of("clipbbbb-2222");
1728 let ctx_a = ctx_of("aaaaaaaa-root-one");
1729 let ctx_b = ctx_of("bbbbbbbb-root-two");
1730 let requests = [
1731 crate::naming::NamingRequest {
1732 clip: &clip_a,
1733 lineage: &ctx_a,
1734 },
1735 crate::naming::NamingRequest {
1736 clip: &clip_b,
1737 lineage: &ctx_b,
1738 },
1739 ];
1740 let names = crate::naming::render_clip_names(
1741 &requests,
1742 &crate::naming::NamingConfig::default(),
1743 &colliding,
1744 );
1745
1746 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1747 crate::reconcile::Desired {
1748 clip: clip.clone(),
1749 lineage: ctx.clone(),
1750 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1751 format: crate::AudioFormat::Flac,
1752 meta_hash: String::new(),
1753 art_hash: String::new(),
1754 modes: vec![crate::reconcile::SourceMode::Mirror],
1755 trashed: false,
1756 private: false,
1757 artifacts: Vec::new(),
1758 }
1759 };
1760 let desired = vec![
1761 desired_of(&clip_a, &ctx_a, &names[0]),
1762 desired_of(&clip_b, &ctx_b, &names[1]),
1763 ];
1764
1765 let albums = crate::reconcile::album_desired(&desired, false);
1766 assert_eq!(albums.len(), 2, "each distinct root is its own album");
1767 let jpg_paths: Vec<String> = albums
1768 .iter()
1769 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1770 .collect();
1771 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1772 assert_ne!(
1773 jpg_paths[0], jpg_paths[1],
1774 "colliding roots must not share one folder.jpg path"
1775 );
1776 }
1777
1778 #[test]
1779 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
1780 let mut store = LineageStore::new();
1787 store.update(
1788 std::slice::from_ref(&Clip {
1789 id: "realroot".into(),
1790 title: "Shared".into(),
1791 clip_type: "gen".into(),
1792 ..Default::default()
1793 }),
1794 &Resolution {
1795 roots: [(
1796 "realroot".to_owned(),
1797 RootInfo {
1798 root_id: "realroot".into(),
1799 root_title: "Shared".into(),
1800 status: ResolveStatus::Resolved,
1801 },
1802 )]
1803 .into_iter()
1804 .collect(),
1805 gap_filled: Vec::new(),
1806 },
1807 "now",
1808 );
1809 let new_clip = Clip {
1812 id: "newnewnew-9999".into(),
1813 title: "Solo Track".into(),
1814 display_name: "alice".into(),
1815 image_large_url: "https://art.example/large.jpg".into(),
1816 ..Default::default()
1817 };
1818 store.set_album_overrides(
1819 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
1820 .into_iter()
1821 .collect(),
1822 );
1823
1824 let new_ctx = store.context_for(&new_clip);
1826 assert_eq!(new_ctx.root_id, "newnewnew-9999");
1827 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
1828
1829 assert!(store.colliding_root_titles().is_empty());
1831
1832 let real_clip = Clip {
1834 id: "realroot".into(),
1835 title: "Shared".into(),
1836 display_name: "alice".into(),
1837 image_large_url: "https://art.example/large.jpg".into(),
1838 ..Default::default()
1839 };
1840 let real_ctx = store.context_for(&real_clip);
1841 let colliding = store.colliding_root_titles();
1842 let requests = [
1843 crate::naming::NamingRequest {
1844 clip: &real_clip,
1845 lineage: &real_ctx,
1846 },
1847 crate::naming::NamingRequest {
1848 clip: &new_clip,
1849 lineage: &new_ctx,
1850 },
1851 ];
1852 let names = crate::naming::render_clip_names(
1853 &requests,
1854 &crate::naming::NamingConfig::default(),
1855 &colliding,
1856 );
1857 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1858 crate::reconcile::Desired {
1859 clip: clip.clone(),
1860 lineage: ctx.clone(),
1861 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1862 format: crate::AudioFormat::Flac,
1863 meta_hash: String::new(),
1864 art_hash: String::new(),
1865 modes: vec![crate::reconcile::SourceMode::Mirror],
1866 trashed: false,
1867 private: false,
1868 artifacts: Vec::new(),
1869 }
1870 };
1871 let desired = vec![
1872 desired_of(&real_clip, &real_ctx, &names[0]),
1873 desired_of(&new_clip, &new_ctx, &names[1]),
1874 ];
1875 let albums = crate::reconcile::album_desired(&desired, false);
1876 let jpg_paths: Vec<String> = albums
1877 .iter()
1878 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1879 .collect();
1880 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1881 assert_ne!(
1882 jpg_paths[0], jpg_paths[1],
1883 "an uncached override must not collapse two albums onto one path"
1884 );
1885 }
1886
1887 #[test]
1888 fn override_on_gap_filled_root_applies_to_children_and_collides() {
1889 let child = Clip {
1896 id: "childclip".into(),
1897 title: "Cover".into(),
1898 clip_type: "gen".into(),
1899 task: "cover".into(),
1900 cover_clip_id: "gaproot".into(),
1901 edited_clip_id: "gaproot".into(),
1902 ..Default::default()
1903 };
1904 let other_root = Clip {
1905 id: "otherroot".into(),
1906 title: "Preferred".into(),
1907 clip_type: "gen".into(),
1908 ..Default::default()
1909 };
1910 let gap_ancestor = Clip {
1911 id: "gaproot".into(),
1912 title: "Working Title".into(),
1913 clip_type: "gen".into(),
1914 ..Default::default()
1915 };
1916 let mut roots = HashMap::new();
1917 roots.insert(
1918 "childclip".to_owned(),
1919 RootInfo {
1920 root_id: "gaproot".into(),
1921 root_title: "Working Title".into(),
1922 status: ResolveStatus::Resolved,
1923 },
1924 );
1925 roots.insert(
1926 "otherroot".to_owned(),
1927 RootInfo {
1928 root_id: "otherroot".into(),
1929 root_title: "Preferred".into(),
1930 status: ResolveStatus::Resolved,
1931 },
1932 );
1933 let mut store = LineageStore::new();
1934 store.update(
1935 &[child.clone(), other_root],
1936 &Resolution {
1937 roots,
1938 gap_filled: vec![gap_ancestor],
1939 },
1940 "now",
1941 );
1942 assert!(store.node("gaproot").is_some());
1944 assert!(!store.resolution_cache.contains_key("gaproot"));
1945
1946 store.set_album_overrides(
1947 [("gaproot".to_owned(), "Preferred".to_owned())]
1948 .into_iter()
1949 .collect(),
1950 );
1951
1952 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
1955 assert_eq!(store.album_for_id("childclip"), "Preferred");
1956
1957 assert!(store.colliding_root_titles().contains("Preferred"));
1960 }
1961
1962 #[test]
1963 fn eligible_root_set_is_exactly_the_cache_value_domain() {
1964 let child = Clip {
1970 id: "childclip".into(),
1971 title: "Cover".into(),
1972 clip_type: "gen".into(),
1973 task: "cover".into(),
1974 cover_clip_id: "gaproot".into(),
1975 edited_clip_id: "gaproot".into(),
1976 ..Default::default()
1977 };
1978 let mut roots = HashMap::new();
1979 roots.insert(
1980 "childclip".to_owned(),
1981 RootInfo {
1982 root_id: "gaproot".into(),
1983 root_title: "Working Title".into(),
1984 status: ResolveStatus::Resolved,
1985 },
1986 );
1987 let mut store = LineageStore::new();
1988 store.update(
1989 std::slice::from_ref(&child),
1990 &Resolution {
1991 roots,
1992 gap_filled: vec![Clip {
1993 id: "gaproot".into(),
1994 title: "Working Title".into(),
1995 clip_type: "gen".into(),
1996 ..Default::default()
1997 }],
1998 },
1999 "now",
2000 );
2001
2002 let expected: std::collections::HashSet<String> = store
2003 .resolution_cache
2004 .values()
2005 .map(|entry| entry.root_id.clone())
2006 .filter(|root_id| !root_id.is_empty())
2007 .collect();
2008 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2009 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2011 assert!(!store.resolution_cache.contains_key("gaproot"));
2012 }
2013
2014 fn owner(id: &str, name: &str) -> Owner {
2015 Owner {
2016 user_id: id.to_owned(),
2017 display_name: name.to_owned(),
2018 }
2019 }
2020
2021 #[test]
2022 fn owner_check_covers_first_use_match_and_mismatch() {
2023 let mut store = LineageStore::new();
2024 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
2025
2026 store.pin_owner(owner("user_a", "Alice"));
2027 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
2028 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
2029 assert_eq!(store.owner().unwrap().display_name, "Alice");
2030 }
2031
2032 #[test]
2033 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2034 let mut store = LineageStore::new();
2035 assert!(!store.refresh_display_name("Alice"));
2037 assert!(store.owner().is_none());
2038
2039 store.pin_owner(owner("user_a", "Alice"));
2040 assert!(!store.refresh_display_name("Alice"));
2042 assert!(store.refresh_display_name("Alice Cooper"));
2044 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2045 assert_eq!(store.owner().unwrap().user_id, "user_a");
2047 }
2048
2049 #[test]
2050 fn owner_gate_covers_the_full_matrix() {
2051 let alice = owner("user_a", "Alice");
2052
2053 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2055 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2056
2057 assert_eq!(
2059 owner_gate(Some(&alice), None, "user_a", false),
2060 OwnerGate::Proceed
2061 );
2062
2063 assert_eq!(
2065 owner_gate(Some(&alice), None, "user_b", false),
2066 OwnerGate::AbortMismatch
2067 );
2068 assert_eq!(
2069 owner_gate(Some(&alice), None, "user_b", true),
2070 OwnerGate::Repin
2071 );
2072
2073 assert_eq!(
2076 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2077 OwnerGate::AbortConfigMismatch
2078 );
2079 assert_eq!(
2080 owner_gate(None, Some("user_c"), "user_a", true),
2081 OwnerGate::AbortConfigMismatch
2082 );
2083 assert_eq!(
2085 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2086 OwnerGate::Proceed
2087 );
2088
2089 assert!(OwnerGate::Repin.is_additive());
2091 for gate in [
2092 OwnerGate::AbortConfigMismatch,
2093 OwnerGate::AbortMismatch,
2094 OwnerGate::Proceed,
2095 OwnerGate::FirstUse,
2096 ] {
2097 assert!(!gate.is_additive());
2098 }
2099 }
2100
2101 #[test]
2102 fn adopt_decision_covers_every_branch() {
2103 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2104 let empty: BTreeSet<&str> = BTreeSet::new();
2105
2106 assert_eq!(
2108 adopt_decision(&["x", "y"], &empty, true, false),
2109 AdoptDecision::PinFresh
2110 );
2111 assert_eq!(
2113 adopt_decision(&["c1"], &owned, false, false),
2114 AdoptDecision::SkipPin
2115 );
2116 assert_eq!(
2117 adopt_decision(&["c1"], &owned, false, true),
2118 AdoptDecision::SkipPin
2119 );
2120 assert_eq!(
2122 adopt_decision(&["c1", "z"], &owned, true, false),
2123 AdoptDecision::PinAdopt
2124 );
2125 assert_eq!(
2127 adopt_decision(&["z1", "z2"], &owned, true, false),
2128 AdoptDecision::Abort
2129 );
2130 assert_eq!(
2131 adopt_decision(&["z1", "z2"], &owned, true, true),
2132 AdoptDecision::AdoptForced
2133 );
2134
2135 assert!(AdoptDecision::AdoptForced.is_additive());
2137 for decision in [
2138 AdoptDecision::PinFresh,
2139 AdoptDecision::PinAdopt,
2140 AdoptDecision::Abort,
2141 AdoptDecision::SkipPin,
2142 ] {
2143 assert!(!decision.is_additive());
2144 }
2145 }
2146
2147 #[test]
2148 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2149 let json = r#"{"nodes":{},"edges":[]}"#;
2151 let store: LineageStore = serde_json::from_str(json).unwrap();
2152 assert!(store.owner().is_none());
2153 let value = serde_json::to_value(&store).unwrap();
2155 assert!(value.get("owner").is_none());
2156
2157 let mut pinned = LineageStore::new();
2159 pinned.pin_owner(owner("user_a", "Alice"));
2160 let back: LineageStore =
2161 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2162 assert_eq!(back, pinned);
2163 assert_eq!(back.owner().unwrap().user_id, "user_a");
2164 }
2165}