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!(
2200 "{}.flac",
2201 crate::desired::rel_to_string(&name.relative_path)
2202 ),
2203 format: crate::AudioFormat::Flac,
2204 meta_hash: String::new(),
2205 art_hash: String::new(),
2206 modes: vec![crate::reconcile::SourceMode::Mirror],
2207 trashed: false,
2208 private: false,
2209 artifacts: Vec::new(),
2210 stems: None,
2211 }
2212 };
2213 let desired = vec![
2214 desired_of(&clip_a, &ctx_a, &names[0]),
2215 desired_of(&clip_b, &ctx_b, &names[1]),
2216 ];
2217
2218 let albums = crate::reconcile::album_desired(
2219 &desired,
2220 false,
2221 false,
2222 crate::ffmpeg::WebpEncodeSettings::default(),
2223 );
2224 assert_eq!(albums.len(), 2, "each distinct root is its own album");
2225 let jpg_paths: Vec<String> = albums
2226 .iter()
2227 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2228 .collect();
2229 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2230 assert_ne!(
2231 jpg_paths[0], jpg_paths[1],
2232 "colliding roots must not share one folder.jpg path"
2233 );
2234 }
2235
2236 #[test]
2237 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2238 let mut store = LineageStore::new();
2245 store.update(
2246 std::slice::from_ref(&Clip {
2247 id: "realroot".into(),
2248 title: "Shared".into(),
2249 clip_type: "gen".into(),
2250 ..Default::default()
2251 }),
2252 &Resolution {
2253 roots: [(
2254 "realroot".to_owned(),
2255 RootInfo {
2256 root_id: "realroot".into(),
2257 root_title: "Shared".into(),
2258 status: ResolveStatus::Resolved,
2259 },
2260 )]
2261 .into_iter()
2262 .collect(),
2263 gap_filled: Vec::new(),
2264 bridges: Vec::new(),
2265 },
2266 "now",
2267 );
2268 let new_clip = Clip {
2271 id: "newnewnew-9999".into(),
2272 title: "Solo Track".into(),
2273 display_name: "alice".into(),
2274 image_large_url: "https://art.example/large.jpg".into(),
2275 ..Default::default()
2276 };
2277 store.set_album_overrides(
2278 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2279 .into_iter()
2280 .collect(),
2281 );
2282
2283 let new_ctx = store.context_for(&new_clip);
2285 assert_eq!(new_ctx.root_id, "newnewnew-9999");
2286 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2287
2288 assert!(store.colliding_root_titles().is_empty());
2290
2291 let real_clip = Clip {
2293 id: "realroot".into(),
2294 title: "Shared".into(),
2295 display_name: "alice".into(),
2296 image_large_url: "https://art.example/large.jpg".into(),
2297 ..Default::default()
2298 };
2299 let real_ctx = store.context_for(&real_clip);
2300 let colliding = store.colliding_root_titles();
2301 let requests = [
2302 crate::naming::NamingRequest {
2303 clip: &real_clip,
2304 lineage: &real_ctx,
2305 },
2306 crate::naming::NamingRequest {
2307 clip: &new_clip,
2308 lineage: &new_ctx,
2309 },
2310 ];
2311 let names = crate::naming::render_clip_names(
2312 &requests,
2313 &crate::naming::NamingConfig::default(),
2314 &colliding,
2315 );
2316 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2317 crate::reconcile::Desired {
2318 clip: clip.clone(),
2319 lineage: ctx.clone(),
2320 path: format!(
2321 "{}.flac",
2322 crate::desired::rel_to_string(&name.relative_path)
2323 ),
2324 format: crate::AudioFormat::Flac,
2325 meta_hash: String::new(),
2326 art_hash: String::new(),
2327 modes: vec![crate::reconcile::SourceMode::Mirror],
2328 trashed: false,
2329 private: false,
2330 artifacts: Vec::new(),
2331 stems: None,
2332 }
2333 };
2334 let desired = vec![
2335 desired_of(&real_clip, &real_ctx, &names[0]),
2336 desired_of(&new_clip, &new_ctx, &names[1]),
2337 ];
2338 let albums = crate::reconcile::album_desired(
2339 &desired,
2340 false,
2341 false,
2342 crate::ffmpeg::WebpEncodeSettings::default(),
2343 );
2344 let jpg_paths: Vec<String> = albums
2345 .iter()
2346 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2347 .collect();
2348 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2349 assert_ne!(
2350 jpg_paths[0], jpg_paths[1],
2351 "an uncached override must not collapse two albums onto one path"
2352 );
2353 }
2354
2355 #[test]
2356 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2357 let child = Clip {
2364 id: "childclip".into(),
2365 title: "Cover".into(),
2366 clip_type: "gen".into(),
2367 task: "cover".into(),
2368 cover_clip_id: "gaproot".into(),
2369 edited_clip_id: "gaproot".into(),
2370 ..Default::default()
2371 };
2372 let other_root = Clip {
2373 id: "otherroot".into(),
2374 title: "Preferred".into(),
2375 clip_type: "gen".into(),
2376 ..Default::default()
2377 };
2378 let gap_ancestor = Clip {
2379 id: "gaproot".into(),
2380 title: "Working Title".into(),
2381 clip_type: "gen".into(),
2382 ..Default::default()
2383 };
2384 let mut roots = HashMap::new();
2385 roots.insert(
2386 "childclip".to_owned(),
2387 RootInfo {
2388 root_id: "gaproot".into(),
2389 root_title: "Working Title".into(),
2390 status: ResolveStatus::Resolved,
2391 },
2392 );
2393 roots.insert(
2394 "otherroot".to_owned(),
2395 RootInfo {
2396 root_id: "otherroot".into(),
2397 root_title: "Preferred".into(),
2398 status: ResolveStatus::Resolved,
2399 },
2400 );
2401 let mut store = LineageStore::new();
2402 store.update(
2403 &[child.clone(), other_root],
2404 &Resolution {
2405 roots,
2406 gap_filled: vec![gap_ancestor],
2407 bridges: Vec::new(),
2408 },
2409 "now",
2410 );
2411 assert!(store.node("gaproot").is_some());
2413 assert!(!store.resolution_cache.contains_key("gaproot"));
2414
2415 store.set_album_overrides(
2416 [("gaproot".to_owned(), "Preferred".to_owned())]
2417 .into_iter()
2418 .collect(),
2419 );
2420
2421 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2424 assert_eq!(store.album_for_id("childclip"), "Preferred");
2425
2426 assert!(store.colliding_root_titles().contains("Preferred"));
2429 }
2430
2431 #[test]
2432 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2433 let child = Clip {
2439 id: "childclip".into(),
2440 title: "Cover".into(),
2441 clip_type: "gen".into(),
2442 task: "cover".into(),
2443 cover_clip_id: "gaproot".into(),
2444 edited_clip_id: "gaproot".into(),
2445 ..Default::default()
2446 };
2447 let mut roots = HashMap::new();
2448 roots.insert(
2449 "childclip".to_owned(),
2450 RootInfo {
2451 root_id: "gaproot".into(),
2452 root_title: "Working Title".into(),
2453 status: ResolveStatus::Resolved,
2454 },
2455 );
2456 let mut store = LineageStore::new();
2457 store.update(
2458 std::slice::from_ref(&child),
2459 &Resolution {
2460 roots,
2461 gap_filled: vec![Clip {
2462 id: "gaproot".into(),
2463 title: "Working Title".into(),
2464 clip_type: "gen".into(),
2465 ..Default::default()
2466 }],
2467 bridges: Vec::new(),
2468 },
2469 "now",
2470 );
2471
2472 let expected: std::collections::HashSet<String> = store
2473 .resolution_cache
2474 .values()
2475 .map(|entry| entry.root_id.clone())
2476 .filter(|root_id| !root_id.is_empty())
2477 .collect();
2478 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2479 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2481 assert!(!store.resolution_cache.contains_key("gaproot"));
2482 }
2483
2484 fn owner(id: &str, name: &str) -> Owner {
2485 Owner {
2486 user_id: id.to_owned(),
2487 display_name: name.to_owned(),
2488 }
2489 }
2490
2491 #[test]
2492 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2493 let mut store = LineageStore::new();
2494 assert!(!store.refresh_display_name("Alice"));
2496 assert!(store.owner().is_none());
2497
2498 store.pin_owner(owner("user_a", "Alice"));
2499 assert!(!store.refresh_display_name("Alice"));
2501 assert!(store.refresh_display_name("Alice Cooper"));
2503 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2504 assert_eq!(store.owner().unwrap().user_id, "user_a");
2506 }
2507
2508 #[test]
2509 fn owner_gate_covers_the_full_matrix() {
2510 let alice = owner("user_a", "Alice");
2511
2512 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2514 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2515
2516 assert_eq!(
2518 owner_gate(Some(&alice), None, "user_a", false),
2519 OwnerGate::Proceed
2520 );
2521
2522 assert_eq!(
2524 owner_gate(Some(&alice), None, "user_b", false),
2525 OwnerGate::AbortMismatch
2526 );
2527 assert_eq!(
2528 owner_gate(Some(&alice), None, "user_b", true),
2529 OwnerGate::Repin
2530 );
2531
2532 assert_eq!(
2535 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2536 OwnerGate::AbortConfigMismatch
2537 );
2538 assert_eq!(
2539 owner_gate(None, Some("user_c"), "user_a", true),
2540 OwnerGate::AbortConfigMismatch
2541 );
2542 assert_eq!(
2544 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2545 OwnerGate::Proceed
2546 );
2547
2548 assert!(OwnerGate::Repin.is_additive());
2550 for gate in [
2551 OwnerGate::AbortConfigMismatch,
2552 OwnerGate::AbortMismatch,
2553 OwnerGate::Proceed,
2554 OwnerGate::FirstUse,
2555 ] {
2556 assert!(!gate.is_additive());
2557 }
2558 }
2559
2560 #[test]
2561 fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
2562 let clips = chain_clips();
2563 let resolution = chain_resolution();
2564
2565 let mut store = LineageStore::new();
2566 store.update(&clips, &resolution, "first");
2567
2568 let json = serde_json::to_string(&store).unwrap();
2569 let mut store: LineageStore = serde_json::from_str(&json).unwrap();
2570
2571 store.update(&clips, &resolution, "second");
2572
2573 assert_eq!(store.edges.len(), 2);
2574 let cb = edge(&store, "c", "b");
2575 assert_eq!(cb.first_seen_at, "first");
2576 assert_eq!(cb.last_seen_at, "second");
2577 let ba = edge(&store, "b", "a");
2578 assert_eq!(ba.first_seen_at, "first");
2579 assert_eq!(ba.last_seen_at, "second");
2580 }
2581
2582 #[test]
2583 fn adopt_decision_covers_every_branch() {
2584 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2585 let empty: BTreeSet<&str> = BTreeSet::new();
2586
2587 assert_eq!(
2589 adopt_decision(&["x", "y"], &empty, true, false),
2590 AdoptDecision::PinFresh
2591 );
2592 assert_eq!(
2594 adopt_decision(&["c1"], &owned, false, false),
2595 AdoptDecision::SkipPin
2596 );
2597 assert_eq!(
2598 adopt_decision(&["c1"], &owned, false, true),
2599 AdoptDecision::SkipPin
2600 );
2601 assert_eq!(
2603 adopt_decision(&["c1", "z"], &owned, true, false),
2604 AdoptDecision::PinAdopt
2605 );
2606 assert_eq!(
2608 adopt_decision(&["z1", "z2"], &owned, true, false),
2609 AdoptDecision::Abort
2610 );
2611 assert_eq!(
2612 adopt_decision(&["z1", "z2"], &owned, true, true),
2613 AdoptDecision::AdoptForced
2614 );
2615
2616 assert!(AdoptDecision::AdoptForced.is_additive());
2618 for decision in [
2619 AdoptDecision::PinFresh,
2620 AdoptDecision::PinAdopt,
2621 AdoptDecision::Abort,
2622 AdoptDecision::SkipPin,
2623 ] {
2624 assert!(!decision.is_additive());
2625 }
2626 }
2627
2628 #[test]
2629 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2630 let json = r#"{"nodes":{},"edges":[]}"#;
2632 let store: LineageStore = serde_json::from_str(json).unwrap();
2633 assert!(store.owner().is_none());
2634 let value = serde_json::to_value(&store).unwrap();
2636 assert!(value.get("owner").is_none());
2637
2638 let mut pinned = LineageStore::new();
2640 pinned.pin_owner(owner("user_a", "Alice"));
2641 let back: LineageStore =
2642 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2643 assert_eq!(back, pinned);
2644 assert_eq!(back.owner().unwrap().user_id, "user_a");
2645 }
2646
2647 #[test]
2648 fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2649 let mut store = LineageStore::new();
2652 store.update(&chain_clips(), &chain_resolution(), "now");
2653
2654 let value = serde_json::to_value(&store).unwrap();
2655 let edges = value.get("edges").unwrap().as_array().unwrap();
2656
2657 let primary_edge = edges
2659 .iter()
2660 .find(|e| e.get("child_id").unwrap() == "c")
2661 .unwrap();
2662 assert_eq!(primary_edge.get("role").unwrap(), "primary");
2663 assert_eq!(primary_edge.get("status").unwrap(), "active");
2664 assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2665
2666 let node = value.get("nodes").unwrap().get("c").unwrap();
2668 assert_eq!(node.get("status").unwrap(), "observed");
2669
2670 let cache = value.get("resolution_cache").unwrap();
2672 assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2673
2674 let mut store2 = LineageStore::new();
2676 let child = Clip {
2677 id: "x".into(),
2678 ..Default::default()
2679 };
2680 let mut roots = HashMap::new();
2681 roots.insert(
2682 "x".to_owned(),
2683 RootInfo {
2684 root_id: "ext".into(),
2685 root_title: String::new(),
2686 status: ResolveStatus::External,
2687 },
2688 );
2689 store2.update(
2690 std::slice::from_ref(&child),
2691 &Resolution {
2692 roots,
2693 gap_filled: Vec::new(),
2694 bridges: Vec::new(),
2695 },
2696 "now",
2697 );
2698 let v2 = serde_json::to_value(&store2).unwrap();
2699 assert_eq!(
2700 v2.get("resolution_cache")
2701 .unwrap()
2702 .get("x")
2703 .unwrap()
2704 .get("status")
2705 .unwrap(),
2706 "external"
2707 );
2708 }
2709
2710 #[test]
2711 fn serde_roundtrip_is_byte_identical() {
2712 let mut store = LineageStore::new();
2715 store.update(&chain_clips(), &chain_resolution(), "now");
2716
2717 let first = serde_json::to_string(&store).unwrap();
2718 let back: LineageStore = serde_json::from_str(&first).unwrap();
2719 let second = serde_json::to_string(&back).unwrap();
2720 assert_eq!(first, second, "round-trip must be byte-identical");
2721 }
2722
2723 #[test]
2724 fn existing_string_form_json_deserialises_correctly() {
2725 let json = r#"{
2728 "nodes": {"a": {"title": "Root", "status": "observed"}},
2729 "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2730 "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2731 }"#;
2732 let store: LineageStore = serde_json::from_str(json).unwrap();
2733 assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2734 assert_eq!(store.edges[0].role, EdgeRole::Primary);
2735 assert_eq!(store.edges[0].status, EdgeStatus::Active);
2736 assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2737 let archived = store.archived_parents();
2739 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2740 }
2741}