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
241 | ArtifactKind::CoverWebp
242 | ArtifactKind::DetailsTxt
243 | ArtifactKind::LyricsTxt
244 | ArtifactKind::Playlist => None,
245 }
246 }
247
248 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
254 match kind {
255 ArtifactKind::FolderJpg => self.folder_jpg = state,
256 ArtifactKind::FolderWebp => self.folder_webp = state,
257 ArtifactKind::CoverJpg
258 | ArtifactKind::CoverWebp
259 | ArtifactKind::DetailsTxt
260 | ArtifactKind::LyricsTxt
261 | ArtifactKind::Playlist => {}
262 }
263 }
264
265 pub fn is_empty(&self) -> bool {
268 self.folder_jpg.is_none() && self.folder_webp.is_none()
269 }
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(default)]
282pub struct PlaylistState {
283 pub name: String,
285 pub path: String,
287 pub hash: String,
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(default)]
295pub struct Node {
296 pub title: String,
297 pub created_at: String,
298 pub clip_type: String,
299 pub task: String,
300 pub is_remix: bool,
301 pub is_trashed: bool,
302 pub status: String,
304 pub first_seen_at: String,
305 pub last_seen_at: String,
306}
307
308impl Default for Node {
309 fn default() -> Self {
310 Self {
311 title: String::new(),
312 created_at: String::new(),
313 clip_type: String::new(),
314 task: String::new(),
315 is_remix: false,
316 is_trashed: false,
317 status: "observed".to_owned(),
318 first_seen_at: String::new(),
319 last_seen_at: String::new(),
320 }
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(default)]
329pub struct StoredEdge {
330 pub child_id: String,
331 pub parent_id: String,
332 pub edge_type: String,
334 pub role: String,
336 pub source_field: String,
338 pub ordinal: u32,
340 pub status: String,
342 pub first_seen_at: String,
343 pub last_seen_at: String,
344}
345
346impl Default for StoredEdge {
347 fn default() -> Self {
348 Self {
349 child_id: String::new(),
350 parent_id: String::new(),
351 edge_type: String::new(),
352 role: String::new(),
353 source_field: String::new(),
354 ordinal: 0,
355 status: "active".to_owned(),
356 first_seen_at: String::new(),
357 last_seen_at: String::new(),
358 }
359 }
360}
361
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
364#[serde(default)]
365pub struct CacheEntry {
366 pub root_id: String,
367 pub status: String,
369 pub algorithm_version: u32,
370 pub computed_at: String,
371}
372
373impl LineageStore {
374 pub fn new() -> Self {
376 Self::default()
377 }
378
379 pub fn node(&self, id: &str) -> Option<&Node> {
381 self.nodes.get(id)
382 }
383
384 pub fn owner(&self) -> Option<&Owner> {
386 self.owner.as_ref()
387 }
388
389 pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
391 match &self.owner {
392 None => OwnerCheck::FirstUse,
393 Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
394 Some(_) => OwnerCheck::Mismatch,
395 }
396 }
397
398 pub fn pin_owner(&mut self, owner: Owner) {
400 self.owner = Some(owner);
401 }
402
403 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
406 match &mut self.owner {
407 Some(owner) if owner.display_name != display_name => {
408 owner.display_name = display_name.to_owned();
409 true
410 }
411 _ => false,
412 }
413 }
414
415 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
417 self.resolution_cache.get(id)
418 }
419
420 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
422 self.albums.get(root_id)
423 }
424
425 pub fn set_album_artifact(
433 &mut self,
434 root_id: &str,
435 kind: ArtifactKind,
436 state: Option<ArtifactState>,
437 ) {
438 match state {
439 Some(state) => self
440 .albums
441 .entry(root_id.to_owned())
442 .or_default()
443 .set(kind, Some(state)),
444 None => {
445 if let Some(art) = self.albums.get_mut(root_id) {
446 art.set(kind, None);
447 if art.is_empty() {
448 self.albums.remove(root_id);
449 }
450 }
451 }
452 }
453 }
454
455 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
457 self.playlists.get(id)
458 }
459
460 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
468 match state {
469 Some(state) => {
470 self.playlists.insert(id.to_owned(), state);
471 }
472 None => {
473 self.playlists.remove(id);
474 }
475 }
476 }
477
478 pub fn context_for(&self, clip: &Clip) -> LineageContext {
488 let cached = self.get_root(&clip.id);
489 let root_id = cached
490 .map(|entry| entry.root_id.clone())
491 .filter(|id| !id.is_empty())
492 .unwrap_or_else(|| clip.id.clone());
493 let root_title = self
494 .node(&root_id)
495 .map(|node| node.title.clone())
496 .unwrap_or_else(|| clip.title.clone());
497 let (parent_id, edge_type) = match immediate_parent(clip) {
498 Some((id, edge)) => (id, Some(edge)),
499 None => (String::new(), None),
500 };
501 let status = cached
502 .map(|entry| status_from_slug(&entry.status))
503 .unwrap_or(ResolveStatus::Resolved);
504 LineageContext {
505 root_id,
506 root_title,
507 parent_id,
508 edge_type,
509 status,
510 }
511 }
512
513 pub fn album_for_id(&self, id: &str) -> String {
522 let own_title = self
523 .node(id)
524 .map(|node| node.title.clone())
525 .unwrap_or_default();
526 let root_id = self
527 .get_root(id)
528 .map(|entry| entry.root_id.clone())
529 .filter(|root| !root.is_empty())
530 .unwrap_or_else(|| id.to_owned());
531 let root_title = self
532 .node(&root_id)
533 .map(|node| node.title.clone())
534 .unwrap_or_else(|| own_title.clone());
535 let context = LineageContext {
536 root_id,
537 root_title,
538 parent_id: String::new(),
539 edge_type: None,
540 status: ResolveStatus::Resolved,
541 };
542 context.album(&own_title)
543 }
544
545 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
556 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
557 for entry in self.resolution_cache.values() {
558 if entry.root_id.is_empty() {
559 continue;
560 }
561 let Some(node) = self.nodes.get(&entry.root_id) else {
562 continue;
563 };
564 let title = node.title.trim();
565 if title.is_empty() {
566 continue;
567 }
568 roots_by_title
569 .entry(title.to_owned())
570 .or_default()
571 .insert(entry.root_id.clone());
572 }
573 roots_by_title
574 .into_iter()
575 .filter(|(_, roots)| roots.len() > 1)
576 .map(|(title, _)| title)
577 .collect()
578 }
579
580 pub fn len(&self) -> usize {
582 self.nodes.len()
583 }
584
585 pub fn is_empty(&self) -> bool {
587 self.nodes.is_empty()
588 }
589
590 pub fn iter(&self) -> Iter<'_, String, Node> {
592 self.nodes.iter()
593 }
594
595 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
603 for clip in clips {
604 self.upsert_node(clip, now);
605 }
606 for clip in &resolution.gap_filled {
609 self.upsert_node(clip, now);
610 }
611
612 for clip in clips {
613 for edge in lineage_edges(clip) {
614 self.upsert_edge(&clip.id, &edge, now);
615 }
616 }
617 self.edges.sort_by(|a, b| {
618 a.child_id
619 .cmp(&b.child_id)
620 .then(a.ordinal.cmp(&b.ordinal))
621 .then(a.parent_id.cmp(&b.parent_id))
622 .then(a.edge_type.cmp(&b.edge_type))
623 .then(a.role.cmp(&b.role))
624 });
625
626 for (child_id, info) in &resolution.roots {
627 self.upsert_cache(child_id, info, now);
628 }
629 }
630
631 fn upsert_node(&mut self, clip: &Clip, now: &str) {
634 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
635 first_seen_at: now.to_owned(),
636 ..Node::default()
637 });
638 node.title = clip.title.clone();
639 node.created_at = clip.created_at.clone();
640 node.clip_type = clip.clip_type.clone();
641 node.task = clip.task.clone();
642 node.is_remix = clip.is_remix;
643 node.is_trashed = clip.is_trashed;
644 node.last_seen_at = now.to_owned();
645 }
646
647 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
650 let edge_type = edge_type_slug(edge.edge_type);
651 let role = edge_role_slug(edge.role);
652 if let Some(existing) = self.edges.iter_mut().find(|stored| {
653 stored.child_id == child_id
654 && stored.parent_id == edge.parent_id
655 && stored.edge_type == edge_type
656 && stored.role == role
657 && stored.ordinal == edge.ordinal
658 }) {
659 existing.source_field = edge.source_field.to_owned();
660 existing.status = "active".to_owned();
661 existing.last_seen_at = now.to_owned();
662 } else {
663 self.edges.push(StoredEdge {
664 child_id: child_id.to_owned(),
665 parent_id: edge.parent_id.clone(),
666 edge_type: edge_type.to_owned(),
667 role: role.to_owned(),
668 source_field: edge.source_field.to_owned(),
669 ordinal: edge.ordinal,
670 status: "active".to_owned(),
671 first_seen_at: now.to_owned(),
672 last_seen_at: now.to_owned(),
673 });
674 }
675 }
676
677 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
684 if info.status != ResolveStatus::Resolved
685 && self
686 .resolution_cache
687 .get(child_id)
688 .is_some_and(|entry| entry.status == "resolved")
689 {
690 return;
691 }
692 self.resolution_cache.insert(
693 child_id.to_owned(),
694 CacheEntry {
695 root_id: info.root_id.clone(),
696 status: resolve_status_slug(info.status).to_owned(),
697 algorithm_version: 1,
698 computed_at: now.to_owned(),
699 },
700 );
701 }
702}
703
704fn edge_type_slug(edge_type: EdgeType) -> &'static str {
706 match edge_type {
707 EdgeType::Cover => "cover",
708 EdgeType::Remaster => "remaster",
709 EdgeType::SpeedEdit => "speed_edit",
710 EdgeType::Edit => "edit",
711 EdgeType::Extend => "extend",
712 EdgeType::SectionReplace => "section_replace",
713 EdgeType::Stitch => "stitch",
714 EdgeType::Derived => "derived",
715 EdgeType::Uploaded => "uploaded",
716 }
717}
718
719fn edge_role_slug(role: EdgeRole) -> &'static str {
721 match role {
722 EdgeRole::Primary => "primary",
723 EdgeRole::Secondary => "secondary",
724 }
725}
726
727fn resolve_status_slug(status: ResolveStatus) -> &'static str {
729 match status {
730 ResolveStatus::Resolved => "resolved",
731 ResolveStatus::External => "external",
732 ResolveStatus::Unresolved => "unresolved",
733 ResolveStatus::Cycle => "cycle",
734 }
735}
736
737fn status_from_slug(slug: &str) -> ResolveStatus {
740 match slug {
741 "external" => ResolveStatus::External,
742 "unresolved" => ResolveStatus::Unresolved,
743 "cycle" => ResolveStatus::Cycle,
744 _ => ResolveStatus::Resolved,
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use std::collections::HashMap;
752
753 fn chain_clips() -> Vec<Clip> {
755 vec![
756 Clip {
757 id: "c".into(),
758 title: "Cover".into(),
759 clip_type: "gen".into(),
760 task: "cover".into(),
761 created_at: "t2".into(),
762 cover_clip_id: "b".into(),
763 edited_clip_id: "b".into(),
764 ..Default::default()
765 },
766 Clip {
767 id: "b".into(),
768 title: "Remaster".into(),
769 clip_type: "upsample".into(),
770 task: "upsample".into(),
771 created_at: "t1".into(),
772 upsample_clip_id: "a".into(),
773 edited_clip_id: "a".into(),
774 ..Default::default()
775 },
776 Clip {
777 id: "a".into(),
778 title: "Root".into(),
779 clip_type: "gen".into(),
780 created_at: "t0".into(),
781 ..Default::default()
782 },
783 ]
784 }
785
786 fn chain_resolution() -> Resolution {
788 let mut roots = HashMap::new();
789 for id in ["a", "b", "c"] {
790 roots.insert(
791 id.to_owned(),
792 RootInfo {
793 root_id: "a".into(),
794 root_title: "Root".into(),
795 status: ResolveStatus::Resolved,
796 },
797 );
798 }
799 Resolution {
800 roots,
801 gap_filled: Vec::new(),
802 }
803 }
804
805 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
806 store
807 .edges
808 .iter()
809 .find(|e| e.child_id == child && e.parent_id == parent)
810 .expect("edge should exist")
811 }
812
813 #[test]
814 fn new_store_is_empty_and_versioned() {
815 let store = LineageStore::new();
816 assert!(store.is_empty());
817 assert_eq!(store.len(), 0);
818 assert_eq!(store.schema_version, 1);
819 }
820
821 #[test]
822 fn update_populates_nodes_edges_and_cache() {
823 let mut store = LineageStore::new();
824 store.update(&chain_clips(), &chain_resolution(), "now");
825
826 assert_eq!(store.len(), 3);
828 let cover = store.node("c").unwrap();
829 assert_eq!(cover.title, "Cover");
830 assert_eq!(cover.clip_type, "gen");
831 assert_eq!(cover.task, "cover");
832 assert_eq!(cover.created_at, "t2");
833 assert_eq!(cover.status, "observed");
834 assert!(!cover.is_trashed);
835 assert_eq!(cover.first_seen_at, "now");
836 assert_eq!(cover.last_seen_at, "now");
837
838 assert_eq!(store.edges.len(), 2);
840 let cb = edge(&store, "c", "b");
841 assert_eq!(cb.edge_type, "cover");
842 assert_eq!(cb.role, "primary");
843 assert_eq!(cb.ordinal, 0);
844 assert_eq!(cb.source_field, "cover_clip_id");
845 assert_eq!(cb.status, "active");
846 let ba = edge(&store, "b", "a");
847 assert_eq!(ba.edge_type, "remaster");
848 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
849
850 for id in ["a", "b", "c"] {
852 let cached = store.get_root(id).unwrap();
853 assert_eq!(cached.root_id, "a");
854 assert_eq!(cached.status, "resolved");
855 assert_eq!(cached.algorithm_version, 1);
856 }
857 }
858
859 #[test]
860 fn album_for_id_matches_context_for_and_handles_unknown() {
861 let mut store = LineageStore::new();
862 store.update(&chain_clips(), &chain_resolution(), "now");
863
864 assert_eq!(store.album_for_id("c"), "Root");
867 let cover = &chain_clips()[0];
868 assert_eq!(
869 store.album_for_id("c"),
870 store.context_for(cover).album(&cover.title)
871 );
872 assert_eq!(store.album_for_id("a"), "Root");
874 assert_eq!(store.album_for_id("missing"), "");
876 }
877
878 #[test]
879 fn serde_roundtrip_preserves_a_relational_shape() {
880 let mut store = LineageStore::new();
881 store.update(&chain_clips(), &chain_resolution(), "now");
882
883 let json = serde_json::to_string(&store).unwrap();
884 let back: LineageStore = serde_json::from_str(&json).unwrap();
885 assert_eq!(store, back);
886
887 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
888 assert_eq!(value.get("schema_version").unwrap(), 1);
889 assert!(value.get("nodes").unwrap().is_object());
890 assert!(value.get("edges").unwrap().is_array());
891 assert!(value.get("resolution_cache").unwrap().is_object());
892
893 let node = value.get("nodes").unwrap().get("c").unwrap();
896 assert!(node.get("edges").is_none());
897 assert!(node.get("parent_id").is_none());
898 let first_edge = value.get("edges").unwrap().get(0).unwrap();
899 assert!(first_edge.get("child_id").is_some());
900 assert!(first_edge.get("parent_id").is_some());
901 }
902
903 #[test]
904 fn update_is_idempotent_bar_last_seen() {
905 let clips = chain_clips();
906 let resolution = chain_resolution();
907 let mut store = LineageStore::new();
908 store.update(&clips, &resolution, "first");
909 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
910 let edge_count = store.edges.len();
911
912 store.update(&clips, &resolution, "second");
913
914 assert_eq!(
916 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
917 node_ids
918 );
919 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
920 assert_eq!(store.resolution_cache.len(), 3);
921
922 let cover = store.node("c").unwrap();
924 assert_eq!(cover.first_seen_at, "first");
925 assert_eq!(cover.last_seen_at, "second");
926 let cb = edge(&store, "c", "b");
927 assert_eq!(cb.first_seen_at, "first");
928 assert_eq!(cb.last_seen_at, "second");
929 assert_eq!(store.get_root("c").unwrap().root_id, "a");
931 }
932
933 #[test]
934 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
935 let mut store = LineageStore::new();
936 store.update(&chain_clips(), &chain_resolution(), "first");
937 assert_eq!(store.get_root("c").unwrap().status, "resolved");
938
939 let child = Clip {
942 id: "c".into(),
943 title: "Cover".into(),
944 clip_type: "gen".into(),
945 task: "cover".into(),
946 cover_clip_id: "b".into(),
947 edited_clip_id: "b".into(),
948 ..Default::default()
949 };
950 let mut roots = HashMap::new();
951 roots.insert(
952 "c".to_owned(),
953 RootInfo {
954 root_id: "elsewhere".into(),
955 root_title: String::new(),
956 status: ResolveStatus::External,
957 },
958 );
959 roots.insert(
960 "d".to_owned(),
961 RootInfo {
962 root_id: "boundary".into(),
963 root_title: String::new(),
964 status: ResolveStatus::External,
965 },
966 );
967 let resolution = Resolution {
968 roots,
969 gap_filled: Vec::new(),
970 };
971 store.update(&[child], &resolution, "second");
972
973 let cached = store.get_root("c").unwrap();
975 assert_eq!(cached.root_id, "a");
976 assert_eq!(cached.status, "resolved");
977 assert_eq!(cached.computed_at, "first");
978 let d = store.get_root("d").unwrap();
980 assert_eq!(d.root_id, "boundary");
981 assert_eq!(d.status, "external");
982 }
983
984 #[test]
985 fn gap_filled_trashed_ancestor_is_a_durable_node() {
986 let child = Clip {
990 id: "c".into(),
991 title: "Cover".into(),
992 clip_type: "gen".into(),
993 task: "cover".into(),
994 cover_clip_id: "t".into(),
995 edited_clip_id: "t".into(),
996 ..Default::default()
997 };
998 let trashed = Clip {
999 id: "t".into(),
1000 title: "Trashed Original".into(),
1001 clip_type: "gen".into(),
1002 is_trashed: true,
1003 ..Default::default()
1004 };
1005 let mut roots = HashMap::new();
1006 roots.insert(
1007 "c".to_owned(),
1008 RootInfo {
1009 root_id: "t".into(),
1010 root_title: "Trashed Original".into(),
1011 status: ResolveStatus::Resolved,
1012 },
1013 );
1014 let resolution = Resolution {
1015 roots,
1016 gap_filled: vec![trashed],
1017 };
1018 store_update_and_assert_trashed(child, resolution);
1019 }
1020
1021 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1022 let mut store = LineageStore::new();
1023 store.update(&[child], &resolution, "now");
1024
1025 let node = store
1026 .node("t")
1027 .expect("trashed ancestor should be archived");
1028 assert!(node.is_trashed);
1029 assert_eq!(node.title, "Trashed Original");
1030 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1032 }
1033
1034 #[test]
1035 fn partial_json_loads_with_defaults() {
1036 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1039 let store: LineageStore = serde_json::from_str(json).unwrap();
1040 assert_eq!(store.schema_version, 1);
1041 let node = store.node("x").unwrap();
1042 assert_eq!(node.title, "Kept");
1043 assert_eq!(node.status, "observed");
1044 assert_eq!(store.edges[0].status, "active");
1045 assert!(store.resolution_cache.is_empty());
1046 assert!(store.albums.is_empty());
1049 assert!(store.album_art("x").is_none());
1050 assert!(store.playlists.is_empty());
1054 assert!(store.playlist("x").is_none());
1055 }
1056
1057 #[test]
1058 fn album_art_roundtrips_and_reads_by_kind() {
1059 let mut store = LineageStore::new();
1060 store.albums.insert(
1061 "root-1".to_owned(),
1062 AlbumArt {
1063 folder_jpg: Some(ArtifactState {
1064 path: "alice/Album/folder.jpg".to_owned(),
1065 hash: "jpg-h".to_owned(),
1066 }),
1067 folder_webp: Some(ArtifactState {
1068 path: "alice/Album/cover.webp".to_owned(),
1069 hash: "webp-h".to_owned(),
1070 }),
1071 },
1072 );
1073
1074 let json = serde_json::to_string(&store).unwrap();
1075 let back: LineageStore = serde_json::from_str(&json).unwrap();
1076 assert_eq!(store, back);
1077
1078 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1080 let album = value.get("albums").unwrap().get("root-1").unwrap();
1081 assert_eq!(
1082 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1083 "jpg-h"
1084 );
1085
1086 let art = back.album_art("root-1").unwrap();
1087 assert_eq!(
1088 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1089 "alice/Album/folder.jpg"
1090 );
1091 assert_eq!(
1092 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1093 "webp-h"
1094 );
1095 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1097 }
1098
1099 #[test]
1100 fn empty_album_art_omits_slots_when_serialised() {
1101 let empty = AlbumArt::default();
1104 assert!(empty.is_empty());
1105 let value = serde_json::to_value(&empty).unwrap();
1106 assert!(value.get("folder_jpg").is_none());
1107 assert!(value.get("folder_webp").is_none());
1108 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1109 assert_eq!(back, empty);
1110 }
1111
1112 #[test]
1113 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1114 let mut store = LineageStore::new();
1115 let jpg = ArtifactState {
1116 path: "a/folder.jpg".to_owned(),
1117 hash: "h1".to_owned(),
1118 };
1119 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1120 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1121
1122 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1124 assert!(store.album_art("root-1").is_none());
1125 assert!(store.albums.is_empty());
1126 }
1127
1128 #[test]
1129 fn playlist_state_roundtrips_by_id() {
1130 let mut store = LineageStore::new();
1131 store.playlists.insert(
1132 "pl1".to_owned(),
1133 PlaylistState {
1134 name: "Road Trip".to_owned(),
1135 path: "Road Trip.m3u8".to_owned(),
1136 hash: "abc123".to_owned(),
1137 },
1138 );
1139
1140 let json = serde_json::to_string(&store).unwrap();
1141 let back: LineageStore = serde_json::from_str(&json).unwrap();
1142 assert_eq!(store, back);
1143
1144 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1146 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1147 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1148 assert_eq!(pl.get("hash").unwrap(), "abc123");
1149
1150 let stored = back.playlist("pl1").unwrap();
1151 assert_eq!(stored.name, "Road Trip");
1152 assert_eq!(stored.hash, "abc123");
1153 }
1154
1155 #[test]
1156 fn set_playlist_upserts_then_clears() {
1157 let mut store = LineageStore::new();
1158 let state = PlaylistState {
1159 name: "Mix".to_owned(),
1160 path: "Mix.m3u8".to_owned(),
1161 hash: "h1".to_owned(),
1162 };
1163 store.set_playlist("pl1", Some(state.clone()));
1164 assert_eq!(store.playlist("pl1"), Some(&state));
1165
1166 let renamed = PlaylistState {
1168 name: "Mix v2".to_owned(),
1169 path: "Mix v2.m3u8".to_owned(),
1170 hash: "h2".to_owned(),
1171 };
1172 store.set_playlist("pl1", Some(renamed.clone()));
1173 assert_eq!(store.playlist("pl1"), Some(&renamed));
1174
1175 store.set_playlist("pl1", None);
1177 assert!(store.playlist("pl1").is_none());
1178 assert!(store.playlists.is_empty());
1179 }
1180
1181 #[test]
1182 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1183 let mut store = LineageStore::new();
1184 store.update(&chain_clips(), &chain_resolution(), "now");
1185
1186 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1188 assert_eq!(ctx.root_id, "a");
1189 assert_eq!(ctx.root_title, "Root");
1190 assert_eq!(ctx.parent_id, "b");
1191 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1192 assert_eq!(ctx.status, ResolveStatus::Resolved);
1193 assert_eq!(ctx.album("Cover"), "Root");
1195 }
1196
1197 #[test]
1198 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1199 let mut store = LineageStore::new();
1200 store.update(&chain_clips(), &chain_resolution(), "now");
1201
1202 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1204 assert_eq!(ctx.root_id, "a");
1205 assert_eq!(ctx.root_title, "Root");
1206 assert_eq!(ctx.parent_id, "");
1207 assert_eq!(ctx.edge_type, None);
1208 assert_eq!(ctx.album("Root"), "Root");
1209 }
1210
1211 #[test]
1212 fn context_for_an_unknown_clip_is_self_rooted() {
1213 let store = LineageStore::new();
1214 let orphan = Clip {
1215 id: "z".into(),
1216 title: "Lonely".into(),
1217 ..Default::default()
1218 };
1219 let ctx = store.context_for(&orphan);
1220 assert_eq!(ctx.root_id, "z");
1221 assert_eq!(ctx.root_title, "Lonely");
1222 assert_eq!(ctx.parent_id, "");
1223 assert_eq!(ctx.status, ResolveStatus::Resolved);
1224 }
1225
1226 #[test]
1227 fn context_for_retains_a_purged_ancestor_album() {
1228 let child = Clip {
1233 id: "c".into(),
1234 title: "Cover".into(),
1235 clip_type: "gen".into(),
1236 task: "cover".into(),
1237 cover_clip_id: "t".into(),
1238 edited_clip_id: "t".into(),
1239 ..Default::default()
1240 };
1241 let trashed = Clip {
1242 id: "t".into(),
1243 title: "Trashed Original".into(),
1244 clip_type: "gen".into(),
1245 is_trashed: true,
1246 ..Default::default()
1247 };
1248 let mut roots = HashMap::new();
1249 roots.insert(
1250 "c".to_owned(),
1251 RootInfo {
1252 root_id: "t".into(),
1253 root_title: "Trashed Original".into(),
1254 status: ResolveStatus::Resolved,
1255 },
1256 );
1257 let resolution = Resolution {
1258 roots,
1259 gap_filled: vec![trashed],
1260 };
1261 let mut store = LineageStore::new();
1262 store.update(std::slice::from_ref(&child), &resolution, "now");
1263
1264 let ctx = store.context_for(&child);
1265 assert_eq!(ctx.root_id, "t");
1266 assert_eq!(ctx.root_title, "Trashed Original");
1267 assert_eq!(ctx.album("Cover"), "Trashed Original");
1268 }
1269
1270 #[test]
1271 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1272 let clips = vec![
1275 Clip {
1276 id: "r1".into(),
1277 title: "Break Through".into(),
1278 clip_type: "gen".into(),
1279 ..Default::default()
1280 },
1281 Clip {
1282 id: "r2".into(),
1283 title: "Break Through".into(),
1284 clip_type: "gen".into(),
1285 ..Default::default()
1286 },
1287 Clip {
1288 id: "r3".into(),
1289 title: "Solo".into(),
1290 clip_type: "gen".into(),
1291 ..Default::default()
1292 },
1293 Clip {
1294 id: "c1".into(),
1295 title: "Break Through".into(),
1296 clip_type: "gen".into(),
1297 task: "cover".into(),
1298 cover_clip_id: "r1".into(),
1299 edited_clip_id: "r1".into(),
1300 ..Default::default()
1301 },
1302 ];
1303 let mut roots = HashMap::new();
1304 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1305 let title = if root == "r3" {
1306 "Solo"
1307 } else {
1308 "Break Through"
1309 };
1310 roots.insert(
1311 id.to_owned(),
1312 RootInfo {
1313 root_id: root.into(),
1314 root_title: title.into(),
1315 status: ResolveStatus::Resolved,
1316 },
1317 );
1318 }
1319 let resolution = Resolution {
1320 roots,
1321 gap_filled: Vec::new(),
1322 };
1323 let mut store = LineageStore::new();
1324 store.update(&clips, &resolution, "now");
1325
1326 let colliding = store.colliding_root_titles();
1327 assert!(colliding.contains("Break Through"));
1328 assert!(!colliding.contains("Solo"));
1329 assert_eq!(colliding.len(), 1);
1330 }
1331
1332 fn owner(id: &str, name: &str) -> Owner {
1333 Owner {
1334 user_id: id.to_owned(),
1335 display_name: name.to_owned(),
1336 }
1337 }
1338
1339 #[test]
1340 fn owner_check_covers_first_use_match_and_mismatch() {
1341 let mut store = LineageStore::new();
1342 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
1343
1344 store.pin_owner(owner("user_a", "Alice"));
1345 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
1346 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
1347 assert_eq!(store.owner().unwrap().display_name, "Alice");
1348 }
1349
1350 #[test]
1351 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
1352 let mut store = LineageStore::new();
1353 assert!(!store.refresh_display_name("Alice"));
1355 assert!(store.owner().is_none());
1356
1357 store.pin_owner(owner("user_a", "Alice"));
1358 assert!(!store.refresh_display_name("Alice"));
1360 assert!(store.refresh_display_name("Alice Cooper"));
1362 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
1363 assert_eq!(store.owner().unwrap().user_id, "user_a");
1365 }
1366
1367 #[test]
1368 fn owner_gate_covers_the_full_matrix() {
1369 let alice = owner("user_a", "Alice");
1370
1371 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
1373 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
1374
1375 assert_eq!(
1377 owner_gate(Some(&alice), None, "user_a", false),
1378 OwnerGate::Proceed
1379 );
1380
1381 assert_eq!(
1383 owner_gate(Some(&alice), None, "user_b", false),
1384 OwnerGate::AbortMismatch
1385 );
1386 assert_eq!(
1387 owner_gate(Some(&alice), None, "user_b", true),
1388 OwnerGate::Repin
1389 );
1390
1391 assert_eq!(
1394 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
1395 OwnerGate::AbortConfigMismatch
1396 );
1397 assert_eq!(
1398 owner_gate(None, Some("user_c"), "user_a", true),
1399 OwnerGate::AbortConfigMismatch
1400 );
1401 assert_eq!(
1403 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
1404 OwnerGate::Proceed
1405 );
1406
1407 assert!(OwnerGate::Repin.is_additive());
1409 for gate in [
1410 OwnerGate::AbortConfigMismatch,
1411 OwnerGate::AbortMismatch,
1412 OwnerGate::Proceed,
1413 OwnerGate::FirstUse,
1414 ] {
1415 assert!(!gate.is_additive());
1416 }
1417 }
1418
1419 #[test]
1420 fn adopt_decision_covers_every_branch() {
1421 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
1422 let empty: BTreeSet<&str> = BTreeSet::new();
1423
1424 assert_eq!(
1426 adopt_decision(&["x", "y"], &empty, true, false),
1427 AdoptDecision::PinFresh
1428 );
1429 assert_eq!(
1431 adopt_decision(&["c1"], &owned, false, false),
1432 AdoptDecision::SkipPin
1433 );
1434 assert_eq!(
1435 adopt_decision(&["c1"], &owned, false, true),
1436 AdoptDecision::SkipPin
1437 );
1438 assert_eq!(
1440 adopt_decision(&["c1", "z"], &owned, true, false),
1441 AdoptDecision::PinAdopt
1442 );
1443 assert_eq!(
1445 adopt_decision(&["z1", "z2"], &owned, true, false),
1446 AdoptDecision::Abort
1447 );
1448 assert_eq!(
1449 adopt_decision(&["z1", "z2"], &owned, true, true),
1450 AdoptDecision::AdoptForced
1451 );
1452
1453 assert!(AdoptDecision::AdoptForced.is_additive());
1455 for decision in [
1456 AdoptDecision::PinFresh,
1457 AdoptDecision::PinAdopt,
1458 AdoptDecision::Abort,
1459 AdoptDecision::SkipPin,
1460 ] {
1461 assert!(!decision.is_additive());
1462 }
1463 }
1464
1465 #[test]
1466 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
1467 let json = r#"{"nodes":{},"edges":[]}"#;
1469 let store: LineageStore = serde_json::from_str(json).unwrap();
1470 assert!(store.owner().is_none());
1471 let value = serde_json::to_value(&store).unwrap();
1473 assert!(value.get("owner").is_none());
1474
1475 let mut pinned = LineageStore::new();
1477 pinned.pin_owner(owner("user_a", "Alice"));
1478 let back: LineageStore =
1479 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
1480 assert_eq!(back, pinned);
1481 assert_eq!(back.owner().unwrap().user_id, "user_a");
1482 }
1483}