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