1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25 Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26 immediate_parent, lineage_edges,
27};
28use crate::manifest::ArtifactState;
29use crate::model::Clip;
30use crate::reconcile::ArtifactKind;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39 pub schema_version: u32,
41 pub(crate) nodes: BTreeMap<String, Node>,
43 pub(crate) edges: Vec<StoredEdge>,
45 pub(crate) resolution_cache: BTreeMap<String, CacheEntry>,
47 pub albums: BTreeMap<String, AlbumArt>,
50 pub playlists: BTreeMap<String, PlaylistState>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub owner: Option<Owner>,
58 #[serde(skip)]
65 pub album_overrides: BTreeMap<String, String>,
66 #[serde(skip)]
80 eligible_root_ids: HashSet<String>,
81 #[serde(skip)]
85 edge_index: HashMap<EdgeKey, usize>,
86}
87
88impl Default for LineageStore {
89 fn default() -> Self {
90 Self {
91 schema_version: 1,
92 nodes: BTreeMap::new(),
93 edges: Vec::new(),
94 resolution_cache: BTreeMap::new(),
95 albums: BTreeMap::new(),
96 playlists: BTreeMap::new(),
97 owner: None,
98 album_overrides: BTreeMap::new(),
99 eligible_root_ids: HashSet::new(),
100 edge_index: HashMap::new(),
101 }
102 }
103}
104
105impl PartialEq for LineageStore {
114 fn eq(&self, other: &Self) -> bool {
115 self.schema_version == other.schema_version
116 && self.nodes == other.nodes
117 && self.edges == other.edges
118 && self.resolution_cache == other.resolution_cache
119 && self.albums == other.albums
120 && self.playlists == other.playlists
121 && self.owner == other.owner
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Owner {
132 pub user_id: String,
133 pub display_name: String,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OwnerGate {
145 AbortConfigMismatch,
148 AbortMismatch,
150 Repin,
153 Proceed,
156 FirstUse,
158}
159
160impl OwnerGate {
161 pub fn is_additive(self) -> bool {
163 matches!(self, OwnerGate::Repin)
164 }
165}
166
167pub fn owner_gate(
174 store_owner: Option<&Owner>,
175 configured_id: Option<&str>,
176 authed_user_id: &str,
177 allow_change: bool,
178) -> OwnerGate {
179 if let Some(configured) = configured_id
180 && configured != authed_user_id
181 {
182 return OwnerGate::AbortConfigMismatch;
183 }
184 match store_owner {
185 None => OwnerGate::FirstUse,
186 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
187 Some(_) if allow_change => OwnerGate::Repin,
188 Some(_) => OwnerGate::AbortMismatch,
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum AdoptDecision {
199 PinFresh,
202 PinAdopt,
205 AdoptForced,
208 Abort,
211 SkipPin,
213}
214
215impl AdoptDecision {
216 pub fn is_additive(self) -> bool {
218 matches!(self, AdoptDecision::AdoptForced)
219 }
220}
221
222pub fn adopt_decision(
232 listed: &[&str],
233 owned: &BTreeSet<&str>,
234 enumerated: bool,
235 allow_change: bool,
236) -> AdoptDecision {
237 if owned.is_empty() {
238 return AdoptDecision::PinFresh;
239 }
240 if !enumerated {
241 return AdoptDecision::SkipPin;
242 }
243 if listed.iter().any(|id| owned.contains(id)) {
244 AdoptDecision::PinAdopt
245 } else if allow_change {
246 AdoptDecision::AdoptForced
247 } else {
248 AdoptDecision::Abort
249 }
250}
251
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(default)]
262pub struct AlbumArt {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub folder_jpg: Option<ArtifactState>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub folder_webp: Option<ArtifactState>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub folder_mp4: Option<ArtifactState>,
273}
274
275impl AlbumArt {
276 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
279 match kind {
280 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
281 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
282 ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
283 ArtifactKind::CoverJpg
284 | ArtifactKind::CoverWebp
285 | ArtifactKind::DetailsTxt
286 | ArtifactKind::LyricsTxt
287 | ArtifactKind::Lrc
288 | ArtifactKind::VideoMp4
289 | ArtifactKind::Playlist => None,
290 }
291 }
292
293 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
299 match kind {
300 ArtifactKind::FolderJpg => self.folder_jpg = state,
301 ArtifactKind::FolderWebp => self.folder_webp = state,
302 ArtifactKind::FolderMp4 => self.folder_mp4 = state,
303 ArtifactKind::CoverJpg
304 | ArtifactKind::CoverWebp
305 | ArtifactKind::DetailsTxt
306 | ArtifactKind::LyricsTxt
307 | ArtifactKind::Lrc
308 | ArtifactKind::VideoMp4
309 | ArtifactKind::Playlist => {}
310 }
311 }
312
313 pub fn is_empty(&self) -> bool {
316 self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
317 }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(default)]
330pub struct PlaylistState {
331 pub name: String,
333 pub path: String,
335 pub hash: String,
337}
338
339#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342#[serde(default)]
343pub struct Node {
344 pub title: String,
345 pub created_at: String,
346 pub clip_type: String,
347 pub task: String,
348 pub is_remix: bool,
349 pub is_trashed: bool,
350 pub status: String,
352 pub first_seen_at: String,
353 pub last_seen_at: String,
354}
355
356impl Default for Node {
357 fn default() -> Self {
358 Self {
359 title: String::new(),
360 created_at: String::new(),
361 clip_type: String::new(),
362 task: String::new(),
363 is_remix: false,
364 is_trashed: false,
365 status: "observed".to_owned(),
366 first_seen_at: String::new(),
367 last_seen_at: String::new(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376#[serde(default)]
377pub struct StoredEdge {
378 pub child_id: String,
379 pub parent_id: String,
380 pub edge_type: String,
382 pub role: String,
384 pub source_field: String,
386 pub ordinal: u32,
388 pub status: String,
390 pub first_seen_at: String,
391 pub last_seen_at: String,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Hash)]
395struct EdgeKey {
396 child_id: String,
397 parent_id: String,
398 edge_type: String,
399 role: String,
400 ordinal: u32,
401}
402
403impl EdgeKey {
404 fn new(child_id: &str, parent_id: &str, edge_type: &str, role: &str, ordinal: u32) -> Self {
405 Self {
406 child_id: child_id.to_owned(),
407 parent_id: parent_id.to_owned(),
408 edge_type: edge_type.to_owned(),
409 role: role.to_owned(),
410 ordinal,
411 }
412 }
413
414 fn from_stored(edge: &StoredEdge) -> Self {
415 Self::new(
416 &edge.child_id,
417 &edge.parent_id,
418 &edge.edge_type,
419 &edge.role,
420 edge.ordinal,
421 )
422 }
423}
424
425impl Default for StoredEdge {
426 fn default() -> Self {
427 Self {
428 child_id: String::new(),
429 parent_id: String::new(),
430 edge_type: String::new(),
431 role: String::new(),
432 source_field: String::new(),
433 ordinal: 0,
434 status: "active".to_owned(),
435 first_seen_at: String::new(),
436 last_seen_at: String::new(),
437 }
438 }
439}
440
441#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
443#[serde(default)]
444pub struct CacheEntry {
445 pub root_id: String,
446 pub status: String,
448 pub algorithm_version: u32,
449 pub computed_at: String,
450}
451
452impl LineageStore {
453 pub fn new() -> Self {
455 Self::default()
456 }
457
458 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
471 self.album_overrides = overrides;
472 }
473
474 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
498 if !self.eligible_root_ids.contains(root_id) {
499 return root_title;
500 }
501 match self.album_overrides.get(root_id) {
502 Some(name) if !name.trim().is_empty() => name.clone(),
503 _ => root_title,
504 }
505 }
506
507 pub fn refresh_eligible_roots(&mut self) {
517 self.eligible_root_ids = self
518 .resolution_cache
519 .values()
520 .map(|entry| entry.root_id.as_str())
521 .filter(|root_id| !root_id.is_empty())
522 .map(str::to_owned)
523 .collect();
524 }
525
526 #[cfg(test)]
529 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
530 &self.eligible_root_ids
531 }
532
533 pub fn node(&self, id: &str) -> Option<&Node> {
535 self.nodes.get(id)
536 }
537
538 pub fn owner(&self) -> Option<&Owner> {
540 self.owner.as_ref()
541 }
542
543 pub fn pin_owner(&mut self, owner: Owner) {
545 self.owner = Some(owner);
546 }
547
548 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
551 match &mut self.owner {
552 Some(owner) if owner.display_name != display_name => {
553 owner.display_name = display_name.to_owned();
554 true
555 }
556 _ => false,
557 }
558 }
559
560 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
562 self.resolution_cache.get(id)
563 }
564
565 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
567 self.albums.get(root_id)
568 }
569
570 pub fn set_album_artifact(
578 &mut self,
579 root_id: &str,
580 kind: ArtifactKind,
581 state: Option<ArtifactState>,
582 ) {
583 match state {
584 Some(state) => self
585 .albums
586 .entry(root_id.to_owned())
587 .or_default()
588 .set(kind, Some(state)),
589 None => {
590 if let Some(art) = self.albums.get_mut(root_id) {
591 art.set(kind, None);
592 if art.is_empty() {
593 self.albums.remove(root_id);
594 }
595 }
596 }
597 }
598 }
599
600 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
602 self.playlists.get(id)
603 }
604
605 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
613 match state {
614 Some(state) => {
615 self.playlists.insert(id.to_owned(), state);
616 }
617 None => {
618 self.playlists.remove(id);
619 }
620 }
621 }
622
623 pub fn context_for(&self, clip: &Clip) -> LineageContext {
634 let cached = self.get_root(&clip.id);
635 let root_id = cached
636 .map(|entry| entry.root_id.clone())
637 .filter(|id| !id.is_empty())
638 .unwrap_or_else(|| clip.id.clone());
639 let root_title = self
640 .node(&root_id)
641 .map(|node| node.title.clone())
642 .unwrap_or_else(|| clip.title.clone());
643 let root_title = self.effective_root_title(&root_id, root_title);
644 let root_date = self
645 .node(&root_id)
646 .map(|node| node.created_at.clone())
647 .unwrap_or_else(|| clip.created_at.clone());
648 let (parent_id, edge_type) = match immediate_parent(clip) {
649 Some((id, edge)) => (id, Some(edge)),
650 None => (String::new(), None),
651 };
652 let status = cached
653 .map(|entry| status_from_slug(&entry.status))
654 .unwrap_or(ResolveStatus::Resolved);
655 LineageContext {
656 root_id,
657 root_title,
658 root_date,
659 parent_id,
660 edge_type,
661 status,
662 }
663 }
664
665 pub fn album_for_id(&self, id: &str) -> String {
674 let own = self.node(id);
675 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
676 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
677 let root_id = self
678 .get_root(id)
679 .map(|entry| entry.root_id.clone())
680 .filter(|root| !root.is_empty())
681 .unwrap_or_else(|| id.to_owned());
682 let root_title = self
683 .node(&root_id)
684 .map(|node| node.title.clone())
685 .unwrap_or_else(|| own_title.clone());
686 let root_title = self.effective_root_title(&root_id, root_title);
687 let root_date = self
688 .node(&root_id)
689 .map(|node| node.created_at.clone())
690 .unwrap_or(own_created_at);
691 let context = LineageContext {
692 root_id,
693 root_title,
694 root_date,
695 parent_id: String::new(),
696 edge_type: None,
697 status: ResolveStatus::Resolved,
698 };
699 context.album(&own_title)
700 }
701
702 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
728 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
729 for root_id in &self.eligible_root_ids {
730 let node_title = self
731 .nodes
732 .get(root_id)
733 .map(|node| node.title.clone())
734 .unwrap_or_default();
735 let effective = self.effective_root_title(root_id, node_title);
736 let title = effective.trim();
737 if title.is_empty() {
738 continue;
739 }
740 roots_by_title
741 .entry(title.to_owned())
742 .or_default()
743 .insert(root_id.clone());
744 }
745 roots_by_title
746 .into_iter()
747 .filter(|(_, roots)| roots.len() > 1)
748 .map(|(title, _)| title)
749 .collect()
750 }
751
752 pub fn len(&self) -> usize {
754 self.nodes.len()
755 }
756
757 pub fn is_empty(&self) -> bool {
759 self.nodes.is_empty()
760 }
761
762 pub fn iter(&self) -> Iter<'_, String, Node> {
764 self.nodes.iter()
765 }
766
767 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
775 self.rebuild_edge_index();
776
777 for clip in clips {
778 self.upsert_node(clip, now);
779 }
780 for clip in &resolution.gap_filled {
783 self.upsert_node(clip, now);
784 }
785
786 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
794 for edge in lineage_edges(clip) {
795 self.upsert_edge(&clip.id, &edge, now);
796 }
797 }
798 for (child_id, parent_id) in &resolution.bridges {
799 let edge = Edge {
800 parent_id: parent_id.clone(),
801 edge_type: EdgeType::Derived,
802 role: EdgeRole::Primary,
803 ordinal: 0,
804 source_field: "parent_endpoint",
805 };
806 self.upsert_edge(child_id, &edge, now);
807 }
808 self.edges.sort_by(|a, b| {
809 a.child_id
810 .cmp(&b.child_id)
811 .then(a.ordinal.cmp(&b.ordinal))
812 .then(a.parent_id.cmp(&b.parent_id))
813 .then(a.edge_type.cmp(&b.edge_type))
814 .then(a.role.cmp(&b.role))
815 });
816 self.rebuild_edge_index();
817
818 for (child_id, info) in &resolution.roots {
819 self.upsert_cache(child_id, info, now);
820 }
821 self.refresh_eligible_roots();
822 }
823
824 pub fn archived_parents(&self) -> HashMap<String, String> {
833 self.edges
834 .iter()
835 .filter(|edge| edge.role == "primary" && edge.ordinal == 0 && edge.status == "active")
836 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
837 .collect()
838 }
839
840 fn upsert_node(&mut self, clip: &Clip, now: &str) {
843 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
844 first_seen_at: now.to_owned(),
845 ..Node::default()
846 });
847 node.title = clip.title.clone();
848 node.created_at = clip.created_at.clone();
849 node.clip_type = clip.clip_type.clone();
850 node.task = clip.task.clone();
851 node.is_remix = clip.is_remix;
852 node.is_trashed = clip.is_trashed;
853 node.last_seen_at = now.to_owned();
854 }
855
856 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
859 let edge_type = edge_type_slug(edge.edge_type);
860 let role = edge_role_slug(edge.role);
861 let key = EdgeKey::new(child_id, &edge.parent_id, edge_type, role, edge.ordinal);
862 if let Some(&index) = self.edge_index.get(&key) {
863 let existing = &mut self.edges[index];
864 existing.source_field = edge.source_field.to_owned();
865 existing.status = "active".to_owned();
866 existing.last_seen_at = now.to_owned();
867 } else {
868 self.edges.push(StoredEdge {
869 child_id: child_id.to_owned(),
870 parent_id: edge.parent_id.clone(),
871 edge_type: edge_type.to_owned(),
872 role: role.to_owned(),
873 source_field: edge.source_field.to_owned(),
874 ordinal: edge.ordinal,
875 status: "active".to_owned(),
876 first_seen_at: now.to_owned(),
877 last_seen_at: now.to_owned(),
878 });
879 self.edge_index.insert(key, self.edges.len() - 1);
880 }
881 }
882
883 fn rebuild_edge_index(&mut self) {
884 self.edge_index.clear();
885 for (index, edge) in self.edges.iter().enumerate() {
886 self.edge_index
887 .entry(EdgeKey::from_stored(edge))
888 .or_insert(index);
889 }
890 }
891
892 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
899 if info.status != ResolveStatus::Resolved
900 && self
901 .resolution_cache
902 .get(child_id)
903 .is_some_and(|entry| entry.status == "resolved")
904 {
905 return;
906 }
907 self.resolution_cache.insert(
908 child_id.to_owned(),
909 CacheEntry {
910 root_id: info.root_id.clone(),
911 status: resolve_status_slug(info.status).to_owned(),
912 algorithm_version: 1,
913 computed_at: now.to_owned(),
914 },
915 );
916 }
917}
918
919fn edge_type_slug(edge_type: EdgeType) -> &'static str {
921 match edge_type {
922 EdgeType::Cover => "cover",
923 EdgeType::Remaster => "remaster",
924 EdgeType::SpeedEdit => "speed_edit",
925 EdgeType::Edit => "edit",
926 EdgeType::Extend => "extend",
927 EdgeType::SectionReplace => "section_replace",
928 EdgeType::Stitch => "stitch",
929 EdgeType::Derived => "derived",
930 EdgeType::Uploaded => "uploaded",
931 }
932}
933
934fn edge_role_slug(role: EdgeRole) -> &'static str {
936 match role {
937 EdgeRole::Primary => "primary",
938 EdgeRole::Secondary => "secondary",
939 }
940}
941
942fn resolve_status_slug(status: ResolveStatus) -> &'static str {
944 match status {
945 ResolveStatus::Resolved => "resolved",
946 ResolveStatus::External => "external",
947 ResolveStatus::Unresolved => "unresolved",
948 ResolveStatus::Cycle => "cycle",
949 }
950}
951
952fn status_from_slug(slug: &str) -> ResolveStatus {
955 match slug {
956 "external" => ResolveStatus::External,
957 "unresolved" => ResolveStatus::Unresolved,
958 "cycle" => ResolveStatus::Cycle,
959 _ => ResolveStatus::Resolved,
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use std::collections::HashMap;
967
968 fn chain_clips() -> Vec<Clip> {
970 vec![
971 Clip {
972 id: "c".into(),
973 title: "Cover".into(),
974 clip_type: "gen".into(),
975 task: "cover".into(),
976 created_at: "t2".into(),
977 cover_clip_id: "b".into(),
978 edited_clip_id: "b".into(),
979 ..Default::default()
980 },
981 Clip {
982 id: "b".into(),
983 title: "Remaster".into(),
984 clip_type: "upsample".into(),
985 task: "upsample".into(),
986 created_at: "t1".into(),
987 upsample_clip_id: "a".into(),
988 edited_clip_id: "a".into(),
989 ..Default::default()
990 },
991 Clip {
992 id: "a".into(),
993 title: "Root".into(),
994 clip_type: "gen".into(),
995 created_at: "t0".into(),
996 ..Default::default()
997 },
998 ]
999 }
1000
1001 fn chain_resolution() -> Resolution {
1003 let mut roots = HashMap::new();
1004 for id in ["a", "b", "c"] {
1005 roots.insert(
1006 id.to_owned(),
1007 RootInfo {
1008 root_id: "a".into(),
1009 root_title: "Root".into(),
1010 status: ResolveStatus::Resolved,
1011 },
1012 );
1013 }
1014 Resolution {
1015 roots,
1016 gap_filled: Vec::new(),
1017 bridges: Vec::new(),
1018 }
1019 }
1020
1021 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
1022 store
1023 .edges
1024 .iter()
1025 .find(|e| e.child_id == child && e.parent_id == parent)
1026 .expect("edge should exist")
1027 }
1028
1029 #[test]
1030 fn new_store_is_empty_and_versioned() {
1031 let store = LineageStore::new();
1032 assert!(store.is_empty());
1033 assert_eq!(store.len(), 0);
1034 assert_eq!(store.schema_version, 1);
1035 }
1036
1037 #[test]
1038 fn update_populates_nodes_edges_and_cache() {
1039 let mut store = LineageStore::new();
1040 store.update(&chain_clips(), &chain_resolution(), "now");
1041
1042 assert_eq!(store.len(), 3);
1044 let cover = store.node("c").unwrap();
1045 assert_eq!(cover.title, "Cover");
1046 assert_eq!(cover.clip_type, "gen");
1047 assert_eq!(cover.task, "cover");
1048 assert_eq!(cover.created_at, "t2");
1049 assert_eq!(cover.status, "observed");
1050 assert!(!cover.is_trashed);
1051 assert_eq!(cover.first_seen_at, "now");
1052 assert_eq!(cover.last_seen_at, "now");
1053
1054 assert_eq!(store.edges.len(), 2);
1056 let cb = edge(&store, "c", "b");
1057 assert_eq!(cb.edge_type, "cover");
1058 assert_eq!(cb.role, "primary");
1059 assert_eq!(cb.ordinal, 0);
1060 assert_eq!(cb.source_field, "cover_clip_id");
1061 assert_eq!(cb.status, "active");
1062 let ba = edge(&store, "b", "a");
1063 assert_eq!(ba.edge_type, "remaster");
1064 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1065
1066 for id in ["a", "b", "c"] {
1068 let cached = store.get_root(id).unwrap();
1069 assert_eq!(cached.root_id, "a");
1070 assert_eq!(cached.status, "resolved");
1071 assert_eq!(cached.algorithm_version, 1);
1072 }
1073 }
1074
1075 #[test]
1076 fn update_persists_edges_for_gap_filled_ancestors() {
1077 let child = Clip {
1081 id: "child".into(),
1082 title: "Cover".into(),
1083 clip_type: "gen".into(),
1084 task: "cover".into(),
1085 cover_clip_id: "mid".into(),
1086 edited_clip_id: "mid".into(),
1087 ..Default::default()
1088 };
1089 let mid = Clip {
1090 id: "mid".into(),
1091 title: "Mid".into(),
1092 clip_type: "gen".into(),
1093 task: "cover".into(),
1094 cover_clip_id: "root".into(),
1095 edited_clip_id: "root".into(),
1096 ..Default::default()
1097 };
1098 let mut roots = HashMap::new();
1099 roots.insert(
1100 "child".to_owned(),
1101 RootInfo {
1102 root_id: "root".into(),
1103 root_title: "Original".into(),
1104 status: ResolveStatus::Resolved,
1105 },
1106 );
1107 let resolution = Resolution {
1108 roots,
1109 gap_filled: vec![mid],
1110 bridges: Vec::new(),
1111 };
1112 let mut store = LineageStore::new();
1113 store.update(std::slice::from_ref(&child), &resolution, "now");
1114
1115 let mid_edge = edge(&store, "mid", "root");
1117 assert_eq!(mid_edge.role, "primary");
1118 assert_eq!(mid_edge.ordinal, 0);
1119 let archived = store.archived_parents();
1121 assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
1122 assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
1123 }
1124
1125 #[test]
1126 fn update_persists_bridges_as_edges() {
1127 let child = Clip {
1130 id: "child".into(),
1131 title: "Cover".into(),
1132 clip_type: "gen".into(),
1133 task: "cover".into(),
1134 cover_clip_id: "gone".into(),
1135 edited_clip_id: "gone".into(),
1136 ..Default::default()
1137 };
1138 let mut roots = HashMap::new();
1139 roots.insert(
1140 "child".to_owned(),
1141 RootInfo {
1142 root_id: "found".into(),
1143 root_title: String::new(),
1144 status: ResolveStatus::External,
1145 },
1146 );
1147 let resolution = Resolution {
1148 roots,
1149 gap_filled: Vec::new(),
1150 bridges: vec![("gone".to_owned(), "found".to_owned())],
1151 };
1152 let mut store = LineageStore::new();
1153 store.update(std::slice::from_ref(&child), &resolution, "now");
1154
1155 let bridged = edge(&store, "gone", "found");
1156 assert_eq!(bridged.source_field, "parent_endpoint");
1157 assert_eq!(bridged.role, "primary");
1158 assert_eq!(bridged.ordinal, 0);
1159 assert_eq!(
1160 store.archived_parents().get("gone").map(String::as_str),
1161 Some("found")
1162 );
1163 }
1164
1165 #[test]
1166 fn archived_parents_maps_children_to_primary_parents_only() {
1167 let mut store = LineageStore::new();
1168 store.update(&chain_clips(), &chain_resolution(), "now");
1169 let archived = store.archived_parents();
1170 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
1171 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
1172 assert!(
1173 !archived.contains_key("a"),
1174 "a root has no primary parent edge"
1175 );
1176 }
1177
1178 #[test]
1179 fn album_for_id_matches_context_for_and_handles_unknown() {
1180 let mut store = LineageStore::new();
1181 store.update(&chain_clips(), &chain_resolution(), "now");
1182
1183 assert_eq!(store.album_for_id("c"), "Root");
1186 let cover = &chain_clips()[0];
1187 assert_eq!(
1188 store.album_for_id("c"),
1189 store.context_for(cover).album(&cover.title)
1190 );
1191 assert_eq!(store.album_for_id("a"), "Root");
1193 assert_eq!(store.album_for_id("missing"), "");
1195 }
1196
1197 #[test]
1198 fn serde_roundtrip_preserves_a_relational_shape() {
1199 let mut store = LineageStore::new();
1200 store.update(&chain_clips(), &chain_resolution(), "now");
1201
1202 let json = serde_json::to_string(&store).unwrap();
1203 let back: LineageStore = serde_json::from_str(&json).unwrap();
1204 assert_eq!(store, back);
1205
1206 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1207 assert_eq!(value.get("schema_version").unwrap(), 1);
1208 assert!(value.get("nodes").unwrap().is_object());
1209 assert!(value.get("edges").unwrap().is_array());
1210 assert!(value.get("resolution_cache").unwrap().is_object());
1211 assert!(value.get("edge_index").is_none());
1212
1213 let node = value.get("nodes").unwrap().get("c").unwrap();
1216 assert!(node.get("edges").is_none());
1217 assert!(node.get("parent_id").is_none());
1218 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1219 assert!(first_edge.get("child_id").is_some());
1220 assert!(first_edge.get("parent_id").is_some());
1221 }
1222
1223 #[test]
1224 fn album_overrides_are_runtime_only_and_never_persist() {
1225 let mut store = LineageStore::new();
1229 store.update(&chain_clips(), &chain_resolution(), "now");
1230 store.set_album_overrides(
1231 [("a".to_owned(), "Preferred".to_owned())]
1232 .into_iter()
1233 .collect(),
1234 );
1235
1236 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1237 assert!(value.get("album_overrides").is_none());
1238
1239 let json = serde_json::to_string(&store).unwrap();
1240 let back: LineageStore = serde_json::from_str(&json).unwrap();
1241 assert!(back.album_overrides.is_empty());
1242 assert_eq!(back.album_for_id("c"), "Root");
1243 }
1244
1245 #[test]
1246 fn update_is_idempotent_bar_last_seen() {
1247 let clips = chain_clips();
1248 let resolution = chain_resolution();
1249 let mut store = LineageStore::new();
1250 store.update(&clips, &resolution, "first");
1251 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1252 let edge_count = store.edges.len();
1253
1254 store.update(&clips, &resolution, "second");
1255
1256 assert_eq!(
1258 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1259 node_ids
1260 );
1261 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1262 assert_eq!(store.resolution_cache.len(), 3);
1263
1264 let cover = store.node("c").unwrap();
1266 assert_eq!(cover.first_seen_at, "first");
1267 assert_eq!(cover.last_seen_at, "second");
1268 let cb = edge(&store, "c", "b");
1269 assert_eq!(cb.first_seen_at, "first");
1270 assert_eq!(cb.last_seen_at, "second");
1271 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1273 }
1274
1275 #[test]
1276 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1277 let mut store = LineageStore::new();
1278 store.update(&chain_clips(), &chain_resolution(), "first");
1279 assert_eq!(store.get_root("c").unwrap().status, "resolved");
1280
1281 let child = Clip {
1284 id: "c".into(),
1285 title: "Cover".into(),
1286 clip_type: "gen".into(),
1287 task: "cover".into(),
1288 cover_clip_id: "b".into(),
1289 edited_clip_id: "b".into(),
1290 ..Default::default()
1291 };
1292 let mut roots = HashMap::new();
1293 roots.insert(
1294 "c".to_owned(),
1295 RootInfo {
1296 root_id: "elsewhere".into(),
1297 root_title: String::new(),
1298 status: ResolveStatus::External,
1299 },
1300 );
1301 roots.insert(
1302 "d".to_owned(),
1303 RootInfo {
1304 root_id: "boundary".into(),
1305 root_title: String::new(),
1306 status: ResolveStatus::External,
1307 },
1308 );
1309 let resolution = Resolution {
1310 roots,
1311 gap_filled: Vec::new(),
1312 bridges: Vec::new(),
1313 };
1314 store.update(&[child], &resolution, "second");
1315
1316 let cached = store.get_root("c").unwrap();
1318 assert_eq!(cached.root_id, "a");
1319 assert_eq!(cached.status, "resolved");
1320 assert_eq!(cached.computed_at, "first");
1321 let d = store.get_root("d").unwrap();
1323 assert_eq!(d.root_id, "boundary");
1324 assert_eq!(d.status, "external");
1325 }
1326
1327 #[test]
1328 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1329 let child = Clip {
1333 id: "c".into(),
1334 title: "Cover".into(),
1335 clip_type: "gen".into(),
1336 task: "cover".into(),
1337 cover_clip_id: "t".into(),
1338 edited_clip_id: "t".into(),
1339 ..Default::default()
1340 };
1341 let trashed = Clip {
1342 id: "t".into(),
1343 title: "Trashed Original".into(),
1344 clip_type: "gen".into(),
1345 is_trashed: true,
1346 ..Default::default()
1347 };
1348 let mut roots = HashMap::new();
1349 roots.insert(
1350 "c".to_owned(),
1351 RootInfo {
1352 root_id: "t".into(),
1353 root_title: "Trashed Original".into(),
1354 status: ResolveStatus::Resolved,
1355 },
1356 );
1357 let resolution = Resolution {
1358 roots,
1359 gap_filled: vec![trashed],
1360 bridges: Vec::new(),
1361 };
1362 store_update_and_assert_trashed(child, resolution);
1363 }
1364
1365 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1366 let mut store = LineageStore::new();
1367 store.update(&[child], &resolution, "now");
1368
1369 let node = store
1370 .node("t")
1371 .expect("trashed ancestor should be archived");
1372 assert!(node.is_trashed);
1373 assert_eq!(node.title, "Trashed Original");
1374 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1376 }
1377
1378 #[test]
1379 fn partial_json_loads_with_defaults() {
1380 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1383 let store: LineageStore = serde_json::from_str(json).unwrap();
1384 assert_eq!(store.schema_version, 1);
1385 let node = store.node("x").unwrap();
1386 assert_eq!(node.title, "Kept");
1387 assert_eq!(node.status, "observed");
1388 assert_eq!(store.edges[0].status, "active");
1389 assert!(store.resolution_cache.is_empty());
1390 assert!(store.albums.is_empty());
1393 assert!(store.album_art("x").is_none());
1394 assert!(store.playlists.is_empty());
1398 assert!(store.playlist("x").is_none());
1399 }
1400
1401 #[test]
1402 fn album_art_roundtrips_and_reads_by_kind() {
1403 let mut store = LineageStore::new();
1404 store.albums.insert(
1405 "root-1".to_owned(),
1406 AlbumArt {
1407 folder_jpg: Some(ArtifactState {
1408 path: "alice/Album/folder.jpg".to_owned(),
1409 hash: "jpg-h".to_owned(),
1410 }),
1411 folder_webp: Some(ArtifactState {
1412 path: "alice/Album/cover.webp".to_owned(),
1413 hash: "webp-h".to_owned(),
1414 }),
1415 folder_mp4: Some(ArtifactState {
1416 path: "alice/Album/cover.mp4".to_owned(),
1417 hash: "mp4-h".to_owned(),
1418 }),
1419 },
1420 );
1421
1422 let json = serde_json::to_string(&store).unwrap();
1423 let back: LineageStore = serde_json::from_str(&json).unwrap();
1424 assert_eq!(store, back);
1425
1426 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1428 let album = value.get("albums").unwrap().get("root-1").unwrap();
1429 assert_eq!(
1430 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1431 "jpg-h"
1432 );
1433
1434 let art = back.album_art("root-1").unwrap();
1435 assert_eq!(
1436 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1437 "alice/Album/folder.jpg"
1438 );
1439 assert_eq!(
1440 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1441 "webp-h"
1442 );
1443 assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1444 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1446 }
1447
1448 #[test]
1449 fn empty_album_art_omits_slots_when_serialised() {
1450 let empty = AlbumArt::default();
1453 assert!(empty.is_empty());
1454 let value = serde_json::to_value(&empty).unwrap();
1455 assert!(value.get("folder_jpg").is_none());
1456 assert!(value.get("folder_webp").is_none());
1457 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1458 assert_eq!(back, empty);
1459 }
1460
1461 #[test]
1462 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1463 let mut store = LineageStore::new();
1464 let jpg = ArtifactState {
1465 path: "a/folder.jpg".to_owned(),
1466 hash: "h1".to_owned(),
1467 };
1468 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1469 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1470
1471 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1473 assert!(store.album_art("root-1").is_none());
1474 assert!(store.albums.is_empty());
1475 }
1476
1477 #[test]
1478 fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1479 let mut store = LineageStore::new();
1484 let state = |p: &str| ArtifactState {
1485 path: p.to_owned(),
1486 hash: "h".to_owned(),
1487 };
1488 store.set_album_artifact(
1489 "root-1",
1490 ArtifactKind::FolderWebp,
1491 Some(state("a/cover.webp")),
1492 );
1493 store.set_album_artifact(
1494 "root-1",
1495 ArtifactKind::FolderMp4,
1496 Some(state("a/cover.mp4")),
1497 );
1498
1499 store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1502 let art = store
1503 .album_art("root-1")
1504 .expect("row kept while folder_mp4 remains");
1505 assert!(!art.is_empty());
1506 assert!(art.folder_mp4.is_some());
1507
1508 store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1510 assert!(store.album_art("root-1").is_none());
1511 assert!(store.albums.is_empty());
1512 }
1513
1514 #[test]
1515 fn playlist_state_roundtrips_by_id() {
1516 let mut store = LineageStore::new();
1517 store.playlists.insert(
1518 "pl1".to_owned(),
1519 PlaylistState {
1520 name: "Road Trip".to_owned(),
1521 path: "Road Trip.m3u8".to_owned(),
1522 hash: "abc123".to_owned(),
1523 },
1524 );
1525
1526 let json = serde_json::to_string(&store).unwrap();
1527 let back: LineageStore = serde_json::from_str(&json).unwrap();
1528 assert_eq!(store, back);
1529
1530 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1532 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1533 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1534 assert_eq!(pl.get("hash").unwrap(), "abc123");
1535
1536 let stored = back.playlist("pl1").unwrap();
1537 assert_eq!(stored.name, "Road Trip");
1538 assert_eq!(stored.hash, "abc123");
1539 }
1540
1541 #[test]
1542 fn set_playlist_upserts_then_clears() {
1543 let mut store = LineageStore::new();
1544 let state = PlaylistState {
1545 name: "Mix".to_owned(),
1546 path: "Mix.m3u8".to_owned(),
1547 hash: "h1".to_owned(),
1548 };
1549 store.set_playlist("pl1", Some(state.clone()));
1550 assert_eq!(store.playlist("pl1"), Some(&state));
1551
1552 let renamed = PlaylistState {
1554 name: "Mix v2".to_owned(),
1555 path: "Mix v2.m3u8".to_owned(),
1556 hash: "h2".to_owned(),
1557 };
1558 store.set_playlist("pl1", Some(renamed.clone()));
1559 assert_eq!(store.playlist("pl1"), Some(&renamed));
1560
1561 store.set_playlist("pl1", None);
1563 assert!(store.playlist("pl1").is_none());
1564 assert!(store.playlists.is_empty());
1565 }
1566
1567 #[test]
1568 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1569 let mut store = LineageStore::new();
1570 store.update(&chain_clips(), &chain_resolution(), "now");
1571
1572 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1574 assert_eq!(ctx.root_id, "a");
1575 assert_eq!(ctx.root_title, "Root");
1576 assert_eq!(ctx.parent_id, "b");
1577 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1578 assert_eq!(ctx.status, ResolveStatus::Resolved);
1579 assert_eq!(ctx.album("Cover"), "Root");
1581 }
1582
1583 #[test]
1584 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1585 let mut store = LineageStore::new();
1586 store.update(&chain_clips(), &chain_resolution(), "now");
1587
1588 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1590 assert_eq!(ctx.root_id, "a");
1591 assert_eq!(ctx.root_title, "Root");
1592 assert_eq!(ctx.parent_id, "");
1593 assert_eq!(ctx.edge_type, None);
1594 assert_eq!(ctx.album("Root"), "Root");
1595 }
1596
1597 #[test]
1598 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1599 let clips = vec![
1602 Clip {
1603 id: "child".into(),
1604 title: "Revision".into(),
1605 clip_type: "gen".into(),
1606 task: "cover".into(),
1607 created_at: "2024-01-02T08:00:00Z".into(),
1608 cover_clip_id: "root".into(),
1609 edited_clip_id: "root".into(),
1610 ..Default::default()
1611 },
1612 Clip {
1613 id: "root".into(),
1614 title: "Origin".into(),
1615 clip_type: "gen".into(),
1616 created_at: "2023-12-30T23:00:00Z".into(),
1617 ..Default::default()
1618 },
1619 ];
1620 let mut roots = HashMap::new();
1621 for id in ["child", "root"] {
1622 roots.insert(
1623 id.to_owned(),
1624 RootInfo {
1625 root_id: "root".into(),
1626 root_title: "Origin".into(),
1627 status: ResolveStatus::Resolved,
1628 },
1629 );
1630 }
1631 let resolution = Resolution {
1632 roots,
1633 gap_filled: Vec::new(),
1634 bridges: Vec::new(),
1635 };
1636 let mut store = LineageStore::new();
1637 store.update(&clips, &resolution, "now");
1638
1639 let child_ctx = store.context_for(&clips[0]);
1640 assert_eq!(child_ctx.root_id, "root");
1641 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1642 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1644
1645 let root_ctx = store.context_for(&clips[1]);
1647 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1648 }
1649
1650 #[test]
1651 fn context_for_an_unknown_clip_is_self_rooted() {
1652 let store = LineageStore::new();
1653 let orphan = Clip {
1654 id: "z".into(),
1655 title: "Lonely".into(),
1656 ..Default::default()
1657 };
1658 let ctx = store.context_for(&orphan);
1659 assert_eq!(ctx.root_id, "z");
1660 assert_eq!(ctx.root_title, "Lonely");
1661 assert_eq!(ctx.parent_id, "");
1662 assert_eq!(ctx.status, ResolveStatus::Resolved);
1663 }
1664
1665 #[test]
1666 fn context_for_retains_a_purged_ancestor_album() {
1667 let child = Clip {
1672 id: "c".into(),
1673 title: "Cover".into(),
1674 clip_type: "gen".into(),
1675 task: "cover".into(),
1676 cover_clip_id: "t".into(),
1677 edited_clip_id: "t".into(),
1678 ..Default::default()
1679 };
1680 let trashed = Clip {
1681 id: "t".into(),
1682 title: "Trashed Original".into(),
1683 clip_type: "gen".into(),
1684 is_trashed: true,
1685 ..Default::default()
1686 };
1687 let mut roots = HashMap::new();
1688 roots.insert(
1689 "c".to_owned(),
1690 RootInfo {
1691 root_id: "t".into(),
1692 root_title: "Trashed Original".into(),
1693 status: ResolveStatus::Resolved,
1694 },
1695 );
1696 let resolution = Resolution {
1697 roots,
1698 gap_filled: vec![trashed],
1699 bridges: Vec::new(),
1700 };
1701 let mut store = LineageStore::new();
1702 store.update(std::slice::from_ref(&child), &resolution, "now");
1703
1704 let ctx = store.context_for(&child);
1705 assert_eq!(ctx.root_id, "t");
1706 assert_eq!(ctx.root_title, "Trashed Original");
1707 assert_eq!(ctx.album("Cover"), "Trashed Original");
1708 }
1709
1710 #[test]
1711 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1712 let clips = vec![
1715 Clip {
1716 id: "r1".into(),
1717 title: "Break Through".into(),
1718 clip_type: "gen".into(),
1719 ..Default::default()
1720 },
1721 Clip {
1722 id: "r2".into(),
1723 title: "Break Through".into(),
1724 clip_type: "gen".into(),
1725 ..Default::default()
1726 },
1727 Clip {
1728 id: "r3".into(),
1729 title: "Solo".into(),
1730 clip_type: "gen".into(),
1731 ..Default::default()
1732 },
1733 Clip {
1734 id: "c1".into(),
1735 title: "Break Through".into(),
1736 clip_type: "gen".into(),
1737 task: "cover".into(),
1738 cover_clip_id: "r1".into(),
1739 edited_clip_id: "r1".into(),
1740 ..Default::default()
1741 },
1742 ];
1743 let mut roots = HashMap::new();
1744 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1745 let title = if root == "r3" {
1746 "Solo"
1747 } else {
1748 "Break Through"
1749 };
1750 roots.insert(
1751 id.to_owned(),
1752 RootInfo {
1753 root_id: root.into(),
1754 root_title: title.into(),
1755 status: ResolveStatus::Resolved,
1756 },
1757 );
1758 }
1759 let resolution = Resolution {
1760 roots,
1761 gap_filled: Vec::new(),
1762 bridges: Vec::new(),
1763 };
1764 let mut store = LineageStore::new();
1765 store.update(&clips, &resolution, "now");
1766
1767 let colliding = store.colliding_root_titles();
1768 assert!(colliding.contains("Break Through"));
1769 assert!(!colliding.contains("Solo"));
1770 assert_eq!(colliding.len(), 1);
1771 }
1772
1773 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1776 let clips = vec![
1777 Clip {
1778 id: "r1".into(),
1779 title: t1.into(),
1780 clip_type: "gen".into(),
1781 ..Default::default()
1782 },
1783 Clip {
1784 id: "r2".into(),
1785 title: t2.into(),
1786 clip_type: "gen".into(),
1787 ..Default::default()
1788 },
1789 ];
1790 let mut roots = HashMap::new();
1791 roots.insert(
1792 "r1".to_owned(),
1793 RootInfo {
1794 root_id: "r1".into(),
1795 root_title: t1.into(),
1796 status: ResolveStatus::Resolved,
1797 },
1798 );
1799 roots.insert(
1800 "r2".to_owned(),
1801 RootInfo {
1802 root_id: "r2".into(),
1803 root_title: t2.into(),
1804 status: ResolveStatus::Resolved,
1805 },
1806 );
1807 let mut store = LineageStore::new();
1808 store.update(
1809 &clips,
1810 &Resolution {
1811 roots,
1812 gap_filled: Vec::new(),
1813 bridges: Vec::new(),
1814 },
1815 "now",
1816 );
1817 store
1818 }
1819
1820 #[test]
1821 fn album_override_flows_into_context_tag_hash_and_index() {
1822 let clips = chain_clips();
1826 let mut store = LineageStore::new();
1827 store.update(&clips, &chain_resolution(), "now");
1828
1829 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1831
1832 store.set_album_overrides(
1833 [("a".to_owned(), "Preferred Name".to_owned())]
1834 .into_iter()
1835 .collect(),
1836 );
1837
1838 for id in ["a", "b", "c"] {
1840 let clip = clips.iter().find(|c| c.id == id).unwrap();
1841 let ctx = store.context_for(clip);
1842 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1843 assert_eq!(store.album_for_id(id), "Preferred Name");
1844 }
1845
1846 let ctx = store.context_for(cover);
1848 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1849 assert_eq!(meta.album, "Preferred Name");
1850
1851 let after_hash = crate::hash::meta_hash(cover, &ctx);
1853 assert_ne!(before_hash, after_hash);
1854 }
1855
1856 #[test]
1857 fn empty_album_override_is_ignored() {
1858 let clips = chain_clips();
1860 let mut store = LineageStore::new();
1861 store.update(&clips, &chain_resolution(), "now");
1862 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1863 assert_eq!(store.album_for_id("c"), "Root");
1864 }
1865
1866 #[test]
1867 fn album_override_creates_a_collision_that_disambiguates() {
1868 let mut store = two_root_store("Alpha", "Beta");
1870 assert!(store.colliding_root_titles().is_empty());
1871
1872 store.set_album_overrides(
1873 [("r2".to_owned(), "Alpha".to_owned())]
1874 .into_iter()
1875 .collect(),
1876 );
1877 let colliding = store.colliding_root_titles();
1878 assert!(colliding.contains("Alpha"));
1879 assert_eq!(colliding.len(), 1);
1880 }
1881
1882 #[test]
1883 fn album_override_resolves_a_natural_collision() {
1884 let mut store = two_root_store("Break Through", "Break Through");
1886 assert!(store.colliding_root_titles().contains("Break Through"));
1887
1888 store.set_album_overrides(
1889 [("r2".to_owned(), "Second Wind".to_owned())]
1890 .into_iter()
1891 .collect(),
1892 );
1893 assert!(store.colliding_root_titles().is_empty());
1894 }
1895
1896 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1901 store.resolution_cache.insert(
1902 root_id.to_owned(),
1903 CacheEntry {
1904 root_id: root_id.to_owned(),
1905 status: "external".to_owned(),
1906 algorithm_version: 1,
1907 computed_at: "now".to_owned(),
1908 },
1909 );
1910 store.refresh_eligible_roots();
1913 }
1914
1915 #[test]
1916 fn override_on_node_less_root_collides_with_a_real_root() {
1917 let mut store = LineageStore::new();
1921 store.update(
1922 std::slice::from_ref(&Clip {
1923 id: "realroot".into(),
1924 title: "Shared".into(),
1925 clip_type: "gen".into(),
1926 ..Default::default()
1927 }),
1928 &Resolution {
1929 roots: [(
1930 "realroot".to_owned(),
1931 RootInfo {
1932 root_id: "realroot".into(),
1933 root_title: "Shared".into(),
1934 status: ResolveStatus::Resolved,
1935 },
1936 )]
1937 .into_iter()
1938 .collect(),
1939 gap_filled: Vec::new(),
1940 bridges: Vec::new(),
1941 },
1942 "now",
1943 );
1944 insert_cache_only_root(&mut store, "extroot");
1945 store.set_album_overrides(
1946 [("extroot".to_owned(), "Shared".to_owned())]
1947 .into_iter()
1948 .collect(),
1949 );
1950
1951 let colliding = store.colliding_root_titles();
1952 assert!(
1953 colliding.contains("Shared"),
1954 "a node-less overridden root must still be seen by collision detection"
1955 );
1956 }
1957
1958 #[test]
1959 fn two_node_less_roots_overridden_to_same_name_collide() {
1960 let mut store = LineageStore::new();
1961 insert_cache_only_root(&mut store, "extone");
1962 insert_cache_only_root(&mut store, "exttwo");
1963 store.set_album_overrides(
1964 [
1965 ("extone".to_owned(), "Shared".to_owned()),
1966 ("exttwo".to_owned(), "Shared".to_owned()),
1967 ]
1968 .into_iter()
1969 .collect(),
1970 );
1971 assert!(store.colliding_root_titles().contains("Shared"));
1972 }
1973
1974 #[test]
1975 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1976 let mut store = LineageStore::new();
1981 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1982 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1983 store.set_album_overrides(
1984 [
1985 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1986 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1987 ]
1988 .into_iter()
1989 .collect(),
1990 );
1991 let colliding = store.colliding_root_titles();
1992
1993 let clip_of = |id: &str| Clip {
1994 id: id.to_owned(),
1995 title: "Track".to_owned(),
1996 display_name: "alice".to_owned(),
1997 image_large_url: "https://art.example/large.jpg".to_owned(),
1998 ..Default::default()
1999 };
2000 let ctx_of = |root_id: &str| LineageContext {
2001 root_id: root_id.to_owned(),
2002 root_title: "Shared".to_owned(),
2003 root_date: String::new(),
2004 parent_id: String::new(),
2005 edge_type: None,
2006 status: ResolveStatus::Resolved,
2007 };
2008 let clip_a = clip_of("clipaaaa-1111");
2009 let clip_b = clip_of("clipbbbb-2222");
2010 let ctx_a = ctx_of("aaaaaaaa-root-one");
2011 let ctx_b = ctx_of("bbbbbbbb-root-two");
2012 let requests = [
2013 crate::naming::NamingRequest {
2014 clip: &clip_a,
2015 lineage: &ctx_a,
2016 },
2017 crate::naming::NamingRequest {
2018 clip: &clip_b,
2019 lineage: &ctx_b,
2020 },
2021 ];
2022 let names = crate::naming::render_clip_names(
2023 &requests,
2024 &crate::naming::NamingConfig::default(),
2025 &colliding,
2026 );
2027
2028 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2029 crate::reconcile::Desired {
2030 clip: clip.clone(),
2031 lineage: ctx.clone(),
2032 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2033 format: crate::AudioFormat::Flac,
2034 meta_hash: String::new(),
2035 art_hash: String::new(),
2036 modes: vec![crate::reconcile::SourceMode::Mirror],
2037 trashed: false,
2038 private: false,
2039 artifacts: Vec::new(),
2040 stems: None,
2041 }
2042 };
2043 let desired = vec![
2044 desired_of(&clip_a, &ctx_a, &names[0]),
2045 desired_of(&clip_b, &ctx_b, &names[1]),
2046 ];
2047
2048 let albums = crate::reconcile::album_desired(&desired, false, false);
2049 assert_eq!(albums.len(), 2, "each distinct root is its own album");
2050 let jpg_paths: Vec<String> = albums
2051 .iter()
2052 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2053 .collect();
2054 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2055 assert_ne!(
2056 jpg_paths[0], jpg_paths[1],
2057 "colliding roots must not share one folder.jpg path"
2058 );
2059 }
2060
2061 #[test]
2062 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2063 let mut store = LineageStore::new();
2070 store.update(
2071 std::slice::from_ref(&Clip {
2072 id: "realroot".into(),
2073 title: "Shared".into(),
2074 clip_type: "gen".into(),
2075 ..Default::default()
2076 }),
2077 &Resolution {
2078 roots: [(
2079 "realroot".to_owned(),
2080 RootInfo {
2081 root_id: "realroot".into(),
2082 root_title: "Shared".into(),
2083 status: ResolveStatus::Resolved,
2084 },
2085 )]
2086 .into_iter()
2087 .collect(),
2088 gap_filled: Vec::new(),
2089 bridges: Vec::new(),
2090 },
2091 "now",
2092 );
2093 let new_clip = Clip {
2096 id: "newnewnew-9999".into(),
2097 title: "Solo Track".into(),
2098 display_name: "alice".into(),
2099 image_large_url: "https://art.example/large.jpg".into(),
2100 ..Default::default()
2101 };
2102 store.set_album_overrides(
2103 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2104 .into_iter()
2105 .collect(),
2106 );
2107
2108 let new_ctx = store.context_for(&new_clip);
2110 assert_eq!(new_ctx.root_id, "newnewnew-9999");
2111 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2112
2113 assert!(store.colliding_root_titles().is_empty());
2115
2116 let real_clip = Clip {
2118 id: "realroot".into(),
2119 title: "Shared".into(),
2120 display_name: "alice".into(),
2121 image_large_url: "https://art.example/large.jpg".into(),
2122 ..Default::default()
2123 };
2124 let real_ctx = store.context_for(&real_clip);
2125 let colliding = store.colliding_root_titles();
2126 let requests = [
2127 crate::naming::NamingRequest {
2128 clip: &real_clip,
2129 lineage: &real_ctx,
2130 },
2131 crate::naming::NamingRequest {
2132 clip: &new_clip,
2133 lineage: &new_ctx,
2134 },
2135 ];
2136 let names = crate::naming::render_clip_names(
2137 &requests,
2138 &crate::naming::NamingConfig::default(),
2139 &colliding,
2140 );
2141 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2142 crate::reconcile::Desired {
2143 clip: clip.clone(),
2144 lineage: ctx.clone(),
2145 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2146 format: crate::AudioFormat::Flac,
2147 meta_hash: String::new(),
2148 art_hash: String::new(),
2149 modes: vec![crate::reconcile::SourceMode::Mirror],
2150 trashed: false,
2151 private: false,
2152 artifacts: Vec::new(),
2153 stems: None,
2154 }
2155 };
2156 let desired = vec![
2157 desired_of(&real_clip, &real_ctx, &names[0]),
2158 desired_of(&new_clip, &new_ctx, &names[1]),
2159 ];
2160 let albums = crate::reconcile::album_desired(&desired, false, false);
2161 let jpg_paths: Vec<String> = albums
2162 .iter()
2163 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2164 .collect();
2165 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2166 assert_ne!(
2167 jpg_paths[0], jpg_paths[1],
2168 "an uncached override must not collapse two albums onto one path"
2169 );
2170 }
2171
2172 #[test]
2173 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2174 let child = Clip {
2181 id: "childclip".into(),
2182 title: "Cover".into(),
2183 clip_type: "gen".into(),
2184 task: "cover".into(),
2185 cover_clip_id: "gaproot".into(),
2186 edited_clip_id: "gaproot".into(),
2187 ..Default::default()
2188 };
2189 let other_root = Clip {
2190 id: "otherroot".into(),
2191 title: "Preferred".into(),
2192 clip_type: "gen".into(),
2193 ..Default::default()
2194 };
2195 let gap_ancestor = Clip {
2196 id: "gaproot".into(),
2197 title: "Working Title".into(),
2198 clip_type: "gen".into(),
2199 ..Default::default()
2200 };
2201 let mut roots = HashMap::new();
2202 roots.insert(
2203 "childclip".to_owned(),
2204 RootInfo {
2205 root_id: "gaproot".into(),
2206 root_title: "Working Title".into(),
2207 status: ResolveStatus::Resolved,
2208 },
2209 );
2210 roots.insert(
2211 "otherroot".to_owned(),
2212 RootInfo {
2213 root_id: "otherroot".into(),
2214 root_title: "Preferred".into(),
2215 status: ResolveStatus::Resolved,
2216 },
2217 );
2218 let mut store = LineageStore::new();
2219 store.update(
2220 &[child.clone(), other_root],
2221 &Resolution {
2222 roots,
2223 gap_filled: vec![gap_ancestor],
2224 bridges: Vec::new(),
2225 },
2226 "now",
2227 );
2228 assert!(store.node("gaproot").is_some());
2230 assert!(!store.resolution_cache.contains_key("gaproot"));
2231
2232 store.set_album_overrides(
2233 [("gaproot".to_owned(), "Preferred".to_owned())]
2234 .into_iter()
2235 .collect(),
2236 );
2237
2238 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2241 assert_eq!(store.album_for_id("childclip"), "Preferred");
2242
2243 assert!(store.colliding_root_titles().contains("Preferred"));
2246 }
2247
2248 #[test]
2249 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2250 let child = Clip {
2256 id: "childclip".into(),
2257 title: "Cover".into(),
2258 clip_type: "gen".into(),
2259 task: "cover".into(),
2260 cover_clip_id: "gaproot".into(),
2261 edited_clip_id: "gaproot".into(),
2262 ..Default::default()
2263 };
2264 let mut roots = HashMap::new();
2265 roots.insert(
2266 "childclip".to_owned(),
2267 RootInfo {
2268 root_id: "gaproot".into(),
2269 root_title: "Working Title".into(),
2270 status: ResolveStatus::Resolved,
2271 },
2272 );
2273 let mut store = LineageStore::new();
2274 store.update(
2275 std::slice::from_ref(&child),
2276 &Resolution {
2277 roots,
2278 gap_filled: vec![Clip {
2279 id: "gaproot".into(),
2280 title: "Working Title".into(),
2281 clip_type: "gen".into(),
2282 ..Default::default()
2283 }],
2284 bridges: Vec::new(),
2285 },
2286 "now",
2287 );
2288
2289 let expected: std::collections::HashSet<String> = store
2290 .resolution_cache
2291 .values()
2292 .map(|entry| entry.root_id.clone())
2293 .filter(|root_id| !root_id.is_empty())
2294 .collect();
2295 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2296 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2298 assert!(!store.resolution_cache.contains_key("gaproot"));
2299 }
2300
2301 fn owner(id: &str, name: &str) -> Owner {
2302 Owner {
2303 user_id: id.to_owned(),
2304 display_name: name.to_owned(),
2305 }
2306 }
2307
2308 #[test]
2309 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2310 let mut store = LineageStore::new();
2311 assert!(!store.refresh_display_name("Alice"));
2313 assert!(store.owner().is_none());
2314
2315 store.pin_owner(owner("user_a", "Alice"));
2316 assert!(!store.refresh_display_name("Alice"));
2318 assert!(store.refresh_display_name("Alice Cooper"));
2320 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2321 assert_eq!(store.owner().unwrap().user_id, "user_a");
2323 }
2324
2325 #[test]
2326 fn owner_gate_covers_the_full_matrix() {
2327 let alice = owner("user_a", "Alice");
2328
2329 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2331 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2332
2333 assert_eq!(
2335 owner_gate(Some(&alice), None, "user_a", false),
2336 OwnerGate::Proceed
2337 );
2338
2339 assert_eq!(
2341 owner_gate(Some(&alice), None, "user_b", false),
2342 OwnerGate::AbortMismatch
2343 );
2344 assert_eq!(
2345 owner_gate(Some(&alice), None, "user_b", true),
2346 OwnerGate::Repin
2347 );
2348
2349 assert_eq!(
2352 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2353 OwnerGate::AbortConfigMismatch
2354 );
2355 assert_eq!(
2356 owner_gate(None, Some("user_c"), "user_a", true),
2357 OwnerGate::AbortConfigMismatch
2358 );
2359 assert_eq!(
2361 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2362 OwnerGate::Proceed
2363 );
2364
2365 assert!(OwnerGate::Repin.is_additive());
2367 for gate in [
2368 OwnerGate::AbortConfigMismatch,
2369 OwnerGate::AbortMismatch,
2370 OwnerGate::Proceed,
2371 OwnerGate::FirstUse,
2372 ] {
2373 assert!(!gate.is_additive());
2374 }
2375 }
2376
2377 #[test]
2378 fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
2379 let clips = chain_clips();
2380 let resolution = chain_resolution();
2381
2382 let mut store = LineageStore::new();
2383 store.update(&clips, &resolution, "first");
2384
2385 let json = serde_json::to_string(&store).unwrap();
2386 let mut store: LineageStore = serde_json::from_str(&json).unwrap();
2387
2388 store.update(&clips, &resolution, "second");
2389
2390 assert_eq!(store.edges.len(), 2);
2391 let cb = edge(&store, "c", "b");
2392 assert_eq!(cb.first_seen_at, "first");
2393 assert_eq!(cb.last_seen_at, "second");
2394 let ba = edge(&store, "b", "a");
2395 assert_eq!(ba.first_seen_at, "first");
2396 assert_eq!(ba.last_seen_at, "second");
2397 }
2398
2399 #[test]
2400 fn adopt_decision_covers_every_branch() {
2401 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2402 let empty: BTreeSet<&str> = BTreeSet::new();
2403
2404 assert_eq!(
2406 adopt_decision(&["x", "y"], &empty, true, false),
2407 AdoptDecision::PinFresh
2408 );
2409 assert_eq!(
2411 adopt_decision(&["c1"], &owned, false, false),
2412 AdoptDecision::SkipPin
2413 );
2414 assert_eq!(
2415 adopt_decision(&["c1"], &owned, false, true),
2416 AdoptDecision::SkipPin
2417 );
2418 assert_eq!(
2420 adopt_decision(&["c1", "z"], &owned, true, false),
2421 AdoptDecision::PinAdopt
2422 );
2423 assert_eq!(
2425 adopt_decision(&["z1", "z2"], &owned, true, false),
2426 AdoptDecision::Abort
2427 );
2428 assert_eq!(
2429 adopt_decision(&["z1", "z2"], &owned, true, true),
2430 AdoptDecision::AdoptForced
2431 );
2432
2433 assert!(AdoptDecision::AdoptForced.is_additive());
2435 for decision in [
2436 AdoptDecision::PinFresh,
2437 AdoptDecision::PinAdopt,
2438 AdoptDecision::Abort,
2439 AdoptDecision::SkipPin,
2440 ] {
2441 assert!(!decision.is_additive());
2442 }
2443 }
2444
2445 #[test]
2446 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2447 let json = r#"{"nodes":{},"edges":[]}"#;
2449 let store: LineageStore = serde_json::from_str(json).unwrap();
2450 assert!(store.owner().is_none());
2451 let value = serde_json::to_value(&store).unwrap();
2453 assert!(value.get("owner").is_none());
2454
2455 let mut pinned = LineageStore::new();
2457 pinned.pin_owner(owner("user_a", "Alice"));
2458 let back: LineageStore =
2459 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2460 assert_eq!(back, pinned);
2461 assert_eq!(back.owner().unwrap().user_id, "user_a");
2462 }
2463}