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 nodes: BTreeMap<String, Node>,
43 pub edges: Vec<StoredEdge>,
45 pub 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}
82
83impl Default for LineageStore {
84 fn default() -> Self {
85 Self {
86 schema_version: 1,
87 nodes: BTreeMap::new(),
88 edges: Vec::new(),
89 resolution_cache: BTreeMap::new(),
90 albums: BTreeMap::new(),
91 playlists: BTreeMap::new(),
92 owner: None,
93 album_overrides: BTreeMap::new(),
94 eligible_root_ids: HashSet::new(),
95 }
96 }
97}
98
99impl PartialEq for LineageStore {
108 fn eq(&self, other: &Self) -> bool {
109 self.schema_version == other.schema_version
110 && self.nodes == other.nodes
111 && self.edges == other.edges
112 && self.resolution_cache == other.resolution_cache
113 && self.albums == other.albums
114 && self.playlists == other.playlists
115 && self.owner == other.owner
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Owner {
126 pub user_id: String,
127 pub display_name: String,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum OwnerCheck {
133 FirstUse,
135 Match,
137 Mismatch,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OwnerGate {
150 AbortConfigMismatch,
153 AbortMismatch,
155 Repin,
158 Proceed,
161 FirstUse,
163}
164
165impl OwnerGate {
166 pub fn is_additive(self) -> bool {
168 matches!(self, OwnerGate::Repin)
169 }
170}
171
172pub fn owner_gate(
179 store_owner: Option<&Owner>,
180 configured_id: Option<&str>,
181 authed_user_id: &str,
182 allow_change: bool,
183) -> OwnerGate {
184 if let Some(configured) = configured_id
185 && configured != authed_user_id
186 {
187 return OwnerGate::AbortConfigMismatch;
188 }
189 match store_owner {
190 None => OwnerGate::FirstUse,
191 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
192 Some(_) if allow_change => OwnerGate::Repin,
193 Some(_) => OwnerGate::AbortMismatch,
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum AdoptDecision {
204 PinFresh,
207 PinAdopt,
210 AdoptForced,
213 Abort,
216 SkipPin,
218}
219
220impl AdoptDecision {
221 pub fn is_additive(self) -> bool {
223 matches!(self, AdoptDecision::AdoptForced)
224 }
225}
226
227pub fn adopt_decision(
237 listed: &[&str],
238 owned: &BTreeSet<&str>,
239 enumerated: bool,
240 allow_change: bool,
241) -> AdoptDecision {
242 if owned.is_empty() {
243 return AdoptDecision::PinFresh;
244 }
245 if !enumerated {
246 return AdoptDecision::SkipPin;
247 }
248 if listed.iter().any(|id| owned.contains(id)) {
249 AdoptDecision::PinAdopt
250 } else if allow_change {
251 AdoptDecision::AdoptForced
252 } else {
253 AdoptDecision::Abort
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct AlbumArt {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub folder_jpg: Option<ArtifactState>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub folder_webp: Option<ArtifactState>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub folder_mp4: Option<ArtifactState>,
278}
279
280impl AlbumArt {
281 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
284 match kind {
285 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
286 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
287 ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
288 ArtifactKind::CoverJpg
289 | ArtifactKind::CoverWebp
290 | ArtifactKind::DetailsTxt
291 | ArtifactKind::LyricsTxt
292 | ArtifactKind::Lrc
293 | ArtifactKind::VideoMp4
294 | ArtifactKind::Playlist => None,
295 }
296 }
297
298 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
304 match kind {
305 ArtifactKind::FolderJpg => self.folder_jpg = state,
306 ArtifactKind::FolderWebp => self.folder_webp = state,
307 ArtifactKind::FolderMp4 => self.folder_mp4 = state,
308 ArtifactKind::CoverJpg
309 | ArtifactKind::CoverWebp
310 | ArtifactKind::DetailsTxt
311 | ArtifactKind::LyricsTxt
312 | ArtifactKind::Lrc
313 | ArtifactKind::VideoMp4
314 | ArtifactKind::Playlist => {}
315 }
316 }
317
318 pub fn is_empty(&self) -> bool {
321 self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
322 }
323}
324
325#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(default)]
335pub struct PlaylistState {
336 pub name: String,
338 pub path: String,
340 pub hash: String,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347#[serde(default)]
348pub struct Node {
349 pub title: String,
350 pub created_at: String,
351 pub clip_type: String,
352 pub task: String,
353 pub is_remix: bool,
354 pub is_trashed: bool,
355 pub status: String,
357 pub first_seen_at: String,
358 pub last_seen_at: String,
359}
360
361impl Default for Node {
362 fn default() -> Self {
363 Self {
364 title: String::new(),
365 created_at: String::new(),
366 clip_type: String::new(),
367 task: String::new(),
368 is_remix: false,
369 is_trashed: false,
370 status: "observed".to_owned(),
371 first_seen_at: String::new(),
372 last_seen_at: String::new(),
373 }
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381#[serde(default)]
382pub struct StoredEdge {
383 pub child_id: String,
384 pub parent_id: String,
385 pub edge_type: String,
387 pub role: String,
389 pub source_field: String,
391 pub ordinal: u32,
393 pub status: String,
395 pub first_seen_at: String,
396 pub last_seen_at: String,
397}
398
399impl Default for StoredEdge {
400 fn default() -> Self {
401 Self {
402 child_id: String::new(),
403 parent_id: String::new(),
404 edge_type: String::new(),
405 role: String::new(),
406 source_field: String::new(),
407 ordinal: 0,
408 status: "active".to_owned(),
409 first_seen_at: String::new(),
410 last_seen_at: String::new(),
411 }
412 }
413}
414
415#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
417#[serde(default)]
418pub struct CacheEntry {
419 pub root_id: String,
420 pub status: String,
422 pub algorithm_version: u32,
423 pub computed_at: String,
424}
425
426impl LineageStore {
427 pub fn new() -> Self {
429 Self::default()
430 }
431
432 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
445 self.album_overrides = overrides;
446 }
447
448 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
472 if !self.eligible_root_ids.contains(root_id) {
473 return root_title;
474 }
475 match self.album_overrides.get(root_id) {
476 Some(name) if !name.trim().is_empty() => name.clone(),
477 _ => root_title,
478 }
479 }
480
481 pub fn refresh_eligible_roots(&mut self) {
491 self.eligible_root_ids = self
492 .resolution_cache
493 .values()
494 .map(|entry| entry.root_id.as_str())
495 .filter(|root_id| !root_id.is_empty())
496 .map(str::to_owned)
497 .collect();
498 }
499
500 #[cfg(test)]
503 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
504 &self.eligible_root_ids
505 }
506
507 pub fn node(&self, id: &str) -> Option<&Node> {
509 self.nodes.get(id)
510 }
511
512 pub fn owner(&self) -> Option<&Owner> {
514 self.owner.as_ref()
515 }
516
517 pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
519 match &self.owner {
520 None => OwnerCheck::FirstUse,
521 Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
522 Some(_) => OwnerCheck::Mismatch,
523 }
524 }
525
526 pub fn pin_owner(&mut self, owner: Owner) {
528 self.owner = Some(owner);
529 }
530
531 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
534 match &mut self.owner {
535 Some(owner) if owner.display_name != display_name => {
536 owner.display_name = display_name.to_owned();
537 true
538 }
539 _ => false,
540 }
541 }
542
543 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
545 self.resolution_cache.get(id)
546 }
547
548 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
550 self.albums.get(root_id)
551 }
552
553 pub fn set_album_artifact(
561 &mut self,
562 root_id: &str,
563 kind: ArtifactKind,
564 state: Option<ArtifactState>,
565 ) {
566 match state {
567 Some(state) => self
568 .albums
569 .entry(root_id.to_owned())
570 .or_default()
571 .set(kind, Some(state)),
572 None => {
573 if let Some(art) = self.albums.get_mut(root_id) {
574 art.set(kind, None);
575 if art.is_empty() {
576 self.albums.remove(root_id);
577 }
578 }
579 }
580 }
581 }
582
583 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
585 self.playlists.get(id)
586 }
587
588 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
596 match state {
597 Some(state) => {
598 self.playlists.insert(id.to_owned(), state);
599 }
600 None => {
601 self.playlists.remove(id);
602 }
603 }
604 }
605
606 pub fn context_for(&self, clip: &Clip) -> LineageContext {
617 let cached = self.get_root(&clip.id);
618 let root_id = cached
619 .map(|entry| entry.root_id.clone())
620 .filter(|id| !id.is_empty())
621 .unwrap_or_else(|| clip.id.clone());
622 let root_title = self
623 .node(&root_id)
624 .map(|node| node.title.clone())
625 .unwrap_or_else(|| clip.title.clone());
626 let root_title = self.effective_root_title(&root_id, root_title);
627 let root_date = self
628 .node(&root_id)
629 .map(|node| node.created_at.clone())
630 .unwrap_or_else(|| clip.created_at.clone());
631 let (parent_id, edge_type) = match immediate_parent(clip) {
632 Some((id, edge)) => (id, Some(edge)),
633 None => (String::new(), None),
634 };
635 let status = cached
636 .map(|entry| status_from_slug(&entry.status))
637 .unwrap_or(ResolveStatus::Resolved);
638 LineageContext {
639 root_id,
640 root_title,
641 root_date,
642 parent_id,
643 edge_type,
644 status,
645 }
646 }
647
648 pub fn album_for_id(&self, id: &str) -> String {
657 let own = self.node(id);
658 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
659 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
660 let root_id = self
661 .get_root(id)
662 .map(|entry| entry.root_id.clone())
663 .filter(|root| !root.is_empty())
664 .unwrap_or_else(|| id.to_owned());
665 let root_title = self
666 .node(&root_id)
667 .map(|node| node.title.clone())
668 .unwrap_or_else(|| own_title.clone());
669 let root_title = self.effective_root_title(&root_id, root_title);
670 let root_date = self
671 .node(&root_id)
672 .map(|node| node.created_at.clone())
673 .unwrap_or(own_created_at);
674 let context = LineageContext {
675 root_id,
676 root_title,
677 root_date,
678 parent_id: String::new(),
679 edge_type: None,
680 status: ResolveStatus::Resolved,
681 };
682 context.album(&own_title)
683 }
684
685 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
711 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
712 for root_id in &self.eligible_root_ids {
713 let node_title = self
714 .nodes
715 .get(root_id)
716 .map(|node| node.title.clone())
717 .unwrap_or_default();
718 let effective = self.effective_root_title(root_id, node_title);
719 let title = effective.trim();
720 if title.is_empty() {
721 continue;
722 }
723 roots_by_title
724 .entry(title.to_owned())
725 .or_default()
726 .insert(root_id.clone());
727 }
728 roots_by_title
729 .into_iter()
730 .filter(|(_, roots)| roots.len() > 1)
731 .map(|(title, _)| title)
732 .collect()
733 }
734
735 pub fn len(&self) -> usize {
737 self.nodes.len()
738 }
739
740 pub fn is_empty(&self) -> bool {
742 self.nodes.is_empty()
743 }
744
745 pub fn iter(&self) -> Iter<'_, String, Node> {
747 self.nodes.iter()
748 }
749
750 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
758 for clip in clips {
759 self.upsert_node(clip, now);
760 }
761 for clip in &resolution.gap_filled {
764 self.upsert_node(clip, now);
765 }
766
767 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
775 for edge in lineage_edges(clip) {
776 self.upsert_edge(&clip.id, &edge, now);
777 }
778 }
779 for (child_id, parent_id) in &resolution.bridges {
780 let edge = Edge {
781 parent_id: parent_id.clone(),
782 edge_type: EdgeType::Derived,
783 role: EdgeRole::Primary,
784 ordinal: 0,
785 source_field: "parent_endpoint",
786 };
787 self.upsert_edge(child_id, &edge, now);
788 }
789 self.edges.sort_by(|a, b| {
790 a.child_id
791 .cmp(&b.child_id)
792 .then(a.ordinal.cmp(&b.ordinal))
793 .then(a.parent_id.cmp(&b.parent_id))
794 .then(a.edge_type.cmp(&b.edge_type))
795 .then(a.role.cmp(&b.role))
796 });
797
798 for (child_id, info) in &resolution.roots {
799 self.upsert_cache(child_id, info, now);
800 }
801 self.refresh_eligible_roots();
802 }
803
804 pub fn archived_parents(&self) -> HashMap<String, String> {
813 self.edges
814 .iter()
815 .filter(|edge| edge.role == "primary" && edge.ordinal == 0 && edge.status == "active")
816 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
817 .collect()
818 }
819
820 fn upsert_node(&mut self, clip: &Clip, now: &str) {
823 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
824 first_seen_at: now.to_owned(),
825 ..Node::default()
826 });
827 node.title = clip.title.clone();
828 node.created_at = clip.created_at.clone();
829 node.clip_type = clip.clip_type.clone();
830 node.task = clip.task.clone();
831 node.is_remix = clip.is_remix;
832 node.is_trashed = clip.is_trashed;
833 node.last_seen_at = now.to_owned();
834 }
835
836 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
839 let edge_type = edge_type_slug(edge.edge_type);
840 let role = edge_role_slug(edge.role);
841 if let Some(existing) = self.edges.iter_mut().find(|stored| {
842 stored.child_id == child_id
843 && stored.parent_id == edge.parent_id
844 && stored.edge_type == edge_type
845 && stored.role == role
846 && stored.ordinal == edge.ordinal
847 }) {
848 existing.source_field = edge.source_field.to_owned();
849 existing.status = "active".to_owned();
850 existing.last_seen_at = now.to_owned();
851 } else {
852 self.edges.push(StoredEdge {
853 child_id: child_id.to_owned(),
854 parent_id: edge.parent_id.clone(),
855 edge_type: edge_type.to_owned(),
856 role: role.to_owned(),
857 source_field: edge.source_field.to_owned(),
858 ordinal: edge.ordinal,
859 status: "active".to_owned(),
860 first_seen_at: now.to_owned(),
861 last_seen_at: now.to_owned(),
862 });
863 }
864 }
865
866 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
873 if info.status != ResolveStatus::Resolved
874 && self
875 .resolution_cache
876 .get(child_id)
877 .is_some_and(|entry| entry.status == "resolved")
878 {
879 return;
880 }
881 self.resolution_cache.insert(
882 child_id.to_owned(),
883 CacheEntry {
884 root_id: info.root_id.clone(),
885 status: resolve_status_slug(info.status).to_owned(),
886 algorithm_version: 1,
887 computed_at: now.to_owned(),
888 },
889 );
890 }
891}
892
893fn edge_type_slug(edge_type: EdgeType) -> &'static str {
895 match edge_type {
896 EdgeType::Cover => "cover",
897 EdgeType::Remaster => "remaster",
898 EdgeType::SpeedEdit => "speed_edit",
899 EdgeType::Edit => "edit",
900 EdgeType::Extend => "extend",
901 EdgeType::SectionReplace => "section_replace",
902 EdgeType::Stitch => "stitch",
903 EdgeType::Derived => "derived",
904 EdgeType::Uploaded => "uploaded",
905 }
906}
907
908fn edge_role_slug(role: EdgeRole) -> &'static str {
910 match role {
911 EdgeRole::Primary => "primary",
912 EdgeRole::Secondary => "secondary",
913 }
914}
915
916fn resolve_status_slug(status: ResolveStatus) -> &'static str {
918 match status {
919 ResolveStatus::Resolved => "resolved",
920 ResolveStatus::External => "external",
921 ResolveStatus::Unresolved => "unresolved",
922 ResolveStatus::Cycle => "cycle",
923 }
924}
925
926fn status_from_slug(slug: &str) -> ResolveStatus {
929 match slug {
930 "external" => ResolveStatus::External,
931 "unresolved" => ResolveStatus::Unresolved,
932 "cycle" => ResolveStatus::Cycle,
933 _ => ResolveStatus::Resolved,
934 }
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940 use std::collections::HashMap;
941
942 fn chain_clips() -> Vec<Clip> {
944 vec![
945 Clip {
946 id: "c".into(),
947 title: "Cover".into(),
948 clip_type: "gen".into(),
949 task: "cover".into(),
950 created_at: "t2".into(),
951 cover_clip_id: "b".into(),
952 edited_clip_id: "b".into(),
953 ..Default::default()
954 },
955 Clip {
956 id: "b".into(),
957 title: "Remaster".into(),
958 clip_type: "upsample".into(),
959 task: "upsample".into(),
960 created_at: "t1".into(),
961 upsample_clip_id: "a".into(),
962 edited_clip_id: "a".into(),
963 ..Default::default()
964 },
965 Clip {
966 id: "a".into(),
967 title: "Root".into(),
968 clip_type: "gen".into(),
969 created_at: "t0".into(),
970 ..Default::default()
971 },
972 ]
973 }
974
975 fn chain_resolution() -> Resolution {
977 let mut roots = HashMap::new();
978 for id in ["a", "b", "c"] {
979 roots.insert(
980 id.to_owned(),
981 RootInfo {
982 root_id: "a".into(),
983 root_title: "Root".into(),
984 status: ResolveStatus::Resolved,
985 },
986 );
987 }
988 Resolution {
989 roots,
990 gap_filled: Vec::new(),
991 bridges: Vec::new(),
992 }
993 }
994
995 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
996 store
997 .edges
998 .iter()
999 .find(|e| e.child_id == child && e.parent_id == parent)
1000 .expect("edge should exist")
1001 }
1002
1003 #[test]
1004 fn new_store_is_empty_and_versioned() {
1005 let store = LineageStore::new();
1006 assert!(store.is_empty());
1007 assert_eq!(store.len(), 0);
1008 assert_eq!(store.schema_version, 1);
1009 }
1010
1011 #[test]
1012 fn update_populates_nodes_edges_and_cache() {
1013 let mut store = LineageStore::new();
1014 store.update(&chain_clips(), &chain_resolution(), "now");
1015
1016 assert_eq!(store.len(), 3);
1018 let cover = store.node("c").unwrap();
1019 assert_eq!(cover.title, "Cover");
1020 assert_eq!(cover.clip_type, "gen");
1021 assert_eq!(cover.task, "cover");
1022 assert_eq!(cover.created_at, "t2");
1023 assert_eq!(cover.status, "observed");
1024 assert!(!cover.is_trashed);
1025 assert_eq!(cover.first_seen_at, "now");
1026 assert_eq!(cover.last_seen_at, "now");
1027
1028 assert_eq!(store.edges.len(), 2);
1030 let cb = edge(&store, "c", "b");
1031 assert_eq!(cb.edge_type, "cover");
1032 assert_eq!(cb.role, "primary");
1033 assert_eq!(cb.ordinal, 0);
1034 assert_eq!(cb.source_field, "cover_clip_id");
1035 assert_eq!(cb.status, "active");
1036 let ba = edge(&store, "b", "a");
1037 assert_eq!(ba.edge_type, "remaster");
1038 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1039
1040 for id in ["a", "b", "c"] {
1042 let cached = store.get_root(id).unwrap();
1043 assert_eq!(cached.root_id, "a");
1044 assert_eq!(cached.status, "resolved");
1045 assert_eq!(cached.algorithm_version, 1);
1046 }
1047 }
1048
1049 #[test]
1050 fn update_persists_edges_for_gap_filled_ancestors() {
1051 let child = Clip {
1055 id: "child".into(),
1056 title: "Cover".into(),
1057 clip_type: "gen".into(),
1058 task: "cover".into(),
1059 cover_clip_id: "mid".into(),
1060 edited_clip_id: "mid".into(),
1061 ..Default::default()
1062 };
1063 let mid = Clip {
1064 id: "mid".into(),
1065 title: "Mid".into(),
1066 clip_type: "gen".into(),
1067 task: "cover".into(),
1068 cover_clip_id: "root".into(),
1069 edited_clip_id: "root".into(),
1070 ..Default::default()
1071 };
1072 let mut roots = HashMap::new();
1073 roots.insert(
1074 "child".to_owned(),
1075 RootInfo {
1076 root_id: "root".into(),
1077 root_title: "Original".into(),
1078 status: ResolveStatus::Resolved,
1079 },
1080 );
1081 let resolution = Resolution {
1082 roots,
1083 gap_filled: vec![mid],
1084 bridges: Vec::new(),
1085 };
1086 let mut store = LineageStore::new();
1087 store.update(std::slice::from_ref(&child), &resolution, "now");
1088
1089 let mid_edge = edge(&store, "mid", "root");
1091 assert_eq!(mid_edge.role, "primary");
1092 assert_eq!(mid_edge.ordinal, 0);
1093 let archived = store.archived_parents();
1095 assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
1096 assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
1097 }
1098
1099 #[test]
1100 fn update_persists_bridges_as_edges() {
1101 let child = Clip {
1104 id: "child".into(),
1105 title: "Cover".into(),
1106 clip_type: "gen".into(),
1107 task: "cover".into(),
1108 cover_clip_id: "gone".into(),
1109 edited_clip_id: "gone".into(),
1110 ..Default::default()
1111 };
1112 let mut roots = HashMap::new();
1113 roots.insert(
1114 "child".to_owned(),
1115 RootInfo {
1116 root_id: "found".into(),
1117 root_title: String::new(),
1118 status: ResolveStatus::External,
1119 },
1120 );
1121 let resolution = Resolution {
1122 roots,
1123 gap_filled: Vec::new(),
1124 bridges: vec![("gone".to_owned(), "found".to_owned())],
1125 };
1126 let mut store = LineageStore::new();
1127 store.update(std::slice::from_ref(&child), &resolution, "now");
1128
1129 let bridged = edge(&store, "gone", "found");
1130 assert_eq!(bridged.source_field, "parent_endpoint");
1131 assert_eq!(bridged.role, "primary");
1132 assert_eq!(bridged.ordinal, 0);
1133 assert_eq!(
1134 store.archived_parents().get("gone").map(String::as_str),
1135 Some("found")
1136 );
1137 }
1138
1139 #[test]
1140 fn archived_parents_maps_children_to_primary_parents_only() {
1141 let mut store = LineageStore::new();
1142 store.update(&chain_clips(), &chain_resolution(), "now");
1143 let archived = store.archived_parents();
1144 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
1145 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
1146 assert!(
1147 !archived.contains_key("a"),
1148 "a root has no primary parent edge"
1149 );
1150 }
1151
1152 #[test]
1153 fn album_for_id_matches_context_for_and_handles_unknown() {
1154 let mut store = LineageStore::new();
1155 store.update(&chain_clips(), &chain_resolution(), "now");
1156
1157 assert_eq!(store.album_for_id("c"), "Root");
1160 let cover = &chain_clips()[0];
1161 assert_eq!(
1162 store.album_for_id("c"),
1163 store.context_for(cover).album(&cover.title)
1164 );
1165 assert_eq!(store.album_for_id("a"), "Root");
1167 assert_eq!(store.album_for_id("missing"), "");
1169 }
1170
1171 #[test]
1172 fn serde_roundtrip_preserves_a_relational_shape() {
1173 let mut store = LineageStore::new();
1174 store.update(&chain_clips(), &chain_resolution(), "now");
1175
1176 let json = serde_json::to_string(&store).unwrap();
1177 let back: LineageStore = serde_json::from_str(&json).unwrap();
1178 assert_eq!(store, back);
1179
1180 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1181 assert_eq!(value.get("schema_version").unwrap(), 1);
1182 assert!(value.get("nodes").unwrap().is_object());
1183 assert!(value.get("edges").unwrap().is_array());
1184 assert!(value.get("resolution_cache").unwrap().is_object());
1185
1186 let node = value.get("nodes").unwrap().get("c").unwrap();
1189 assert!(node.get("edges").is_none());
1190 assert!(node.get("parent_id").is_none());
1191 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1192 assert!(first_edge.get("child_id").is_some());
1193 assert!(first_edge.get("parent_id").is_some());
1194 }
1195
1196 #[test]
1197 fn album_overrides_are_runtime_only_and_never_persist() {
1198 let mut store = LineageStore::new();
1202 store.update(&chain_clips(), &chain_resolution(), "now");
1203 store.set_album_overrides(
1204 [("a".to_owned(), "Preferred".to_owned())]
1205 .into_iter()
1206 .collect(),
1207 );
1208
1209 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1210 assert!(value.get("album_overrides").is_none());
1211
1212 let json = serde_json::to_string(&store).unwrap();
1213 let back: LineageStore = serde_json::from_str(&json).unwrap();
1214 assert!(back.album_overrides.is_empty());
1215 assert_eq!(back.album_for_id("c"), "Root");
1216 }
1217
1218 #[test]
1219 fn update_is_idempotent_bar_last_seen() {
1220 let clips = chain_clips();
1221 let resolution = chain_resolution();
1222 let mut store = LineageStore::new();
1223 store.update(&clips, &resolution, "first");
1224 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1225 let edge_count = store.edges.len();
1226
1227 store.update(&clips, &resolution, "second");
1228
1229 assert_eq!(
1231 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1232 node_ids
1233 );
1234 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1235 assert_eq!(store.resolution_cache.len(), 3);
1236
1237 let cover = store.node("c").unwrap();
1239 assert_eq!(cover.first_seen_at, "first");
1240 assert_eq!(cover.last_seen_at, "second");
1241 let cb = edge(&store, "c", "b");
1242 assert_eq!(cb.first_seen_at, "first");
1243 assert_eq!(cb.last_seen_at, "second");
1244 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1246 }
1247
1248 #[test]
1249 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1250 let mut store = LineageStore::new();
1251 store.update(&chain_clips(), &chain_resolution(), "first");
1252 assert_eq!(store.get_root("c").unwrap().status, "resolved");
1253
1254 let child = Clip {
1257 id: "c".into(),
1258 title: "Cover".into(),
1259 clip_type: "gen".into(),
1260 task: "cover".into(),
1261 cover_clip_id: "b".into(),
1262 edited_clip_id: "b".into(),
1263 ..Default::default()
1264 };
1265 let mut roots = HashMap::new();
1266 roots.insert(
1267 "c".to_owned(),
1268 RootInfo {
1269 root_id: "elsewhere".into(),
1270 root_title: String::new(),
1271 status: ResolveStatus::External,
1272 },
1273 );
1274 roots.insert(
1275 "d".to_owned(),
1276 RootInfo {
1277 root_id: "boundary".into(),
1278 root_title: String::new(),
1279 status: ResolveStatus::External,
1280 },
1281 );
1282 let resolution = Resolution {
1283 roots,
1284 gap_filled: Vec::new(),
1285 bridges: Vec::new(),
1286 };
1287 store.update(&[child], &resolution, "second");
1288
1289 let cached = store.get_root("c").unwrap();
1291 assert_eq!(cached.root_id, "a");
1292 assert_eq!(cached.status, "resolved");
1293 assert_eq!(cached.computed_at, "first");
1294 let d = store.get_root("d").unwrap();
1296 assert_eq!(d.root_id, "boundary");
1297 assert_eq!(d.status, "external");
1298 }
1299
1300 #[test]
1301 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1302 let child = Clip {
1306 id: "c".into(),
1307 title: "Cover".into(),
1308 clip_type: "gen".into(),
1309 task: "cover".into(),
1310 cover_clip_id: "t".into(),
1311 edited_clip_id: "t".into(),
1312 ..Default::default()
1313 };
1314 let trashed = Clip {
1315 id: "t".into(),
1316 title: "Trashed Original".into(),
1317 clip_type: "gen".into(),
1318 is_trashed: true,
1319 ..Default::default()
1320 };
1321 let mut roots = HashMap::new();
1322 roots.insert(
1323 "c".to_owned(),
1324 RootInfo {
1325 root_id: "t".into(),
1326 root_title: "Trashed Original".into(),
1327 status: ResolveStatus::Resolved,
1328 },
1329 );
1330 let resolution = Resolution {
1331 roots,
1332 gap_filled: vec![trashed],
1333 bridges: Vec::new(),
1334 };
1335 store_update_and_assert_trashed(child, resolution);
1336 }
1337
1338 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1339 let mut store = LineageStore::new();
1340 store.update(&[child], &resolution, "now");
1341
1342 let node = store
1343 .node("t")
1344 .expect("trashed ancestor should be archived");
1345 assert!(node.is_trashed);
1346 assert_eq!(node.title, "Trashed Original");
1347 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1349 }
1350
1351 #[test]
1352 fn partial_json_loads_with_defaults() {
1353 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1356 let store: LineageStore = serde_json::from_str(json).unwrap();
1357 assert_eq!(store.schema_version, 1);
1358 let node = store.node("x").unwrap();
1359 assert_eq!(node.title, "Kept");
1360 assert_eq!(node.status, "observed");
1361 assert_eq!(store.edges[0].status, "active");
1362 assert!(store.resolution_cache.is_empty());
1363 assert!(store.albums.is_empty());
1366 assert!(store.album_art("x").is_none());
1367 assert!(store.playlists.is_empty());
1371 assert!(store.playlist("x").is_none());
1372 }
1373
1374 #[test]
1375 fn album_art_roundtrips_and_reads_by_kind() {
1376 let mut store = LineageStore::new();
1377 store.albums.insert(
1378 "root-1".to_owned(),
1379 AlbumArt {
1380 folder_jpg: Some(ArtifactState {
1381 path: "alice/Album/folder.jpg".to_owned(),
1382 hash: "jpg-h".to_owned(),
1383 }),
1384 folder_webp: Some(ArtifactState {
1385 path: "alice/Album/cover.webp".to_owned(),
1386 hash: "webp-h".to_owned(),
1387 }),
1388 folder_mp4: Some(ArtifactState {
1389 path: "alice/Album/cover.mp4".to_owned(),
1390 hash: "mp4-h".to_owned(),
1391 }),
1392 },
1393 );
1394
1395 let json = serde_json::to_string(&store).unwrap();
1396 let back: LineageStore = serde_json::from_str(&json).unwrap();
1397 assert_eq!(store, back);
1398
1399 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1401 let album = value.get("albums").unwrap().get("root-1").unwrap();
1402 assert_eq!(
1403 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1404 "jpg-h"
1405 );
1406
1407 let art = back.album_art("root-1").unwrap();
1408 assert_eq!(
1409 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1410 "alice/Album/folder.jpg"
1411 );
1412 assert_eq!(
1413 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1414 "webp-h"
1415 );
1416 assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1417 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1419 }
1420
1421 #[test]
1422 fn empty_album_art_omits_slots_when_serialised() {
1423 let empty = AlbumArt::default();
1426 assert!(empty.is_empty());
1427 let value = serde_json::to_value(&empty).unwrap();
1428 assert!(value.get("folder_jpg").is_none());
1429 assert!(value.get("folder_webp").is_none());
1430 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1431 assert_eq!(back, empty);
1432 }
1433
1434 #[test]
1435 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1436 let mut store = LineageStore::new();
1437 let jpg = ArtifactState {
1438 path: "a/folder.jpg".to_owned(),
1439 hash: "h1".to_owned(),
1440 };
1441 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1442 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1443
1444 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1446 assert!(store.album_art("root-1").is_none());
1447 assert!(store.albums.is_empty());
1448 }
1449
1450 #[test]
1451 fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1452 let mut store = LineageStore::new();
1457 let state = |p: &str| ArtifactState {
1458 path: p.to_owned(),
1459 hash: "h".to_owned(),
1460 };
1461 store.set_album_artifact(
1462 "root-1",
1463 ArtifactKind::FolderWebp,
1464 Some(state("a/cover.webp")),
1465 );
1466 store.set_album_artifact(
1467 "root-1",
1468 ArtifactKind::FolderMp4,
1469 Some(state("a/cover.mp4")),
1470 );
1471
1472 store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1475 let art = store
1476 .album_art("root-1")
1477 .expect("row kept while folder_mp4 remains");
1478 assert!(!art.is_empty());
1479 assert!(art.folder_mp4.is_some());
1480
1481 store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1483 assert!(store.album_art("root-1").is_none());
1484 assert!(store.albums.is_empty());
1485 }
1486
1487 #[test]
1488 fn playlist_state_roundtrips_by_id() {
1489 let mut store = LineageStore::new();
1490 store.playlists.insert(
1491 "pl1".to_owned(),
1492 PlaylistState {
1493 name: "Road Trip".to_owned(),
1494 path: "Road Trip.m3u8".to_owned(),
1495 hash: "abc123".to_owned(),
1496 },
1497 );
1498
1499 let json = serde_json::to_string(&store).unwrap();
1500 let back: LineageStore = serde_json::from_str(&json).unwrap();
1501 assert_eq!(store, back);
1502
1503 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1505 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1506 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1507 assert_eq!(pl.get("hash").unwrap(), "abc123");
1508
1509 let stored = back.playlist("pl1").unwrap();
1510 assert_eq!(stored.name, "Road Trip");
1511 assert_eq!(stored.hash, "abc123");
1512 }
1513
1514 #[test]
1515 fn set_playlist_upserts_then_clears() {
1516 let mut store = LineageStore::new();
1517 let state = PlaylistState {
1518 name: "Mix".to_owned(),
1519 path: "Mix.m3u8".to_owned(),
1520 hash: "h1".to_owned(),
1521 };
1522 store.set_playlist("pl1", Some(state.clone()));
1523 assert_eq!(store.playlist("pl1"), Some(&state));
1524
1525 let renamed = PlaylistState {
1527 name: "Mix v2".to_owned(),
1528 path: "Mix v2.m3u8".to_owned(),
1529 hash: "h2".to_owned(),
1530 };
1531 store.set_playlist("pl1", Some(renamed.clone()));
1532 assert_eq!(store.playlist("pl1"), Some(&renamed));
1533
1534 store.set_playlist("pl1", None);
1536 assert!(store.playlist("pl1").is_none());
1537 assert!(store.playlists.is_empty());
1538 }
1539
1540 #[test]
1541 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1542 let mut store = LineageStore::new();
1543 store.update(&chain_clips(), &chain_resolution(), "now");
1544
1545 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1547 assert_eq!(ctx.root_id, "a");
1548 assert_eq!(ctx.root_title, "Root");
1549 assert_eq!(ctx.parent_id, "b");
1550 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1551 assert_eq!(ctx.status, ResolveStatus::Resolved);
1552 assert_eq!(ctx.album("Cover"), "Root");
1554 }
1555
1556 #[test]
1557 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1558 let mut store = LineageStore::new();
1559 store.update(&chain_clips(), &chain_resolution(), "now");
1560
1561 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1563 assert_eq!(ctx.root_id, "a");
1564 assert_eq!(ctx.root_title, "Root");
1565 assert_eq!(ctx.parent_id, "");
1566 assert_eq!(ctx.edge_type, None);
1567 assert_eq!(ctx.album("Root"), "Root");
1568 }
1569
1570 #[test]
1571 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1572 let clips = vec![
1575 Clip {
1576 id: "child".into(),
1577 title: "Revision".into(),
1578 clip_type: "gen".into(),
1579 task: "cover".into(),
1580 created_at: "2024-01-02T08:00:00Z".into(),
1581 cover_clip_id: "root".into(),
1582 edited_clip_id: "root".into(),
1583 ..Default::default()
1584 },
1585 Clip {
1586 id: "root".into(),
1587 title: "Origin".into(),
1588 clip_type: "gen".into(),
1589 created_at: "2023-12-30T23:00:00Z".into(),
1590 ..Default::default()
1591 },
1592 ];
1593 let mut roots = HashMap::new();
1594 for id in ["child", "root"] {
1595 roots.insert(
1596 id.to_owned(),
1597 RootInfo {
1598 root_id: "root".into(),
1599 root_title: "Origin".into(),
1600 status: ResolveStatus::Resolved,
1601 },
1602 );
1603 }
1604 let resolution = Resolution {
1605 roots,
1606 gap_filled: Vec::new(),
1607 bridges: Vec::new(),
1608 };
1609 let mut store = LineageStore::new();
1610 store.update(&clips, &resolution, "now");
1611
1612 let child_ctx = store.context_for(&clips[0]);
1613 assert_eq!(child_ctx.root_id, "root");
1614 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1615 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1617
1618 let root_ctx = store.context_for(&clips[1]);
1620 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1621 }
1622
1623 #[test]
1624 fn context_for_an_unknown_clip_is_self_rooted() {
1625 let store = LineageStore::new();
1626 let orphan = Clip {
1627 id: "z".into(),
1628 title: "Lonely".into(),
1629 ..Default::default()
1630 };
1631 let ctx = store.context_for(&orphan);
1632 assert_eq!(ctx.root_id, "z");
1633 assert_eq!(ctx.root_title, "Lonely");
1634 assert_eq!(ctx.parent_id, "");
1635 assert_eq!(ctx.status, ResolveStatus::Resolved);
1636 }
1637
1638 #[test]
1639 fn context_for_retains_a_purged_ancestor_album() {
1640 let child = Clip {
1645 id: "c".into(),
1646 title: "Cover".into(),
1647 clip_type: "gen".into(),
1648 task: "cover".into(),
1649 cover_clip_id: "t".into(),
1650 edited_clip_id: "t".into(),
1651 ..Default::default()
1652 };
1653 let trashed = Clip {
1654 id: "t".into(),
1655 title: "Trashed Original".into(),
1656 clip_type: "gen".into(),
1657 is_trashed: true,
1658 ..Default::default()
1659 };
1660 let mut roots = HashMap::new();
1661 roots.insert(
1662 "c".to_owned(),
1663 RootInfo {
1664 root_id: "t".into(),
1665 root_title: "Trashed Original".into(),
1666 status: ResolveStatus::Resolved,
1667 },
1668 );
1669 let resolution = Resolution {
1670 roots,
1671 gap_filled: vec![trashed],
1672 bridges: Vec::new(),
1673 };
1674 let mut store = LineageStore::new();
1675 store.update(std::slice::from_ref(&child), &resolution, "now");
1676
1677 let ctx = store.context_for(&child);
1678 assert_eq!(ctx.root_id, "t");
1679 assert_eq!(ctx.root_title, "Trashed Original");
1680 assert_eq!(ctx.album("Cover"), "Trashed Original");
1681 }
1682
1683 #[test]
1684 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1685 let clips = vec![
1688 Clip {
1689 id: "r1".into(),
1690 title: "Break Through".into(),
1691 clip_type: "gen".into(),
1692 ..Default::default()
1693 },
1694 Clip {
1695 id: "r2".into(),
1696 title: "Break Through".into(),
1697 clip_type: "gen".into(),
1698 ..Default::default()
1699 },
1700 Clip {
1701 id: "r3".into(),
1702 title: "Solo".into(),
1703 clip_type: "gen".into(),
1704 ..Default::default()
1705 },
1706 Clip {
1707 id: "c1".into(),
1708 title: "Break Through".into(),
1709 clip_type: "gen".into(),
1710 task: "cover".into(),
1711 cover_clip_id: "r1".into(),
1712 edited_clip_id: "r1".into(),
1713 ..Default::default()
1714 },
1715 ];
1716 let mut roots = HashMap::new();
1717 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1718 let title = if root == "r3" {
1719 "Solo"
1720 } else {
1721 "Break Through"
1722 };
1723 roots.insert(
1724 id.to_owned(),
1725 RootInfo {
1726 root_id: root.into(),
1727 root_title: title.into(),
1728 status: ResolveStatus::Resolved,
1729 },
1730 );
1731 }
1732 let resolution = Resolution {
1733 roots,
1734 gap_filled: Vec::new(),
1735 bridges: Vec::new(),
1736 };
1737 let mut store = LineageStore::new();
1738 store.update(&clips, &resolution, "now");
1739
1740 let colliding = store.colliding_root_titles();
1741 assert!(colliding.contains("Break Through"));
1742 assert!(!colliding.contains("Solo"));
1743 assert_eq!(colliding.len(), 1);
1744 }
1745
1746 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1749 let clips = vec![
1750 Clip {
1751 id: "r1".into(),
1752 title: t1.into(),
1753 clip_type: "gen".into(),
1754 ..Default::default()
1755 },
1756 Clip {
1757 id: "r2".into(),
1758 title: t2.into(),
1759 clip_type: "gen".into(),
1760 ..Default::default()
1761 },
1762 ];
1763 let mut roots = HashMap::new();
1764 roots.insert(
1765 "r1".to_owned(),
1766 RootInfo {
1767 root_id: "r1".into(),
1768 root_title: t1.into(),
1769 status: ResolveStatus::Resolved,
1770 },
1771 );
1772 roots.insert(
1773 "r2".to_owned(),
1774 RootInfo {
1775 root_id: "r2".into(),
1776 root_title: t2.into(),
1777 status: ResolveStatus::Resolved,
1778 },
1779 );
1780 let mut store = LineageStore::new();
1781 store.update(
1782 &clips,
1783 &Resolution {
1784 roots,
1785 gap_filled: Vec::new(),
1786 bridges: Vec::new(),
1787 },
1788 "now",
1789 );
1790 store
1791 }
1792
1793 #[test]
1794 fn album_override_flows_into_context_tag_hash_and_index() {
1795 let clips = chain_clips();
1799 let mut store = LineageStore::new();
1800 store.update(&clips, &chain_resolution(), "now");
1801
1802 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1804
1805 store.set_album_overrides(
1806 [("a".to_owned(), "Preferred Name".to_owned())]
1807 .into_iter()
1808 .collect(),
1809 );
1810
1811 for id in ["a", "b", "c"] {
1813 let clip = clips.iter().find(|c| c.id == id).unwrap();
1814 let ctx = store.context_for(clip);
1815 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1816 assert_eq!(store.album_for_id(id), "Preferred Name");
1817 }
1818
1819 let ctx = store.context_for(cover);
1821 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1822 assert_eq!(meta.album, "Preferred Name");
1823
1824 let after_hash = crate::hash::meta_hash(cover, &ctx);
1826 assert_ne!(before_hash, after_hash);
1827 }
1828
1829 #[test]
1830 fn empty_album_override_is_ignored() {
1831 let clips = chain_clips();
1833 let mut store = LineageStore::new();
1834 store.update(&clips, &chain_resolution(), "now");
1835 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1836 assert_eq!(store.album_for_id("c"), "Root");
1837 }
1838
1839 #[test]
1840 fn album_override_creates_a_collision_that_disambiguates() {
1841 let mut store = two_root_store("Alpha", "Beta");
1843 assert!(store.colliding_root_titles().is_empty());
1844
1845 store.set_album_overrides(
1846 [("r2".to_owned(), "Alpha".to_owned())]
1847 .into_iter()
1848 .collect(),
1849 );
1850 let colliding = store.colliding_root_titles();
1851 assert!(colliding.contains("Alpha"));
1852 assert_eq!(colliding.len(), 1);
1853 }
1854
1855 #[test]
1856 fn album_override_resolves_a_natural_collision() {
1857 let mut store = two_root_store("Break Through", "Break Through");
1859 assert!(store.colliding_root_titles().contains("Break Through"));
1860
1861 store.set_album_overrides(
1862 [("r2".to_owned(), "Second Wind".to_owned())]
1863 .into_iter()
1864 .collect(),
1865 );
1866 assert!(store.colliding_root_titles().is_empty());
1867 }
1868
1869 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1874 store.resolution_cache.insert(
1875 root_id.to_owned(),
1876 CacheEntry {
1877 root_id: root_id.to_owned(),
1878 status: "external".to_owned(),
1879 algorithm_version: 1,
1880 computed_at: "now".to_owned(),
1881 },
1882 );
1883 store.refresh_eligible_roots();
1886 }
1887
1888 #[test]
1889 fn override_on_node_less_root_collides_with_a_real_root() {
1890 let mut store = LineageStore::new();
1894 store.update(
1895 std::slice::from_ref(&Clip {
1896 id: "realroot".into(),
1897 title: "Shared".into(),
1898 clip_type: "gen".into(),
1899 ..Default::default()
1900 }),
1901 &Resolution {
1902 roots: [(
1903 "realroot".to_owned(),
1904 RootInfo {
1905 root_id: "realroot".into(),
1906 root_title: "Shared".into(),
1907 status: ResolveStatus::Resolved,
1908 },
1909 )]
1910 .into_iter()
1911 .collect(),
1912 gap_filled: Vec::new(),
1913 bridges: Vec::new(),
1914 },
1915 "now",
1916 );
1917 insert_cache_only_root(&mut store, "extroot");
1918 store.set_album_overrides(
1919 [("extroot".to_owned(), "Shared".to_owned())]
1920 .into_iter()
1921 .collect(),
1922 );
1923
1924 let colliding = store.colliding_root_titles();
1925 assert!(
1926 colliding.contains("Shared"),
1927 "a node-less overridden root must still be seen by collision detection"
1928 );
1929 }
1930
1931 #[test]
1932 fn two_node_less_roots_overridden_to_same_name_collide() {
1933 let mut store = LineageStore::new();
1934 insert_cache_only_root(&mut store, "extone");
1935 insert_cache_only_root(&mut store, "exttwo");
1936 store.set_album_overrides(
1937 [
1938 ("extone".to_owned(), "Shared".to_owned()),
1939 ("exttwo".to_owned(), "Shared".to_owned()),
1940 ]
1941 .into_iter()
1942 .collect(),
1943 );
1944 assert!(store.colliding_root_titles().contains("Shared"));
1945 }
1946
1947 #[test]
1948 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1949 let mut store = LineageStore::new();
1954 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1955 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1956 store.set_album_overrides(
1957 [
1958 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1959 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1960 ]
1961 .into_iter()
1962 .collect(),
1963 );
1964 let colliding = store.colliding_root_titles();
1965
1966 let clip_of = |id: &str| Clip {
1967 id: id.to_owned(),
1968 title: "Track".to_owned(),
1969 display_name: "alice".to_owned(),
1970 image_large_url: "https://art.example/large.jpg".to_owned(),
1971 ..Default::default()
1972 };
1973 let ctx_of = |root_id: &str| LineageContext {
1974 root_id: root_id.to_owned(),
1975 root_title: "Shared".to_owned(),
1976 root_date: String::new(),
1977 parent_id: String::new(),
1978 edge_type: None,
1979 status: ResolveStatus::Resolved,
1980 };
1981 let clip_a = clip_of("clipaaaa-1111");
1982 let clip_b = clip_of("clipbbbb-2222");
1983 let ctx_a = ctx_of("aaaaaaaa-root-one");
1984 let ctx_b = ctx_of("bbbbbbbb-root-two");
1985 let requests = [
1986 crate::naming::NamingRequest {
1987 clip: &clip_a,
1988 lineage: &ctx_a,
1989 },
1990 crate::naming::NamingRequest {
1991 clip: &clip_b,
1992 lineage: &ctx_b,
1993 },
1994 ];
1995 let names = crate::naming::render_clip_names(
1996 &requests,
1997 &crate::naming::NamingConfig::default(),
1998 &colliding,
1999 );
2000
2001 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2002 crate::reconcile::Desired {
2003 clip: clip.clone(),
2004 lineage: ctx.clone(),
2005 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2006 format: crate::AudioFormat::Flac,
2007 meta_hash: String::new(),
2008 art_hash: String::new(),
2009 modes: vec![crate::reconcile::SourceMode::Mirror],
2010 trashed: false,
2011 private: false,
2012 artifacts: Vec::new(),
2013 stems: None,
2014 }
2015 };
2016 let desired = vec![
2017 desired_of(&clip_a, &ctx_a, &names[0]),
2018 desired_of(&clip_b, &ctx_b, &names[1]),
2019 ];
2020
2021 let albums = crate::reconcile::album_desired(&desired, false, false);
2022 assert_eq!(albums.len(), 2, "each distinct root is its own album");
2023 let jpg_paths: Vec<String> = albums
2024 .iter()
2025 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2026 .collect();
2027 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2028 assert_ne!(
2029 jpg_paths[0], jpg_paths[1],
2030 "colliding roots must not share one folder.jpg path"
2031 );
2032 }
2033
2034 #[test]
2035 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2036 let mut store = LineageStore::new();
2043 store.update(
2044 std::slice::from_ref(&Clip {
2045 id: "realroot".into(),
2046 title: "Shared".into(),
2047 clip_type: "gen".into(),
2048 ..Default::default()
2049 }),
2050 &Resolution {
2051 roots: [(
2052 "realroot".to_owned(),
2053 RootInfo {
2054 root_id: "realroot".into(),
2055 root_title: "Shared".into(),
2056 status: ResolveStatus::Resolved,
2057 },
2058 )]
2059 .into_iter()
2060 .collect(),
2061 gap_filled: Vec::new(),
2062 bridges: Vec::new(),
2063 },
2064 "now",
2065 );
2066 let new_clip = Clip {
2069 id: "newnewnew-9999".into(),
2070 title: "Solo Track".into(),
2071 display_name: "alice".into(),
2072 image_large_url: "https://art.example/large.jpg".into(),
2073 ..Default::default()
2074 };
2075 store.set_album_overrides(
2076 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2077 .into_iter()
2078 .collect(),
2079 );
2080
2081 let new_ctx = store.context_for(&new_clip);
2083 assert_eq!(new_ctx.root_id, "newnewnew-9999");
2084 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2085
2086 assert!(store.colliding_root_titles().is_empty());
2088
2089 let real_clip = Clip {
2091 id: "realroot".into(),
2092 title: "Shared".into(),
2093 display_name: "alice".into(),
2094 image_large_url: "https://art.example/large.jpg".into(),
2095 ..Default::default()
2096 };
2097 let real_ctx = store.context_for(&real_clip);
2098 let colliding = store.colliding_root_titles();
2099 let requests = [
2100 crate::naming::NamingRequest {
2101 clip: &real_clip,
2102 lineage: &real_ctx,
2103 },
2104 crate::naming::NamingRequest {
2105 clip: &new_clip,
2106 lineage: &new_ctx,
2107 },
2108 ];
2109 let names = crate::naming::render_clip_names(
2110 &requests,
2111 &crate::naming::NamingConfig::default(),
2112 &colliding,
2113 );
2114 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2115 crate::reconcile::Desired {
2116 clip: clip.clone(),
2117 lineage: ctx.clone(),
2118 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2119 format: crate::AudioFormat::Flac,
2120 meta_hash: String::new(),
2121 art_hash: String::new(),
2122 modes: vec![crate::reconcile::SourceMode::Mirror],
2123 trashed: false,
2124 private: false,
2125 artifacts: Vec::new(),
2126 stems: None,
2127 }
2128 };
2129 let desired = vec![
2130 desired_of(&real_clip, &real_ctx, &names[0]),
2131 desired_of(&new_clip, &new_ctx, &names[1]),
2132 ];
2133 let albums = crate::reconcile::album_desired(&desired, false, false);
2134 let jpg_paths: Vec<String> = albums
2135 .iter()
2136 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2137 .collect();
2138 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2139 assert_ne!(
2140 jpg_paths[0], jpg_paths[1],
2141 "an uncached override must not collapse two albums onto one path"
2142 );
2143 }
2144
2145 #[test]
2146 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2147 let child = Clip {
2154 id: "childclip".into(),
2155 title: "Cover".into(),
2156 clip_type: "gen".into(),
2157 task: "cover".into(),
2158 cover_clip_id: "gaproot".into(),
2159 edited_clip_id: "gaproot".into(),
2160 ..Default::default()
2161 };
2162 let other_root = Clip {
2163 id: "otherroot".into(),
2164 title: "Preferred".into(),
2165 clip_type: "gen".into(),
2166 ..Default::default()
2167 };
2168 let gap_ancestor = Clip {
2169 id: "gaproot".into(),
2170 title: "Working Title".into(),
2171 clip_type: "gen".into(),
2172 ..Default::default()
2173 };
2174 let mut roots = HashMap::new();
2175 roots.insert(
2176 "childclip".to_owned(),
2177 RootInfo {
2178 root_id: "gaproot".into(),
2179 root_title: "Working Title".into(),
2180 status: ResolveStatus::Resolved,
2181 },
2182 );
2183 roots.insert(
2184 "otherroot".to_owned(),
2185 RootInfo {
2186 root_id: "otherroot".into(),
2187 root_title: "Preferred".into(),
2188 status: ResolveStatus::Resolved,
2189 },
2190 );
2191 let mut store = LineageStore::new();
2192 store.update(
2193 &[child.clone(), other_root],
2194 &Resolution {
2195 roots,
2196 gap_filled: vec![gap_ancestor],
2197 bridges: Vec::new(),
2198 },
2199 "now",
2200 );
2201 assert!(store.node("gaproot").is_some());
2203 assert!(!store.resolution_cache.contains_key("gaproot"));
2204
2205 store.set_album_overrides(
2206 [("gaproot".to_owned(), "Preferred".to_owned())]
2207 .into_iter()
2208 .collect(),
2209 );
2210
2211 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2214 assert_eq!(store.album_for_id("childclip"), "Preferred");
2215
2216 assert!(store.colliding_root_titles().contains("Preferred"));
2219 }
2220
2221 #[test]
2222 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2223 let child = Clip {
2229 id: "childclip".into(),
2230 title: "Cover".into(),
2231 clip_type: "gen".into(),
2232 task: "cover".into(),
2233 cover_clip_id: "gaproot".into(),
2234 edited_clip_id: "gaproot".into(),
2235 ..Default::default()
2236 };
2237 let mut roots = HashMap::new();
2238 roots.insert(
2239 "childclip".to_owned(),
2240 RootInfo {
2241 root_id: "gaproot".into(),
2242 root_title: "Working Title".into(),
2243 status: ResolveStatus::Resolved,
2244 },
2245 );
2246 let mut store = LineageStore::new();
2247 store.update(
2248 std::slice::from_ref(&child),
2249 &Resolution {
2250 roots,
2251 gap_filled: vec![Clip {
2252 id: "gaproot".into(),
2253 title: "Working Title".into(),
2254 clip_type: "gen".into(),
2255 ..Default::default()
2256 }],
2257 bridges: Vec::new(),
2258 },
2259 "now",
2260 );
2261
2262 let expected: std::collections::HashSet<String> = store
2263 .resolution_cache
2264 .values()
2265 .map(|entry| entry.root_id.clone())
2266 .filter(|root_id| !root_id.is_empty())
2267 .collect();
2268 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2269 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2271 assert!(!store.resolution_cache.contains_key("gaproot"));
2272 }
2273
2274 fn owner(id: &str, name: &str) -> Owner {
2275 Owner {
2276 user_id: id.to_owned(),
2277 display_name: name.to_owned(),
2278 }
2279 }
2280
2281 #[test]
2282 fn owner_check_covers_first_use_match_and_mismatch() {
2283 let mut store = LineageStore::new();
2284 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
2285
2286 store.pin_owner(owner("user_a", "Alice"));
2287 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
2288 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
2289 assert_eq!(store.owner().unwrap().display_name, "Alice");
2290 }
2291
2292 #[test]
2293 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2294 let mut store = LineageStore::new();
2295 assert!(!store.refresh_display_name("Alice"));
2297 assert!(store.owner().is_none());
2298
2299 store.pin_owner(owner("user_a", "Alice"));
2300 assert!(!store.refresh_display_name("Alice"));
2302 assert!(store.refresh_display_name("Alice Cooper"));
2304 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2305 assert_eq!(store.owner().unwrap().user_id, "user_a");
2307 }
2308
2309 #[test]
2310 fn owner_gate_covers_the_full_matrix() {
2311 let alice = owner("user_a", "Alice");
2312
2313 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2315 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2316
2317 assert_eq!(
2319 owner_gate(Some(&alice), None, "user_a", false),
2320 OwnerGate::Proceed
2321 );
2322
2323 assert_eq!(
2325 owner_gate(Some(&alice), None, "user_b", false),
2326 OwnerGate::AbortMismatch
2327 );
2328 assert_eq!(
2329 owner_gate(Some(&alice), None, "user_b", true),
2330 OwnerGate::Repin
2331 );
2332
2333 assert_eq!(
2336 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2337 OwnerGate::AbortConfigMismatch
2338 );
2339 assert_eq!(
2340 owner_gate(None, Some("user_c"), "user_a", true),
2341 OwnerGate::AbortConfigMismatch
2342 );
2343 assert_eq!(
2345 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2346 OwnerGate::Proceed
2347 );
2348
2349 assert!(OwnerGate::Repin.is_additive());
2351 for gate in [
2352 OwnerGate::AbortConfigMismatch,
2353 OwnerGate::AbortMismatch,
2354 OwnerGate::Proceed,
2355 OwnerGate::FirstUse,
2356 ] {
2357 assert!(!gate.is_additive());
2358 }
2359 }
2360
2361 #[test]
2362 fn adopt_decision_covers_every_branch() {
2363 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2364 let empty: BTreeSet<&str> = BTreeSet::new();
2365
2366 assert_eq!(
2368 adopt_decision(&["x", "y"], &empty, true, false),
2369 AdoptDecision::PinFresh
2370 );
2371 assert_eq!(
2373 adopt_decision(&["c1"], &owned, false, false),
2374 AdoptDecision::SkipPin
2375 );
2376 assert_eq!(
2377 adopt_decision(&["c1"], &owned, false, true),
2378 AdoptDecision::SkipPin
2379 );
2380 assert_eq!(
2382 adopt_decision(&["c1", "z"], &owned, true, false),
2383 AdoptDecision::PinAdopt
2384 );
2385 assert_eq!(
2387 adopt_decision(&["z1", "z2"], &owned, true, false),
2388 AdoptDecision::Abort
2389 );
2390 assert_eq!(
2391 adopt_decision(&["z1", "z2"], &owned, true, true),
2392 AdoptDecision::AdoptForced
2393 );
2394
2395 assert!(AdoptDecision::AdoptForced.is_additive());
2397 for decision in [
2398 AdoptDecision::PinFresh,
2399 AdoptDecision::PinAdopt,
2400 AdoptDecision::Abort,
2401 AdoptDecision::SkipPin,
2402 ] {
2403 assert!(!decision.is_additive());
2404 }
2405 }
2406
2407 #[test]
2408 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2409 let json = r#"{"nodes":{},"edges":[]}"#;
2411 let store: LineageStore = serde_json::from_str(json).unwrap();
2412 assert!(store.owner().is_none());
2413 let value = serde_json::to_value(&store).unwrap();
2415 assert!(value.get("owner").is_none());
2416
2417 let mut pinned = LineageStore::new();
2419 pinned.pin_owner(owner("user_a", "Alice"));
2420 let back: LineageStore =
2421 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2422 assert_eq!(back, pinned);
2423 assert_eq!(back.owner().unwrap().user_id, "user_a");
2424 }
2425}