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 album_for_id(&self, id: &str) -> String {
514 let own_title = self
515 .node(id)
516 .map(|node| node.title.clone())
517 .unwrap_or_default();
518 let root_id = self
519 .get_root(id)
520 .map(|entry| entry.root_id.clone())
521 .filter(|root| !root.is_empty())
522 .unwrap_or_else(|| id.to_owned());
523 let root_title = self
524 .node(&root_id)
525 .map(|node| node.title.clone())
526 .unwrap_or_else(|| own_title.clone());
527 let context = LineageContext {
528 root_id,
529 root_title,
530 parent_id: String::new(),
531 edge_type: None,
532 status: ResolveStatus::Resolved,
533 };
534 context.album(&own_title)
535 }
536
537 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
548 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
549 for entry in self.resolution_cache.values() {
550 if entry.root_id.is_empty() {
551 continue;
552 }
553 let Some(node) = self.nodes.get(&entry.root_id) else {
554 continue;
555 };
556 let title = node.title.trim();
557 if title.is_empty() {
558 continue;
559 }
560 roots_by_title
561 .entry(title.to_owned())
562 .or_default()
563 .insert(entry.root_id.clone());
564 }
565 roots_by_title
566 .into_iter()
567 .filter(|(_, roots)| roots.len() > 1)
568 .map(|(title, _)| title)
569 .collect()
570 }
571
572 pub fn len(&self) -> usize {
574 self.nodes.len()
575 }
576
577 pub fn is_empty(&self) -> bool {
579 self.nodes.is_empty()
580 }
581
582 pub fn iter(&self) -> Iter<'_, String, Node> {
584 self.nodes.iter()
585 }
586
587 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
595 for clip in clips {
596 self.upsert_node(clip, now);
597 }
598 for clip in &resolution.gap_filled {
601 self.upsert_node(clip, now);
602 }
603
604 for clip in clips {
605 for edge in lineage_edges(clip) {
606 self.upsert_edge(&clip.id, &edge, now);
607 }
608 }
609 self.edges.sort_by(|a, b| {
610 a.child_id
611 .cmp(&b.child_id)
612 .then(a.ordinal.cmp(&b.ordinal))
613 .then(a.parent_id.cmp(&b.parent_id))
614 .then(a.edge_type.cmp(&b.edge_type))
615 .then(a.role.cmp(&b.role))
616 });
617
618 for (child_id, info) in &resolution.roots {
619 self.upsert_cache(child_id, info, now);
620 }
621 }
622
623 fn upsert_node(&mut self, clip: &Clip, now: &str) {
626 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
627 first_seen_at: now.to_owned(),
628 ..Node::default()
629 });
630 node.title = clip.title.clone();
631 node.created_at = clip.created_at.clone();
632 node.clip_type = clip.clip_type.clone();
633 node.task = clip.task.clone();
634 node.is_remix = clip.is_remix;
635 node.is_trashed = clip.is_trashed;
636 node.last_seen_at = now.to_owned();
637 }
638
639 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
642 let edge_type = edge_type_slug(edge.edge_type);
643 let role = edge_role_slug(edge.role);
644 if let Some(existing) = self.edges.iter_mut().find(|stored| {
645 stored.child_id == child_id
646 && stored.parent_id == edge.parent_id
647 && stored.edge_type == edge_type
648 && stored.role == role
649 && stored.ordinal == edge.ordinal
650 }) {
651 existing.source_field = edge.source_field.to_owned();
652 existing.status = "active".to_owned();
653 existing.last_seen_at = now.to_owned();
654 } else {
655 self.edges.push(StoredEdge {
656 child_id: child_id.to_owned(),
657 parent_id: edge.parent_id.clone(),
658 edge_type: edge_type.to_owned(),
659 role: role.to_owned(),
660 source_field: edge.source_field.to_owned(),
661 ordinal: edge.ordinal,
662 status: "active".to_owned(),
663 first_seen_at: now.to_owned(),
664 last_seen_at: now.to_owned(),
665 });
666 }
667 }
668
669 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
676 if info.status != ResolveStatus::Resolved
677 && self
678 .resolution_cache
679 .get(child_id)
680 .is_some_and(|entry| entry.status == "resolved")
681 {
682 return;
683 }
684 self.resolution_cache.insert(
685 child_id.to_owned(),
686 CacheEntry {
687 root_id: info.root_id.clone(),
688 status: resolve_status_slug(info.status).to_owned(),
689 algorithm_version: 1,
690 computed_at: now.to_owned(),
691 },
692 );
693 }
694}
695
696fn edge_type_slug(edge_type: EdgeType) -> &'static str {
698 match edge_type {
699 EdgeType::Cover => "cover",
700 EdgeType::Remaster => "remaster",
701 EdgeType::SpeedEdit => "speed_edit",
702 EdgeType::Edit => "edit",
703 EdgeType::Extend => "extend",
704 EdgeType::SectionReplace => "section_replace",
705 EdgeType::Stitch => "stitch",
706 EdgeType::Derived => "derived",
707 EdgeType::Uploaded => "uploaded",
708 }
709}
710
711fn edge_role_slug(role: EdgeRole) -> &'static str {
713 match role {
714 EdgeRole::Primary => "primary",
715 EdgeRole::Secondary => "secondary",
716 }
717}
718
719fn resolve_status_slug(status: ResolveStatus) -> &'static str {
721 match status {
722 ResolveStatus::Resolved => "resolved",
723 ResolveStatus::External => "external",
724 ResolveStatus::Unresolved => "unresolved",
725 ResolveStatus::Cycle => "cycle",
726 }
727}
728
729fn status_from_slug(slug: &str) -> ResolveStatus {
732 match slug {
733 "external" => ResolveStatus::External,
734 "unresolved" => ResolveStatus::Unresolved,
735 "cycle" => ResolveStatus::Cycle,
736 _ => ResolveStatus::Resolved,
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use std::collections::HashMap;
744
745 fn chain_clips() -> Vec<Clip> {
747 vec![
748 Clip {
749 id: "c".into(),
750 title: "Cover".into(),
751 clip_type: "gen".into(),
752 task: "cover".into(),
753 created_at: "t2".into(),
754 cover_clip_id: "b".into(),
755 edited_clip_id: "b".into(),
756 ..Default::default()
757 },
758 Clip {
759 id: "b".into(),
760 title: "Remaster".into(),
761 clip_type: "upsample".into(),
762 task: "upsample".into(),
763 created_at: "t1".into(),
764 upsample_clip_id: "a".into(),
765 edited_clip_id: "a".into(),
766 ..Default::default()
767 },
768 Clip {
769 id: "a".into(),
770 title: "Root".into(),
771 clip_type: "gen".into(),
772 created_at: "t0".into(),
773 ..Default::default()
774 },
775 ]
776 }
777
778 fn chain_resolution() -> Resolution {
780 let mut roots = HashMap::new();
781 for id in ["a", "b", "c"] {
782 roots.insert(
783 id.to_owned(),
784 RootInfo {
785 root_id: "a".into(),
786 root_title: "Root".into(),
787 status: ResolveStatus::Resolved,
788 },
789 );
790 }
791 Resolution {
792 roots,
793 gap_filled: Vec::new(),
794 }
795 }
796
797 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
798 store
799 .edges
800 .iter()
801 .find(|e| e.child_id == child && e.parent_id == parent)
802 .expect("edge should exist")
803 }
804
805 #[test]
806 fn new_store_is_empty_and_versioned() {
807 let store = LineageStore::new();
808 assert!(store.is_empty());
809 assert_eq!(store.len(), 0);
810 assert_eq!(store.schema_version, 1);
811 }
812
813 #[test]
814 fn update_populates_nodes_edges_and_cache() {
815 let mut store = LineageStore::new();
816 store.update(&chain_clips(), &chain_resolution(), "now");
817
818 assert_eq!(store.len(), 3);
820 let cover = store.node("c").unwrap();
821 assert_eq!(cover.title, "Cover");
822 assert_eq!(cover.clip_type, "gen");
823 assert_eq!(cover.task, "cover");
824 assert_eq!(cover.created_at, "t2");
825 assert_eq!(cover.status, "observed");
826 assert!(!cover.is_trashed);
827 assert_eq!(cover.first_seen_at, "now");
828 assert_eq!(cover.last_seen_at, "now");
829
830 assert_eq!(store.edges.len(), 2);
832 let cb = edge(&store, "c", "b");
833 assert_eq!(cb.edge_type, "cover");
834 assert_eq!(cb.role, "primary");
835 assert_eq!(cb.ordinal, 0);
836 assert_eq!(cb.source_field, "cover_clip_id");
837 assert_eq!(cb.status, "active");
838 let ba = edge(&store, "b", "a");
839 assert_eq!(ba.edge_type, "remaster");
840 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
841
842 for id in ["a", "b", "c"] {
844 let cached = store.get_root(id).unwrap();
845 assert_eq!(cached.root_id, "a");
846 assert_eq!(cached.status, "resolved");
847 assert_eq!(cached.algorithm_version, 1);
848 }
849 }
850
851 #[test]
852 fn album_for_id_matches_context_for_and_handles_unknown() {
853 let mut store = LineageStore::new();
854 store.update(&chain_clips(), &chain_resolution(), "now");
855
856 assert_eq!(store.album_for_id("c"), "Root");
859 let cover = &chain_clips()[0];
860 assert_eq!(
861 store.album_for_id("c"),
862 store.context_for(cover).album(&cover.title)
863 );
864 assert_eq!(store.album_for_id("a"), "Root");
866 assert_eq!(store.album_for_id("missing"), "");
868 }
869
870 #[test]
871 fn serde_roundtrip_preserves_a_relational_shape() {
872 let mut store = LineageStore::new();
873 store.update(&chain_clips(), &chain_resolution(), "now");
874
875 let json = serde_json::to_string(&store).unwrap();
876 let back: LineageStore = serde_json::from_str(&json).unwrap();
877 assert_eq!(store, back);
878
879 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
880 assert_eq!(value.get("schema_version").unwrap(), 1);
881 assert!(value.get("nodes").unwrap().is_object());
882 assert!(value.get("edges").unwrap().is_array());
883 assert!(value.get("resolution_cache").unwrap().is_object());
884
885 let node = value.get("nodes").unwrap().get("c").unwrap();
888 assert!(node.get("edges").is_none());
889 assert!(node.get("parent_id").is_none());
890 let first_edge = value.get("edges").unwrap().get(0).unwrap();
891 assert!(first_edge.get("child_id").is_some());
892 assert!(first_edge.get("parent_id").is_some());
893 }
894
895 #[test]
896 fn update_is_idempotent_bar_last_seen() {
897 let clips = chain_clips();
898 let resolution = chain_resolution();
899 let mut store = LineageStore::new();
900 store.update(&clips, &resolution, "first");
901 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
902 let edge_count = store.edges.len();
903
904 store.update(&clips, &resolution, "second");
905
906 assert_eq!(
908 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
909 node_ids
910 );
911 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
912 assert_eq!(store.resolution_cache.len(), 3);
913
914 let cover = store.node("c").unwrap();
916 assert_eq!(cover.first_seen_at, "first");
917 assert_eq!(cover.last_seen_at, "second");
918 let cb = edge(&store, "c", "b");
919 assert_eq!(cb.first_seen_at, "first");
920 assert_eq!(cb.last_seen_at, "second");
921 assert_eq!(store.get_root("c").unwrap().root_id, "a");
923 }
924
925 #[test]
926 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
927 let mut store = LineageStore::new();
928 store.update(&chain_clips(), &chain_resolution(), "first");
929 assert_eq!(store.get_root("c").unwrap().status, "resolved");
930
931 let child = Clip {
934 id: "c".into(),
935 title: "Cover".into(),
936 clip_type: "gen".into(),
937 task: "cover".into(),
938 cover_clip_id: "b".into(),
939 edited_clip_id: "b".into(),
940 ..Default::default()
941 };
942 let mut roots = HashMap::new();
943 roots.insert(
944 "c".to_owned(),
945 RootInfo {
946 root_id: "elsewhere".into(),
947 root_title: String::new(),
948 status: ResolveStatus::External,
949 },
950 );
951 roots.insert(
952 "d".to_owned(),
953 RootInfo {
954 root_id: "boundary".into(),
955 root_title: String::new(),
956 status: ResolveStatus::External,
957 },
958 );
959 let resolution = Resolution {
960 roots,
961 gap_filled: Vec::new(),
962 };
963 store.update(&[child], &resolution, "second");
964
965 let cached = store.get_root("c").unwrap();
967 assert_eq!(cached.root_id, "a");
968 assert_eq!(cached.status, "resolved");
969 assert_eq!(cached.computed_at, "first");
970 let d = store.get_root("d").unwrap();
972 assert_eq!(d.root_id, "boundary");
973 assert_eq!(d.status, "external");
974 }
975
976 #[test]
977 fn gap_filled_trashed_ancestor_is_a_durable_node() {
978 let child = Clip {
982 id: "c".into(),
983 title: "Cover".into(),
984 clip_type: "gen".into(),
985 task: "cover".into(),
986 cover_clip_id: "t".into(),
987 edited_clip_id: "t".into(),
988 ..Default::default()
989 };
990 let trashed = Clip {
991 id: "t".into(),
992 title: "Trashed Original".into(),
993 clip_type: "gen".into(),
994 is_trashed: true,
995 ..Default::default()
996 };
997 let mut roots = HashMap::new();
998 roots.insert(
999 "c".to_owned(),
1000 RootInfo {
1001 root_id: "t".into(),
1002 root_title: "Trashed Original".into(),
1003 status: ResolveStatus::Resolved,
1004 },
1005 );
1006 let resolution = Resolution {
1007 roots,
1008 gap_filled: vec![trashed],
1009 };
1010 store_update_and_assert_trashed(child, resolution);
1011 }
1012
1013 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1014 let mut store = LineageStore::new();
1015 store.update(&[child], &resolution, "now");
1016
1017 let node = store
1018 .node("t")
1019 .expect("trashed ancestor should be archived");
1020 assert!(node.is_trashed);
1021 assert_eq!(node.title, "Trashed Original");
1022 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1024 }
1025
1026 #[test]
1027 fn partial_json_loads_with_defaults() {
1028 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1031 let store: LineageStore = serde_json::from_str(json).unwrap();
1032 assert_eq!(store.schema_version, 1);
1033 let node = store.node("x").unwrap();
1034 assert_eq!(node.title, "Kept");
1035 assert_eq!(node.status, "observed");
1036 assert_eq!(store.edges[0].status, "active");
1037 assert!(store.resolution_cache.is_empty());
1038 assert!(store.albums.is_empty());
1041 assert!(store.album_art("x").is_none());
1042 assert!(store.playlists.is_empty());
1046 assert!(store.playlist("x").is_none());
1047 }
1048
1049 #[test]
1050 fn album_art_roundtrips_and_reads_by_kind() {
1051 let mut store = LineageStore::new();
1052 store.albums.insert(
1053 "root-1".to_owned(),
1054 AlbumArt {
1055 folder_jpg: Some(ArtifactState {
1056 path: "alice/Album/folder.jpg".to_owned(),
1057 hash: "jpg-h".to_owned(),
1058 }),
1059 folder_webp: Some(ArtifactState {
1060 path: "alice/Album/cover.webp".to_owned(),
1061 hash: "webp-h".to_owned(),
1062 }),
1063 },
1064 );
1065
1066 let json = serde_json::to_string(&store).unwrap();
1067 let back: LineageStore = serde_json::from_str(&json).unwrap();
1068 assert_eq!(store, back);
1069
1070 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1072 let album = value.get("albums").unwrap().get("root-1").unwrap();
1073 assert_eq!(
1074 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1075 "jpg-h"
1076 );
1077
1078 let art = back.album_art("root-1").unwrap();
1079 assert_eq!(
1080 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1081 "alice/Album/folder.jpg"
1082 );
1083 assert_eq!(
1084 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1085 "webp-h"
1086 );
1087 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1089 }
1090
1091 #[test]
1092 fn empty_album_art_omits_slots_when_serialised() {
1093 let empty = AlbumArt::default();
1096 assert!(empty.is_empty());
1097 let value = serde_json::to_value(&empty).unwrap();
1098 assert!(value.get("folder_jpg").is_none());
1099 assert!(value.get("folder_webp").is_none());
1100 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1101 assert_eq!(back, empty);
1102 }
1103
1104 #[test]
1105 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1106 let mut store = LineageStore::new();
1107 let jpg = ArtifactState {
1108 path: "a/folder.jpg".to_owned(),
1109 hash: "h1".to_owned(),
1110 };
1111 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1112 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1113
1114 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1116 assert!(store.album_art("root-1").is_none());
1117 assert!(store.albums.is_empty());
1118 }
1119
1120 #[test]
1121 fn playlist_state_roundtrips_by_id() {
1122 let mut store = LineageStore::new();
1123 store.playlists.insert(
1124 "pl1".to_owned(),
1125 PlaylistState {
1126 name: "Road Trip".to_owned(),
1127 path: "Road Trip.m3u8".to_owned(),
1128 hash: "abc123".to_owned(),
1129 },
1130 );
1131
1132 let json = serde_json::to_string(&store).unwrap();
1133 let back: LineageStore = serde_json::from_str(&json).unwrap();
1134 assert_eq!(store, back);
1135
1136 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1138 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1139 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1140 assert_eq!(pl.get("hash").unwrap(), "abc123");
1141
1142 let stored = back.playlist("pl1").unwrap();
1143 assert_eq!(stored.name, "Road Trip");
1144 assert_eq!(stored.hash, "abc123");
1145 }
1146
1147 #[test]
1148 fn set_playlist_upserts_then_clears() {
1149 let mut store = LineageStore::new();
1150 let state = PlaylistState {
1151 name: "Mix".to_owned(),
1152 path: "Mix.m3u8".to_owned(),
1153 hash: "h1".to_owned(),
1154 };
1155 store.set_playlist("pl1", Some(state.clone()));
1156 assert_eq!(store.playlist("pl1"), Some(&state));
1157
1158 let renamed = PlaylistState {
1160 name: "Mix v2".to_owned(),
1161 path: "Mix v2.m3u8".to_owned(),
1162 hash: "h2".to_owned(),
1163 };
1164 store.set_playlist("pl1", Some(renamed.clone()));
1165 assert_eq!(store.playlist("pl1"), Some(&renamed));
1166
1167 store.set_playlist("pl1", None);
1169 assert!(store.playlist("pl1").is_none());
1170 assert!(store.playlists.is_empty());
1171 }
1172
1173 #[test]
1174 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1175 let mut store = LineageStore::new();
1176 store.update(&chain_clips(), &chain_resolution(), "now");
1177
1178 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1180 assert_eq!(ctx.root_id, "a");
1181 assert_eq!(ctx.root_title, "Root");
1182 assert_eq!(ctx.parent_id, "b");
1183 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1184 assert_eq!(ctx.status, ResolveStatus::Resolved);
1185 assert_eq!(ctx.album("Cover"), "Root");
1187 }
1188
1189 #[test]
1190 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1191 let mut store = LineageStore::new();
1192 store.update(&chain_clips(), &chain_resolution(), "now");
1193
1194 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1196 assert_eq!(ctx.root_id, "a");
1197 assert_eq!(ctx.root_title, "Root");
1198 assert_eq!(ctx.parent_id, "");
1199 assert_eq!(ctx.edge_type, None);
1200 assert_eq!(ctx.album("Root"), "Root");
1201 }
1202
1203 #[test]
1204 fn context_for_an_unknown_clip_is_self_rooted() {
1205 let store = LineageStore::new();
1206 let orphan = Clip {
1207 id: "z".into(),
1208 title: "Lonely".into(),
1209 ..Default::default()
1210 };
1211 let ctx = store.context_for(&orphan);
1212 assert_eq!(ctx.root_id, "z");
1213 assert_eq!(ctx.root_title, "Lonely");
1214 assert_eq!(ctx.parent_id, "");
1215 assert_eq!(ctx.status, ResolveStatus::Resolved);
1216 }
1217
1218 #[test]
1219 fn context_for_retains_a_purged_ancestor_album() {
1220 let child = Clip {
1225 id: "c".into(),
1226 title: "Cover".into(),
1227 clip_type: "gen".into(),
1228 task: "cover".into(),
1229 cover_clip_id: "t".into(),
1230 edited_clip_id: "t".into(),
1231 ..Default::default()
1232 };
1233 let trashed = Clip {
1234 id: "t".into(),
1235 title: "Trashed Original".into(),
1236 clip_type: "gen".into(),
1237 is_trashed: true,
1238 ..Default::default()
1239 };
1240 let mut roots = HashMap::new();
1241 roots.insert(
1242 "c".to_owned(),
1243 RootInfo {
1244 root_id: "t".into(),
1245 root_title: "Trashed Original".into(),
1246 status: ResolveStatus::Resolved,
1247 },
1248 );
1249 let resolution = Resolution {
1250 roots,
1251 gap_filled: vec![trashed],
1252 };
1253 let mut store = LineageStore::new();
1254 store.update(std::slice::from_ref(&child), &resolution, "now");
1255
1256 let ctx = store.context_for(&child);
1257 assert_eq!(ctx.root_id, "t");
1258 assert_eq!(ctx.root_title, "Trashed Original");
1259 assert_eq!(ctx.album("Cover"), "Trashed Original");
1260 }
1261
1262 #[test]
1263 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1264 let clips = vec![
1267 Clip {
1268 id: "r1".into(),
1269 title: "Break Through".into(),
1270 clip_type: "gen".into(),
1271 ..Default::default()
1272 },
1273 Clip {
1274 id: "r2".into(),
1275 title: "Break Through".into(),
1276 clip_type: "gen".into(),
1277 ..Default::default()
1278 },
1279 Clip {
1280 id: "r3".into(),
1281 title: "Solo".into(),
1282 clip_type: "gen".into(),
1283 ..Default::default()
1284 },
1285 Clip {
1286 id: "c1".into(),
1287 title: "Break Through".into(),
1288 clip_type: "gen".into(),
1289 task: "cover".into(),
1290 cover_clip_id: "r1".into(),
1291 edited_clip_id: "r1".into(),
1292 ..Default::default()
1293 },
1294 ];
1295 let mut roots = HashMap::new();
1296 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1297 let title = if root == "r3" {
1298 "Solo"
1299 } else {
1300 "Break Through"
1301 };
1302 roots.insert(
1303 id.to_owned(),
1304 RootInfo {
1305 root_id: root.into(),
1306 root_title: title.into(),
1307 status: ResolveStatus::Resolved,
1308 },
1309 );
1310 }
1311 let resolution = Resolution {
1312 roots,
1313 gap_filled: Vec::new(),
1314 };
1315 let mut store = LineageStore::new();
1316 store.update(&clips, &resolution, "now");
1317
1318 let colliding = store.colliding_root_titles();
1319 assert!(colliding.contains("Break Through"));
1320 assert!(!colliding.contains("Solo"));
1321 assert_eq!(colliding.len(), 1);
1322 }
1323
1324 fn owner(id: &str, name: &str) -> Owner {
1325 Owner {
1326 user_id: id.to_owned(),
1327 display_name: name.to_owned(),
1328 }
1329 }
1330
1331 #[test]
1332 fn owner_check_covers_first_use_match_and_mismatch() {
1333 let mut store = LineageStore::new();
1334 assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
1335
1336 store.pin_owner(owner("user_a", "Alice"));
1337 assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
1338 assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
1339 assert_eq!(store.owner().unwrap().display_name, "Alice");
1340 }
1341
1342 #[test]
1343 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
1344 let mut store = LineageStore::new();
1345 assert!(!store.refresh_display_name("Alice"));
1347 assert!(store.owner().is_none());
1348
1349 store.pin_owner(owner("user_a", "Alice"));
1350 assert!(!store.refresh_display_name("Alice"));
1352 assert!(store.refresh_display_name("Alice Cooper"));
1354 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
1355 assert_eq!(store.owner().unwrap().user_id, "user_a");
1357 }
1358
1359 #[test]
1360 fn owner_gate_covers_the_full_matrix() {
1361 let alice = owner("user_a", "Alice");
1362
1363 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
1365 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
1366
1367 assert_eq!(
1369 owner_gate(Some(&alice), None, "user_a", false),
1370 OwnerGate::Proceed
1371 );
1372
1373 assert_eq!(
1375 owner_gate(Some(&alice), None, "user_b", false),
1376 OwnerGate::AbortMismatch
1377 );
1378 assert_eq!(
1379 owner_gate(Some(&alice), None, "user_b", true),
1380 OwnerGate::Repin
1381 );
1382
1383 assert_eq!(
1386 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
1387 OwnerGate::AbortConfigMismatch
1388 );
1389 assert_eq!(
1390 owner_gate(None, Some("user_c"), "user_a", true),
1391 OwnerGate::AbortConfigMismatch
1392 );
1393 assert_eq!(
1395 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
1396 OwnerGate::Proceed
1397 );
1398
1399 assert!(OwnerGate::Repin.is_additive());
1401 for gate in [
1402 OwnerGate::AbortConfigMismatch,
1403 OwnerGate::AbortMismatch,
1404 OwnerGate::Proceed,
1405 OwnerGate::FirstUse,
1406 ] {
1407 assert!(!gate.is_additive());
1408 }
1409 }
1410
1411 #[test]
1412 fn adopt_decision_covers_every_branch() {
1413 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
1414 let empty: BTreeSet<&str> = BTreeSet::new();
1415
1416 assert_eq!(
1418 adopt_decision(&["x", "y"], &empty, true, false),
1419 AdoptDecision::PinFresh
1420 );
1421 assert_eq!(
1423 adopt_decision(&["c1"], &owned, false, false),
1424 AdoptDecision::SkipPin
1425 );
1426 assert_eq!(
1427 adopt_decision(&["c1"], &owned, false, true),
1428 AdoptDecision::SkipPin
1429 );
1430 assert_eq!(
1432 adopt_decision(&["c1", "z"], &owned, true, false),
1433 AdoptDecision::PinAdopt
1434 );
1435 assert_eq!(
1437 adopt_decision(&["z1", "z2"], &owned, true, false),
1438 AdoptDecision::Abort
1439 );
1440 assert_eq!(
1441 adopt_decision(&["z1", "z2"], &owned, true, true),
1442 AdoptDecision::AdoptForced
1443 );
1444
1445 assert!(AdoptDecision::AdoptForced.is_additive());
1447 for decision in [
1448 AdoptDecision::PinFresh,
1449 AdoptDecision::PinAdopt,
1450 AdoptDecision::Abort,
1451 AdoptDecision::SkipPin,
1452 ] {
1453 assert!(!decision.is_additive());
1454 }
1455 }
1456
1457 #[test]
1458 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
1459 let json = r#"{"nodes":{},"edges":[]}"#;
1461 let store: LineageStore = serde_json::from_str(json).unwrap();
1462 assert!(store.owner().is_none());
1463 let value = serde_json::to_value(&store).unwrap();
1465 assert!(value.get("owner").is_none());
1466
1467 let mut pinned = LineageStore::new();
1469 pinned.pin_owner(owner("user_a", "Alice"));
1470 let back: LineageStore =
1471 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
1472 assert_eq!(back, pinned);
1473 assert_eq!(back.owner().unwrap().user_id, "user_a");
1474 }
1475}