1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25 AttributionEdge, Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26 attribution_edges, 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(crate) nodes: BTreeMap<String, Node>,
43 pub(crate) edges: Vec<StoredEdge>,
45 pub(crate) 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 #[serde(skip)]
85 edge_index: HashMap<EdgeKey, usize>,
86}
87
88impl Default for LineageStore {
89 fn default() -> Self {
90 Self {
91 schema_version: 1,
92 nodes: BTreeMap::new(),
93 edges: Vec::new(),
94 resolution_cache: BTreeMap::new(),
95 albums: BTreeMap::new(),
96 playlists: BTreeMap::new(),
97 owner: None,
98 album_overrides: BTreeMap::new(),
99 eligible_root_ids: HashSet::new(),
100 edge_index: HashMap::new(),
101 }
102 }
103}
104
105impl PartialEq for LineageStore {
114 fn eq(&self, other: &Self) -> bool {
115 self.schema_version == other.schema_version
116 && self.nodes == other.nodes
117 && self.edges == other.edges
118 && self.resolution_cache == other.resolution_cache
119 && self.albums == other.albums
120 && self.playlists == other.playlists
121 && self.owner == other.owner
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Owner {
132 pub user_id: String,
133 pub display_name: String,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OwnerGate {
145 AbortConfigMismatch,
148 AbortMismatch,
150 Repin,
153 Proceed,
156 FirstUse,
158}
159
160impl OwnerGate {
161 pub fn is_additive(self) -> bool {
163 matches!(self, OwnerGate::Repin)
164 }
165}
166
167pub fn owner_gate(
174 store_owner: Option<&Owner>,
175 configured_id: Option<&str>,
176 authed_user_id: &str,
177 allow_change: bool,
178) -> OwnerGate {
179 if let Some(configured) = configured_id
180 && configured != authed_user_id
181 {
182 return OwnerGate::AbortConfigMismatch;
183 }
184 match store_owner {
185 None => OwnerGate::FirstUse,
186 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
187 Some(_) if allow_change => OwnerGate::Repin,
188 Some(_) => OwnerGate::AbortMismatch,
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum AdoptDecision {
199 PinFresh,
202 PinAdopt,
205 AdoptForced,
208 Abort,
211 SkipPin,
213}
214
215impl AdoptDecision {
216 pub fn is_additive(self) -> bool {
218 matches!(self, AdoptDecision::AdoptForced)
219 }
220}
221
222pub fn adopt_decision(
232 listed: &[&str],
233 owned: &BTreeSet<&str>,
234 enumerated: bool,
235 allow_change: bool,
236) -> AdoptDecision {
237 if owned.is_empty() {
238 return AdoptDecision::PinFresh;
239 }
240 if !enumerated {
241 return AdoptDecision::SkipPin;
242 }
243 if listed.iter().any(|id| owned.contains(id)) {
244 AdoptDecision::PinAdopt
245 } else if allow_change {
246 AdoptDecision::AdoptForced
247 } else {
248 AdoptDecision::Abort
249 }
250}
251
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(default)]
262pub struct AlbumArt {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub folder_jpg: Option<ArtifactState>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub folder_webp: Option<ArtifactState>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub folder_mp4: Option<ArtifactState>,
273}
274
275impl AlbumArt {
276 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
279 match kind {
280 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
281 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
282 ArtifactKind::FolderMp4 => self.folder_mp4.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::FolderMp4 => self.folder_mp4 = state,
303 ArtifactKind::CoverJpg
304 | ArtifactKind::CoverWebp
305 | ArtifactKind::DetailsTxt
306 | ArtifactKind::LyricsTxt
307 | ArtifactKind::Lrc
308 | ArtifactKind::VideoMp4
309 | ArtifactKind::Playlist => {}
310 }
311 }
312
313 pub fn is_empty(&self) -> bool {
316 self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
317 }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(default)]
330pub struct PlaylistState {
331 pub name: String,
333 pub path: String,
335 pub hash: String,
337}
338
339#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum NodeStatus {
343 #[default]
344 #[serde(other)]
345 Observed,
346}
347
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum EdgeStatus {
352 #[default]
353 #[serde(other)]
354 Active,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(default)]
361pub struct Node {
362 pub title: String,
363 pub created_at: String,
364 pub clip_type: String,
365 pub task: String,
366 pub is_remix: bool,
367 pub is_trashed: bool,
368 pub status: NodeStatus,
369 pub first_seen_at: String,
370 pub last_seen_at: String,
371}
372
373impl Default for Node {
374 fn default() -> Self {
375 Self {
376 title: String::new(),
377 created_at: String::new(),
378 clip_type: String::new(),
379 task: String::new(),
380 is_remix: false,
381 is_trashed: false,
382 status: NodeStatus::Observed,
383 first_seen_at: String::new(),
384 last_seen_at: String::new(),
385 }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393#[serde(default)]
394pub struct StoredEdge {
395 pub child_id: String,
396 pub parent_id: String,
397 pub edge_type: String,
399 pub role: EdgeRole,
400 pub source_field: String,
402 pub ordinal: u32,
404 pub status: EdgeStatus,
405 pub first_seen_at: String,
406 pub last_seen_at: String,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Hash)]
410struct EdgeKey {
411 child_id: String,
412 parent_id: String,
413 edge_type: String,
414 role: EdgeRole,
415 ordinal: u32,
416}
417
418impl EdgeKey {
419 fn new(child_id: &str, parent_id: &str, edge_type: &str, role: EdgeRole, ordinal: u32) -> Self {
420 Self {
421 child_id: child_id.to_owned(),
422 parent_id: parent_id.to_owned(),
423 edge_type: edge_type.to_owned(),
424 role,
425 ordinal,
426 }
427 }
428
429 fn from_stored(edge: &StoredEdge) -> Self {
430 Self::new(
431 &edge.child_id,
432 &edge.parent_id,
433 &edge.edge_type,
434 edge.role,
435 edge.ordinal,
436 )
437 }
438}
439
440impl Default for StoredEdge {
441 fn default() -> Self {
442 Self {
443 child_id: String::new(),
444 parent_id: String::new(),
445 edge_type: String::new(),
446 role: EdgeRole::Primary,
447 source_field: String::new(),
448 ordinal: 0,
449 status: EdgeStatus::Active,
450 first_seen_at: String::new(),
451 last_seen_at: String::new(),
452 }
453 }
454}
455
456#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
458#[serde(default)]
459pub struct CacheEntry {
460 pub root_id: String,
461 pub status: ResolveStatus,
462 pub algorithm_version: u32,
463 pub computed_at: String,
464}
465
466impl LineageStore {
467 pub fn new() -> Self {
469 Self::default()
470 }
471
472 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
485 self.album_overrides = overrides;
486 }
487
488 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
512 if !self.eligible_root_ids.contains(root_id) {
513 return root_title;
514 }
515 match self.album_overrides.get(root_id) {
516 Some(name) if !name.trim().is_empty() => name.clone(),
517 _ => root_title,
518 }
519 }
520
521 pub fn refresh_eligible_roots(&mut self) {
531 self.eligible_root_ids = self
532 .resolution_cache
533 .values()
534 .map(|entry| entry.root_id.as_str())
535 .filter(|root_id| !root_id.is_empty())
536 .map(str::to_owned)
537 .collect();
538 }
539
540 #[cfg(test)]
543 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
544 &self.eligible_root_ids
545 }
546
547 pub fn node(&self, id: &str) -> Option<&Node> {
549 self.nodes.get(id)
550 }
551
552 pub fn owner(&self) -> Option<&Owner> {
554 self.owner.as_ref()
555 }
556
557 pub fn pin_owner(&mut self, owner: Owner) {
559 self.owner = Some(owner);
560 }
561
562 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
565 match &mut self.owner {
566 Some(owner) if owner.display_name != display_name => {
567 owner.display_name = display_name.to_owned();
568 true
569 }
570 _ => false,
571 }
572 }
573
574 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
576 self.resolution_cache.get(id)
577 }
578
579 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
581 self.albums.get(root_id)
582 }
583
584 pub fn set_album_artifact(
592 &mut self,
593 root_id: &str,
594 kind: ArtifactKind,
595 state: Option<ArtifactState>,
596 ) {
597 match state {
598 Some(state) => self
599 .albums
600 .entry(root_id.to_owned())
601 .or_default()
602 .set(kind, Some(state)),
603 None => {
604 if let Some(art) = self.albums.get_mut(root_id) {
605 art.set(kind, None);
606 if art.is_empty() {
607 self.albums.remove(root_id);
608 }
609 }
610 }
611 }
612 }
613
614 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
616 self.playlists.get(id)
617 }
618
619 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
627 match state {
628 Some(state) => {
629 self.playlists.insert(id.to_owned(), state);
630 }
631 None => {
632 self.playlists.remove(id);
633 }
634 }
635 }
636
637 pub fn context_for(&self, clip: &Clip) -> LineageContext {
648 let cached = self.get_root(&clip.id);
649 let root_id = cached
650 .map(|entry| entry.root_id.clone())
651 .filter(|id| !id.is_empty())
652 .unwrap_or_else(|| clip.id.clone());
653 let root_title = self
654 .node(&root_id)
655 .map(|node| node.title.clone())
656 .unwrap_or_else(|| clip.title.clone());
657 let root_title = self.effective_root_title(&root_id, root_title);
658 let root_date = self
659 .node(&root_id)
660 .map(|node| node.created_at.clone())
661 .unwrap_or_else(|| clip.created_at.clone());
662 let (parent_id, edge_type) = match immediate_parent(clip) {
663 Some((id, edge)) => (id, Some(edge)),
664 None => (String::new(), None),
665 };
666 let status = cached
667 .map(|entry| entry.status)
668 .unwrap_or(ResolveStatus::Resolved);
669 LineageContext {
670 root_id,
671 root_title,
672 root_date,
673 parent_id,
674 edge_type,
675 status,
676 }
677 }
678
679 pub fn album_for_id(&self, id: &str) -> String {
688 let own = self.node(id);
689 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
690 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
691 let root_id = self
692 .get_root(id)
693 .map(|entry| entry.root_id.clone())
694 .filter(|root| !root.is_empty())
695 .unwrap_or_else(|| id.to_owned());
696 let root_title = self
697 .node(&root_id)
698 .map(|node| node.title.clone())
699 .unwrap_or_else(|| own_title.clone());
700 let root_title = self.effective_root_title(&root_id, root_title);
701 let root_date = self
702 .node(&root_id)
703 .map(|node| node.created_at.clone())
704 .unwrap_or(own_created_at);
705 let context = LineageContext {
706 root_id,
707 root_title,
708 root_date,
709 parent_id: String::new(),
710 edge_type: None,
711 status: ResolveStatus::Resolved,
712 };
713 context.album(&own_title)
714 }
715
716 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
742 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
743 for root_id in &self.eligible_root_ids {
744 let node_title = self
745 .nodes
746 .get(root_id)
747 .map(|node| node.title.clone())
748 .unwrap_or_default();
749 let effective = self.effective_root_title(root_id, node_title);
750 let title = effective.trim();
751 if title.is_empty() {
752 continue;
753 }
754 roots_by_title
755 .entry(title.to_owned())
756 .or_default()
757 .insert(root_id.clone());
758 }
759 roots_by_title
760 .into_iter()
761 .filter(|(_, roots)| roots.len() > 1)
762 .map(|(title, _)| title)
763 .collect()
764 }
765
766 pub fn len(&self) -> usize {
768 self.nodes.len()
769 }
770
771 pub fn is_empty(&self) -> bool {
773 self.nodes.is_empty()
774 }
775
776 pub fn iter(&self) -> Iter<'_, String, Node> {
778 self.nodes.iter()
779 }
780
781 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
789 self.rebuild_edge_index();
790
791 for clip in clips {
792 self.upsert_node(clip, now);
793 }
794 for clip in &resolution.gap_filled {
797 self.upsert_node(clip, now);
798 }
799
800 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
808 for edge in lineage_edges(clip) {
809 self.upsert_edge(&clip.id, &edge, now);
810 }
811 }
812 for (child_id, parent_id) in &resolution.bridges {
813 let edge = Edge {
814 parent_id: parent_id.clone(),
815 edge_type: EdgeType::Derived,
816 role: EdgeRole::Primary,
817 ordinal: 0,
818 source_field: "parent_endpoint",
819 };
820 self.upsert_edge(child_id, &edge, now);
821 }
822 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
827 for edge in attribution_edges(clip) {
828 self.upsert_attribution_edge(&clip.id, &edge, now);
829 }
830 }
831 self.edges.sort_by(|a, b| {
832 a.child_id
833 .cmp(&b.child_id)
834 .then(a.ordinal.cmp(&b.ordinal))
835 .then(a.parent_id.cmp(&b.parent_id))
836 .then(a.edge_type.cmp(&b.edge_type))
837 .then(a.role.cmp(&b.role))
838 });
839 self.rebuild_edge_index();
840
841 for (child_id, info) in &resolution.roots {
842 self.upsert_cache(child_id, info, now);
843 }
844 self.refresh_eligible_roots();
845 }
846
847 pub fn archived_parents(&self) -> HashMap<String, String> {
856 self.edges
857 .iter()
858 .filter(|edge| {
859 edge.role == EdgeRole::Primary
860 && edge.ordinal == 0
861 && edge.status == EdgeStatus::Active
862 })
863 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
864 .collect()
865 }
866
867 fn upsert_node(&mut self, clip: &Clip, now: &str) {
870 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
871 first_seen_at: now.to_owned(),
872 ..Node::default()
873 });
874 node.title = clip.title.clone();
875 node.created_at = clip.created_at.clone();
876 node.clip_type = clip.clip_type.clone();
877 node.task = clip.task.clone();
878 node.is_remix = clip.is_remix;
879 node.is_trashed = clip.is_trashed;
880 node.last_seen_at = now.to_owned();
881 }
882
883 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
886 let edge_type = edge_type_slug(edge.edge_type);
887 let key = EdgeKey::new(
888 child_id,
889 &edge.parent_id,
890 edge_type,
891 edge.role,
892 edge.ordinal,
893 );
894 if let Some(&index) = self.edge_index.get(&key) {
895 let existing = &mut self.edges[index];
896 existing.source_field = edge.source_field.to_owned();
897 existing.status = EdgeStatus::Active;
898 existing.last_seen_at = now.to_owned();
899 } else {
900 self.edges.push(StoredEdge {
901 child_id: child_id.to_owned(),
902 parent_id: edge.parent_id.clone(),
903 edge_type: edge_type.to_owned(),
904 role: edge.role,
905 source_field: edge.source_field.to_owned(),
906 ordinal: edge.ordinal,
907 status: EdgeStatus::Active,
908 first_seen_at: now.to_owned(),
909 last_seen_at: now.to_owned(),
910 });
911 self.edge_index.insert(key, self.edges.len() - 1);
912 }
913 }
914
915 fn upsert_attribution_edge(&mut self, child_id: &str, edge: &AttributionEdge, now: &str) {
923 let edge_type = normalise_slug(&edge.edge_slug);
924 let key = EdgeKey::new(
925 child_id,
926 &edge.parent_id,
927 &edge_type,
928 edge.role,
929 edge.ordinal,
930 );
931 if let Some(&index) = self.edge_index.get(&key) {
932 let existing = &mut self.edges[index];
933 existing.source_field = edge.source_field.to_owned();
934 existing.status = EdgeStatus::Active;
935 existing.last_seen_at = now.to_owned();
936 } else {
937 self.edges.push(StoredEdge {
938 child_id: child_id.to_owned(),
939 parent_id: edge.parent_id.clone(),
940 edge_type,
941 role: edge.role,
942 source_field: edge.source_field.to_owned(),
943 ordinal: edge.ordinal,
944 status: EdgeStatus::Active,
945 first_seen_at: now.to_owned(),
946 last_seen_at: now.to_owned(),
947 });
948 self.edge_index.insert(key, self.edges.len() - 1);
949 }
950 }
951
952 fn rebuild_edge_index(&mut self) {
953 self.edge_index.clear();
954 for (index, edge) in self.edges.iter().enumerate() {
955 self.edge_index
956 .entry(EdgeKey::from_stored(edge))
957 .or_insert(index);
958 }
959 }
960
961 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
968 if info.status != ResolveStatus::Resolved
969 && self
970 .resolution_cache
971 .get(child_id)
972 .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
973 {
974 return;
975 }
976 self.resolution_cache.insert(
977 child_id.to_owned(),
978 CacheEntry {
979 root_id: info.root_id.clone(),
980 status: info.status,
981 algorithm_version: 1,
982 computed_at: now.to_owned(),
983 },
984 );
985 }
986}
987
988fn edge_type_slug(edge_type: EdgeType) -> &'static str {
990 match edge_type {
991 EdgeType::Cover => "cover",
992 EdgeType::Remaster => "remaster",
993 EdgeType::SpeedEdit => "speed_edit",
994 EdgeType::Edit => "edit",
995 EdgeType::Extend => "extend",
996 EdgeType::SectionReplace => "section_replace",
997 EdgeType::Stitch => "stitch",
998 EdgeType::Derived => "derived",
999 EdgeType::Uploaded => "uploaded",
1000 }
1001}
1002
1003fn normalise_slug(slug: &str) -> String {
1007 let normalised = slug
1008 .split_whitespace()
1009 .collect::<Vec<_>>()
1010 .join("_")
1011 .to_lowercase();
1012 if normalised.is_empty() {
1013 "attribution".to_owned()
1014 } else {
1015 normalised
1016 }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use std::collections::HashMap;
1023
1024 fn chain_clips() -> Vec<Clip> {
1026 vec![
1027 Clip {
1028 id: "c".into(),
1029 title: "Cover".into(),
1030 clip_type: "gen".into(),
1031 task: "cover".into(),
1032 created_at: "t2".into(),
1033 cover_clip_id: "b".into(),
1034 edited_clip_id: "b".into(),
1035 ..Default::default()
1036 },
1037 Clip {
1038 id: "b".into(),
1039 title: "Remaster".into(),
1040 clip_type: "upsample".into(),
1041 task: "upsample".into(),
1042 created_at: "t1".into(),
1043 upsample_clip_id: "a".into(),
1044 edited_clip_id: "a".into(),
1045 ..Default::default()
1046 },
1047 Clip {
1048 id: "a".into(),
1049 title: "Root".into(),
1050 clip_type: "gen".into(),
1051 created_at: "t0".into(),
1052 ..Default::default()
1053 },
1054 ]
1055 }
1056
1057 fn chain_resolution() -> Resolution {
1059 let mut roots = HashMap::new();
1060 for id in ["a", "b", "c"] {
1061 roots.insert(
1062 id.to_owned(),
1063 RootInfo {
1064 root_id: "a".into(),
1065 root_title: "Root".into(),
1066 status: ResolveStatus::Resolved,
1067 },
1068 );
1069 }
1070 Resolution {
1071 roots,
1072 gap_filled: Vec::new(),
1073 bridges: Vec::new(),
1074 }
1075 }
1076
1077 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
1078 store
1079 .edges
1080 .iter()
1081 .find(|e| e.child_id == child && e.parent_id == parent)
1082 .expect("edge should exist")
1083 }
1084
1085 #[test]
1086 fn new_store_is_empty_and_versioned() {
1087 let store = LineageStore::new();
1088 assert!(store.is_empty());
1089 assert_eq!(store.len(), 0);
1090 assert_eq!(store.schema_version, 1);
1091 }
1092
1093 #[test]
1094 fn update_populates_nodes_edges_and_cache() {
1095 let mut store = LineageStore::new();
1096 store.update(&chain_clips(), &chain_resolution(), "now");
1097
1098 assert_eq!(store.len(), 3);
1100 let cover = store.node("c").unwrap();
1101 assert_eq!(cover.title, "Cover");
1102 assert_eq!(cover.clip_type, "gen");
1103 assert_eq!(cover.task, "cover");
1104 assert_eq!(cover.created_at, "t2");
1105 assert_eq!(cover.status, NodeStatus::Observed);
1106 assert!(!cover.is_trashed);
1107 assert_eq!(cover.first_seen_at, "now");
1108 assert_eq!(cover.last_seen_at, "now");
1109
1110 assert_eq!(store.edges.len(), 2);
1112 let cb = edge(&store, "c", "b");
1113 assert_eq!(cb.edge_type, "cover");
1114 assert_eq!(cb.role, EdgeRole::Primary);
1115 assert_eq!(cb.ordinal, 0);
1116 assert_eq!(cb.source_field, "cover_clip_id");
1117 assert_eq!(cb.status, EdgeStatus::Active);
1118 let ba = edge(&store, "b", "a");
1119 assert_eq!(ba.edge_type, "remaster");
1120 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1121
1122 for id in ["a", "b", "c"] {
1124 let cached = store.get_root(id).unwrap();
1125 assert_eq!(cached.root_id, "a");
1126 assert_eq!(cached.status, ResolveStatus::Resolved);
1127 assert_eq!(cached.algorithm_version, 1);
1128 }
1129 }
1130
1131 #[test]
1132 fn update_persists_edges_for_gap_filled_ancestors() {
1133 let child = Clip {
1137 id: "child".into(),
1138 title: "Cover".into(),
1139 clip_type: "gen".into(),
1140 task: "cover".into(),
1141 cover_clip_id: "mid".into(),
1142 edited_clip_id: "mid".into(),
1143 ..Default::default()
1144 };
1145 let mid = Clip {
1146 id: "mid".into(),
1147 title: "Mid".into(),
1148 clip_type: "gen".into(),
1149 task: "cover".into(),
1150 cover_clip_id: "root".into(),
1151 edited_clip_id: "root".into(),
1152 ..Default::default()
1153 };
1154 let mut roots = HashMap::new();
1155 roots.insert(
1156 "child".to_owned(),
1157 RootInfo {
1158 root_id: "root".into(),
1159 root_title: "Original".into(),
1160 status: ResolveStatus::Resolved,
1161 },
1162 );
1163 let resolution = Resolution {
1164 roots,
1165 gap_filled: vec![mid],
1166 bridges: Vec::new(),
1167 };
1168 let mut store = LineageStore::new();
1169 store.update(std::slice::from_ref(&child), &resolution, "now");
1170
1171 let mid_edge = edge(&store, "mid", "root");
1173 assert_eq!(mid_edge.role, EdgeRole::Primary);
1174 assert_eq!(mid_edge.ordinal, 0);
1175 let archived = store.archived_parents();
1177 assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
1178 assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
1179 }
1180
1181 #[test]
1182 fn update_persists_bridges_as_edges() {
1183 let child = Clip {
1186 id: "child".into(),
1187 title: "Cover".into(),
1188 clip_type: "gen".into(),
1189 task: "cover".into(),
1190 cover_clip_id: "gone".into(),
1191 edited_clip_id: "gone".into(),
1192 ..Default::default()
1193 };
1194 let mut roots = HashMap::new();
1195 roots.insert(
1196 "child".to_owned(),
1197 RootInfo {
1198 root_id: "found".into(),
1199 root_title: String::new(),
1200 status: ResolveStatus::External,
1201 },
1202 );
1203 let resolution = Resolution {
1204 roots,
1205 gap_filled: Vec::new(),
1206 bridges: vec![("gone".to_owned(), "found".to_owned())],
1207 };
1208 let mut store = LineageStore::new();
1209 store.update(std::slice::from_ref(&child), &resolution, "now");
1210
1211 let bridged = edge(&store, "gone", "found");
1212 assert_eq!(bridged.source_field, "parent_endpoint");
1213 assert_eq!(bridged.role, EdgeRole::Primary);
1214 assert_eq!(bridged.ordinal, 0);
1215 assert_eq!(
1216 store.archived_parents().get("gone").map(String::as_str),
1217 Some("found")
1218 );
1219 }
1220
1221 #[test]
1222 fn archived_parents_maps_children_to_primary_parents_only() {
1223 let mut store = LineageStore::new();
1224 store.update(&chain_clips(), &chain_resolution(), "now");
1225 let archived = store.archived_parents();
1226 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
1227 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
1228 assert!(
1229 !archived.contains_key("a"),
1230 "a root has no primary parent edge"
1231 );
1232 }
1233
1234 #[test]
1235 fn update_persists_attribution_edges_without_polluting_resolution() {
1236 let child = Clip {
1241 id: "child".into(),
1242 title: "Remix".into(),
1243 clip_type: "gen".into(),
1244 task: "cover".into(),
1245 cover_clip_id: "struct-parent".into(),
1246 edited_clip_id: "struct-parent".into(),
1247 handle: "me".into(),
1248 clip_attribution_type: "remix".into(),
1249 clip_roots: vec![crate::model::ClipRoot {
1250 id: "attr-root".into(),
1251 handle: "me".into(),
1252 ..Default::default()
1253 }],
1254 ..Default::default()
1255 };
1256 let mut roots = HashMap::new();
1257 roots.insert(
1258 "child".to_owned(),
1259 RootInfo {
1260 root_id: "struct-parent".into(),
1261 root_title: "Structural Root".into(),
1262 status: ResolveStatus::Resolved,
1263 },
1264 );
1265 let resolution = Resolution {
1266 roots,
1267 gap_filled: Vec::new(),
1268 bridges: Vec::new(),
1269 };
1270 let mut store = LineageStore::new();
1271 store.update(std::slice::from_ref(&child), &resolution, "now");
1272
1273 let attr = edge(&store, "child", "attr-root");
1275 assert_eq!(attr.edge_type, "remix");
1276 assert_eq!(attr.role, EdgeRole::Secondary);
1277 assert_eq!(attr.ordinal, 0);
1278 assert_eq!(attr.source_field, "clip_roots");
1279
1280 let structural = edge(&store, "child", "struct-parent");
1282 assert_eq!(structural.role, EdgeRole::Primary);
1283
1284 let archived = store.archived_parents();
1286 assert_eq!(
1287 archived.get("child").map(String::as_str),
1288 Some("struct-parent"),
1289 "archived_parents reads only the structural primary, never clip_roots"
1290 );
1291 assert_eq!(
1292 store.get_root("child").unwrap().root_id,
1293 "struct-parent",
1294 "the resolution cache roots at the structural parent, not the attribution root"
1295 );
1296 }
1297
1298 #[test]
1299 fn update_defaults_a_blank_attribution_type_to_attribution() {
1300 let child = Clip {
1303 id: "child".into(),
1304 title: "Remix".into(),
1305 handle: "me".into(),
1306 clip_attribution_type: "".into(),
1307 clip_roots: vec![crate::model::ClipRoot {
1308 id: "attr-root".into(),
1309 handle: "me".into(),
1310 ..Default::default()
1311 }],
1312 ..Default::default()
1313 };
1314 let mut roots = HashMap::new();
1315 roots.insert(
1316 "child".to_owned(),
1317 RootInfo {
1318 root_id: "child".into(),
1319 root_title: "Remix".into(),
1320 status: ResolveStatus::Resolved,
1321 },
1322 );
1323 let resolution = Resolution {
1324 roots,
1325 gap_filled: Vec::new(),
1326 bridges: Vec::new(),
1327 };
1328 let mut store = LineageStore::new();
1329 store.update(std::slice::from_ref(&child), &resolution, "now");
1330 assert_eq!(edge(&store, "child", "attr-root").edge_type, "attribution");
1331 }
1332
1333 #[test]
1334 fn normalise_slug_lowercases_joins_and_defaults() {
1335 assert_eq!(normalise_slug("remix"), "remix");
1336 assert_eq!(normalise_slug("Remix Cover"), "remix_cover");
1337 assert_eq!(
1338 normalise_slug(" Remix Reuse Style "),
1339 "remix_reuse_style"
1340 );
1341 assert_eq!(normalise_slug(""), "attribution");
1342 assert_eq!(normalise_slug(" "), "attribution");
1343 }
1344
1345 #[test]
1346 fn album_for_id_matches_context_for_and_handles_unknown() {
1347 let mut store = LineageStore::new();
1348 store.update(&chain_clips(), &chain_resolution(), "now");
1349
1350 assert_eq!(store.album_for_id("c"), "Root");
1353 let cover = &chain_clips()[0];
1354 assert_eq!(
1355 store.album_for_id("c"),
1356 store.context_for(cover).album(&cover.title)
1357 );
1358 assert_eq!(store.album_for_id("a"), "Root");
1360 assert_eq!(store.album_for_id("missing"), "");
1362 }
1363
1364 #[test]
1365 fn serde_roundtrip_preserves_a_relational_shape() {
1366 let mut store = LineageStore::new();
1367 store.update(&chain_clips(), &chain_resolution(), "now");
1368
1369 let json = serde_json::to_string(&store).unwrap();
1370 let back: LineageStore = serde_json::from_str(&json).unwrap();
1371 assert_eq!(store, back);
1372
1373 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1374 assert_eq!(value.get("schema_version").unwrap(), 1);
1375 assert!(value.get("nodes").unwrap().is_object());
1376 assert!(value.get("edges").unwrap().is_array());
1377 assert!(value.get("resolution_cache").unwrap().is_object());
1378 assert!(value.get("edge_index").is_none());
1379
1380 let node = value.get("nodes").unwrap().get("c").unwrap();
1383 assert!(node.get("edges").is_none());
1384 assert!(node.get("parent_id").is_none());
1385 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1386 assert!(first_edge.get("child_id").is_some());
1387 assert!(first_edge.get("parent_id").is_some());
1388 }
1389
1390 #[test]
1391 fn album_overrides_are_runtime_only_and_never_persist() {
1392 let mut store = LineageStore::new();
1396 store.update(&chain_clips(), &chain_resolution(), "now");
1397 store.set_album_overrides(
1398 [("a".to_owned(), "Preferred".to_owned())]
1399 .into_iter()
1400 .collect(),
1401 );
1402
1403 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1404 assert!(value.get("album_overrides").is_none());
1405
1406 let json = serde_json::to_string(&store).unwrap();
1407 let back: LineageStore = serde_json::from_str(&json).unwrap();
1408 assert!(back.album_overrides.is_empty());
1409 assert_eq!(back.album_for_id("c"), "Root");
1410 }
1411
1412 #[test]
1413 fn update_is_idempotent_bar_last_seen() {
1414 let clips = chain_clips();
1415 let resolution = chain_resolution();
1416 let mut store = LineageStore::new();
1417 store.update(&clips, &resolution, "first");
1418 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1419 let edge_count = store.edges.len();
1420
1421 store.update(&clips, &resolution, "second");
1422
1423 assert_eq!(
1425 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1426 node_ids
1427 );
1428 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1429 assert_eq!(store.resolution_cache.len(), 3);
1430
1431 let cover = store.node("c").unwrap();
1433 assert_eq!(cover.first_seen_at, "first");
1434 assert_eq!(cover.last_seen_at, "second");
1435 let cb = edge(&store, "c", "b");
1436 assert_eq!(cb.first_seen_at, "first");
1437 assert_eq!(cb.last_seen_at, "second");
1438 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1440 }
1441
1442 #[test]
1443 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1444 let mut store = LineageStore::new();
1445 store.update(&chain_clips(), &chain_resolution(), "first");
1446 assert_eq!(store.get_root("c").unwrap().status, ResolveStatus::Resolved);
1447
1448 let child = Clip {
1451 id: "c".into(),
1452 title: "Cover".into(),
1453 clip_type: "gen".into(),
1454 task: "cover".into(),
1455 cover_clip_id: "b".into(),
1456 edited_clip_id: "b".into(),
1457 ..Default::default()
1458 };
1459 let mut roots = HashMap::new();
1460 roots.insert(
1461 "c".to_owned(),
1462 RootInfo {
1463 root_id: "elsewhere".into(),
1464 root_title: String::new(),
1465 status: ResolveStatus::External,
1466 },
1467 );
1468 roots.insert(
1469 "d".to_owned(),
1470 RootInfo {
1471 root_id: "boundary".into(),
1472 root_title: String::new(),
1473 status: ResolveStatus::External,
1474 },
1475 );
1476 let resolution = Resolution {
1477 roots,
1478 gap_filled: Vec::new(),
1479 bridges: Vec::new(),
1480 };
1481 store.update(&[child], &resolution, "second");
1482
1483 let cached = store.get_root("c").unwrap();
1485 assert_eq!(cached.root_id, "a");
1486 assert_eq!(cached.status, ResolveStatus::Resolved);
1487 assert_eq!(cached.computed_at, "first");
1488 let d = store.get_root("d").unwrap();
1490 assert_eq!(d.root_id, "boundary");
1491 assert_eq!(d.status, ResolveStatus::External);
1492 }
1493
1494 #[test]
1495 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1496 let child = Clip {
1500 id: "c".into(),
1501 title: "Cover".into(),
1502 clip_type: "gen".into(),
1503 task: "cover".into(),
1504 cover_clip_id: "t".into(),
1505 edited_clip_id: "t".into(),
1506 ..Default::default()
1507 };
1508 let trashed = Clip {
1509 id: "t".into(),
1510 title: "Trashed Original".into(),
1511 clip_type: "gen".into(),
1512 is_trashed: true,
1513 ..Default::default()
1514 };
1515 let mut roots = HashMap::new();
1516 roots.insert(
1517 "c".to_owned(),
1518 RootInfo {
1519 root_id: "t".into(),
1520 root_title: "Trashed Original".into(),
1521 status: ResolveStatus::Resolved,
1522 },
1523 );
1524 let resolution = Resolution {
1525 roots,
1526 gap_filled: vec![trashed],
1527 bridges: Vec::new(),
1528 };
1529 store_update_and_assert_trashed(child, resolution);
1530 }
1531
1532 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1533 let mut store = LineageStore::new();
1534 store.update(&[child], &resolution, "now");
1535
1536 let node = store
1537 .node("t")
1538 .expect("trashed ancestor should be archived");
1539 assert!(node.is_trashed);
1540 assert_eq!(node.title, "Trashed Original");
1541 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1543 }
1544
1545 #[test]
1546 fn partial_json_loads_with_defaults() {
1547 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1550 let store: LineageStore = serde_json::from_str(json).unwrap();
1551 assert_eq!(store.schema_version, 1);
1552 let node = store.node("x").unwrap();
1553 assert_eq!(node.title, "Kept");
1554 assert_eq!(node.status, NodeStatus::Observed);
1555 assert_eq!(store.edges[0].status, EdgeStatus::Active);
1556 assert!(store.resolution_cache.is_empty());
1557 assert!(store.albums.is_empty());
1560 assert!(store.album_art("x").is_none());
1561 assert!(store.playlists.is_empty());
1565 assert!(store.playlist("x").is_none());
1566 }
1567
1568 #[test]
1569 fn album_art_roundtrips_and_reads_by_kind() {
1570 let mut store = LineageStore::new();
1571 store.albums.insert(
1572 "root-1".to_owned(),
1573 AlbumArt {
1574 folder_jpg: Some(ArtifactState {
1575 path: "alice/Album/folder.jpg".to_owned(),
1576 hash: "jpg-h".to_owned(),
1577 }),
1578 folder_webp: Some(ArtifactState {
1579 path: "alice/Album/cover.webp".to_owned(),
1580 hash: "webp-h".to_owned(),
1581 }),
1582 folder_mp4: Some(ArtifactState {
1583 path: "alice/Album/cover.mp4".to_owned(),
1584 hash: "mp4-h".to_owned(),
1585 }),
1586 },
1587 );
1588
1589 let json = serde_json::to_string(&store).unwrap();
1590 let back: LineageStore = serde_json::from_str(&json).unwrap();
1591 assert_eq!(store, back);
1592
1593 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1595 let album = value.get("albums").unwrap().get("root-1").unwrap();
1596 assert_eq!(
1597 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1598 "jpg-h"
1599 );
1600
1601 let art = back.album_art("root-1").unwrap();
1602 assert_eq!(
1603 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1604 "alice/Album/folder.jpg"
1605 );
1606 assert_eq!(
1607 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1608 "webp-h"
1609 );
1610 assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1611 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1613 }
1614
1615 #[test]
1616 fn empty_album_art_omits_slots_when_serialised() {
1617 let empty = AlbumArt::default();
1620 assert!(empty.is_empty());
1621 let value = serde_json::to_value(&empty).unwrap();
1622 assert!(value.get("folder_jpg").is_none());
1623 assert!(value.get("folder_webp").is_none());
1624 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1625 assert_eq!(back, empty);
1626 }
1627
1628 #[test]
1629 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1630 let mut store = LineageStore::new();
1631 let jpg = ArtifactState {
1632 path: "a/folder.jpg".to_owned(),
1633 hash: "h1".to_owned(),
1634 };
1635 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1636 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1637
1638 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1640 assert!(store.album_art("root-1").is_none());
1641 assert!(store.albums.is_empty());
1642 }
1643
1644 #[test]
1645 fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1646 let mut store = LineageStore::new();
1651 let state = |p: &str| ArtifactState {
1652 path: p.to_owned(),
1653 hash: "h".to_owned(),
1654 };
1655 store.set_album_artifact(
1656 "root-1",
1657 ArtifactKind::FolderWebp,
1658 Some(state("a/cover.webp")),
1659 );
1660 store.set_album_artifact(
1661 "root-1",
1662 ArtifactKind::FolderMp4,
1663 Some(state("a/cover.mp4")),
1664 );
1665
1666 store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1669 let art = store
1670 .album_art("root-1")
1671 .expect("row kept while folder_mp4 remains");
1672 assert!(!art.is_empty());
1673 assert!(art.folder_mp4.is_some());
1674
1675 store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1677 assert!(store.album_art("root-1").is_none());
1678 assert!(store.albums.is_empty());
1679 }
1680
1681 #[test]
1682 fn playlist_state_roundtrips_by_id() {
1683 let mut store = LineageStore::new();
1684 store.playlists.insert(
1685 "pl1".to_owned(),
1686 PlaylistState {
1687 name: "Road Trip".to_owned(),
1688 path: "Road Trip.m3u8".to_owned(),
1689 hash: "abc123".to_owned(),
1690 },
1691 );
1692
1693 let json = serde_json::to_string(&store).unwrap();
1694 let back: LineageStore = serde_json::from_str(&json).unwrap();
1695 assert_eq!(store, back);
1696
1697 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1699 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1700 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1701 assert_eq!(pl.get("hash").unwrap(), "abc123");
1702
1703 let stored = back.playlist("pl1").unwrap();
1704 assert_eq!(stored.name, "Road Trip");
1705 assert_eq!(stored.hash, "abc123");
1706 }
1707
1708 #[test]
1709 fn set_playlist_upserts_then_clears() {
1710 let mut store = LineageStore::new();
1711 let state = PlaylistState {
1712 name: "Mix".to_owned(),
1713 path: "Mix.m3u8".to_owned(),
1714 hash: "h1".to_owned(),
1715 };
1716 store.set_playlist("pl1", Some(state.clone()));
1717 assert_eq!(store.playlist("pl1"), Some(&state));
1718
1719 let renamed = PlaylistState {
1721 name: "Mix v2".to_owned(),
1722 path: "Mix v2.m3u8".to_owned(),
1723 hash: "h2".to_owned(),
1724 };
1725 store.set_playlist("pl1", Some(renamed.clone()));
1726 assert_eq!(store.playlist("pl1"), Some(&renamed));
1727
1728 store.set_playlist("pl1", None);
1730 assert!(store.playlist("pl1").is_none());
1731 assert!(store.playlists.is_empty());
1732 }
1733
1734 #[test]
1735 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1736 let mut store = LineageStore::new();
1737 store.update(&chain_clips(), &chain_resolution(), "now");
1738
1739 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1741 assert_eq!(ctx.root_id, "a");
1742 assert_eq!(ctx.root_title, "Root");
1743 assert_eq!(ctx.parent_id, "b");
1744 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1745 assert_eq!(ctx.status, ResolveStatus::Resolved);
1746 assert_eq!(ctx.album("Cover"), "Root");
1748 }
1749
1750 #[test]
1751 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1752 let mut store = LineageStore::new();
1753 store.update(&chain_clips(), &chain_resolution(), "now");
1754
1755 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1757 assert_eq!(ctx.root_id, "a");
1758 assert_eq!(ctx.root_title, "Root");
1759 assert_eq!(ctx.parent_id, "");
1760 assert_eq!(ctx.edge_type, None);
1761 assert_eq!(ctx.album("Root"), "Root");
1762 }
1763
1764 #[test]
1765 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1766 let clips = vec![
1769 Clip {
1770 id: "child".into(),
1771 title: "Revision".into(),
1772 clip_type: "gen".into(),
1773 task: "cover".into(),
1774 created_at: "2024-01-02T08:00:00Z".into(),
1775 cover_clip_id: "root".into(),
1776 edited_clip_id: "root".into(),
1777 ..Default::default()
1778 },
1779 Clip {
1780 id: "root".into(),
1781 title: "Origin".into(),
1782 clip_type: "gen".into(),
1783 created_at: "2023-12-30T23:00:00Z".into(),
1784 ..Default::default()
1785 },
1786 ];
1787 let mut roots = HashMap::new();
1788 for id in ["child", "root"] {
1789 roots.insert(
1790 id.to_owned(),
1791 RootInfo {
1792 root_id: "root".into(),
1793 root_title: "Origin".into(),
1794 status: ResolveStatus::Resolved,
1795 },
1796 );
1797 }
1798 let resolution = Resolution {
1799 roots,
1800 gap_filled: Vec::new(),
1801 bridges: Vec::new(),
1802 };
1803 let mut store = LineageStore::new();
1804 store.update(&clips, &resolution, "now");
1805
1806 let child_ctx = store.context_for(&clips[0]);
1807 assert_eq!(child_ctx.root_id, "root");
1808 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1809 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1811
1812 let root_ctx = store.context_for(&clips[1]);
1814 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1815 }
1816
1817 #[test]
1818 fn context_for_an_unknown_clip_is_self_rooted() {
1819 let store = LineageStore::new();
1820 let orphan = Clip {
1821 id: "z".into(),
1822 title: "Lonely".into(),
1823 ..Default::default()
1824 };
1825 let ctx = store.context_for(&orphan);
1826 assert_eq!(ctx.root_id, "z");
1827 assert_eq!(ctx.root_title, "Lonely");
1828 assert_eq!(ctx.parent_id, "");
1829 assert_eq!(ctx.status, ResolveStatus::Resolved);
1830 }
1831
1832 #[test]
1833 fn context_for_retains_a_purged_ancestor_album() {
1834 let child = Clip {
1839 id: "c".into(),
1840 title: "Cover".into(),
1841 clip_type: "gen".into(),
1842 task: "cover".into(),
1843 cover_clip_id: "t".into(),
1844 edited_clip_id: "t".into(),
1845 ..Default::default()
1846 };
1847 let trashed = Clip {
1848 id: "t".into(),
1849 title: "Trashed Original".into(),
1850 clip_type: "gen".into(),
1851 is_trashed: true,
1852 ..Default::default()
1853 };
1854 let mut roots = HashMap::new();
1855 roots.insert(
1856 "c".to_owned(),
1857 RootInfo {
1858 root_id: "t".into(),
1859 root_title: "Trashed Original".into(),
1860 status: ResolveStatus::Resolved,
1861 },
1862 );
1863 let resolution = Resolution {
1864 roots,
1865 gap_filled: vec![trashed],
1866 bridges: Vec::new(),
1867 };
1868 let mut store = LineageStore::new();
1869 store.update(std::slice::from_ref(&child), &resolution, "now");
1870
1871 let ctx = store.context_for(&child);
1872 assert_eq!(ctx.root_id, "t");
1873 assert_eq!(ctx.root_title, "Trashed Original");
1874 assert_eq!(ctx.album("Cover"), "Trashed Original");
1875 }
1876
1877 #[test]
1878 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1879 let clips = vec![
1882 Clip {
1883 id: "r1".into(),
1884 title: "Break Through".into(),
1885 clip_type: "gen".into(),
1886 ..Default::default()
1887 },
1888 Clip {
1889 id: "r2".into(),
1890 title: "Break Through".into(),
1891 clip_type: "gen".into(),
1892 ..Default::default()
1893 },
1894 Clip {
1895 id: "r3".into(),
1896 title: "Solo".into(),
1897 clip_type: "gen".into(),
1898 ..Default::default()
1899 },
1900 Clip {
1901 id: "c1".into(),
1902 title: "Break Through".into(),
1903 clip_type: "gen".into(),
1904 task: "cover".into(),
1905 cover_clip_id: "r1".into(),
1906 edited_clip_id: "r1".into(),
1907 ..Default::default()
1908 },
1909 ];
1910 let mut roots = HashMap::new();
1911 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1912 let title = if root == "r3" {
1913 "Solo"
1914 } else {
1915 "Break Through"
1916 };
1917 roots.insert(
1918 id.to_owned(),
1919 RootInfo {
1920 root_id: root.into(),
1921 root_title: title.into(),
1922 status: ResolveStatus::Resolved,
1923 },
1924 );
1925 }
1926 let resolution = Resolution {
1927 roots,
1928 gap_filled: Vec::new(),
1929 bridges: Vec::new(),
1930 };
1931 let mut store = LineageStore::new();
1932 store.update(&clips, &resolution, "now");
1933
1934 let colliding = store.colliding_root_titles();
1935 assert!(colliding.contains("Break Through"));
1936 assert!(!colliding.contains("Solo"));
1937 assert_eq!(colliding.len(), 1);
1938 }
1939
1940 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1943 let clips = vec![
1944 Clip {
1945 id: "r1".into(),
1946 title: t1.into(),
1947 clip_type: "gen".into(),
1948 ..Default::default()
1949 },
1950 Clip {
1951 id: "r2".into(),
1952 title: t2.into(),
1953 clip_type: "gen".into(),
1954 ..Default::default()
1955 },
1956 ];
1957 let mut roots = HashMap::new();
1958 roots.insert(
1959 "r1".to_owned(),
1960 RootInfo {
1961 root_id: "r1".into(),
1962 root_title: t1.into(),
1963 status: ResolveStatus::Resolved,
1964 },
1965 );
1966 roots.insert(
1967 "r2".to_owned(),
1968 RootInfo {
1969 root_id: "r2".into(),
1970 root_title: t2.into(),
1971 status: ResolveStatus::Resolved,
1972 },
1973 );
1974 let mut store = LineageStore::new();
1975 store.update(
1976 &clips,
1977 &Resolution {
1978 roots,
1979 gap_filled: Vec::new(),
1980 bridges: Vec::new(),
1981 },
1982 "now",
1983 );
1984 store
1985 }
1986
1987 #[test]
1988 fn album_override_flows_into_context_tag_hash_and_index() {
1989 let clips = chain_clips();
1993 let mut store = LineageStore::new();
1994 store.update(&clips, &chain_resolution(), "now");
1995
1996 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1998
1999 store.set_album_overrides(
2000 [("a".to_owned(), "Preferred Name".to_owned())]
2001 .into_iter()
2002 .collect(),
2003 );
2004
2005 for id in ["a", "b", "c"] {
2007 let clip = clips.iter().find(|c| c.id == id).unwrap();
2008 let ctx = store.context_for(clip);
2009 assert_eq!(ctx.album(&clip.title), "Preferred Name");
2010 assert_eq!(store.album_for_id(id), "Preferred Name");
2011 }
2012
2013 let ctx = store.context_for(cover);
2015 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
2016 assert_eq!(meta.album, "Preferred Name");
2017
2018 let after_hash = crate::hash::meta_hash(cover, &ctx);
2020 assert_ne!(before_hash, after_hash);
2021 }
2022
2023 #[test]
2024 fn empty_album_override_is_ignored() {
2025 let clips = chain_clips();
2027 let mut store = LineageStore::new();
2028 store.update(&clips, &chain_resolution(), "now");
2029 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
2030 assert_eq!(store.album_for_id("c"), "Root");
2031 }
2032
2033 #[test]
2034 fn album_override_creates_a_collision_that_disambiguates() {
2035 let mut store = two_root_store("Alpha", "Beta");
2037 assert!(store.colliding_root_titles().is_empty());
2038
2039 store.set_album_overrides(
2040 [("r2".to_owned(), "Alpha".to_owned())]
2041 .into_iter()
2042 .collect(),
2043 );
2044 let colliding = store.colliding_root_titles();
2045 assert!(colliding.contains("Alpha"));
2046 assert_eq!(colliding.len(), 1);
2047 }
2048
2049 #[test]
2050 fn album_override_resolves_a_natural_collision() {
2051 let mut store = two_root_store("Break Through", "Break Through");
2053 assert!(store.colliding_root_titles().contains("Break Through"));
2054
2055 store.set_album_overrides(
2056 [("r2".to_owned(), "Second Wind".to_owned())]
2057 .into_iter()
2058 .collect(),
2059 );
2060 assert!(store.colliding_root_titles().is_empty());
2061 }
2062
2063 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
2068 store.resolution_cache.insert(
2069 root_id.to_owned(),
2070 CacheEntry {
2071 root_id: root_id.to_owned(),
2072 status: ResolveStatus::External,
2073 algorithm_version: 1,
2074 computed_at: "now".to_owned(),
2075 },
2076 );
2077 store.refresh_eligible_roots();
2080 }
2081
2082 #[test]
2083 fn override_on_node_less_root_collides_with_a_real_root() {
2084 let mut store = LineageStore::new();
2088 store.update(
2089 std::slice::from_ref(&Clip {
2090 id: "realroot".into(),
2091 title: "Shared".into(),
2092 clip_type: "gen".into(),
2093 ..Default::default()
2094 }),
2095 &Resolution {
2096 roots: [(
2097 "realroot".to_owned(),
2098 RootInfo {
2099 root_id: "realroot".into(),
2100 root_title: "Shared".into(),
2101 status: ResolveStatus::Resolved,
2102 },
2103 )]
2104 .into_iter()
2105 .collect(),
2106 gap_filled: Vec::new(),
2107 bridges: Vec::new(),
2108 },
2109 "now",
2110 );
2111 insert_cache_only_root(&mut store, "extroot");
2112 store.set_album_overrides(
2113 [("extroot".to_owned(), "Shared".to_owned())]
2114 .into_iter()
2115 .collect(),
2116 );
2117
2118 let colliding = store.colliding_root_titles();
2119 assert!(
2120 colliding.contains("Shared"),
2121 "a node-less overridden root must still be seen by collision detection"
2122 );
2123 }
2124
2125 #[test]
2126 fn two_node_less_roots_overridden_to_same_name_collide() {
2127 let mut store = LineageStore::new();
2128 insert_cache_only_root(&mut store, "extone");
2129 insert_cache_only_root(&mut store, "exttwo");
2130 store.set_album_overrides(
2131 [
2132 ("extone".to_owned(), "Shared".to_owned()),
2133 ("exttwo".to_owned(), "Shared".to_owned()),
2134 ]
2135 .into_iter()
2136 .collect(),
2137 );
2138 assert!(store.colliding_root_titles().contains("Shared"));
2139 }
2140
2141 #[test]
2142 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
2143 let mut store = LineageStore::new();
2148 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
2149 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
2150 store.set_album_overrides(
2151 [
2152 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
2153 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
2154 ]
2155 .into_iter()
2156 .collect(),
2157 );
2158 let colliding = store.colliding_root_titles();
2159
2160 let clip_of = |id: &str| Clip {
2161 id: id.to_owned(),
2162 title: "Track".to_owned(),
2163 display_name: "alice".to_owned(),
2164 image_large_url: "https://art.example/large.jpg".to_owned(),
2165 ..Default::default()
2166 };
2167 let ctx_of = |root_id: &str| LineageContext {
2168 root_id: root_id.to_owned(),
2169 root_title: "Shared".to_owned(),
2170 root_date: String::new(),
2171 parent_id: String::new(),
2172 edge_type: None,
2173 status: ResolveStatus::Resolved,
2174 };
2175 let clip_a = clip_of("clipaaaa-1111");
2176 let clip_b = clip_of("clipbbbb-2222");
2177 let ctx_a = ctx_of("aaaaaaaa-root-one");
2178 let ctx_b = ctx_of("bbbbbbbb-root-two");
2179 let requests = [
2180 crate::naming::NamingRequest {
2181 clip: &clip_a,
2182 lineage: &ctx_a,
2183 },
2184 crate::naming::NamingRequest {
2185 clip: &clip_b,
2186 lineage: &ctx_b,
2187 },
2188 ];
2189 let names = crate::naming::render_clip_names(
2190 &requests,
2191 &crate::naming::NamingConfig::default(),
2192 &colliding,
2193 );
2194
2195 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2196 crate::reconcile::Desired {
2197 clip: clip.clone(),
2198 lineage: ctx.clone(),
2199 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2200 format: crate::AudioFormat::Flac,
2201 meta_hash: String::new(),
2202 art_hash: String::new(),
2203 modes: vec![crate::reconcile::SourceMode::Mirror],
2204 trashed: false,
2205 private: false,
2206 artifacts: Vec::new(),
2207 stems: None,
2208 }
2209 };
2210 let desired = vec![
2211 desired_of(&clip_a, &ctx_a, &names[0]),
2212 desired_of(&clip_b, &ctx_b, &names[1]),
2213 ];
2214
2215 let albums = crate::reconcile::album_desired(&desired, false, false);
2216 assert_eq!(albums.len(), 2, "each distinct root is its own album");
2217 let jpg_paths: Vec<String> = albums
2218 .iter()
2219 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2220 .collect();
2221 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2222 assert_ne!(
2223 jpg_paths[0], jpg_paths[1],
2224 "colliding roots must not share one folder.jpg path"
2225 );
2226 }
2227
2228 #[test]
2229 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2230 let mut store = LineageStore::new();
2237 store.update(
2238 std::slice::from_ref(&Clip {
2239 id: "realroot".into(),
2240 title: "Shared".into(),
2241 clip_type: "gen".into(),
2242 ..Default::default()
2243 }),
2244 &Resolution {
2245 roots: [(
2246 "realroot".to_owned(),
2247 RootInfo {
2248 root_id: "realroot".into(),
2249 root_title: "Shared".into(),
2250 status: ResolveStatus::Resolved,
2251 },
2252 )]
2253 .into_iter()
2254 .collect(),
2255 gap_filled: Vec::new(),
2256 bridges: Vec::new(),
2257 },
2258 "now",
2259 );
2260 let new_clip = Clip {
2263 id: "newnewnew-9999".into(),
2264 title: "Solo Track".into(),
2265 display_name: "alice".into(),
2266 image_large_url: "https://art.example/large.jpg".into(),
2267 ..Default::default()
2268 };
2269 store.set_album_overrides(
2270 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2271 .into_iter()
2272 .collect(),
2273 );
2274
2275 let new_ctx = store.context_for(&new_clip);
2277 assert_eq!(new_ctx.root_id, "newnewnew-9999");
2278 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2279
2280 assert!(store.colliding_root_titles().is_empty());
2282
2283 let real_clip = Clip {
2285 id: "realroot".into(),
2286 title: "Shared".into(),
2287 display_name: "alice".into(),
2288 image_large_url: "https://art.example/large.jpg".into(),
2289 ..Default::default()
2290 };
2291 let real_ctx = store.context_for(&real_clip);
2292 let colliding = store.colliding_root_titles();
2293 let requests = [
2294 crate::naming::NamingRequest {
2295 clip: &real_clip,
2296 lineage: &real_ctx,
2297 },
2298 crate::naming::NamingRequest {
2299 clip: &new_clip,
2300 lineage: &new_ctx,
2301 },
2302 ];
2303 let names = crate::naming::render_clip_names(
2304 &requests,
2305 &crate::naming::NamingConfig::default(),
2306 &colliding,
2307 );
2308 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2309 crate::reconcile::Desired {
2310 clip: clip.clone(),
2311 lineage: ctx.clone(),
2312 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2313 format: crate::AudioFormat::Flac,
2314 meta_hash: String::new(),
2315 art_hash: String::new(),
2316 modes: vec![crate::reconcile::SourceMode::Mirror],
2317 trashed: false,
2318 private: false,
2319 artifacts: Vec::new(),
2320 stems: None,
2321 }
2322 };
2323 let desired = vec![
2324 desired_of(&real_clip, &real_ctx, &names[0]),
2325 desired_of(&new_clip, &new_ctx, &names[1]),
2326 ];
2327 let albums = crate::reconcile::album_desired(&desired, false, false);
2328 let jpg_paths: Vec<String> = albums
2329 .iter()
2330 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2331 .collect();
2332 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2333 assert_ne!(
2334 jpg_paths[0], jpg_paths[1],
2335 "an uncached override must not collapse two albums onto one path"
2336 );
2337 }
2338
2339 #[test]
2340 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2341 let child = Clip {
2348 id: "childclip".into(),
2349 title: "Cover".into(),
2350 clip_type: "gen".into(),
2351 task: "cover".into(),
2352 cover_clip_id: "gaproot".into(),
2353 edited_clip_id: "gaproot".into(),
2354 ..Default::default()
2355 };
2356 let other_root = Clip {
2357 id: "otherroot".into(),
2358 title: "Preferred".into(),
2359 clip_type: "gen".into(),
2360 ..Default::default()
2361 };
2362 let gap_ancestor = Clip {
2363 id: "gaproot".into(),
2364 title: "Working Title".into(),
2365 clip_type: "gen".into(),
2366 ..Default::default()
2367 };
2368 let mut roots = HashMap::new();
2369 roots.insert(
2370 "childclip".to_owned(),
2371 RootInfo {
2372 root_id: "gaproot".into(),
2373 root_title: "Working Title".into(),
2374 status: ResolveStatus::Resolved,
2375 },
2376 );
2377 roots.insert(
2378 "otherroot".to_owned(),
2379 RootInfo {
2380 root_id: "otherroot".into(),
2381 root_title: "Preferred".into(),
2382 status: ResolveStatus::Resolved,
2383 },
2384 );
2385 let mut store = LineageStore::new();
2386 store.update(
2387 &[child.clone(), other_root],
2388 &Resolution {
2389 roots,
2390 gap_filled: vec![gap_ancestor],
2391 bridges: Vec::new(),
2392 },
2393 "now",
2394 );
2395 assert!(store.node("gaproot").is_some());
2397 assert!(!store.resolution_cache.contains_key("gaproot"));
2398
2399 store.set_album_overrides(
2400 [("gaproot".to_owned(), "Preferred".to_owned())]
2401 .into_iter()
2402 .collect(),
2403 );
2404
2405 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2408 assert_eq!(store.album_for_id("childclip"), "Preferred");
2409
2410 assert!(store.colliding_root_titles().contains("Preferred"));
2413 }
2414
2415 #[test]
2416 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2417 let child = Clip {
2423 id: "childclip".into(),
2424 title: "Cover".into(),
2425 clip_type: "gen".into(),
2426 task: "cover".into(),
2427 cover_clip_id: "gaproot".into(),
2428 edited_clip_id: "gaproot".into(),
2429 ..Default::default()
2430 };
2431 let mut roots = HashMap::new();
2432 roots.insert(
2433 "childclip".to_owned(),
2434 RootInfo {
2435 root_id: "gaproot".into(),
2436 root_title: "Working Title".into(),
2437 status: ResolveStatus::Resolved,
2438 },
2439 );
2440 let mut store = LineageStore::new();
2441 store.update(
2442 std::slice::from_ref(&child),
2443 &Resolution {
2444 roots,
2445 gap_filled: vec![Clip {
2446 id: "gaproot".into(),
2447 title: "Working Title".into(),
2448 clip_type: "gen".into(),
2449 ..Default::default()
2450 }],
2451 bridges: Vec::new(),
2452 },
2453 "now",
2454 );
2455
2456 let expected: std::collections::HashSet<String> = store
2457 .resolution_cache
2458 .values()
2459 .map(|entry| entry.root_id.clone())
2460 .filter(|root_id| !root_id.is_empty())
2461 .collect();
2462 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2463 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2465 assert!(!store.resolution_cache.contains_key("gaproot"));
2466 }
2467
2468 fn owner(id: &str, name: &str) -> Owner {
2469 Owner {
2470 user_id: id.to_owned(),
2471 display_name: name.to_owned(),
2472 }
2473 }
2474
2475 #[test]
2476 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2477 let mut store = LineageStore::new();
2478 assert!(!store.refresh_display_name("Alice"));
2480 assert!(store.owner().is_none());
2481
2482 store.pin_owner(owner("user_a", "Alice"));
2483 assert!(!store.refresh_display_name("Alice"));
2485 assert!(store.refresh_display_name("Alice Cooper"));
2487 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2488 assert_eq!(store.owner().unwrap().user_id, "user_a");
2490 }
2491
2492 #[test]
2493 fn owner_gate_covers_the_full_matrix() {
2494 let alice = owner("user_a", "Alice");
2495
2496 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2498 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2499
2500 assert_eq!(
2502 owner_gate(Some(&alice), None, "user_a", false),
2503 OwnerGate::Proceed
2504 );
2505
2506 assert_eq!(
2508 owner_gate(Some(&alice), None, "user_b", false),
2509 OwnerGate::AbortMismatch
2510 );
2511 assert_eq!(
2512 owner_gate(Some(&alice), None, "user_b", true),
2513 OwnerGate::Repin
2514 );
2515
2516 assert_eq!(
2519 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2520 OwnerGate::AbortConfigMismatch
2521 );
2522 assert_eq!(
2523 owner_gate(None, Some("user_c"), "user_a", true),
2524 OwnerGate::AbortConfigMismatch
2525 );
2526 assert_eq!(
2528 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2529 OwnerGate::Proceed
2530 );
2531
2532 assert!(OwnerGate::Repin.is_additive());
2534 for gate in [
2535 OwnerGate::AbortConfigMismatch,
2536 OwnerGate::AbortMismatch,
2537 OwnerGate::Proceed,
2538 OwnerGate::FirstUse,
2539 ] {
2540 assert!(!gate.is_additive());
2541 }
2542 }
2543
2544 #[test]
2545 fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
2546 let clips = chain_clips();
2547 let resolution = chain_resolution();
2548
2549 let mut store = LineageStore::new();
2550 store.update(&clips, &resolution, "first");
2551
2552 let json = serde_json::to_string(&store).unwrap();
2553 let mut store: LineageStore = serde_json::from_str(&json).unwrap();
2554
2555 store.update(&clips, &resolution, "second");
2556
2557 assert_eq!(store.edges.len(), 2);
2558 let cb = edge(&store, "c", "b");
2559 assert_eq!(cb.first_seen_at, "first");
2560 assert_eq!(cb.last_seen_at, "second");
2561 let ba = edge(&store, "b", "a");
2562 assert_eq!(ba.first_seen_at, "first");
2563 assert_eq!(ba.last_seen_at, "second");
2564 }
2565
2566 #[test]
2567 fn adopt_decision_covers_every_branch() {
2568 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2569 let empty: BTreeSet<&str> = BTreeSet::new();
2570
2571 assert_eq!(
2573 adopt_decision(&["x", "y"], &empty, true, false),
2574 AdoptDecision::PinFresh
2575 );
2576 assert_eq!(
2578 adopt_decision(&["c1"], &owned, false, false),
2579 AdoptDecision::SkipPin
2580 );
2581 assert_eq!(
2582 adopt_decision(&["c1"], &owned, false, true),
2583 AdoptDecision::SkipPin
2584 );
2585 assert_eq!(
2587 adopt_decision(&["c1", "z"], &owned, true, false),
2588 AdoptDecision::PinAdopt
2589 );
2590 assert_eq!(
2592 adopt_decision(&["z1", "z2"], &owned, true, false),
2593 AdoptDecision::Abort
2594 );
2595 assert_eq!(
2596 adopt_decision(&["z1", "z2"], &owned, true, true),
2597 AdoptDecision::AdoptForced
2598 );
2599
2600 assert!(AdoptDecision::AdoptForced.is_additive());
2602 for decision in [
2603 AdoptDecision::PinFresh,
2604 AdoptDecision::PinAdopt,
2605 AdoptDecision::Abort,
2606 AdoptDecision::SkipPin,
2607 ] {
2608 assert!(!decision.is_additive());
2609 }
2610 }
2611
2612 #[test]
2613 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2614 let json = r#"{"nodes":{},"edges":[]}"#;
2616 let store: LineageStore = serde_json::from_str(json).unwrap();
2617 assert!(store.owner().is_none());
2618 let value = serde_json::to_value(&store).unwrap();
2620 assert!(value.get("owner").is_none());
2621
2622 let mut pinned = LineageStore::new();
2624 pinned.pin_owner(owner("user_a", "Alice"));
2625 let back: LineageStore =
2626 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2627 assert_eq!(back, pinned);
2628 assert_eq!(back.owner().unwrap().user_id, "user_a");
2629 }
2630
2631 #[test]
2632 fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2633 let mut store = LineageStore::new();
2636 store.update(&chain_clips(), &chain_resolution(), "now");
2637
2638 let value = serde_json::to_value(&store).unwrap();
2639 let edges = value.get("edges").unwrap().as_array().unwrap();
2640
2641 let primary_edge = edges
2643 .iter()
2644 .find(|e| e.get("child_id").unwrap() == "c")
2645 .unwrap();
2646 assert_eq!(primary_edge.get("role").unwrap(), "primary");
2647 assert_eq!(primary_edge.get("status").unwrap(), "active");
2648 assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2649
2650 let node = value.get("nodes").unwrap().get("c").unwrap();
2652 assert_eq!(node.get("status").unwrap(), "observed");
2653
2654 let cache = value.get("resolution_cache").unwrap();
2656 assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2657
2658 let mut store2 = LineageStore::new();
2660 let child = Clip {
2661 id: "x".into(),
2662 ..Default::default()
2663 };
2664 let mut roots = HashMap::new();
2665 roots.insert(
2666 "x".to_owned(),
2667 RootInfo {
2668 root_id: "ext".into(),
2669 root_title: String::new(),
2670 status: ResolveStatus::External,
2671 },
2672 );
2673 store2.update(
2674 std::slice::from_ref(&child),
2675 &Resolution {
2676 roots,
2677 gap_filled: Vec::new(),
2678 bridges: Vec::new(),
2679 },
2680 "now",
2681 );
2682 let v2 = serde_json::to_value(&store2).unwrap();
2683 assert_eq!(
2684 v2.get("resolution_cache")
2685 .unwrap()
2686 .get("x")
2687 .unwrap()
2688 .get("status")
2689 .unwrap(),
2690 "external"
2691 );
2692 }
2693
2694 #[test]
2695 fn serde_roundtrip_is_byte_identical() {
2696 let mut store = LineageStore::new();
2699 store.update(&chain_clips(), &chain_resolution(), "now");
2700
2701 let first = serde_json::to_string(&store).unwrap();
2702 let back: LineageStore = serde_json::from_str(&first).unwrap();
2703 let second = serde_json::to_string(&back).unwrap();
2704 assert_eq!(first, second, "round-trip must be byte-identical");
2705 }
2706
2707 #[test]
2708 fn existing_string_form_json_deserialises_correctly() {
2709 let json = r#"{
2712 "nodes": {"a": {"title": "Root", "status": "observed"}},
2713 "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2714 "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2715 }"#;
2716 let store: LineageStore = serde_json::from_str(json).unwrap();
2717 assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2718 assert_eq!(store.edges[0].role, EdgeRole::Primary);
2719 assert_eq!(store.edges[0].status, EdgeStatus::Active);
2720 assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2721 let archived = store.archived_parents();
2723 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2724 }
2725}