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