1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, 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 {
768 for edge in lineage_edges(clip) {
769 self.upsert_edge(&clip.id, &edge, now);
770 }
771 }
772 self.edges.sort_by(|a, b| {
773 a.child_id
774 .cmp(&b.child_id)
775 .then(a.ordinal.cmp(&b.ordinal))
776 .then(a.parent_id.cmp(&b.parent_id))
777 .then(a.edge_type.cmp(&b.edge_type))
778 .then(a.role.cmp(&b.role))
779 });
780
781 for (child_id, info) in &resolution.roots {
782 self.upsert_cache(child_id, info, now);
783 }
784 self.refresh_eligible_roots();
785 }
786
787 fn upsert_node(&mut self, clip: &Clip, now: &str) {
790 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
791 first_seen_at: now.to_owned(),
792 ..Node::default()
793 });
794 node.title = clip.title.clone();
795 node.created_at = clip.created_at.clone();
796 node.clip_type = clip.clip_type.clone();
797 node.task = clip.task.clone();
798 node.is_remix = clip.is_remix;
799 node.is_trashed = clip.is_trashed;
800 node.last_seen_at = now.to_owned();
801 }
802
803 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
806 let edge_type = edge_type_slug(edge.edge_type);
807 let role = edge_role_slug(edge.role);
808 if let Some(existing) = self.edges.iter_mut().find(|stored| {
809 stored.child_id == child_id
810 && stored.parent_id == edge.parent_id
811 && stored.edge_type == edge_type
812 && stored.role == role
813 && stored.ordinal == edge.ordinal
814 }) {
815 existing.source_field = edge.source_field.to_owned();
816 existing.status = "active".to_owned();
817 existing.last_seen_at = now.to_owned();
818 } else {
819 self.edges.push(StoredEdge {
820 child_id: child_id.to_owned(),
821 parent_id: edge.parent_id.clone(),
822 edge_type: edge_type.to_owned(),
823 role: role.to_owned(),
824 source_field: edge.source_field.to_owned(),
825 ordinal: edge.ordinal,
826 status: "active".to_owned(),
827 first_seen_at: now.to_owned(),
828 last_seen_at: now.to_owned(),
829 });
830 }
831 }
832
833 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
840 if info.status != ResolveStatus::Resolved
841 && self
842 .resolution_cache
843 .get(child_id)
844 .is_some_and(|entry| entry.status == "resolved")
845 {
846 return;
847 }
848 self.resolution_cache.insert(
849 child_id.to_owned(),
850 CacheEntry {
851 root_id: info.root_id.clone(),
852 status: resolve_status_slug(info.status).to_owned(),
853 algorithm_version: 1,
854 computed_at: now.to_owned(),
855 },
856 );
857 }
858}
859
860fn edge_type_slug(edge_type: EdgeType) -> &'static str {
862 match edge_type {
863 EdgeType::Cover => "cover",
864 EdgeType::Remaster => "remaster",
865 EdgeType::SpeedEdit => "speed_edit",
866 EdgeType::Edit => "edit",
867 EdgeType::Extend => "extend",
868 EdgeType::SectionReplace => "section_replace",
869 EdgeType::Stitch => "stitch",
870 EdgeType::Derived => "derived",
871 EdgeType::Uploaded => "uploaded",
872 }
873}
874
875fn edge_role_slug(role: EdgeRole) -> &'static str {
877 match role {
878 EdgeRole::Primary => "primary",
879 EdgeRole::Secondary => "secondary",
880 }
881}
882
883fn resolve_status_slug(status: ResolveStatus) -> &'static str {
885 match status {
886 ResolveStatus::Resolved => "resolved",
887 ResolveStatus::External => "external",
888 ResolveStatus::Unresolved => "unresolved",
889 ResolveStatus::Cycle => "cycle",
890 }
891}
892
893fn status_from_slug(slug: &str) -> ResolveStatus {
896 match slug {
897 "external" => ResolveStatus::External,
898 "unresolved" => ResolveStatus::Unresolved,
899 "cycle" => ResolveStatus::Cycle,
900 _ => ResolveStatus::Resolved,
901 }
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use std::collections::HashMap;
908
909 fn chain_clips() -> Vec<Clip> {
911 vec![
912 Clip {
913 id: "c".into(),
914 title: "Cover".into(),
915 clip_type: "gen".into(),
916 task: "cover".into(),
917 created_at: "t2".into(),
918 cover_clip_id: "b".into(),
919 edited_clip_id: "b".into(),
920 ..Default::default()
921 },
922 Clip {
923 id: "b".into(),
924 title: "Remaster".into(),
925 clip_type: "upsample".into(),
926 task: "upsample".into(),
927 created_at: "t1".into(),
928 upsample_clip_id: "a".into(),
929 edited_clip_id: "a".into(),
930 ..Default::default()
931 },
932 Clip {
933 id: "a".into(),
934 title: "Root".into(),
935 clip_type: "gen".into(),
936 created_at: "t0".into(),
937 ..Default::default()
938 },
939 ]
940 }
941
942 fn chain_resolution() -> Resolution {
944 let mut roots = HashMap::new();
945 for id in ["a", "b", "c"] {
946 roots.insert(
947 id.to_owned(),
948 RootInfo {
949 root_id: "a".into(),
950 root_title: "Root".into(),
951 status: ResolveStatus::Resolved,
952 },
953 );
954 }
955 Resolution {
956 roots,
957 gap_filled: Vec::new(),
958 }
959 }
960
961 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
962 store
963 .edges
964 .iter()
965 .find(|e| e.child_id == child && e.parent_id == parent)
966 .expect("edge should exist")
967 }
968
969 #[test]
970 fn new_store_is_empty_and_versioned() {
971 let store = LineageStore::new();
972 assert!(store.is_empty());
973 assert_eq!(store.len(), 0);
974 assert_eq!(store.schema_version, 1);
975 }
976
977 #[test]
978 fn update_populates_nodes_edges_and_cache() {
979 let mut store = LineageStore::new();
980 store.update(&chain_clips(), &chain_resolution(), "now");
981
982 assert_eq!(store.len(), 3);
984 let cover = store.node("c").unwrap();
985 assert_eq!(cover.title, "Cover");
986 assert_eq!(cover.clip_type, "gen");
987 assert_eq!(cover.task, "cover");
988 assert_eq!(cover.created_at, "t2");
989 assert_eq!(cover.status, "observed");
990 assert!(!cover.is_trashed);
991 assert_eq!(cover.first_seen_at, "now");
992 assert_eq!(cover.last_seen_at, "now");
993
994 assert_eq!(store.edges.len(), 2);
996 let cb = edge(&store, "c", "b");
997 assert_eq!(cb.edge_type, "cover");
998 assert_eq!(cb.role, "primary");
999 assert_eq!(cb.ordinal, 0);
1000 assert_eq!(cb.source_field, "cover_clip_id");
1001 assert_eq!(cb.status, "active");
1002 let ba = edge(&store, "b", "a");
1003 assert_eq!(ba.edge_type, "remaster");
1004 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1005
1006 for id in ["a", "b", "c"] {
1008 let cached = store.get_root(id).unwrap();
1009 assert_eq!(cached.root_id, "a");
1010 assert_eq!(cached.status, "resolved");
1011 assert_eq!(cached.algorithm_version, 1);
1012 }
1013 }
1014
1015 #[test]
1016 fn album_for_id_matches_context_for_and_handles_unknown() {
1017 let mut store = LineageStore::new();
1018 store.update(&chain_clips(), &chain_resolution(), "now");
1019
1020 assert_eq!(store.album_for_id("c"), "Root");
1023 let cover = &chain_clips()[0];
1024 assert_eq!(
1025 store.album_for_id("c"),
1026 store.context_for(cover).album(&cover.title)
1027 );
1028 assert_eq!(store.album_for_id("a"), "Root");
1030 assert_eq!(store.album_for_id("missing"), "");
1032 }
1033
1034 #[test]
1035 fn serde_roundtrip_preserves_a_relational_shape() {
1036 let mut store = LineageStore::new();
1037 store.update(&chain_clips(), &chain_resolution(), "now");
1038
1039 let json = serde_json::to_string(&store).unwrap();
1040 let back: LineageStore = serde_json::from_str(&json).unwrap();
1041 assert_eq!(store, back);
1042
1043 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1044 assert_eq!(value.get("schema_version").unwrap(), 1);
1045 assert!(value.get("nodes").unwrap().is_object());
1046 assert!(value.get("edges").unwrap().is_array());
1047 assert!(value.get("resolution_cache").unwrap().is_object());
1048
1049 let node = value.get("nodes").unwrap().get("c").unwrap();
1052 assert!(node.get("edges").is_none());
1053 assert!(node.get("parent_id").is_none());
1054 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1055 assert!(first_edge.get("child_id").is_some());
1056 assert!(first_edge.get("parent_id").is_some());
1057 }
1058
1059 #[test]
1060 fn album_overrides_are_runtime_only_and_never_persist() {
1061 let mut store = LineageStore::new();
1065 store.update(&chain_clips(), &chain_resolution(), "now");
1066 store.set_album_overrides(
1067 [("a".to_owned(), "Preferred".to_owned())]
1068 .into_iter()
1069 .collect(),
1070 );
1071
1072 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1073 assert!(value.get("album_overrides").is_none());
1074
1075 let json = serde_json::to_string(&store).unwrap();
1076 let back: LineageStore = serde_json::from_str(&json).unwrap();
1077 assert!(back.album_overrides.is_empty());
1078 assert_eq!(back.album_for_id("c"), "Root");
1079 }
1080
1081 #[test]
1082 fn update_is_idempotent_bar_last_seen() {
1083 let clips = chain_clips();
1084 let resolution = chain_resolution();
1085 let mut store = LineageStore::new();
1086 store.update(&clips, &resolution, "first");
1087 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1088 let edge_count = store.edges.len();
1089
1090 store.update(&clips, &resolution, "second");
1091
1092 assert_eq!(
1094 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1095 node_ids
1096 );
1097 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1098 assert_eq!(store.resolution_cache.len(), 3);
1099
1100 let cover = store.node("c").unwrap();
1102 assert_eq!(cover.first_seen_at, "first");
1103 assert_eq!(cover.last_seen_at, "second");
1104 let cb = edge(&store, "c", "b");
1105 assert_eq!(cb.first_seen_at, "first");
1106 assert_eq!(cb.last_seen_at, "second");
1107 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1109 }
1110
1111 #[test]
1112 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1113 let mut store = LineageStore::new();
1114 store.update(&chain_clips(), &chain_resolution(), "first");
1115 assert_eq!(store.get_root("c").unwrap().status, "resolved");
1116
1117 let child = Clip {
1120 id: "c".into(),
1121 title: "Cover".into(),
1122 clip_type: "gen".into(),
1123 task: "cover".into(),
1124 cover_clip_id: "b".into(),
1125 edited_clip_id: "b".into(),
1126 ..Default::default()
1127 };
1128 let mut roots = HashMap::new();
1129 roots.insert(
1130 "c".to_owned(),
1131 RootInfo {
1132 root_id: "elsewhere".into(),
1133 root_title: String::new(),
1134 status: ResolveStatus::External,
1135 },
1136 );
1137 roots.insert(
1138 "d".to_owned(),
1139 RootInfo {
1140 root_id: "boundary".into(),
1141 root_title: String::new(),
1142 status: ResolveStatus::External,
1143 },
1144 );
1145 let resolution = Resolution {
1146 roots,
1147 gap_filled: Vec::new(),
1148 };
1149 store.update(&[child], &resolution, "second");
1150
1151 let cached = store.get_root("c").unwrap();
1153 assert_eq!(cached.root_id, "a");
1154 assert_eq!(cached.status, "resolved");
1155 assert_eq!(cached.computed_at, "first");
1156 let d = store.get_root("d").unwrap();
1158 assert_eq!(d.root_id, "boundary");
1159 assert_eq!(d.status, "external");
1160 }
1161
1162 #[test]
1163 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1164 let child = Clip {
1168 id: "c".into(),
1169 title: "Cover".into(),
1170 clip_type: "gen".into(),
1171 task: "cover".into(),
1172 cover_clip_id: "t".into(),
1173 edited_clip_id: "t".into(),
1174 ..Default::default()
1175 };
1176 let trashed = Clip {
1177 id: "t".into(),
1178 title: "Trashed Original".into(),
1179 clip_type: "gen".into(),
1180 is_trashed: true,
1181 ..Default::default()
1182 };
1183 let mut roots = HashMap::new();
1184 roots.insert(
1185 "c".to_owned(),
1186 RootInfo {
1187 root_id: "t".into(),
1188 root_title: "Trashed Original".into(),
1189 status: ResolveStatus::Resolved,
1190 },
1191 );
1192 let resolution = Resolution {
1193 roots,
1194 gap_filled: vec![trashed],
1195 };
1196 store_update_and_assert_trashed(child, resolution);
1197 }
1198
1199 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1200 let mut store = LineageStore::new();
1201 store.update(&[child], &resolution, "now");
1202
1203 let node = store
1204 .node("t")
1205 .expect("trashed ancestor should be archived");
1206 assert!(node.is_trashed);
1207 assert_eq!(node.title, "Trashed Original");
1208 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1210 }
1211
1212 #[test]
1213 fn partial_json_loads_with_defaults() {
1214 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1217 let store: LineageStore = serde_json::from_str(json).unwrap();
1218 assert_eq!(store.schema_version, 1);
1219 let node = store.node("x").unwrap();
1220 assert_eq!(node.title, "Kept");
1221 assert_eq!(node.status, "observed");
1222 assert_eq!(store.edges[0].status, "active");
1223 assert!(store.resolution_cache.is_empty());
1224 assert!(store.albums.is_empty());
1227 assert!(store.album_art("x").is_none());
1228 assert!(store.playlists.is_empty());
1232 assert!(store.playlist("x").is_none());
1233 }
1234
1235 #[test]
1236 fn album_art_roundtrips_and_reads_by_kind() {
1237 let mut store = LineageStore::new();
1238 store.albums.insert(
1239 "root-1".to_owned(),
1240 AlbumArt {
1241 folder_jpg: Some(ArtifactState {
1242 path: "alice/Album/folder.jpg".to_owned(),
1243 hash: "jpg-h".to_owned(),
1244 }),
1245 folder_webp: Some(ArtifactState {
1246 path: "alice/Album/cover.webp".to_owned(),
1247 hash: "webp-h".to_owned(),
1248 }),
1249 folder_mp4: Some(ArtifactState {
1250 path: "alice/Album/cover.mp4".to_owned(),
1251 hash: "mp4-h".to_owned(),
1252 }),
1253 },
1254 );
1255
1256 let json = serde_json::to_string(&store).unwrap();
1257 let back: LineageStore = serde_json::from_str(&json).unwrap();
1258 assert_eq!(store, back);
1259
1260 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1262 let album = value.get("albums").unwrap().get("root-1").unwrap();
1263 assert_eq!(
1264 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1265 "jpg-h"
1266 );
1267
1268 let art = back.album_art("root-1").unwrap();
1269 assert_eq!(
1270 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1271 "alice/Album/folder.jpg"
1272 );
1273 assert_eq!(
1274 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1275 "webp-h"
1276 );
1277 assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1278 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1280 }
1281
1282 #[test]
1283 fn empty_album_art_omits_slots_when_serialised() {
1284 let empty = AlbumArt::default();
1287 assert!(empty.is_empty());
1288 let value = serde_json::to_value(&empty).unwrap();
1289 assert!(value.get("folder_jpg").is_none());
1290 assert!(value.get("folder_webp").is_none());
1291 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1292 assert_eq!(back, empty);
1293 }
1294
1295 #[test]
1296 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1297 let mut store = LineageStore::new();
1298 let jpg = ArtifactState {
1299 path: "a/folder.jpg".to_owned(),
1300 hash: "h1".to_owned(),
1301 };
1302 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1303 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1304
1305 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1307 assert!(store.album_art("root-1").is_none());
1308 assert!(store.albums.is_empty());
1309 }
1310
1311 #[test]
1312 fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1313 let mut store = LineageStore::new();
1318 let state = |p: &str| ArtifactState {
1319 path: p.to_owned(),
1320 hash: "h".to_owned(),
1321 };
1322 store.set_album_artifact(
1323 "root-1",
1324 ArtifactKind::FolderWebp,
1325 Some(state("a/cover.webp")),
1326 );
1327 store.set_album_artifact(
1328 "root-1",
1329 ArtifactKind::FolderMp4,
1330 Some(state("a/cover.mp4")),
1331 );
1332
1333 store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1336 let art = store
1337 .album_art("root-1")
1338 .expect("row kept while folder_mp4 remains");
1339 assert!(!art.is_empty());
1340 assert!(art.folder_mp4.is_some());
1341
1342 store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1344 assert!(store.album_art("root-1").is_none());
1345 assert!(store.albums.is_empty());
1346 }
1347
1348 #[test]
1349 fn playlist_state_roundtrips_by_id() {
1350 let mut store = LineageStore::new();
1351 store.playlists.insert(
1352 "pl1".to_owned(),
1353 PlaylistState {
1354 name: "Road Trip".to_owned(),
1355 path: "Road Trip.m3u8".to_owned(),
1356 hash: "abc123".to_owned(),
1357 },
1358 );
1359
1360 let json = serde_json::to_string(&store).unwrap();
1361 let back: LineageStore = serde_json::from_str(&json).unwrap();
1362 assert_eq!(store, back);
1363
1364 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1366 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1367 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1368 assert_eq!(pl.get("hash").unwrap(), "abc123");
1369
1370 let stored = back.playlist("pl1").unwrap();
1371 assert_eq!(stored.name, "Road Trip");
1372 assert_eq!(stored.hash, "abc123");
1373 }
1374
1375 #[test]
1376 fn set_playlist_upserts_then_clears() {
1377 let mut store = LineageStore::new();
1378 let state = PlaylistState {
1379 name: "Mix".to_owned(),
1380 path: "Mix.m3u8".to_owned(),
1381 hash: "h1".to_owned(),
1382 };
1383 store.set_playlist("pl1", Some(state.clone()));
1384 assert_eq!(store.playlist("pl1"), Some(&state));
1385
1386 let renamed = PlaylistState {
1388 name: "Mix v2".to_owned(),
1389 path: "Mix v2.m3u8".to_owned(),
1390 hash: "h2".to_owned(),
1391 };
1392 store.set_playlist("pl1", Some(renamed.clone()));
1393 assert_eq!(store.playlist("pl1"), Some(&renamed));
1394
1395 store.set_playlist("pl1", None);
1397 assert!(store.playlist("pl1").is_none());
1398 assert!(store.playlists.is_empty());
1399 }
1400
1401 #[test]
1402 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1403 let mut store = LineageStore::new();
1404 store.update(&chain_clips(), &chain_resolution(), "now");
1405
1406 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1408 assert_eq!(ctx.root_id, "a");
1409 assert_eq!(ctx.root_title, "Root");
1410 assert_eq!(ctx.parent_id, "b");
1411 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1412 assert_eq!(ctx.status, ResolveStatus::Resolved);
1413 assert_eq!(ctx.album("Cover"), "Root");
1415 }
1416
1417 #[test]
1418 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1419 let mut store = LineageStore::new();
1420 store.update(&chain_clips(), &chain_resolution(), "now");
1421
1422 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1424 assert_eq!(ctx.root_id, "a");
1425 assert_eq!(ctx.root_title, "Root");
1426 assert_eq!(ctx.parent_id, "");
1427 assert_eq!(ctx.edge_type, None);
1428 assert_eq!(ctx.album("Root"), "Root");
1429 }
1430
1431 #[test]
1432 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1433 let clips = vec![
1436 Clip {
1437 id: "child".into(),
1438 title: "Revision".into(),
1439 clip_type: "gen".into(),
1440 task: "cover".into(),
1441 created_at: "2024-01-02T08:00:00Z".into(),
1442 cover_clip_id: "root".into(),
1443 edited_clip_id: "root".into(),
1444 ..Default::default()
1445 },
1446 Clip {
1447 id: "root".into(),
1448 title: "Origin".into(),
1449 clip_type: "gen".into(),
1450 created_at: "2023-12-30T23:00:00Z".into(),
1451 ..Default::default()
1452 },
1453 ];
1454 let mut roots = HashMap::new();
1455 for id in ["child", "root"] {
1456 roots.insert(
1457 id.to_owned(),
1458 RootInfo {
1459 root_id: "root".into(),
1460 root_title: "Origin".into(),
1461 status: ResolveStatus::Resolved,
1462 },
1463 );
1464 }
1465 let resolution = Resolution {
1466 roots,
1467 gap_filled: Vec::new(),
1468 };
1469 let mut store = LineageStore::new();
1470 store.update(&clips, &resolution, "now");
1471
1472 let child_ctx = store.context_for(&clips[0]);
1473 assert_eq!(child_ctx.root_id, "root");
1474 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1475 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1477
1478 let root_ctx = store.context_for(&clips[1]);
1480 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1481 }
1482
1483 #[test]
1484 fn context_for_an_unknown_clip_is_self_rooted() {
1485 let store = LineageStore::new();
1486 let orphan = Clip {
1487 id: "z".into(),
1488 title: "Lonely".into(),
1489 ..Default::default()
1490 };
1491 let ctx = store.context_for(&orphan);
1492 assert_eq!(ctx.root_id, "z");
1493 assert_eq!(ctx.root_title, "Lonely");
1494 assert_eq!(ctx.parent_id, "");
1495 assert_eq!(ctx.status, ResolveStatus::Resolved);
1496 }
1497
1498 #[test]
1499 fn context_for_retains_a_purged_ancestor_album() {
1500 let child = Clip {
1505 id: "c".into(),
1506 title: "Cover".into(),
1507 clip_type: "gen".into(),
1508 task: "cover".into(),
1509 cover_clip_id: "t".into(),
1510 edited_clip_id: "t".into(),
1511 ..Default::default()
1512 };
1513 let trashed = Clip {
1514 id: "t".into(),
1515 title: "Trashed Original".into(),
1516 clip_type: "gen".into(),
1517 is_trashed: true,
1518 ..Default::default()
1519 };
1520 let mut roots = HashMap::new();
1521 roots.insert(
1522 "c".to_owned(),
1523 RootInfo {
1524 root_id: "t".into(),
1525 root_title: "Trashed Original".into(),
1526 status: ResolveStatus::Resolved,
1527 },
1528 );
1529 let resolution = Resolution {
1530 roots,
1531 gap_filled: vec![trashed],
1532 };
1533 let mut store = LineageStore::new();
1534 store.update(std::slice::from_ref(&child), &resolution, "now");
1535
1536 let ctx = store.context_for(&child);
1537 assert_eq!(ctx.root_id, "t");
1538 assert_eq!(ctx.root_title, "Trashed Original");
1539 assert_eq!(ctx.album("Cover"), "Trashed Original");
1540 }
1541
1542 #[test]
1543 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1544 let clips = vec![
1547 Clip {
1548 id: "r1".into(),
1549 title: "Break Through".into(),
1550 clip_type: "gen".into(),
1551 ..Default::default()
1552 },
1553 Clip {
1554 id: "r2".into(),
1555 title: "Break Through".into(),
1556 clip_type: "gen".into(),
1557 ..Default::default()
1558 },
1559 Clip {
1560 id: "r3".into(),
1561 title: "Solo".into(),
1562 clip_type: "gen".into(),
1563 ..Default::default()
1564 },
1565 Clip {
1566 id: "c1".into(),
1567 title: "Break Through".into(),
1568 clip_type: "gen".into(),
1569 task: "cover".into(),
1570 cover_clip_id: "r1".into(),
1571 edited_clip_id: "r1".into(),
1572 ..Default::default()
1573 },
1574 ];
1575 let mut roots = HashMap::new();
1576 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1577 let title = if root == "r3" {
1578 "Solo"
1579 } else {
1580 "Break Through"
1581 };
1582 roots.insert(
1583 id.to_owned(),
1584 RootInfo {
1585 root_id: root.into(),
1586 root_title: title.into(),
1587 status: ResolveStatus::Resolved,
1588 },
1589 );
1590 }
1591 let resolution = Resolution {
1592 roots,
1593 gap_filled: Vec::new(),
1594 };
1595 let mut store = LineageStore::new();
1596 store.update(&clips, &resolution, "now");
1597
1598 let colliding = store.colliding_root_titles();
1599 assert!(colliding.contains("Break Through"));
1600 assert!(!colliding.contains("Solo"));
1601 assert_eq!(colliding.len(), 1);
1602 }
1603
1604 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1607 let clips = vec![
1608 Clip {
1609 id: "r1".into(),
1610 title: t1.into(),
1611 clip_type: "gen".into(),
1612 ..Default::default()
1613 },
1614 Clip {
1615 id: "r2".into(),
1616 title: t2.into(),
1617 clip_type: "gen".into(),
1618 ..Default::default()
1619 },
1620 ];
1621 let mut roots = HashMap::new();
1622 roots.insert(
1623 "r1".to_owned(),
1624 RootInfo {
1625 root_id: "r1".into(),
1626 root_title: t1.into(),
1627 status: ResolveStatus::Resolved,
1628 },
1629 );
1630 roots.insert(
1631 "r2".to_owned(),
1632 RootInfo {
1633 root_id: "r2".into(),
1634 root_title: t2.into(),
1635 status: ResolveStatus::Resolved,
1636 },
1637 );
1638 let mut store = LineageStore::new();
1639 store.update(
1640 &clips,
1641 &Resolution {
1642 roots,
1643 gap_filled: Vec::new(),
1644 },
1645 "now",
1646 );
1647 store
1648 }
1649
1650 #[test]
1651 fn album_override_flows_into_context_tag_hash_and_index() {
1652 let clips = chain_clips();
1656 let mut store = LineageStore::new();
1657 store.update(&clips, &chain_resolution(), "now");
1658
1659 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1661
1662 store.set_album_overrides(
1663 [("a".to_owned(), "Preferred Name".to_owned())]
1664 .into_iter()
1665 .collect(),
1666 );
1667
1668 for id in ["a", "b", "c"] {
1670 let clip = clips.iter().find(|c| c.id == id).unwrap();
1671 let ctx = store.context_for(clip);
1672 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1673 assert_eq!(store.album_for_id(id), "Preferred Name");
1674 }
1675
1676 let ctx = store.context_for(cover);
1678 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1679 assert_eq!(meta.album, "Preferred Name");
1680
1681 let after_hash = crate::hash::meta_hash(cover, &ctx);
1683 assert_ne!(before_hash, after_hash);
1684 }
1685
1686 #[test]
1687 fn empty_album_override_is_ignored() {
1688 let clips = chain_clips();
1690 let mut store = LineageStore::new();
1691 store.update(&clips, &chain_resolution(), "now");
1692 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1693 assert_eq!(store.album_for_id("c"), "Root");
1694 }
1695
1696 #[test]
1697 fn album_override_creates_a_collision_that_disambiguates() {
1698 let mut store = two_root_store("Alpha", "Beta");
1700 assert!(store.colliding_root_titles().is_empty());
1701
1702 store.set_album_overrides(
1703 [("r2".to_owned(), "Alpha".to_owned())]
1704 .into_iter()
1705 .collect(),
1706 );
1707 let colliding = store.colliding_root_titles();
1708 assert!(colliding.contains("Alpha"));
1709 assert_eq!(colliding.len(), 1);
1710 }
1711
1712 #[test]
1713 fn album_override_resolves_a_natural_collision() {
1714 let mut store = two_root_store("Break Through", "Break Through");
1716 assert!(store.colliding_root_titles().contains("Break Through"));
1717
1718 store.set_album_overrides(
1719 [("r2".to_owned(), "Second Wind".to_owned())]
1720 .into_iter()
1721 .collect(),
1722 );
1723 assert!(store.colliding_root_titles().is_empty());
1724 }
1725
1726 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1731 store.resolution_cache.insert(
1732 root_id.to_owned(),
1733 CacheEntry {
1734 root_id: root_id.to_owned(),
1735 status: "external".to_owned(),
1736 algorithm_version: 1,
1737 computed_at: "now".to_owned(),
1738 },
1739 );
1740 store.refresh_eligible_roots();
1743 }
1744
1745 #[test]
1746 fn override_on_node_less_root_collides_with_a_real_root() {
1747 let mut store = LineageStore::new();
1751 store.update(
1752 std::slice::from_ref(&Clip {
1753 id: "realroot".into(),
1754 title: "Shared".into(),
1755 clip_type: "gen".into(),
1756 ..Default::default()
1757 }),
1758 &Resolution {
1759 roots: [(
1760 "realroot".to_owned(),
1761 RootInfo {
1762 root_id: "realroot".into(),
1763 root_title: "Shared".into(),
1764 status: ResolveStatus::Resolved,
1765 },
1766 )]
1767 .into_iter()
1768 .collect(),
1769 gap_filled: Vec::new(),
1770 },
1771 "now",
1772 );
1773 insert_cache_only_root(&mut store, "extroot");
1774 store.set_album_overrides(
1775 [("extroot".to_owned(), "Shared".to_owned())]
1776 .into_iter()
1777 .collect(),
1778 );
1779
1780 let colliding = store.colliding_root_titles();
1781 assert!(
1782 colliding.contains("Shared"),
1783 "a node-less overridden root must still be seen by collision detection"
1784 );
1785 }
1786
1787 #[test]
1788 fn two_node_less_roots_overridden_to_same_name_collide() {
1789 let mut store = LineageStore::new();
1790 insert_cache_only_root(&mut store, "extone");
1791 insert_cache_only_root(&mut store, "exttwo");
1792 store.set_album_overrides(
1793 [
1794 ("extone".to_owned(), "Shared".to_owned()),
1795 ("exttwo".to_owned(), "Shared".to_owned()),
1796 ]
1797 .into_iter()
1798 .collect(),
1799 );
1800 assert!(store.colliding_root_titles().contains("Shared"));
1801 }
1802
1803 #[test]
1804 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1805 let mut store = LineageStore::new();
1810 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1811 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1812 store.set_album_overrides(
1813 [
1814 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1815 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1816 ]
1817 .into_iter()
1818 .collect(),
1819 );
1820 let colliding = store.colliding_root_titles();
1821
1822 let clip_of = |id: &str| Clip {
1823 id: id.to_owned(),
1824 title: "Track".to_owned(),
1825 display_name: "alice".to_owned(),
1826 image_large_url: "https://art.example/large.jpg".to_owned(),
1827 ..Default::default()
1828 };
1829 let ctx_of = |root_id: &str| LineageContext {
1830 root_id: root_id.to_owned(),
1831 root_title: "Shared".to_owned(),
1832 root_date: String::new(),
1833 parent_id: String::new(),
1834 edge_type: None,
1835 status: ResolveStatus::Resolved,
1836 };
1837 let clip_a = clip_of("clipaaaa-1111");
1838 let clip_b = clip_of("clipbbbb-2222");
1839 let ctx_a = ctx_of("aaaaaaaa-root-one");
1840 let ctx_b = ctx_of("bbbbbbbb-root-two");
1841 let requests = [
1842 crate::naming::NamingRequest {
1843 clip: &clip_a,
1844 lineage: &ctx_a,
1845 },
1846 crate::naming::NamingRequest {
1847 clip: &clip_b,
1848 lineage: &ctx_b,
1849 },
1850 ];
1851 let names = crate::naming::render_clip_names(
1852 &requests,
1853 &crate::naming::NamingConfig::default(),
1854 &colliding,
1855 );
1856
1857 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1858 crate::reconcile::Desired {
1859 clip: clip.clone(),
1860 lineage: ctx.clone(),
1861 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1862 format: crate::AudioFormat::Flac,
1863 meta_hash: String::new(),
1864 art_hash: String::new(),
1865 modes: vec![crate::reconcile::SourceMode::Mirror],
1866 trashed: false,
1867 private: false,
1868 artifacts: Vec::new(),
1869 stems: None,
1870 }
1871 };
1872 let desired = vec![
1873 desired_of(&clip_a, &ctx_a, &names[0]),
1874 desired_of(&clip_b, &ctx_b, &names[1]),
1875 ];
1876
1877 let albums = crate::reconcile::album_desired(&desired, false, false);
1878 assert_eq!(albums.len(), 2, "each distinct root is its own album");
1879 let jpg_paths: Vec<String> = albums
1880 .iter()
1881 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1882 .collect();
1883 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1884 assert_ne!(
1885 jpg_paths[0], jpg_paths[1],
1886 "colliding roots must not share one folder.jpg path"
1887 );
1888 }
1889
1890 #[test]
1891 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
1892 let mut store = LineageStore::new();
1899 store.update(
1900 std::slice::from_ref(&Clip {
1901 id: "realroot".into(),
1902 title: "Shared".into(),
1903 clip_type: "gen".into(),
1904 ..Default::default()
1905 }),
1906 &Resolution {
1907 roots: [(
1908 "realroot".to_owned(),
1909 RootInfo {
1910 root_id: "realroot".into(),
1911 root_title: "Shared".into(),
1912 status: ResolveStatus::Resolved,
1913 },
1914 )]
1915 .into_iter()
1916 .collect(),
1917 gap_filled: Vec::new(),
1918 },
1919 "now",
1920 );
1921 let new_clip = Clip {
1924 id: "newnewnew-9999".into(),
1925 title: "Solo Track".into(),
1926 display_name: "alice".into(),
1927 image_large_url: "https://art.example/large.jpg".into(),
1928 ..Default::default()
1929 };
1930 store.set_album_overrides(
1931 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
1932 .into_iter()
1933 .collect(),
1934 );
1935
1936 let new_ctx = store.context_for(&new_clip);
1938 assert_eq!(new_ctx.root_id, "newnewnew-9999");
1939 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
1940
1941 assert!(store.colliding_root_titles().is_empty());
1943
1944 let real_clip = Clip {
1946 id: "realroot".into(),
1947 title: "Shared".into(),
1948 display_name: "alice".into(),
1949 image_large_url: "https://art.example/large.jpg".into(),
1950 ..Default::default()
1951 };
1952 let real_ctx = store.context_for(&real_clip);
1953 let colliding = store.colliding_root_titles();
1954 let requests = [
1955 crate::naming::NamingRequest {
1956 clip: &real_clip,
1957 lineage: &real_ctx,
1958 },
1959 crate::naming::NamingRequest {
1960 clip: &new_clip,
1961 lineage: &new_ctx,
1962 },
1963 ];
1964 let names = crate::naming::render_clip_names(
1965 &requests,
1966 &crate::naming::NamingConfig::default(),
1967 &colliding,
1968 );
1969 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
1970 crate::reconcile::Desired {
1971 clip: clip.clone(),
1972 lineage: ctx.clone(),
1973 path: format!("{}.flac", name.relative_path.to_string_lossy()),
1974 format: crate::AudioFormat::Flac,
1975 meta_hash: String::new(),
1976 art_hash: String::new(),
1977 modes: vec![crate::reconcile::SourceMode::Mirror],
1978 trashed: false,
1979 private: false,
1980 artifacts: Vec::new(),
1981 stems: None,
1982 }
1983 };
1984 let desired = vec![
1985 desired_of(&real_clip, &real_ctx, &names[0]),
1986 desired_of(&new_clip, &new_ctx, &names[1]),
1987 ];
1988 let albums = crate::reconcile::album_desired(&desired, false, false);
1989 let jpg_paths: Vec<String> = albums
1990 .iter()
1991 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
1992 .collect();
1993 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
1994 assert_ne!(
1995 jpg_paths[0], jpg_paths[1],
1996 "an uncached override must not collapse two albums onto one path"
1997 );
1998 }
1999
2000 #[test]
2001 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2002 let child = Clip {
2009 id: "childclip".into(),
2010 title: "Cover".into(),
2011 clip_type: "gen".into(),
2012 task: "cover".into(),
2013 cover_clip_id: "gaproot".into(),
2014 edited_clip_id: "gaproot".into(),
2015 ..Default::default()
2016 };
2017 let other_root = Clip {
2018 id: "otherroot".into(),
2019 title: "Preferred".into(),
2020 clip_type: "gen".into(),
2021 ..Default::default()
2022 };
2023 let gap_ancestor = Clip {
2024 id: "gaproot".into(),
2025 title: "Working Title".into(),
2026 clip_type: "gen".into(),
2027 ..Default::default()
2028 };
2029 let mut roots = HashMap::new();
2030 roots.insert(
2031 "childclip".to_owned(),
2032 RootInfo {
2033 root_id: "gaproot".into(),
2034 root_title: "Working Title".into(),
2035 status: ResolveStatus::Resolved,
2036 },
2037 );
2038 roots.insert(
2039 "otherroot".to_owned(),
2040 RootInfo {
2041 root_id: "otherroot".into(),
2042 root_title: "Preferred".into(),
2043 status: ResolveStatus::Resolved,
2044 },
2045 );
2046 let mut store = LineageStore::new();
2047 store.update(
2048 &[child.clone(), other_root],
2049 &Resolution {
2050 roots,
2051 gap_filled: vec![gap_ancestor],
2052 },
2053 "now",
2054 );
2055 assert!(store.node("gaproot").is_some());
2057 assert!(!store.resolution_cache.contains_key("gaproot"));
2058
2059 store.set_album_overrides(
2060 [("gaproot".to_owned(), "Preferred".to_owned())]
2061 .into_iter()
2062 .collect(),
2063 );
2064
2065 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2068 assert_eq!(store.album_for_id("childclip"), "Preferred");
2069
2070 assert!(store.colliding_root_titles().contains("Preferred"));
2073 }
2074
2075 #[test]
2076 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2077 let child = Clip {
2083 id: "childclip".into(),
2084 title: "Cover".into(),
2085 clip_type: "gen".into(),
2086 task: "cover".into(),
2087 cover_clip_id: "gaproot".into(),
2088 edited_clip_id: "gaproot".into(),
2089 ..Default::default()
2090 };
2091 let mut roots = HashMap::new();
2092 roots.insert(
2093 "childclip".to_owned(),
2094 RootInfo {
2095 root_id: "gaproot".into(),
2096 root_title: "Working Title".into(),
2097 status: ResolveStatus::Resolved,
2098 },
2099 );
2100 let mut store = LineageStore::new();
2101 store.update(
2102 std::slice::from_ref(&child),
2103 &Resolution {
2104 roots,
2105 gap_filled: vec![Clip {
2106 id: "gaproot".into(),
2107 title: "Working Title".into(),
2108 clip_type: "gen".into(),
2109 ..Default::default()
2110 }],
2111 },
2112 "now",
2113 );
2114
2115 let expected: std::collections::HashSet<String> = store
2116 .resolution_cache
2117 .values()
2118 .map(|entry| entry.root_id.clone())
2119 .filter(|root_id| !root_id.is_empty())
2120 .collect();
2121 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2122 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2124 assert!(!store.resolution_cache.contains_key("gaproot"));
2125 }
2126
2127 fn owner(id: &str, name: &str) -> Owner {
2128 Owner {
2129 user_id: id.to_owned(),
2130 display_name: name.to_owned(),
2131 }
2132 }
2133
2134 #[test]
2135 fn owner_check_covers_first_use_match_and_mismatch() {
2136 let mut store = LineageStore::new();
2137 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
2138
2139 store.pin_owner(owner("user_a", "Alice"));
2140 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
2141 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
2142 assert_eq!(store.owner().unwrap().display_name, "Alice");
2143 }
2144
2145 #[test]
2146 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2147 let mut store = LineageStore::new();
2148 assert!(!store.refresh_display_name("Alice"));
2150 assert!(store.owner().is_none());
2151
2152 store.pin_owner(owner("user_a", "Alice"));
2153 assert!(!store.refresh_display_name("Alice"));
2155 assert!(store.refresh_display_name("Alice Cooper"));
2157 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2158 assert_eq!(store.owner().unwrap().user_id, "user_a");
2160 }
2161
2162 #[test]
2163 fn owner_gate_covers_the_full_matrix() {
2164 let alice = owner("user_a", "Alice");
2165
2166 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2168 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2169
2170 assert_eq!(
2172 owner_gate(Some(&alice), None, "user_a", false),
2173 OwnerGate::Proceed
2174 );
2175
2176 assert_eq!(
2178 owner_gate(Some(&alice), None, "user_b", false),
2179 OwnerGate::AbortMismatch
2180 );
2181 assert_eq!(
2182 owner_gate(Some(&alice), None, "user_b", true),
2183 OwnerGate::Repin
2184 );
2185
2186 assert_eq!(
2189 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2190 OwnerGate::AbortConfigMismatch
2191 );
2192 assert_eq!(
2193 owner_gate(None, Some("user_c"), "user_a", true),
2194 OwnerGate::AbortConfigMismatch
2195 );
2196 assert_eq!(
2198 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2199 OwnerGate::Proceed
2200 );
2201
2202 assert!(OwnerGate::Repin.is_additive());
2204 for gate in [
2205 OwnerGate::AbortConfigMismatch,
2206 OwnerGate::AbortMismatch,
2207 OwnerGate::Proceed,
2208 OwnerGate::FirstUse,
2209 ] {
2210 assert!(!gate.is_additive());
2211 }
2212 }
2213
2214 #[test]
2215 fn adopt_decision_covers_every_branch() {
2216 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2217 let empty: BTreeSet<&str> = BTreeSet::new();
2218
2219 assert_eq!(
2221 adopt_decision(&["x", "y"], &empty, true, false),
2222 AdoptDecision::PinFresh
2223 );
2224 assert_eq!(
2226 adopt_decision(&["c1"], &owned, false, false),
2227 AdoptDecision::SkipPin
2228 );
2229 assert_eq!(
2230 adopt_decision(&["c1"], &owned, false, true),
2231 AdoptDecision::SkipPin
2232 );
2233 assert_eq!(
2235 adopt_decision(&["c1", "z"], &owned, true, false),
2236 AdoptDecision::PinAdopt
2237 );
2238 assert_eq!(
2240 adopt_decision(&["z1", "z2"], &owned, true, false),
2241 AdoptDecision::Abort
2242 );
2243 assert_eq!(
2244 adopt_decision(&["z1", "z2"], &owned, true, true),
2245 AdoptDecision::AdoptForced
2246 );
2247
2248 assert!(AdoptDecision::AdoptForced.is_additive());
2250 for decision in [
2251 AdoptDecision::PinFresh,
2252 AdoptDecision::PinAdopt,
2253 AdoptDecision::Abort,
2254 AdoptDecision::SkipPin,
2255 ] {
2256 assert!(!decision.is_additive());
2257 }
2258 }
2259
2260 #[test]
2261 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2262 let json = r#"{"nodes":{},"edges":[]}"#;
2264 let store: LineageStore = serde_json::from_str(json).unwrap();
2265 assert!(store.owner().is_none());
2266 let value = serde_json::to_value(&store).unwrap();
2268 assert!(value.get("owner").is_none());
2269
2270 let mut pinned = LineageStore::new();
2272 pinned.pin_owner(owner("user_a", "Alice"));
2273 let back: LineageStore =
2274 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2275 assert_eq!(back, pinned);
2276 assert_eq!(back.owner().unwrap().user_id, "user_a");
2277 }
2278}