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