1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::album_art::{AlbumArt, PlaylistState};
25use crate::identity::Owner;
26use crate::lineage::{
27 AttributionEdge, Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
28 attribution_edges, immediate_parent, lineage_edges,
29};
30use crate::model::Clip;
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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum NodeStatus {
129 #[default]
130 #[serde(other)]
131 Observed,
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum EdgeStatus {
138 #[default]
139 #[serde(other)]
140 Active,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(default)]
147pub struct Node {
148 pub title: String,
149 pub created_at: String,
150 pub clip_type: String,
151 pub task: String,
152 pub is_remix: bool,
153 pub is_trashed: bool,
154 pub status: NodeStatus,
155 pub first_seen_at: String,
156 pub last_seen_at: String,
157}
158
159impl Default for Node {
160 fn default() -> Self {
161 Self {
162 title: String::new(),
163 created_at: String::new(),
164 clip_type: String::new(),
165 task: String::new(),
166 is_remix: false,
167 is_trashed: false,
168 status: NodeStatus::Observed,
169 first_seen_at: String::new(),
170 last_seen_at: String::new(),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(default)]
180pub struct StoredEdge {
181 pub child_id: String,
182 pub parent_id: String,
183 pub edge_type: String,
185 pub role: EdgeRole,
186 pub source_field: String,
188 pub ordinal: u32,
190 pub status: EdgeStatus,
191 pub first_seen_at: String,
192 pub last_seen_at: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Hash)]
196struct EdgeKey {
197 child_id: String,
198 parent_id: String,
199 edge_type: String,
200 role: EdgeRole,
201 ordinal: u32,
202}
203
204impl EdgeKey {
205 fn new(child_id: &str, parent_id: &str, edge_type: &str, role: EdgeRole, ordinal: u32) -> Self {
206 Self {
207 child_id: child_id.to_owned(),
208 parent_id: parent_id.to_owned(),
209 edge_type: edge_type.to_owned(),
210 role,
211 ordinal,
212 }
213 }
214
215 fn from_stored(edge: &StoredEdge) -> Self {
216 Self::new(
217 &edge.child_id,
218 &edge.parent_id,
219 &edge.edge_type,
220 edge.role,
221 edge.ordinal,
222 )
223 }
224}
225
226impl Default for StoredEdge {
227 fn default() -> Self {
228 Self {
229 child_id: String::new(),
230 parent_id: String::new(),
231 edge_type: String::new(),
232 role: EdgeRole::Primary,
233 source_field: String::new(),
234 ordinal: 0,
235 status: EdgeStatus::Active,
236 first_seen_at: String::new(),
237 last_seen_at: String::new(),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
244#[serde(default)]
245pub struct CacheEntry {
246 pub root_id: String,
247 pub status: ResolveStatus,
248 pub algorithm_version: u32,
249 pub computed_at: String,
250}
251
252impl LineageStore {
253 pub fn new() -> Self {
255 Self::default()
256 }
257
258 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
271 self.album_overrides = overrides;
272 }
273
274 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
298 if !self.eligible_root_ids.contains(root_id) {
299 return root_title;
300 }
301 match self.album_overrides.get(root_id) {
302 Some(name) if !name.trim().is_empty() => name.clone(),
303 _ => root_title,
304 }
305 }
306
307 pub fn refresh_eligible_roots(&mut self) {
317 self.eligible_root_ids = self
318 .resolution_cache
319 .values()
320 .map(|entry| entry.root_id.as_str())
321 .filter(|root_id| !root_id.is_empty())
322 .map(str::to_owned)
323 .collect();
324 }
325
326 #[cfg(test)]
329 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
330 &self.eligible_root_ids
331 }
332
333 pub fn node(&self, id: &str) -> Option<&Node> {
335 self.nodes.get(id)
336 }
337
338 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
340 self.resolution_cache.get(id)
341 }
342
343 pub fn context_for(&self, clip: &Clip) -> LineageContext {
354 let cached = self.get_root(&clip.id);
355 let root_id = cached
356 .map(|entry| entry.root_id.clone())
357 .filter(|id| !id.is_empty())
358 .unwrap_or_else(|| clip.id.clone());
359 let root_title = self
360 .node(&root_id)
361 .map(|node| node.title.clone())
362 .unwrap_or_else(|| clip.title.clone());
363 let root_title = self.effective_root_title(&root_id, root_title);
364 let root_date = self
365 .node(&root_id)
366 .map(|node| node.created_at.clone())
367 .unwrap_or_else(|| clip.created_at.clone());
368 let (parent_id, edge_type) = match immediate_parent(clip) {
369 Some((id, edge)) => (id, Some(edge)),
370 None => (String::new(), None),
371 };
372 let status = cached
373 .map(|entry| entry.status)
374 .unwrap_or(ResolveStatus::Resolved);
375 LineageContext {
376 root_id,
377 root_title,
378 root_date,
379 parent_id,
380 edge_type,
381 status,
382 }
383 }
384
385 pub fn album_for_id(&self, id: &str) -> String {
394 let own = self.node(id);
395 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
396 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
397 let root_id = self
398 .get_root(id)
399 .map(|entry| entry.root_id.clone())
400 .filter(|root| !root.is_empty())
401 .unwrap_or_else(|| id.to_owned());
402 let root_title = self
403 .node(&root_id)
404 .map(|node| node.title.clone())
405 .unwrap_or_else(|| own_title.clone());
406 let root_title = self.effective_root_title(&root_id, root_title);
407 let root_date = self
408 .node(&root_id)
409 .map(|node| node.created_at.clone())
410 .unwrap_or(own_created_at);
411 let context = LineageContext {
412 root_id,
413 root_title,
414 root_date,
415 parent_id: String::new(),
416 edge_type: None,
417 status: ResolveStatus::Resolved,
418 };
419 context.album(&own_title)
420 }
421
422 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
448 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
449 for root_id in &self.eligible_root_ids {
450 let node_title = self
451 .nodes
452 .get(root_id)
453 .map(|node| node.title.clone())
454 .unwrap_or_default();
455 let effective = self.effective_root_title(root_id, node_title);
456 let title = effective.trim();
457 if title.is_empty() {
458 continue;
459 }
460 roots_by_title
461 .entry(title.to_owned())
462 .or_default()
463 .insert(root_id.clone());
464 }
465 roots_by_title
466 .into_iter()
467 .filter(|(_, roots)| roots.len() > 1)
468 .map(|(title, _)| title)
469 .collect()
470 }
471
472 pub fn len(&self) -> usize {
474 self.nodes.len()
475 }
476
477 pub fn is_empty(&self) -> bool {
479 self.nodes.is_empty()
480 }
481
482 pub fn iter(&self) -> Iter<'_, String, Node> {
484 self.nodes.iter()
485 }
486
487 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
495 self.rebuild_edge_index();
496
497 for clip in clips {
498 self.upsert_node(clip, now);
499 }
500 for clip in &resolution.gap_filled {
503 self.upsert_node(clip, now);
504 }
505
506 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
514 for edge in lineage_edges(clip) {
515 self.upsert_edge(&clip.id, &edge, now);
516 }
517 }
518 for (child_id, parent_id) in &resolution.bridges {
519 let edge = Edge {
520 parent_id: parent_id.clone(),
521 edge_type: EdgeType::Derived,
522 role: EdgeRole::Primary,
523 ordinal: 0,
524 source_field: "parent_endpoint",
525 };
526 self.upsert_edge(child_id, &edge, now);
527 }
528 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
533 for edge in attribution_edges(clip) {
534 self.upsert_attribution_edge(&clip.id, &edge, now);
535 }
536 }
537 self.edges.sort_by(|a, b| {
538 a.child_id
539 .cmp(&b.child_id)
540 .then(a.ordinal.cmp(&b.ordinal))
541 .then(a.parent_id.cmp(&b.parent_id))
542 .then(a.edge_type.cmp(&b.edge_type))
543 .then(a.role.cmp(&b.role))
544 });
545 self.rebuild_edge_index();
546
547 for (child_id, info) in &resolution.roots {
548 self.upsert_cache(child_id, info, now);
549 }
550 self.refresh_eligible_roots();
551 }
552
553 pub fn archived_parents(&self) -> HashMap<String, String> {
562 self.edges
563 .iter()
564 .filter(|edge| {
565 edge.role == EdgeRole::Primary
566 && edge.ordinal == 0
567 && edge.status == EdgeStatus::Active
568 })
569 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
570 .collect()
571 }
572
573 fn upsert_node(&mut self, clip: &Clip, now: &str) {
576 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
577 first_seen_at: now.to_owned(),
578 ..Node::default()
579 });
580 node.title = clip.title.clone();
581 node.created_at = clip.created_at.clone();
582 node.clip_type = clip.clip_type.clone();
583 node.task = clip.task.clone();
584 node.is_remix = clip.is_remix;
585 node.is_trashed = clip.is_trashed;
586 node.last_seen_at = now.to_owned();
587 }
588
589 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
592 let edge_type = edge_type_slug(edge.edge_type);
593 let key = EdgeKey::new(
594 child_id,
595 &edge.parent_id,
596 edge_type,
597 edge.role,
598 edge.ordinal,
599 );
600 if let Some(&index) = self.edge_index.get(&key) {
601 let existing = &mut self.edges[index];
602 existing.source_field = edge.source_field.to_owned();
603 existing.status = EdgeStatus::Active;
604 existing.last_seen_at = now.to_owned();
605 } else {
606 self.edges.push(StoredEdge {
607 child_id: child_id.to_owned(),
608 parent_id: edge.parent_id.clone(),
609 edge_type: edge_type.to_owned(),
610 role: edge.role,
611 source_field: edge.source_field.to_owned(),
612 ordinal: edge.ordinal,
613 status: EdgeStatus::Active,
614 first_seen_at: now.to_owned(),
615 last_seen_at: now.to_owned(),
616 });
617 self.edge_index.insert(key, self.edges.len() - 1);
618 }
619 }
620
621 fn upsert_attribution_edge(&mut self, child_id: &str, edge: &AttributionEdge, now: &str) {
629 let edge_type = normalise_slug(&edge.edge_slug);
630 let key = EdgeKey::new(
631 child_id,
632 &edge.parent_id,
633 &edge_type,
634 edge.role,
635 edge.ordinal,
636 );
637 if let Some(&index) = self.edge_index.get(&key) {
638 let existing = &mut self.edges[index];
639 existing.source_field = edge.source_field.to_owned();
640 existing.status = EdgeStatus::Active;
641 existing.last_seen_at = now.to_owned();
642 } else {
643 self.edges.push(StoredEdge {
644 child_id: child_id.to_owned(),
645 parent_id: edge.parent_id.clone(),
646 edge_type,
647 role: edge.role,
648 source_field: edge.source_field.to_owned(),
649 ordinal: edge.ordinal,
650 status: EdgeStatus::Active,
651 first_seen_at: now.to_owned(),
652 last_seen_at: now.to_owned(),
653 });
654 self.edge_index.insert(key, self.edges.len() - 1);
655 }
656 }
657
658 fn rebuild_edge_index(&mut self) {
659 self.edge_index.clear();
660 for (index, edge) in self.edges.iter().enumerate() {
661 self.edge_index
662 .entry(EdgeKey::from_stored(edge))
663 .or_insert(index);
664 }
665 }
666
667 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
674 if info.status != ResolveStatus::Resolved
675 && self
676 .resolution_cache
677 .get(child_id)
678 .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
679 {
680 return;
681 }
682 self.resolution_cache.insert(
683 child_id.to_owned(),
684 CacheEntry {
685 root_id: info.root_id.clone(),
686 status: info.status,
687 algorithm_version: 1,
688 computed_at: now.to_owned(),
689 },
690 );
691 }
692}
693
694fn edge_type_slug(edge_type: EdgeType) -> &'static str {
696 match edge_type {
697 EdgeType::Cover => "cover",
698 EdgeType::Remaster => "remaster",
699 EdgeType::SpeedEdit => "speed_edit",
700 EdgeType::Edit => "edit",
701 EdgeType::Extend => "extend",
702 EdgeType::SectionReplace => "section_replace",
703 EdgeType::Stitch => "stitch",
704 EdgeType::Derived => "derived",
705 EdgeType::Uploaded => "uploaded",
706 }
707}
708
709fn normalise_slug(slug: &str) -> String {
713 let normalised = slug
714 .split_whitespace()
715 .collect::<Vec<_>>()
716 .join("_")
717 .to_lowercase();
718 if normalised.is_empty() {
719 "attribution".to_owned()
720 } else {
721 normalised
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use std::collections::HashMap;
729
730 fn chain_clips() -> Vec<Clip> {
732 vec![
733 Clip {
734 id: "c".into(),
735 title: "Cover".into(),
736 clip_type: "gen".into(),
737 task: "cover".into(),
738 created_at: "t2".into(),
739 cover_clip_id: "b".into(),
740 edited_clip_id: "b".into(),
741 ..Default::default()
742 },
743 Clip {
744 id: "b".into(),
745 title: "Remaster".into(),
746 clip_type: "upsample".into(),
747 task: "upsample".into(),
748 created_at: "t1".into(),
749 upsample_clip_id: "a".into(),
750 edited_clip_id: "a".into(),
751 ..Default::default()
752 },
753 Clip {
754 id: "a".into(),
755 title: "Root".into(),
756 clip_type: "gen".into(),
757 created_at: "t0".into(),
758 ..Default::default()
759 },
760 ]
761 }
762
763 fn chain_resolution() -> Resolution {
765 let mut roots = HashMap::new();
766 for id in ["a", "b", "c"] {
767 roots.insert(
768 id.to_owned(),
769 RootInfo {
770 root_id: "a".into(),
771 root_title: "Root".into(),
772 status: ResolveStatus::Resolved,
773 },
774 );
775 }
776 Resolution {
777 roots,
778 gap_filled: Vec::new(),
779 bridges: Vec::new(),
780 }
781 }
782
783 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
784 store
785 .edges
786 .iter()
787 .find(|e| e.child_id == child && e.parent_id == parent)
788 .expect("edge should exist")
789 }
790
791 #[test]
792 fn new_store_is_empty_and_versioned() {
793 let store = LineageStore::new();
794 assert!(store.is_empty());
795 assert_eq!(store.len(), 0);
796 assert_eq!(store.schema_version, 1);
797 }
798
799 #[test]
800 fn update_populates_nodes_edges_and_cache() {
801 let mut store = LineageStore::new();
802 store.update(&chain_clips(), &chain_resolution(), "now");
803
804 assert_eq!(store.len(), 3);
806 let cover = store.node("c").unwrap();
807 assert_eq!(cover.title, "Cover");
808 assert_eq!(cover.clip_type, "gen");
809 assert_eq!(cover.task, "cover");
810 assert_eq!(cover.created_at, "t2");
811 assert_eq!(cover.status, NodeStatus::Observed);
812 assert!(!cover.is_trashed);
813 assert_eq!(cover.first_seen_at, "now");
814 assert_eq!(cover.last_seen_at, "now");
815
816 assert_eq!(store.edges.len(), 2);
818 let cb = edge(&store, "c", "b");
819 assert_eq!(cb.edge_type, "cover");
820 assert_eq!(cb.role, EdgeRole::Primary);
821 assert_eq!(cb.ordinal, 0);
822 assert_eq!(cb.source_field, "cover_clip_id");
823 assert_eq!(cb.status, EdgeStatus::Active);
824 let ba = edge(&store, "b", "a");
825 assert_eq!(ba.edge_type, "remaster");
826 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
827
828 for id in ["a", "b", "c"] {
830 let cached = store.get_root(id).unwrap();
831 assert_eq!(cached.root_id, "a");
832 assert_eq!(cached.status, ResolveStatus::Resolved);
833 assert_eq!(cached.algorithm_version, 1);
834 }
835 }
836
837 #[test]
838 fn update_persists_edges_for_gap_filled_ancestors() {
839 let child = Clip {
843 id: "child".into(),
844 title: "Cover".into(),
845 clip_type: "gen".into(),
846 task: "cover".into(),
847 cover_clip_id: "mid".into(),
848 edited_clip_id: "mid".into(),
849 ..Default::default()
850 };
851 let mid = Clip {
852 id: "mid".into(),
853 title: "Mid".into(),
854 clip_type: "gen".into(),
855 task: "cover".into(),
856 cover_clip_id: "root".into(),
857 edited_clip_id: "root".into(),
858 ..Default::default()
859 };
860 let mut roots = HashMap::new();
861 roots.insert(
862 "child".to_owned(),
863 RootInfo {
864 root_id: "root".into(),
865 root_title: "Original".into(),
866 status: ResolveStatus::Resolved,
867 },
868 );
869 let resolution = Resolution {
870 roots,
871 gap_filled: vec![mid],
872 bridges: Vec::new(),
873 };
874 let mut store = LineageStore::new();
875 store.update(std::slice::from_ref(&child), &resolution, "now");
876
877 let mid_edge = edge(&store, "mid", "root");
879 assert_eq!(mid_edge.role, EdgeRole::Primary);
880 assert_eq!(mid_edge.ordinal, 0);
881 let archived = store.archived_parents();
883 assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
884 assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
885 }
886
887 #[test]
888 fn update_persists_bridges_as_edges() {
889 let child = Clip {
892 id: "child".into(),
893 title: "Cover".into(),
894 clip_type: "gen".into(),
895 task: "cover".into(),
896 cover_clip_id: "gone".into(),
897 edited_clip_id: "gone".into(),
898 ..Default::default()
899 };
900 let mut roots = HashMap::new();
901 roots.insert(
902 "child".to_owned(),
903 RootInfo {
904 root_id: "found".into(),
905 root_title: String::new(),
906 status: ResolveStatus::External,
907 },
908 );
909 let resolution = Resolution {
910 roots,
911 gap_filled: Vec::new(),
912 bridges: vec![("gone".to_owned(), "found".to_owned())],
913 };
914 let mut store = LineageStore::new();
915 store.update(std::slice::from_ref(&child), &resolution, "now");
916
917 let bridged = edge(&store, "gone", "found");
918 assert_eq!(bridged.source_field, "parent_endpoint");
919 assert_eq!(bridged.role, EdgeRole::Primary);
920 assert_eq!(bridged.ordinal, 0);
921 assert_eq!(
922 store.archived_parents().get("gone").map(String::as_str),
923 Some("found")
924 );
925 }
926
927 #[test]
928 fn archived_parents_maps_children_to_primary_parents_only() {
929 let mut store = LineageStore::new();
930 store.update(&chain_clips(), &chain_resolution(), "now");
931 let archived = store.archived_parents();
932 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
933 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
934 assert!(
935 !archived.contains_key("a"),
936 "a root has no primary parent edge"
937 );
938 }
939
940 #[test]
941 fn update_persists_attribution_edges_without_polluting_resolution() {
942 let child = Clip {
947 id: "child".into(),
948 title: "Remix".into(),
949 clip_type: "gen".into(),
950 task: "cover".into(),
951 cover_clip_id: "struct-parent".into(),
952 edited_clip_id: "struct-parent".into(),
953 handle: "me".into(),
954 clip_attribution_type: "remix".into(),
955 clip_roots: vec![crate::model::ClipRoot {
956 id: "attr-root".into(),
957 handle: "me".into(),
958 ..Default::default()
959 }],
960 ..Default::default()
961 };
962 let mut roots = HashMap::new();
963 roots.insert(
964 "child".to_owned(),
965 RootInfo {
966 root_id: "struct-parent".into(),
967 root_title: "Structural Root".into(),
968 status: ResolveStatus::Resolved,
969 },
970 );
971 let resolution = Resolution {
972 roots,
973 gap_filled: Vec::new(),
974 bridges: Vec::new(),
975 };
976 let mut store = LineageStore::new();
977 store.update(std::slice::from_ref(&child), &resolution, "now");
978
979 let attr = edge(&store, "child", "attr-root");
981 assert_eq!(attr.edge_type, "remix");
982 assert_eq!(attr.role, EdgeRole::Secondary);
983 assert_eq!(attr.ordinal, 0);
984 assert_eq!(attr.source_field, "clip_roots");
985
986 let structural = edge(&store, "child", "struct-parent");
988 assert_eq!(structural.role, EdgeRole::Primary);
989
990 let archived = store.archived_parents();
992 assert_eq!(
993 archived.get("child").map(String::as_str),
994 Some("struct-parent"),
995 "archived_parents reads only the structural primary, never clip_roots"
996 );
997 assert_eq!(
998 store.get_root("child").unwrap().root_id,
999 "struct-parent",
1000 "the resolution cache roots at the structural parent, not the attribution root"
1001 );
1002 }
1003
1004 #[test]
1005 fn update_defaults_a_blank_attribution_type_to_attribution() {
1006 let child = Clip {
1009 id: "child".into(),
1010 title: "Remix".into(),
1011 handle: "me".into(),
1012 clip_attribution_type: "".into(),
1013 clip_roots: vec![crate::model::ClipRoot {
1014 id: "attr-root".into(),
1015 handle: "me".into(),
1016 ..Default::default()
1017 }],
1018 ..Default::default()
1019 };
1020 let mut roots = HashMap::new();
1021 roots.insert(
1022 "child".to_owned(),
1023 RootInfo {
1024 root_id: "child".into(),
1025 root_title: "Remix".into(),
1026 status: ResolveStatus::Resolved,
1027 },
1028 );
1029 let resolution = Resolution {
1030 roots,
1031 gap_filled: Vec::new(),
1032 bridges: Vec::new(),
1033 };
1034 let mut store = LineageStore::new();
1035 store.update(std::slice::from_ref(&child), &resolution, "now");
1036 assert_eq!(edge(&store, "child", "attr-root").edge_type, "attribution");
1037 }
1038
1039 #[test]
1040 fn normalise_slug_lowercases_joins_and_defaults() {
1041 assert_eq!(normalise_slug("remix"), "remix");
1042 assert_eq!(normalise_slug("Remix Cover"), "remix_cover");
1043 assert_eq!(
1044 normalise_slug(" Remix Reuse Style "),
1045 "remix_reuse_style"
1046 );
1047 assert_eq!(normalise_slug(""), "attribution");
1048 assert_eq!(normalise_slug(" "), "attribution");
1049 }
1050
1051 #[test]
1052 fn album_for_id_matches_context_for_and_handles_unknown() {
1053 let mut store = LineageStore::new();
1054 store.update(&chain_clips(), &chain_resolution(), "now");
1055
1056 assert_eq!(store.album_for_id("c"), "Root");
1059 let cover = &chain_clips()[0];
1060 assert_eq!(
1061 store.album_for_id("c"),
1062 store.context_for(cover).album(&cover.title)
1063 );
1064 assert_eq!(store.album_for_id("a"), "Root");
1066 assert_eq!(store.album_for_id("missing"), "");
1068 }
1069
1070 #[test]
1071 fn serde_roundtrip_preserves_a_relational_shape() {
1072 let mut store = LineageStore::new();
1073 store.update(&chain_clips(), &chain_resolution(), "now");
1074
1075 let json = serde_json::to_string(&store).unwrap();
1076 let back: LineageStore = serde_json::from_str(&json).unwrap();
1077 assert_eq!(store, back);
1078
1079 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1080 assert_eq!(value.get("schema_version").unwrap(), 1);
1081 assert!(value.get("nodes").unwrap().is_object());
1082 assert!(value.get("edges").unwrap().is_array());
1083 assert!(value.get("resolution_cache").unwrap().is_object());
1084 assert!(value.get("edge_index").is_none());
1085
1086 let node = value.get("nodes").unwrap().get("c").unwrap();
1089 assert!(node.get("edges").is_none());
1090 assert!(node.get("parent_id").is_none());
1091 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1092 assert!(first_edge.get("child_id").is_some());
1093 assert!(first_edge.get("parent_id").is_some());
1094 }
1095
1096 #[test]
1097 fn album_overrides_are_runtime_only_and_never_persist() {
1098 let mut store = LineageStore::new();
1102 store.update(&chain_clips(), &chain_resolution(), "now");
1103 store.set_album_overrides(
1104 [("a".to_owned(), "Preferred".to_owned())]
1105 .into_iter()
1106 .collect(),
1107 );
1108
1109 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1110 assert!(value.get("album_overrides").is_none());
1111
1112 let json = serde_json::to_string(&store).unwrap();
1113 let back: LineageStore = serde_json::from_str(&json).unwrap();
1114 assert!(back.album_overrides.is_empty());
1115 assert_eq!(back.album_for_id("c"), "Root");
1116 }
1117
1118 #[test]
1119 fn update_is_idempotent_bar_last_seen() {
1120 let clips = chain_clips();
1121 let resolution = chain_resolution();
1122 let mut store = LineageStore::new();
1123 store.update(&clips, &resolution, "first");
1124 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1125 let edge_count = store.edges.len();
1126
1127 store.update(&clips, &resolution, "second");
1128
1129 assert_eq!(
1131 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1132 node_ids
1133 );
1134 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1135 assert_eq!(store.resolution_cache.len(), 3);
1136
1137 let cover = store.node("c").unwrap();
1139 assert_eq!(cover.first_seen_at, "first");
1140 assert_eq!(cover.last_seen_at, "second");
1141 let cb = edge(&store, "c", "b");
1142 assert_eq!(cb.first_seen_at, "first");
1143 assert_eq!(cb.last_seen_at, "second");
1144 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1146 }
1147
1148 #[test]
1149 fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
1150 let clips = chain_clips();
1151 let resolution = chain_resolution();
1152
1153 let mut store = LineageStore::new();
1154 store.update(&clips, &resolution, "first");
1155
1156 let json = serde_json::to_string(&store).unwrap();
1157 let mut store: LineageStore = serde_json::from_str(&json).unwrap();
1158
1159 store.update(&clips, &resolution, "second");
1160
1161 assert_eq!(store.edges.len(), 2);
1162 let cb = edge(&store, "c", "b");
1163 assert_eq!(cb.first_seen_at, "first");
1164 assert_eq!(cb.last_seen_at, "second");
1165 let ba = edge(&store, "b", "a");
1166 assert_eq!(ba.first_seen_at, "first");
1167 assert_eq!(ba.last_seen_at, "second");
1168 }
1169
1170 #[test]
1171 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1172 let mut store = LineageStore::new();
1173 store.update(&chain_clips(), &chain_resolution(), "first");
1174 assert_eq!(store.get_root("c").unwrap().status, ResolveStatus::Resolved);
1175
1176 let child = Clip {
1179 id: "c".into(),
1180 title: "Cover".into(),
1181 clip_type: "gen".into(),
1182 task: "cover".into(),
1183 cover_clip_id: "b".into(),
1184 edited_clip_id: "b".into(),
1185 ..Default::default()
1186 };
1187 let mut roots = HashMap::new();
1188 roots.insert(
1189 "c".to_owned(),
1190 RootInfo {
1191 root_id: "elsewhere".into(),
1192 root_title: String::new(),
1193 status: ResolveStatus::External,
1194 },
1195 );
1196 roots.insert(
1197 "d".to_owned(),
1198 RootInfo {
1199 root_id: "boundary".into(),
1200 root_title: String::new(),
1201 status: ResolveStatus::External,
1202 },
1203 );
1204 let resolution = Resolution {
1205 roots,
1206 gap_filled: Vec::new(),
1207 bridges: Vec::new(),
1208 };
1209 store.update(&[child], &resolution, "second");
1210
1211 let cached = store.get_root("c").unwrap();
1213 assert_eq!(cached.root_id, "a");
1214 assert_eq!(cached.status, ResolveStatus::Resolved);
1215 assert_eq!(cached.computed_at, "first");
1216 let d = store.get_root("d").unwrap();
1218 assert_eq!(d.root_id, "boundary");
1219 assert_eq!(d.status, ResolveStatus::External);
1220 }
1221
1222 #[test]
1223 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1224 let child = Clip {
1228 id: "c".into(),
1229 title: "Cover".into(),
1230 clip_type: "gen".into(),
1231 task: "cover".into(),
1232 cover_clip_id: "t".into(),
1233 edited_clip_id: "t".into(),
1234 ..Default::default()
1235 };
1236 let trashed = Clip {
1237 id: "t".into(),
1238 title: "Trashed Original".into(),
1239 clip_type: "gen".into(),
1240 is_trashed: true,
1241 ..Default::default()
1242 };
1243 let mut roots = HashMap::new();
1244 roots.insert(
1245 "c".to_owned(),
1246 RootInfo {
1247 root_id: "t".into(),
1248 root_title: "Trashed Original".into(),
1249 status: ResolveStatus::Resolved,
1250 },
1251 );
1252 let resolution = Resolution {
1253 roots,
1254 gap_filled: vec![trashed],
1255 bridges: Vec::new(),
1256 };
1257 store_update_and_assert_trashed(child, resolution);
1258 }
1259
1260 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1261 let mut store = LineageStore::new();
1262 store.update(&[child], &resolution, "now");
1263
1264 let node = store
1265 .node("t")
1266 .expect("trashed ancestor should be archived");
1267 assert!(node.is_trashed);
1268 assert_eq!(node.title, "Trashed Original");
1269 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1271 }
1272
1273 #[test]
1274 fn partial_json_loads_with_defaults() {
1275 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1278 let store: LineageStore = serde_json::from_str(json).unwrap();
1279 assert_eq!(store.schema_version, 1);
1280 let node = store.node("x").unwrap();
1281 assert_eq!(node.title, "Kept");
1282 assert_eq!(node.status, NodeStatus::Observed);
1283 assert_eq!(store.edges[0].status, EdgeStatus::Active);
1284 assert!(store.resolution_cache.is_empty());
1285 assert!(store.albums.is_empty());
1288 assert!(store.album_art("x").is_none());
1289 assert!(store.playlists.is_empty());
1293 assert!(store.playlist("x").is_none());
1294 }
1295
1296 #[test]
1297 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1298 let mut store = LineageStore::new();
1299 store.update(&chain_clips(), &chain_resolution(), "now");
1300
1301 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1303 assert_eq!(ctx.root_id, "a");
1304 assert_eq!(ctx.root_title, "Root");
1305 assert_eq!(ctx.parent_id, "b");
1306 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1307 assert_eq!(ctx.status, ResolveStatus::Resolved);
1308 assert_eq!(ctx.album("Cover"), "Root");
1310 }
1311
1312 #[test]
1313 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1314 let mut store = LineageStore::new();
1315 store.update(&chain_clips(), &chain_resolution(), "now");
1316
1317 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1319 assert_eq!(ctx.root_id, "a");
1320 assert_eq!(ctx.root_title, "Root");
1321 assert_eq!(ctx.parent_id, "");
1322 assert_eq!(ctx.edge_type, None);
1323 assert_eq!(ctx.album("Root"), "Root");
1324 }
1325
1326 #[test]
1327 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1328 let clips = vec![
1331 Clip {
1332 id: "child".into(),
1333 title: "Revision".into(),
1334 clip_type: "gen".into(),
1335 task: "cover".into(),
1336 created_at: "2024-01-02T08:00:00Z".into(),
1337 cover_clip_id: "root".into(),
1338 edited_clip_id: "root".into(),
1339 ..Default::default()
1340 },
1341 Clip {
1342 id: "root".into(),
1343 title: "Origin".into(),
1344 clip_type: "gen".into(),
1345 created_at: "2023-12-30T23:00:00Z".into(),
1346 ..Default::default()
1347 },
1348 ];
1349 let mut roots = HashMap::new();
1350 for id in ["child", "root"] {
1351 roots.insert(
1352 id.to_owned(),
1353 RootInfo {
1354 root_id: "root".into(),
1355 root_title: "Origin".into(),
1356 status: ResolveStatus::Resolved,
1357 },
1358 );
1359 }
1360 let resolution = Resolution {
1361 roots,
1362 gap_filled: Vec::new(),
1363 bridges: Vec::new(),
1364 };
1365 let mut store = LineageStore::new();
1366 store.update(&clips, &resolution, "now");
1367
1368 let child_ctx = store.context_for(&clips[0]);
1369 assert_eq!(child_ctx.root_id, "root");
1370 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1371 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1373
1374 let root_ctx = store.context_for(&clips[1]);
1376 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1377 }
1378
1379 #[test]
1380 fn context_for_an_unknown_clip_is_self_rooted() {
1381 let store = LineageStore::new();
1382 let orphan = Clip {
1383 id: "z".into(),
1384 title: "Lonely".into(),
1385 ..Default::default()
1386 };
1387 let ctx = store.context_for(&orphan);
1388 assert_eq!(ctx.root_id, "z");
1389 assert_eq!(ctx.root_title, "Lonely");
1390 assert_eq!(ctx.parent_id, "");
1391 assert_eq!(ctx.status, ResolveStatus::Resolved);
1392 }
1393
1394 #[test]
1395 fn context_for_retains_a_purged_ancestor_album() {
1396 let child = Clip {
1401 id: "c".into(),
1402 title: "Cover".into(),
1403 clip_type: "gen".into(),
1404 task: "cover".into(),
1405 cover_clip_id: "t".into(),
1406 edited_clip_id: "t".into(),
1407 ..Default::default()
1408 };
1409 let trashed = Clip {
1410 id: "t".into(),
1411 title: "Trashed Original".into(),
1412 clip_type: "gen".into(),
1413 is_trashed: true,
1414 ..Default::default()
1415 };
1416 let mut roots = HashMap::new();
1417 roots.insert(
1418 "c".to_owned(),
1419 RootInfo {
1420 root_id: "t".into(),
1421 root_title: "Trashed Original".into(),
1422 status: ResolveStatus::Resolved,
1423 },
1424 );
1425 let resolution = Resolution {
1426 roots,
1427 gap_filled: vec![trashed],
1428 bridges: Vec::new(),
1429 };
1430 let mut store = LineageStore::new();
1431 store.update(std::slice::from_ref(&child), &resolution, "now");
1432
1433 let ctx = store.context_for(&child);
1434 assert_eq!(ctx.root_id, "t");
1435 assert_eq!(ctx.root_title, "Trashed Original");
1436 assert_eq!(ctx.album("Cover"), "Trashed Original");
1437 }
1438
1439 #[test]
1440 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1441 let clips = vec![
1444 Clip {
1445 id: "r1".into(),
1446 title: "Break Through".into(),
1447 clip_type: "gen".into(),
1448 ..Default::default()
1449 },
1450 Clip {
1451 id: "r2".into(),
1452 title: "Break Through".into(),
1453 clip_type: "gen".into(),
1454 ..Default::default()
1455 },
1456 Clip {
1457 id: "r3".into(),
1458 title: "Solo".into(),
1459 clip_type: "gen".into(),
1460 ..Default::default()
1461 },
1462 Clip {
1463 id: "c1".into(),
1464 title: "Break Through".into(),
1465 clip_type: "gen".into(),
1466 task: "cover".into(),
1467 cover_clip_id: "r1".into(),
1468 edited_clip_id: "r1".into(),
1469 ..Default::default()
1470 },
1471 ];
1472 let mut roots = HashMap::new();
1473 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1474 let title = if root == "r3" {
1475 "Solo"
1476 } else {
1477 "Break Through"
1478 };
1479 roots.insert(
1480 id.to_owned(),
1481 RootInfo {
1482 root_id: root.into(),
1483 root_title: title.into(),
1484 status: ResolveStatus::Resolved,
1485 },
1486 );
1487 }
1488 let resolution = Resolution {
1489 roots,
1490 gap_filled: Vec::new(),
1491 bridges: Vec::new(),
1492 };
1493 let mut store = LineageStore::new();
1494 store.update(&clips, &resolution, "now");
1495
1496 let colliding = store.colliding_root_titles();
1497 assert!(colliding.contains("Break Through"));
1498 assert!(!colliding.contains("Solo"));
1499 assert_eq!(colliding.len(), 1);
1500 }
1501
1502 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1505 let clips = vec![
1506 Clip {
1507 id: "r1".into(),
1508 title: t1.into(),
1509 clip_type: "gen".into(),
1510 ..Default::default()
1511 },
1512 Clip {
1513 id: "r2".into(),
1514 title: t2.into(),
1515 clip_type: "gen".into(),
1516 ..Default::default()
1517 },
1518 ];
1519 let mut roots = HashMap::new();
1520 roots.insert(
1521 "r1".to_owned(),
1522 RootInfo {
1523 root_id: "r1".into(),
1524 root_title: t1.into(),
1525 status: ResolveStatus::Resolved,
1526 },
1527 );
1528 roots.insert(
1529 "r2".to_owned(),
1530 RootInfo {
1531 root_id: "r2".into(),
1532 root_title: t2.into(),
1533 status: ResolveStatus::Resolved,
1534 },
1535 );
1536 let mut store = LineageStore::new();
1537 store.update(
1538 &clips,
1539 &Resolution {
1540 roots,
1541 gap_filled: Vec::new(),
1542 bridges: Vec::new(),
1543 },
1544 "now",
1545 );
1546 store
1547 }
1548
1549 #[test]
1550 fn album_override_flows_into_context_tag_hash_and_index() {
1551 let clips = chain_clips();
1555 let mut store = LineageStore::new();
1556 store.update(&clips, &chain_resolution(), "now");
1557
1558 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1560
1561 store.set_album_overrides(
1562 [("a".to_owned(), "Preferred Name".to_owned())]
1563 .into_iter()
1564 .collect(),
1565 );
1566
1567 for id in ["a", "b", "c"] {
1569 let clip = clips.iter().find(|c| c.id == id).unwrap();
1570 let ctx = store.context_for(clip);
1571 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1572 assert_eq!(store.album_for_id(id), "Preferred Name");
1573 }
1574
1575 let ctx = store.context_for(cover);
1577 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1578 assert_eq!(meta.album, "Preferred Name");
1579
1580 let after_hash = crate::hash::meta_hash(cover, &ctx);
1582 assert_ne!(before_hash, after_hash);
1583 }
1584
1585 #[test]
1586 fn empty_album_override_is_ignored() {
1587 let clips = chain_clips();
1589 let mut store = LineageStore::new();
1590 store.update(&clips, &chain_resolution(), "now");
1591 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1592 assert_eq!(store.album_for_id("c"), "Root");
1593 }
1594
1595 #[test]
1596 fn album_override_creates_a_collision_that_disambiguates() {
1597 let mut store = two_root_store("Alpha", "Beta");
1599 assert!(store.colliding_root_titles().is_empty());
1600
1601 store.set_album_overrides(
1602 [("r2".to_owned(), "Alpha".to_owned())]
1603 .into_iter()
1604 .collect(),
1605 );
1606 let colliding = store.colliding_root_titles();
1607 assert!(colliding.contains("Alpha"));
1608 assert_eq!(colliding.len(), 1);
1609 }
1610
1611 #[test]
1612 fn album_override_resolves_a_natural_collision() {
1613 let mut store = two_root_store("Break Through", "Break Through");
1615 assert!(store.colliding_root_titles().contains("Break Through"));
1616
1617 store.set_album_overrides(
1618 [("r2".to_owned(), "Second Wind".to_owned())]
1619 .into_iter()
1620 .collect(),
1621 );
1622 assert!(store.colliding_root_titles().is_empty());
1623 }
1624
1625 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1630 store.resolution_cache.insert(
1631 root_id.to_owned(),
1632 CacheEntry {
1633 root_id: root_id.to_owned(),
1634 status: ResolveStatus::External,
1635 algorithm_version: 1,
1636 computed_at: "now".to_owned(),
1637 },
1638 );
1639 store.refresh_eligible_roots();
1642 }
1643
1644 #[test]
1645 fn override_on_node_less_root_collides_with_a_real_root() {
1646 let mut store = LineageStore::new();
1650 store.update(
1651 std::slice::from_ref(&Clip {
1652 id: "realroot".into(),
1653 title: "Shared".into(),
1654 clip_type: "gen".into(),
1655 ..Default::default()
1656 }),
1657 &Resolution {
1658 roots: [(
1659 "realroot".to_owned(),
1660 RootInfo {
1661 root_id: "realroot".into(),
1662 root_title: "Shared".into(),
1663 status: ResolveStatus::Resolved,
1664 },
1665 )]
1666 .into_iter()
1667 .collect(),
1668 gap_filled: Vec::new(),
1669 bridges: Vec::new(),
1670 },
1671 "now",
1672 );
1673 insert_cache_only_root(&mut store, "extroot");
1674 store.set_album_overrides(
1675 [("extroot".to_owned(), "Shared".to_owned())]
1676 .into_iter()
1677 .collect(),
1678 );
1679
1680 let colliding = store.colliding_root_titles();
1681 assert!(
1682 colliding.contains("Shared"),
1683 "a node-less overridden root must still be seen by collision detection"
1684 );
1685 }
1686
1687 #[test]
1688 fn two_node_less_roots_overridden_to_same_name_collide() {
1689 let mut store = LineageStore::new();
1690 insert_cache_only_root(&mut store, "extone");
1691 insert_cache_only_root(&mut store, "exttwo");
1692 store.set_album_overrides(
1693 [
1694 ("extone".to_owned(), "Shared".to_owned()),
1695 ("exttwo".to_owned(), "Shared".to_owned()),
1696 ]
1697 .into_iter()
1698 .collect(),
1699 );
1700 assert!(store.colliding_root_titles().contains("Shared"));
1701 }
1702
1703 #[test]
1704 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1705 let mut store = LineageStore::new();
1710 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1711 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1712 store.set_album_overrides(
1713 [
1714 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1715 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1716 ]
1717 .into_iter()
1718 .collect(),
1719 );
1720 let colliding = store.colliding_root_titles();
1721
1722 let clip_of = |id: &str| Clip {
1723 id: id.to_owned(),
1724 title: "Track".to_owned(),
1725 display_name: "alice".to_owned(),
1726 image_large_url: "https://art.example/large.jpg".to_owned(),
1727 ..Default::default()
1728 };
1729 let ctx_of = |root_id: &str| LineageContext {
1730 root_id: root_id.to_owned(),
1731 root_title: "Shared".to_owned(),
1732 root_date: String::new(),
1733 parent_id: String::new(),
1734 edge_type: None,
1735 status: ResolveStatus::Resolved,
1736 };
1737 let clip_a = clip_of("clipaaaa-1111");
1738 let clip_b = clip_of("clipbbbb-2222");
1739 let ctx_a = ctx_of("aaaaaaaa-root-one");
1740 let ctx_b = ctx_of("bbbbbbbb-root-two");
1741 let requests = [
1742 crate::naming::NamingRequest {
1743 clip: &clip_a,
1744 lineage: &ctx_a,
1745 },
1746 crate::naming::NamingRequest {
1747 clip: &clip_b,
1748 lineage: &ctx_b,
1749 },
1750 ];
1751 let names = crate::naming::render_clip_names(
1752 &requests,
1753 &crate::naming::NamingConfig::default(),
1754 &colliding,
1755 );
1756
1757 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1758 crate::reconcile::Desired {
1759 clip: clip.clone(),
1760 lineage: ctx.clone(),
1761 path: format!(
1762 "{}.flac",
1763 crate::desired::rel_to_string(&name.relative_path)
1764 ),
1765 format: crate::AudioFormat::Flac,
1766 meta_hash: String::new(),
1767 art_hash: String::new(),
1768 modes: vec![crate::reconcile::SourceMode::Mirror],
1769 trashed: false,
1770 private: false,
1771 artifacts: Vec::new(),
1772 stems: None,
1773 }
1774 };
1775 let desired = vec![
1776 desired_of(&clip_a, &ctx_a, &names[0]),
1777 desired_of(&clip_b, &ctx_b, &names[1]),
1778 ];
1779
1780 let albums = crate::reconcile::album_desired(
1781 &desired,
1782 false,
1783 false,
1784 crate::ffmpeg::WebpEncodeSettings::default(),
1785 );
1786 assert_eq!(albums.len(), 2, "each distinct root is its own album");
1787 let jpg_paths: Vec<String> = albums
1788 .iter()
1789 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1790 .collect();
1791 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1792 assert_ne!(
1793 jpg_paths[0], jpg_paths[1],
1794 "colliding roots must not share one folder.jpg path"
1795 );
1796 }
1797
1798 #[test]
1799 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
1800 let mut store = LineageStore::new();
1807 store.update(
1808 std::slice::from_ref(&Clip {
1809 id: "realroot".into(),
1810 title: "Shared".into(),
1811 clip_type: "gen".into(),
1812 ..Default::default()
1813 }),
1814 &Resolution {
1815 roots: [(
1816 "realroot".to_owned(),
1817 RootInfo {
1818 root_id: "realroot".into(),
1819 root_title: "Shared".into(),
1820 status: ResolveStatus::Resolved,
1821 },
1822 )]
1823 .into_iter()
1824 .collect(),
1825 gap_filled: Vec::new(),
1826 bridges: Vec::new(),
1827 },
1828 "now",
1829 );
1830 let new_clip = Clip {
1833 id: "newnewnew-9999".into(),
1834 title: "Solo Track".into(),
1835 display_name: "alice".into(),
1836 image_large_url: "https://art.example/large.jpg".into(),
1837 ..Default::default()
1838 };
1839 store.set_album_overrides(
1840 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
1841 .into_iter()
1842 .collect(),
1843 );
1844
1845 let new_ctx = store.context_for(&new_clip);
1847 assert_eq!(new_ctx.root_id, "newnewnew-9999");
1848 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
1849
1850 assert!(store.colliding_root_titles().is_empty());
1852
1853 let real_clip = Clip {
1855 id: "realroot".into(),
1856 title: "Shared".into(),
1857 display_name: "alice".into(),
1858 image_large_url: "https://art.example/large.jpg".into(),
1859 ..Default::default()
1860 };
1861 let real_ctx = store.context_for(&real_clip);
1862 let colliding = store.colliding_root_titles();
1863 let requests = [
1864 crate::naming::NamingRequest {
1865 clip: &real_clip,
1866 lineage: &real_ctx,
1867 },
1868 crate::naming::NamingRequest {
1869 clip: &new_clip,
1870 lineage: &new_ctx,
1871 },
1872 ];
1873 let names = crate::naming::render_clip_names(
1874 &requests,
1875 &crate::naming::NamingConfig::default(),
1876 &colliding,
1877 );
1878 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1879 crate::reconcile::Desired {
1880 clip: clip.clone(),
1881 lineage: ctx.clone(),
1882 path: format!(
1883 "{}.flac",
1884 crate::desired::rel_to_string(&name.relative_path)
1885 ),
1886 format: crate::AudioFormat::Flac,
1887 meta_hash: String::new(),
1888 art_hash: String::new(),
1889 modes: vec![crate::reconcile::SourceMode::Mirror],
1890 trashed: false,
1891 private: false,
1892 artifacts: Vec::new(),
1893 stems: None,
1894 }
1895 };
1896 let desired = vec![
1897 desired_of(&real_clip, &real_ctx, &names[0]),
1898 desired_of(&new_clip, &new_ctx, &names[1]),
1899 ];
1900 let albums = crate::reconcile::album_desired(
1901 &desired,
1902 false,
1903 false,
1904 crate::ffmpeg::WebpEncodeSettings::default(),
1905 );
1906 let jpg_paths: Vec<String> = albums
1907 .iter()
1908 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1909 .collect();
1910 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1911 assert_ne!(
1912 jpg_paths[0], jpg_paths[1],
1913 "an uncached override must not collapse two albums onto one path"
1914 );
1915 }
1916
1917 #[test]
1918 fn override_on_gap_filled_root_applies_to_children_and_collides() {
1919 let child = Clip {
1926 id: "childclip".into(),
1927 title: "Cover".into(),
1928 clip_type: "gen".into(),
1929 task: "cover".into(),
1930 cover_clip_id: "gaproot".into(),
1931 edited_clip_id: "gaproot".into(),
1932 ..Default::default()
1933 };
1934 let other_root = Clip {
1935 id: "otherroot".into(),
1936 title: "Preferred".into(),
1937 clip_type: "gen".into(),
1938 ..Default::default()
1939 };
1940 let gap_ancestor = Clip {
1941 id: "gaproot".into(),
1942 title: "Working Title".into(),
1943 clip_type: "gen".into(),
1944 ..Default::default()
1945 };
1946 let mut roots = HashMap::new();
1947 roots.insert(
1948 "childclip".to_owned(),
1949 RootInfo {
1950 root_id: "gaproot".into(),
1951 root_title: "Working Title".into(),
1952 status: ResolveStatus::Resolved,
1953 },
1954 );
1955 roots.insert(
1956 "otherroot".to_owned(),
1957 RootInfo {
1958 root_id: "otherroot".into(),
1959 root_title: "Preferred".into(),
1960 status: ResolveStatus::Resolved,
1961 },
1962 );
1963 let mut store = LineageStore::new();
1964 store.update(
1965 &[child.clone(), other_root],
1966 &Resolution {
1967 roots,
1968 gap_filled: vec![gap_ancestor],
1969 bridges: Vec::new(),
1970 },
1971 "now",
1972 );
1973 assert!(store.node("gaproot").is_some());
1975 assert!(!store.resolution_cache.contains_key("gaproot"));
1976
1977 store.set_album_overrides(
1978 [("gaproot".to_owned(), "Preferred".to_owned())]
1979 .into_iter()
1980 .collect(),
1981 );
1982
1983 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
1986 assert_eq!(store.album_for_id("childclip"), "Preferred");
1987
1988 assert!(store.colliding_root_titles().contains("Preferred"));
1991 }
1992
1993 #[test]
1994 fn eligible_root_set_is_exactly_the_cache_value_domain() {
1995 let child = Clip {
2001 id: "childclip".into(),
2002 title: "Cover".into(),
2003 clip_type: "gen".into(),
2004 task: "cover".into(),
2005 cover_clip_id: "gaproot".into(),
2006 edited_clip_id: "gaproot".into(),
2007 ..Default::default()
2008 };
2009 let mut roots = HashMap::new();
2010 roots.insert(
2011 "childclip".to_owned(),
2012 RootInfo {
2013 root_id: "gaproot".into(),
2014 root_title: "Working Title".into(),
2015 status: ResolveStatus::Resolved,
2016 },
2017 );
2018 let mut store = LineageStore::new();
2019 store.update(
2020 std::slice::from_ref(&child),
2021 &Resolution {
2022 roots,
2023 gap_filled: vec![Clip {
2024 id: "gaproot".into(),
2025 title: "Working Title".into(),
2026 clip_type: "gen".into(),
2027 ..Default::default()
2028 }],
2029 bridges: Vec::new(),
2030 },
2031 "now",
2032 );
2033
2034 let expected: std::collections::HashSet<String> = store
2035 .resolution_cache
2036 .values()
2037 .map(|entry| entry.root_id.clone())
2038 .filter(|root_id| !root_id.is_empty())
2039 .collect();
2040 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2041 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2043 assert!(!store.resolution_cache.contains_key("gaproot"));
2044 }
2045
2046 #[test]
2047 fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2048 let mut store = LineageStore::new();
2051 store.update(&chain_clips(), &chain_resolution(), "now");
2052
2053 let value = serde_json::to_value(&store).unwrap();
2054 let edges = value.get("edges").unwrap().as_array().unwrap();
2055
2056 let primary_edge = edges
2058 .iter()
2059 .find(|e| e.get("child_id").unwrap() == "c")
2060 .unwrap();
2061 assert_eq!(primary_edge.get("role").unwrap(), "primary");
2062 assert_eq!(primary_edge.get("status").unwrap(), "active");
2063 assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2064
2065 let node = value.get("nodes").unwrap().get("c").unwrap();
2067 assert_eq!(node.get("status").unwrap(), "observed");
2068
2069 let cache = value.get("resolution_cache").unwrap();
2071 assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2072
2073 let mut store2 = LineageStore::new();
2075 let child = Clip {
2076 id: "x".into(),
2077 ..Default::default()
2078 };
2079 let mut roots = HashMap::new();
2080 roots.insert(
2081 "x".to_owned(),
2082 RootInfo {
2083 root_id: "ext".into(),
2084 root_title: String::new(),
2085 status: ResolveStatus::External,
2086 },
2087 );
2088 store2.update(
2089 std::slice::from_ref(&child),
2090 &Resolution {
2091 roots,
2092 gap_filled: Vec::new(),
2093 bridges: Vec::new(),
2094 },
2095 "now",
2096 );
2097 let v2 = serde_json::to_value(&store2).unwrap();
2098 assert_eq!(
2099 v2.get("resolution_cache")
2100 .unwrap()
2101 .get("x")
2102 .unwrap()
2103 .get("status")
2104 .unwrap(),
2105 "external"
2106 );
2107 }
2108
2109 #[test]
2110 fn serde_roundtrip_is_byte_identical() {
2111 let mut store = LineageStore::new();
2114 store.update(&chain_clips(), &chain_resolution(), "now");
2115
2116 let first = serde_json::to_string(&store).unwrap();
2117 let back: LineageStore = serde_json::from_str(&first).unwrap();
2118 let second = serde_json::to_string(&back).unwrap();
2119 assert_eq!(first, second, "round-trip must be byte-identical");
2120 }
2121
2122 #[test]
2123 fn existing_string_form_json_deserialises_correctly() {
2124 let json = r#"{
2127 "nodes": {"a": {"title": "Root", "status": "observed"}},
2128 "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2129 "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2130 }"#;
2131 let store: LineageStore = serde_json::from_str(json).unwrap();
2132 assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2133 assert_eq!(store.edges[0].role, EdgeRole::Primary);
2134 assert_eq!(store.edges[0].status, EdgeStatus::Active);
2135 assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2136 let archived = store.archived_parents();
2138 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2139 }
2140
2141 #[test]
2142 fn full_store_json_is_stable_across_the_split() {
2143 let mut store = LineageStore::new();
2149 store.update(&chain_clips(), &chain_resolution(), "now");
2150 store.albums.insert(
2151 "a".to_owned(),
2152 AlbumArt {
2153 folder_jpg: Some(crate::ArtifactState {
2154 path: "alice/Root/folder.jpg".to_owned(),
2155 hash: "jpg-h".to_owned(),
2156 }),
2157 folder_webp: None,
2158 folder_mp4: None,
2159 },
2160 );
2161 store.playlists.insert(
2162 "liked".to_owned(),
2163 PlaylistState {
2164 name: "Liked".to_owned(),
2165 path: "Liked.m3u8".to_owned(),
2166 hash: "pl-h".to_owned(),
2167 },
2168 );
2169 store.pin_owner(Owner {
2170 user_id: "user_a".to_owned(),
2171 display_name: "Alice".to_owned(),
2172 });
2173
2174 let value = serde_json::to_value(&store).unwrap();
2176 assert_eq!(
2177 value,
2178 serde_json::json!({
2179 "schema_version": 1,
2180 "nodes": {
2181 "a": {"title": "Root", "created_at": "t0", "clip_type": "gen", "task": "", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"},
2182 "b": {"title": "Remaster", "created_at": "t1", "clip_type": "upsample", "task": "upsample", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"},
2183 "c": {"title": "Cover", "created_at": "t2", "clip_type": "gen", "task": "cover", "is_remix": false, "is_trashed": false, "status": "observed", "first_seen_at": "now", "last_seen_at": "now"}
2184 },
2185 "edges": [
2186 {"child_id": "b", "parent_id": "a", "edge_type": "remaster", "role": "primary", "source_field": "upsample_clip_id", "ordinal": 0, "status": "active", "first_seen_at": "now", "last_seen_at": "now"},
2187 {"child_id": "c", "parent_id": "b", "edge_type": "cover", "role": "primary", "source_field": "cover_clip_id", "ordinal": 0, "status": "active", "first_seen_at": "now", "last_seen_at": "now"}
2188 ],
2189 "resolution_cache": {
2190 "a": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"},
2191 "b": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"},
2192 "c": {"root_id": "a", "status": "resolved", "algorithm_version": 1, "computed_at": "now"}
2193 },
2194 "albums": {"a": {"folder_jpg": {"path": "alice/Root/folder.jpg", "hash": "jpg-h"}}},
2195 "playlists": {"liked": {"name": "Liked", "path": "Liked.m3u8", "hash": "pl-h"}},
2196 "owner": {"user_id": "user_a", "display_name": "Alice"}
2197 })
2198 );
2199
2200 let expected = r#"{"schema_version":1,"nodes":{"a":{"title":"Root","created_at":"t0","clip_type":"gen","task":"","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"},"b":{"title":"Remaster","created_at":"t1","clip_type":"upsample","task":"upsample","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"},"c":{"title":"Cover","created_at":"t2","clip_type":"gen","task":"cover","is_remix":false,"is_trashed":false,"status":"observed","first_seen_at":"now","last_seen_at":"now"}},"edges":[{"child_id":"b","parent_id":"a","edge_type":"remaster","role":"primary","source_field":"upsample_clip_id","ordinal":0,"status":"active","first_seen_at":"now","last_seen_at":"now"},{"child_id":"c","parent_id":"b","edge_type":"cover","role":"primary","source_field":"cover_clip_id","ordinal":0,"status":"active","first_seen_at":"now","last_seen_at":"now"}],"resolution_cache":{"a":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"},"b":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"},"c":{"root_id":"a","status":"resolved","algorithm_version":1,"computed_at":"now"}},"albums":{"a":{"folder_jpg":{"path":"alice/Root/folder.jpg","hash":"jpg-h"}}},"playlists":{"liked":{"name":"Liked","path":"Liked.m3u8","hash":"pl-h"}},"owner":{"user_id":"user_a","display_name":"Alice"}}"#;
2203 assert_eq!(serde_json::to_string(&store).unwrap(), expected);
2204
2205 assert!(value.get("album_overrides").is_none());
2207 assert!(value.get("eligible_root_ids").is_none());
2208 assert!(value.get("edge_index").is_none());
2209
2210 let back: LineageStore = serde_json::from_str(expected).unwrap();
2212 assert_eq!(store, back);
2213 }
2214}