1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet};
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, PartialEq, 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}
59
60impl Default for LineageStore {
61 fn default() -> Self {
62 Self {
63 schema_version: 1,
64 nodes: BTreeMap::new(),
65 edges: Vec::new(),
66 resolution_cache: BTreeMap::new(),
67 albums: BTreeMap::new(),
68 playlists: BTreeMap::new(),
69 owner: None,
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Owner {
83 pub user_id: String,
84 pub display_name: String,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum OwnerCheck {
90 FirstUse,
92 Match,
94 Mismatch,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum OwnerGate {
107 AbortConfigMismatch,
110 AbortMismatch,
112 Repin,
115 Proceed,
118 FirstUse,
120}
121
122impl OwnerGate {
123 pub fn is_additive(self) -> bool {
125 matches!(self, OwnerGate::Repin)
126 }
127}
128
129pub fn owner_gate(
136 store_owner: Option<&Owner>,
137 configured_id: Option<&str>,
138 authed_user_id: &str,
139 allow_change: bool,
140) -> OwnerGate {
141 if let Some(configured) = configured_id
142 && configured != authed_user_id
143 {
144 return OwnerGate::AbortConfigMismatch;
145 }
146 match store_owner {
147 None => OwnerGate::FirstUse,
148 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
149 Some(_) if allow_change => OwnerGate::Repin,
150 Some(_) => OwnerGate::AbortMismatch,
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum AdoptDecision {
161 PinFresh,
164 PinAdopt,
167 AdoptForced,
170 Abort,
173 SkipPin,
175}
176
177impl AdoptDecision {
178 pub fn is_additive(self) -> bool {
180 matches!(self, AdoptDecision::AdoptForced)
181 }
182}
183
184pub fn adopt_decision(
194 listed: &[&str],
195 owned: &BTreeSet<&str>,
196 enumerated: bool,
197 allow_change: bool,
198) -> AdoptDecision {
199 if owned.is_empty() {
200 return AdoptDecision::PinFresh;
201 }
202 if !enumerated {
203 return AdoptDecision::SkipPin;
204 }
205 if listed.iter().any(|id| owned.contains(id)) {
206 AdoptDecision::PinAdopt
207 } else if allow_change {
208 AdoptDecision::AdoptForced
209 } else {
210 AdoptDecision::Abort
211 }
212}
213
214#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(default)]
224pub struct AlbumArt {
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub folder_jpg: Option<ArtifactState>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub folder_webp: Option<ArtifactState>,
231}
232
233impl AlbumArt {
234 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
237 match kind {
238 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
239 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
240 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => None,
241 }
242 }
243
244 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
250 match kind {
251 ArtifactKind::FolderJpg => self.folder_jpg = state,
252 ArtifactKind::FolderWebp => self.folder_webp = state,
253 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => {}
254 }
255 }
256
257 pub fn is_empty(&self) -> bool {
260 self.folder_jpg.is_none() && self.folder_webp.is_none()
261 }
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(default)]
274pub struct PlaylistState {
275 pub name: String,
277 pub path: String,
279 pub hash: String,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(default)]
287pub struct Node {
288 pub title: String,
289 pub created_at: String,
290 pub clip_type: String,
291 pub task: String,
292 pub is_remix: bool,
293 pub is_trashed: bool,
294 pub status: String,
296 pub first_seen_at: String,
297 pub last_seen_at: String,
298}
299
300impl Default for Node {
301 fn default() -> Self {
302 Self {
303 title: String::new(),
304 created_at: String::new(),
305 clip_type: String::new(),
306 task: String::new(),
307 is_remix: false,
308 is_trashed: false,
309 status: "observed".to_owned(),
310 first_seen_at: String::new(),
311 last_seen_at: String::new(),
312 }
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320#[serde(default)]
321pub struct StoredEdge {
322 pub child_id: String,
323 pub parent_id: String,
324 pub edge_type: String,
326 pub role: String,
328 pub source_field: String,
330 pub ordinal: u32,
332 pub status: String,
334 pub first_seen_at: String,
335 pub last_seen_at: String,
336}
337
338impl Default for StoredEdge {
339 fn default() -> Self {
340 Self {
341 child_id: String::new(),
342 parent_id: String::new(),
343 edge_type: String::new(),
344 role: String::new(),
345 source_field: String::new(),
346 ordinal: 0,
347 status: "active".to_owned(),
348 first_seen_at: String::new(),
349 last_seen_at: String::new(),
350 }
351 }
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
356#[serde(default)]
357pub struct CacheEntry {
358 pub root_id: String,
359 pub status: String,
361 pub algorithm_version: u32,
362 pub computed_at: String,
363}
364
365impl LineageStore {
366 pub fn new() -> Self {
368 Self::default()
369 }
370
371 pub fn node(&self, id: &str) -> Option<&Node> {
373 self.nodes.get(id)
374 }
375
376 pub fn owner(&self) -> Option<&Owner> {
378 self.owner.as_ref()
379 }
380
381 pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
383 match &self.owner {
384 None => OwnerCheck::FirstUse,
385 Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
386 Some(_) => OwnerCheck::Mismatch,
387 }
388 }
389
390 pub fn pin_owner(&mut self, owner: Owner) {
392 self.owner = Some(owner);
393 }
394
395 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
398 match &mut self.owner {
399 Some(owner) if owner.display_name != display_name => {
400 owner.display_name = display_name.to_owned();
401 true
402 }
403 _ => false,
404 }
405 }
406
407 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
409 self.resolution_cache.get(id)
410 }
411
412 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
414 self.albums.get(root_id)
415 }
416
417 pub fn set_album_artifact(
425 &mut self,
426 root_id: &str,
427 kind: ArtifactKind,
428 state: Option<ArtifactState>,
429 ) {
430 match state {
431 Some(state) => self
432 .albums
433 .entry(root_id.to_owned())
434 .or_default()
435 .set(kind, Some(state)),
436 None => {
437 if let Some(art) = self.albums.get_mut(root_id) {
438 art.set(kind, None);
439 if art.is_empty() {
440 self.albums.remove(root_id);
441 }
442 }
443 }
444 }
445 }
446
447 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
449 self.playlists.get(id)
450 }
451
452 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
460 match state {
461 Some(state) => {
462 self.playlists.insert(id.to_owned(), state);
463 }
464 None => {
465 self.playlists.remove(id);
466 }
467 }
468 }
469
470 pub fn context_for(&self, clip: &Clip) -> LineageContext {
480 let cached = self.get_root(&clip.id);
481 let root_id = cached
482 .map(|entry| entry.root_id.clone())
483 .filter(|id| !id.is_empty())
484 .unwrap_or_else(|| clip.id.clone());
485 let root_title = self
486 .node(&root_id)
487 .map(|node| node.title.clone())
488 .unwrap_or_else(|| clip.title.clone());
489 let (parent_id, edge_type) = match immediate_parent(clip) {
490 Some((id, edge)) => (id, Some(edge)),
491 None => (String::new(), None),
492 };
493 let status = cached
494 .map(|entry| status_from_slug(&entry.status))
495 .unwrap_or(ResolveStatus::Resolved);
496 LineageContext {
497 root_id,
498 root_title,
499 parent_id,
500 edge_type,
501 status,
502 }
503 }
504
505 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
516 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
517 for entry in self.resolution_cache.values() {
518 if entry.root_id.is_empty() {
519 continue;
520 }
521 let Some(node) = self.nodes.get(&entry.root_id) else {
522 continue;
523 };
524 let title = node.title.trim();
525 if title.is_empty() {
526 continue;
527 }
528 roots_by_title
529 .entry(title.to_owned())
530 .or_default()
531 .insert(entry.root_id.clone());
532 }
533 roots_by_title
534 .into_iter()
535 .filter(|(_, roots)| roots.len() > 1)
536 .map(|(title, _)| title)
537 .collect()
538 }
539
540 pub fn len(&self) -> usize {
542 self.nodes.len()
543 }
544
545 pub fn is_empty(&self) -> bool {
547 self.nodes.is_empty()
548 }
549
550 pub fn iter(&self) -> Iter<'_, String, Node> {
552 self.nodes.iter()
553 }
554
555 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
563 for clip in clips {
564 self.upsert_node(clip, now);
565 }
566 for clip in &resolution.gap_filled {
569 self.upsert_node(clip, now);
570 }
571
572 for clip in clips {
573 for edge in lineage_edges(clip) {
574 self.upsert_edge(&clip.id, &edge, now);
575 }
576 }
577 self.edges.sort_by(|a, b| {
578 a.child_id
579 .cmp(&b.child_id)
580 .then(a.ordinal.cmp(&b.ordinal))
581 .then(a.parent_id.cmp(&b.parent_id))
582 .then(a.edge_type.cmp(&b.edge_type))
583 .then(a.role.cmp(&b.role))
584 });
585
586 for (child_id, info) in &resolution.roots {
587 self.upsert_cache(child_id, info, now);
588 }
589 }
590
591 fn upsert_node(&mut self, clip: &Clip, now: &str) {
594 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
595 first_seen_at: now.to_owned(),
596 ..Node::default()
597 });
598 node.title = clip.title.clone();
599 node.created_at = clip.created_at.clone();
600 node.clip_type = clip.clip_type.clone();
601 node.task = clip.task.clone();
602 node.is_remix = clip.is_remix;
603 node.is_trashed = clip.is_trashed;
604 node.last_seen_at = now.to_owned();
605 }
606
607 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
610 let edge_type = edge_type_slug(edge.edge_type);
611 let role = edge_role_slug(edge.role);
612 if let Some(existing) = self.edges.iter_mut().find(|stored| {
613 stored.child_id == child_id
614 && stored.parent_id == edge.parent_id
615 && stored.edge_type == edge_type
616 && stored.role == role
617 && stored.ordinal == edge.ordinal
618 }) {
619 existing.source_field = edge.source_field.to_owned();
620 existing.status = "active".to_owned();
621 existing.last_seen_at = now.to_owned();
622 } else {
623 self.edges.push(StoredEdge {
624 child_id: child_id.to_owned(),
625 parent_id: edge.parent_id.clone(),
626 edge_type: edge_type.to_owned(),
627 role: role.to_owned(),
628 source_field: edge.source_field.to_owned(),
629 ordinal: edge.ordinal,
630 status: "active".to_owned(),
631 first_seen_at: now.to_owned(),
632 last_seen_at: now.to_owned(),
633 });
634 }
635 }
636
637 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
644 if info.status != ResolveStatus::Resolved
645 && self
646 .resolution_cache
647 .get(child_id)
648 .is_some_and(|entry| entry.status == "resolved")
649 {
650 return;
651 }
652 self.resolution_cache.insert(
653 child_id.to_owned(),
654 CacheEntry {
655 root_id: info.root_id.clone(),
656 status: resolve_status_slug(info.status).to_owned(),
657 algorithm_version: 1,
658 computed_at: now.to_owned(),
659 },
660 );
661 }
662}
663
664fn edge_type_slug(edge_type: EdgeType) -> &'static str {
666 match edge_type {
667 EdgeType::Cover => "cover",
668 EdgeType::Remaster => "remaster",
669 EdgeType::SpeedEdit => "speed_edit",
670 EdgeType::Edit => "edit",
671 EdgeType::Extend => "extend",
672 EdgeType::SectionReplace => "section_replace",
673 EdgeType::Stitch => "stitch",
674 EdgeType::Derived => "derived",
675 EdgeType::Uploaded => "uploaded",
676 }
677}
678
679fn edge_role_slug(role: EdgeRole) -> &'static str {
681 match role {
682 EdgeRole::Primary => "primary",
683 EdgeRole::Secondary => "secondary",
684 }
685}
686
687fn resolve_status_slug(status: ResolveStatus) -> &'static str {
689 match status {
690 ResolveStatus::Resolved => "resolved",
691 ResolveStatus::External => "external",
692 ResolveStatus::Unresolved => "unresolved",
693 ResolveStatus::Cycle => "cycle",
694 }
695}
696
697fn status_from_slug(slug: &str) -> ResolveStatus {
700 match slug {
701 "external" => ResolveStatus::External,
702 "unresolved" => ResolveStatus::Unresolved,
703 "cycle" => ResolveStatus::Cycle,
704 _ => ResolveStatus::Resolved,
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use std::collections::HashMap;
712
713 fn chain_clips() -> Vec<Clip> {
715 vec![
716 Clip {
717 id: "c".into(),
718 title: "Cover".into(),
719 clip_type: "gen".into(),
720 task: "cover".into(),
721 created_at: "t2".into(),
722 cover_clip_id: "b".into(),
723 edited_clip_id: "b".into(),
724 ..Default::default()
725 },
726 Clip {
727 id: "b".into(),
728 title: "Remaster".into(),
729 clip_type: "upsample".into(),
730 task: "upsample".into(),
731 created_at: "t1".into(),
732 upsample_clip_id: "a".into(),
733 edited_clip_id: "a".into(),
734 ..Default::default()
735 },
736 Clip {
737 id: "a".into(),
738 title: "Root".into(),
739 clip_type: "gen".into(),
740 created_at: "t0".into(),
741 ..Default::default()
742 },
743 ]
744 }
745
746 fn chain_resolution() -> Resolution {
748 let mut roots = HashMap::new();
749 for id in ["a", "b", "c"] {
750 roots.insert(
751 id.to_owned(),
752 RootInfo {
753 root_id: "a".into(),
754 root_title: "Root".into(),
755 status: ResolveStatus::Resolved,
756 },
757 );
758 }
759 Resolution {
760 roots,
761 gap_filled: Vec::new(),
762 }
763 }
764
765 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
766 store
767 .edges
768 .iter()
769 .find(|e| e.child_id == child && e.parent_id == parent)
770 .expect("edge should exist")
771 }
772
773 #[test]
774 fn new_store_is_empty_and_versioned() {
775 let store = LineageStore::new();
776 assert!(store.is_empty());
777 assert_eq!(store.len(), 0);
778 assert_eq!(store.schema_version, 1);
779 }
780
781 #[test]
782 fn update_populates_nodes_edges_and_cache() {
783 let mut store = LineageStore::new();
784 store.update(&chain_clips(), &chain_resolution(), "now");
785
786 assert_eq!(store.len(), 3);
788 let cover = store.node("c").unwrap();
789 assert_eq!(cover.title, "Cover");
790 assert_eq!(cover.clip_type, "gen");
791 assert_eq!(cover.task, "cover");
792 assert_eq!(cover.created_at, "t2");
793 assert_eq!(cover.status, "observed");
794 assert!(!cover.is_trashed);
795 assert_eq!(cover.first_seen_at, "now");
796 assert_eq!(cover.last_seen_at, "now");
797
798 assert_eq!(store.edges.len(), 2);
800 let cb = edge(&store, "c", "b");
801 assert_eq!(cb.edge_type, "cover");
802 assert_eq!(cb.role, "primary");
803 assert_eq!(cb.ordinal, 0);
804 assert_eq!(cb.source_field, "cover_clip_id");
805 assert_eq!(cb.status, "active");
806 let ba = edge(&store, "b", "a");
807 assert_eq!(ba.edge_type, "remaster");
808 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
809
810 for id in ["a", "b", "c"] {
812 let cached = store.get_root(id).unwrap();
813 assert_eq!(cached.root_id, "a");
814 assert_eq!(cached.status, "resolved");
815 assert_eq!(cached.algorithm_version, 1);
816 }
817 }
818
819 #[test]
820 fn serde_roundtrip_preserves_a_relational_shape() {
821 let mut store = LineageStore::new();
822 store.update(&chain_clips(), &chain_resolution(), "now");
823
824 let json = serde_json::to_string(&store).unwrap();
825 let back: LineageStore = serde_json::from_str(&json).unwrap();
826 assert_eq!(store, back);
827
828 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
829 assert_eq!(value.get("schema_version").unwrap(), 1);
830 assert!(value.get("nodes").unwrap().is_object());
831 assert!(value.get("edges").unwrap().is_array());
832 assert!(value.get("resolution_cache").unwrap().is_object());
833
834 let node = value.get("nodes").unwrap().get("c").unwrap();
837 assert!(node.get("edges").is_none());
838 assert!(node.get("parent_id").is_none());
839 let first_edge = value.get("edges").unwrap().get(0).unwrap();
840 assert!(first_edge.get("child_id").is_some());
841 assert!(first_edge.get("parent_id").is_some());
842 }
843
844 #[test]
845 fn update_is_idempotent_bar_last_seen() {
846 let clips = chain_clips();
847 let resolution = chain_resolution();
848 let mut store = LineageStore::new();
849 store.update(&clips, &resolution, "first");
850 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
851 let edge_count = store.edges.len();
852
853 store.update(&clips, &resolution, "second");
854
855 assert_eq!(
857 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
858 node_ids
859 );
860 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
861 assert_eq!(store.resolution_cache.len(), 3);
862
863 let cover = store.node("c").unwrap();
865 assert_eq!(cover.first_seen_at, "first");
866 assert_eq!(cover.last_seen_at, "second");
867 let cb = edge(&store, "c", "b");
868 assert_eq!(cb.first_seen_at, "first");
869 assert_eq!(cb.last_seen_at, "second");
870 assert_eq!(store.get_root("c").unwrap().root_id, "a");
872 }
873
874 #[test]
875 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
876 let mut store = LineageStore::new();
877 store.update(&chain_clips(), &chain_resolution(), "first");
878 assert_eq!(store.get_root("c").unwrap().status, "resolved");
879
880 let child = Clip {
883 id: "c".into(),
884 title: "Cover".into(),
885 clip_type: "gen".into(),
886 task: "cover".into(),
887 cover_clip_id: "b".into(),
888 edited_clip_id: "b".into(),
889 ..Default::default()
890 };
891 let mut roots = HashMap::new();
892 roots.insert(
893 "c".to_owned(),
894 RootInfo {
895 root_id: "elsewhere".into(),
896 root_title: String::new(),
897 status: ResolveStatus::External,
898 },
899 );
900 roots.insert(
901 "d".to_owned(),
902 RootInfo {
903 root_id: "boundary".into(),
904 root_title: String::new(),
905 status: ResolveStatus::External,
906 },
907 );
908 let resolution = Resolution {
909 roots,
910 gap_filled: Vec::new(),
911 };
912 store.update(&[child], &resolution, "second");
913
914 let cached = store.get_root("c").unwrap();
916 assert_eq!(cached.root_id, "a");
917 assert_eq!(cached.status, "resolved");
918 assert_eq!(cached.computed_at, "first");
919 let d = store.get_root("d").unwrap();
921 assert_eq!(d.root_id, "boundary");
922 assert_eq!(d.status, "external");
923 }
924
925 #[test]
926 fn gap_filled_trashed_ancestor_is_a_durable_node() {
927 let child = Clip {
931 id: "c".into(),
932 title: "Cover".into(),
933 clip_type: "gen".into(),
934 task: "cover".into(),
935 cover_clip_id: "t".into(),
936 edited_clip_id: "t".into(),
937 ..Default::default()
938 };
939 let trashed = Clip {
940 id: "t".into(),
941 title: "Trashed Original".into(),
942 clip_type: "gen".into(),
943 is_trashed: true,
944 ..Default::default()
945 };
946 let mut roots = HashMap::new();
947 roots.insert(
948 "c".to_owned(),
949 RootInfo {
950 root_id: "t".into(),
951 root_title: "Trashed Original".into(),
952 status: ResolveStatus::Resolved,
953 },
954 );
955 let resolution = Resolution {
956 roots,
957 gap_filled: vec![trashed],
958 };
959 store_update_and_assert_trashed(child, resolution);
960 }
961
962 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
963 let mut store = LineageStore::new();
964 store.update(&[child], &resolution, "now");
965
966 let node = store
967 .node("t")
968 .expect("trashed ancestor should be archived");
969 assert!(node.is_trashed);
970 assert_eq!(node.title, "Trashed Original");
971 assert_eq!(store.get_root("c").unwrap().root_id, "t");
973 }
974
975 #[test]
976 fn partial_json_loads_with_defaults() {
977 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
980 let store: LineageStore = serde_json::from_str(json).unwrap();
981 assert_eq!(store.schema_version, 1);
982 let node = store.node("x").unwrap();
983 assert_eq!(node.title, "Kept");
984 assert_eq!(node.status, "observed");
985 assert_eq!(store.edges[0].status, "active");
986 assert!(store.resolution_cache.is_empty());
987 assert!(store.albums.is_empty());
990 assert!(store.album_art("x").is_none());
991 assert!(store.playlists.is_empty());
995 assert!(store.playlist("x").is_none());
996 }
997
998 #[test]
999 fn album_art_roundtrips_and_reads_by_kind() {
1000 let mut store = LineageStore::new();
1001 store.albums.insert(
1002 "root-1".to_owned(),
1003 AlbumArt {
1004 folder_jpg: Some(ArtifactState {
1005 path: "alice/Album/folder.jpg".to_owned(),
1006 hash: "jpg-h".to_owned(),
1007 }),
1008 folder_webp: Some(ArtifactState {
1009 path: "alice/Album/cover.webp".to_owned(),
1010 hash: "webp-h".to_owned(),
1011 }),
1012 },
1013 );
1014
1015 let json = serde_json::to_string(&store).unwrap();
1016 let back: LineageStore = serde_json::from_str(&json).unwrap();
1017 assert_eq!(store, back);
1018
1019 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1021 let album = value.get("albums").unwrap().get("root-1").unwrap();
1022 assert_eq!(
1023 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1024 "jpg-h"
1025 );
1026
1027 let art = back.album_art("root-1").unwrap();
1028 assert_eq!(
1029 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1030 "alice/Album/folder.jpg"
1031 );
1032 assert_eq!(
1033 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1034 "webp-h"
1035 );
1036 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1038 }
1039
1040 #[test]
1041 fn empty_album_art_omits_slots_when_serialised() {
1042 let empty = AlbumArt::default();
1045 assert!(empty.is_empty());
1046 let value = serde_json::to_value(&empty).unwrap();
1047 assert!(value.get("folder_jpg").is_none());
1048 assert!(value.get("folder_webp").is_none());
1049 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1050 assert_eq!(back, empty);
1051 }
1052
1053 #[test]
1054 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1055 let mut store = LineageStore::new();
1056 let jpg = ArtifactState {
1057 path: "a/folder.jpg".to_owned(),
1058 hash: "h1".to_owned(),
1059 };
1060 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1061 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1062
1063 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1065 assert!(store.album_art("root-1").is_none());
1066 assert!(store.albums.is_empty());
1067 }
1068
1069 #[test]
1070 fn playlist_state_roundtrips_by_id() {
1071 let mut store = LineageStore::new();
1072 store.playlists.insert(
1073 "pl1".to_owned(),
1074 PlaylistState {
1075 name: "Road Trip".to_owned(),
1076 path: "Road Trip.m3u8".to_owned(),
1077 hash: "abc123".to_owned(),
1078 },
1079 );
1080
1081 let json = serde_json::to_string(&store).unwrap();
1082 let back: LineageStore = serde_json::from_str(&json).unwrap();
1083 assert_eq!(store, back);
1084
1085 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1087 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1088 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1089 assert_eq!(pl.get("hash").unwrap(), "abc123");
1090
1091 let stored = back.playlist("pl1").unwrap();
1092 assert_eq!(stored.name, "Road Trip");
1093 assert_eq!(stored.hash, "abc123");
1094 }
1095
1096 #[test]
1097 fn set_playlist_upserts_then_clears() {
1098 let mut store = LineageStore::new();
1099 let state = PlaylistState {
1100 name: "Mix".to_owned(),
1101 path: "Mix.m3u8".to_owned(),
1102 hash: "h1".to_owned(),
1103 };
1104 store.set_playlist("pl1", Some(state.clone()));
1105 assert_eq!(store.playlist("pl1"), Some(&state));
1106
1107 let renamed = PlaylistState {
1109 name: "Mix v2".to_owned(),
1110 path: "Mix v2.m3u8".to_owned(),
1111 hash: "h2".to_owned(),
1112 };
1113 store.set_playlist("pl1", Some(renamed.clone()));
1114 assert_eq!(store.playlist("pl1"), Some(&renamed));
1115
1116 store.set_playlist("pl1", None);
1118 assert!(store.playlist("pl1").is_none());
1119 assert!(store.playlists.is_empty());
1120 }
1121
1122 #[test]
1123 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1124 let mut store = LineageStore::new();
1125 store.update(&chain_clips(), &chain_resolution(), "now");
1126
1127 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1129 assert_eq!(ctx.root_id, "a");
1130 assert_eq!(ctx.root_title, "Root");
1131 assert_eq!(ctx.parent_id, "b");
1132 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1133 assert_eq!(ctx.status, ResolveStatus::Resolved);
1134 assert_eq!(ctx.album("Cover"), "Root");
1136 }
1137
1138 #[test]
1139 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1140 let mut store = LineageStore::new();
1141 store.update(&chain_clips(), &chain_resolution(), "now");
1142
1143 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1145 assert_eq!(ctx.root_id, "a");
1146 assert_eq!(ctx.root_title, "Root");
1147 assert_eq!(ctx.parent_id, "");
1148 assert_eq!(ctx.edge_type, None);
1149 assert_eq!(ctx.album("Root"), "Root");
1150 }
1151
1152 #[test]
1153 fn context_for_an_unknown_clip_is_self_rooted() {
1154 let store = LineageStore::new();
1155 let orphan = Clip {
1156 id: "z".into(),
1157 title: "Lonely".into(),
1158 ..Default::default()
1159 };
1160 let ctx = store.context_for(&orphan);
1161 assert_eq!(ctx.root_id, "z");
1162 assert_eq!(ctx.root_title, "Lonely");
1163 assert_eq!(ctx.parent_id, "");
1164 assert_eq!(ctx.status, ResolveStatus::Resolved);
1165 }
1166
1167 #[test]
1168 fn context_for_retains_a_purged_ancestor_album() {
1169 let child = Clip {
1174 id: "c".into(),
1175 title: "Cover".into(),
1176 clip_type: "gen".into(),
1177 task: "cover".into(),
1178 cover_clip_id: "t".into(),
1179 edited_clip_id: "t".into(),
1180 ..Default::default()
1181 };
1182 let trashed = Clip {
1183 id: "t".into(),
1184 title: "Trashed Original".into(),
1185 clip_type: "gen".into(),
1186 is_trashed: true,
1187 ..Default::default()
1188 };
1189 let mut roots = HashMap::new();
1190 roots.insert(
1191 "c".to_owned(),
1192 RootInfo {
1193 root_id: "t".into(),
1194 root_title: "Trashed Original".into(),
1195 status: ResolveStatus::Resolved,
1196 },
1197 );
1198 let resolution = Resolution {
1199 roots,
1200 gap_filled: vec![trashed],
1201 };
1202 let mut store = LineageStore::new();
1203 store.update(std::slice::from_ref(&child), &resolution, "now");
1204
1205 let ctx = store.context_for(&child);
1206 assert_eq!(ctx.root_id, "t");
1207 assert_eq!(ctx.root_title, "Trashed Original");
1208 assert_eq!(ctx.album("Cover"), "Trashed Original");
1209 }
1210
1211 #[test]
1212 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1213 let clips = vec![
1216 Clip {
1217 id: "r1".into(),
1218 title: "Break Through".into(),
1219 clip_type: "gen".into(),
1220 ..Default::default()
1221 },
1222 Clip {
1223 id: "r2".into(),
1224 title: "Break Through".into(),
1225 clip_type: "gen".into(),
1226 ..Default::default()
1227 },
1228 Clip {
1229 id: "r3".into(),
1230 title: "Solo".into(),
1231 clip_type: "gen".into(),
1232 ..Default::default()
1233 },
1234 Clip {
1235 id: "c1".into(),
1236 title: "Break Through".into(),
1237 clip_type: "gen".into(),
1238 task: "cover".into(),
1239 cover_clip_id: "r1".into(),
1240 edited_clip_id: "r1".into(),
1241 ..Default::default()
1242 },
1243 ];
1244 let mut roots = HashMap::new();
1245 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1246 let title = if root == "r3" {
1247 "Solo"
1248 } else {
1249 "Break Through"
1250 };
1251 roots.insert(
1252 id.to_owned(),
1253 RootInfo {
1254 root_id: root.into(),
1255 root_title: title.into(),
1256 status: ResolveStatus::Resolved,
1257 },
1258 );
1259 }
1260 let resolution = Resolution {
1261 roots,
1262 gap_filled: Vec::new(),
1263 };
1264 let mut store = LineageStore::new();
1265 store.update(&clips, &resolution, "now");
1266
1267 let colliding = store.colliding_root_titles();
1268 assert!(colliding.contains("Break Through"));
1269 assert!(!colliding.contains("Solo"));
1270 assert_eq!(colliding.len(), 1);
1271 }
1272
1273 fn owner(id: &str, name: &str) -> Owner {
1274 Owner {
1275 user_id: id.to_owned(),
1276 display_name: name.to_owned(),
1277 }
1278 }
1279
1280 #[test]
1281 fn owner_check_covers_first_use_match_and_mismatch() {
1282 let mut store = LineageStore::new();
1283 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
1284
1285 store.pin_owner(owner("user_a", "Alice"));
1286 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
1287 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
1288 assert_eq!(store.owner().unwrap().display_name, "Alice");
1289 }
1290
1291 #[test]
1292 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
1293 let mut store = LineageStore::new();
1294 assert!(!store.refresh_display_name("Alice"));
1296 assert!(store.owner().is_none());
1297
1298 store.pin_owner(owner("user_a", "Alice"));
1299 assert!(!store.refresh_display_name("Alice"));
1301 assert!(store.refresh_display_name("Alice Cooper"));
1303 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
1304 assert_eq!(store.owner().unwrap().user_id, "user_a");
1306 }
1307
1308 #[test]
1309 fn owner_gate_covers_the_full_matrix() {
1310 let alice = owner("user_a", "Alice");
1311
1312 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
1314 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
1315
1316 assert_eq!(
1318 owner_gate(Some(&alice), None, "user_a", false),
1319 OwnerGate::Proceed
1320 );
1321
1322 assert_eq!(
1324 owner_gate(Some(&alice), None, "user_b", false),
1325 OwnerGate::AbortMismatch
1326 );
1327 assert_eq!(
1328 owner_gate(Some(&alice), None, "user_b", true),
1329 OwnerGate::Repin
1330 );
1331
1332 assert_eq!(
1335 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
1336 OwnerGate::AbortConfigMismatch
1337 );
1338 assert_eq!(
1339 owner_gate(None, Some("user_c"), "user_a", true),
1340 OwnerGate::AbortConfigMismatch
1341 );
1342 assert_eq!(
1344 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
1345 OwnerGate::Proceed
1346 );
1347
1348 assert!(OwnerGate::Repin.is_additive());
1350 for gate in [
1351 OwnerGate::AbortConfigMismatch,
1352 OwnerGate::AbortMismatch,
1353 OwnerGate::Proceed,
1354 OwnerGate::FirstUse,
1355 ] {
1356 assert!(!gate.is_additive());
1357 }
1358 }
1359
1360 #[test]
1361 fn adopt_decision_covers_every_branch() {
1362 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
1363 let empty: BTreeSet<&str> = BTreeSet::new();
1364
1365 assert_eq!(
1367 adopt_decision(&["x", "y"], &empty, true, false),
1368 AdoptDecision::PinFresh
1369 );
1370 assert_eq!(
1372 adopt_decision(&["c1"], &owned, false, false),
1373 AdoptDecision::SkipPin
1374 );
1375 assert_eq!(
1376 adopt_decision(&["c1"], &owned, false, true),
1377 AdoptDecision::SkipPin
1378 );
1379 assert_eq!(
1381 adopt_decision(&["c1", "z"], &owned, true, false),
1382 AdoptDecision::PinAdopt
1383 );
1384 assert_eq!(
1386 adopt_decision(&["z1", "z2"], &owned, true, false),
1387 AdoptDecision::Abort
1388 );
1389 assert_eq!(
1390 adopt_decision(&["z1", "z2"], &owned, true, true),
1391 AdoptDecision::AdoptForced
1392 );
1393
1394 assert!(AdoptDecision::AdoptForced.is_additive());
1396 for decision in [
1397 AdoptDecision::PinFresh,
1398 AdoptDecision::PinAdopt,
1399 AdoptDecision::Abort,
1400 AdoptDecision::SkipPin,
1401 ] {
1402 assert!(!decision.is_additive());
1403 }
1404 }
1405
1406 #[test]
1407 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
1408 let json = r#"{"nodes":{},"edges":[]}"#;
1410 let store: LineageStore = serde_json::from_str(json).unwrap();
1411 assert!(store.owner().is_none());
1412 let value = serde_json::to_value(&store).unwrap();
1414 assert!(value.get("owner").is_none());
1415
1416 let mut pinned = LineageStore::new();
1418 pinned.pin_owner(owner("user_a", "Alice"));
1419 let back: LineageStore =
1420 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
1421 assert_eq!(back, pinned);
1422 assert_eq!(back.owner().unwrap().user_id, "user_a");
1423 }
1424}