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}
55
56impl Default for LineageStore {
57 fn default() -> Self {
58 Self {
59 schema_version: 1,
60 nodes: BTreeMap::new(),
61 edges: Vec::new(),
62 resolution_cache: BTreeMap::new(),
63 albums: BTreeMap::new(),
64 playlists: BTreeMap::new(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(default)]
79pub struct AlbumArt {
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub folder_jpg: Option<ArtifactState>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub folder_webp: Option<ArtifactState>,
86}
87
88impl AlbumArt {
89 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
92 match kind {
93 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
94 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
95 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => None,
96 }
97 }
98
99 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
105 match kind {
106 ArtifactKind::FolderJpg => self.folder_jpg = state,
107 ArtifactKind::FolderWebp => self.folder_webp = state,
108 ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => {}
109 }
110 }
111
112 pub fn is_empty(&self) -> bool {
115 self.folder_jpg.is_none() && self.folder_webp.is_none()
116 }
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(default)]
129pub struct PlaylistState {
130 pub name: String,
132 pub path: String,
134 pub hash: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(default)]
142pub struct Node {
143 pub title: String,
144 pub created_at: String,
145 pub clip_type: String,
146 pub task: String,
147 pub is_remix: bool,
148 pub is_trashed: bool,
149 pub status: String,
151 pub first_seen_at: String,
152 pub last_seen_at: String,
153}
154
155impl Default for Node {
156 fn default() -> Self {
157 Self {
158 title: String::new(),
159 created_at: String::new(),
160 clip_type: String::new(),
161 task: String::new(),
162 is_remix: false,
163 is_trashed: false,
164 status: "observed".to_owned(),
165 first_seen_at: String::new(),
166 last_seen_at: String::new(),
167 }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(default)]
176pub struct StoredEdge {
177 pub child_id: String,
178 pub parent_id: String,
179 pub edge_type: String,
181 pub role: String,
183 pub source_field: String,
185 pub ordinal: u32,
187 pub status: String,
189 pub first_seen_at: String,
190 pub last_seen_at: String,
191}
192
193impl Default for StoredEdge {
194 fn default() -> Self {
195 Self {
196 child_id: String::new(),
197 parent_id: String::new(),
198 edge_type: String::new(),
199 role: String::new(),
200 source_field: String::new(),
201 ordinal: 0,
202 status: "active".to_owned(),
203 first_seen_at: String::new(),
204 last_seen_at: String::new(),
205 }
206 }
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211#[serde(default)]
212pub struct CacheEntry {
213 pub root_id: String,
214 pub status: String,
216 pub algorithm_version: u32,
217 pub computed_at: String,
218}
219
220impl LineageStore {
221 pub fn new() -> Self {
223 Self::default()
224 }
225
226 pub fn node(&self, id: &str) -> Option<&Node> {
228 self.nodes.get(id)
229 }
230
231 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
233 self.resolution_cache.get(id)
234 }
235
236 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
238 self.albums.get(root_id)
239 }
240
241 pub fn set_album_artifact(
249 &mut self,
250 root_id: &str,
251 kind: ArtifactKind,
252 state: Option<ArtifactState>,
253 ) {
254 match state {
255 Some(state) => self
256 .albums
257 .entry(root_id.to_owned())
258 .or_default()
259 .set(kind, Some(state)),
260 None => {
261 if let Some(art) = self.albums.get_mut(root_id) {
262 art.set(kind, None);
263 if art.is_empty() {
264 self.albums.remove(root_id);
265 }
266 }
267 }
268 }
269 }
270
271 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
273 self.playlists.get(id)
274 }
275
276 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
284 match state {
285 Some(state) => {
286 self.playlists.insert(id.to_owned(), state);
287 }
288 None => {
289 self.playlists.remove(id);
290 }
291 }
292 }
293
294 pub fn context_for(&self, clip: &Clip) -> LineageContext {
304 let cached = self.get_root(&clip.id);
305 let root_id = cached
306 .map(|entry| entry.root_id.clone())
307 .filter(|id| !id.is_empty())
308 .unwrap_or_else(|| clip.id.clone());
309 let root_title = self
310 .node(&root_id)
311 .map(|node| node.title.clone())
312 .unwrap_or_else(|| clip.title.clone());
313 let (parent_id, edge_type) = match immediate_parent(clip) {
314 Some((id, edge)) => (id, Some(edge)),
315 None => (String::new(), None),
316 };
317 let status = cached
318 .map(|entry| status_from_slug(&entry.status))
319 .unwrap_or(ResolveStatus::Resolved);
320 LineageContext {
321 root_id,
322 root_title,
323 parent_id,
324 edge_type,
325 status,
326 }
327 }
328
329 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
340 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
341 for entry in self.resolution_cache.values() {
342 if entry.root_id.is_empty() {
343 continue;
344 }
345 let Some(node) = self.nodes.get(&entry.root_id) else {
346 continue;
347 };
348 let title = node.title.trim();
349 if title.is_empty() {
350 continue;
351 }
352 roots_by_title
353 .entry(title.to_owned())
354 .or_default()
355 .insert(entry.root_id.clone());
356 }
357 roots_by_title
358 .into_iter()
359 .filter(|(_, roots)| roots.len() > 1)
360 .map(|(title, _)| title)
361 .collect()
362 }
363
364 pub fn len(&self) -> usize {
366 self.nodes.len()
367 }
368
369 pub fn is_empty(&self) -> bool {
371 self.nodes.is_empty()
372 }
373
374 pub fn iter(&self) -> Iter<'_, String, Node> {
376 self.nodes.iter()
377 }
378
379 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
387 for clip in clips {
388 self.upsert_node(clip, now);
389 }
390 for clip in &resolution.gap_filled {
393 self.upsert_node(clip, now);
394 }
395
396 for clip in clips {
397 for edge in lineage_edges(clip) {
398 self.upsert_edge(&clip.id, &edge, now);
399 }
400 }
401 self.edges.sort_by(|a, b| {
402 a.child_id
403 .cmp(&b.child_id)
404 .then(a.ordinal.cmp(&b.ordinal))
405 .then(a.parent_id.cmp(&b.parent_id))
406 .then(a.edge_type.cmp(&b.edge_type))
407 .then(a.role.cmp(&b.role))
408 });
409
410 for (child_id, info) in &resolution.roots {
411 self.upsert_cache(child_id, info, now);
412 }
413 }
414
415 fn upsert_node(&mut self, clip: &Clip, now: &str) {
418 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
419 first_seen_at: now.to_owned(),
420 ..Node::default()
421 });
422 node.title = clip.title.clone();
423 node.created_at = clip.created_at.clone();
424 node.clip_type = clip.clip_type.clone();
425 node.task = clip.task.clone();
426 node.is_remix = clip.is_remix;
427 node.is_trashed = clip.is_trashed;
428 node.last_seen_at = now.to_owned();
429 }
430
431 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
434 let edge_type = edge_type_slug(edge.edge_type);
435 let role = edge_role_slug(edge.role);
436 if let Some(existing) = self.edges.iter_mut().find(|stored| {
437 stored.child_id == child_id
438 && stored.parent_id == edge.parent_id
439 && stored.edge_type == edge_type
440 && stored.role == role
441 && stored.ordinal == edge.ordinal
442 }) {
443 existing.source_field = edge.source_field.to_owned();
444 existing.status = "active".to_owned();
445 existing.last_seen_at = now.to_owned();
446 } else {
447 self.edges.push(StoredEdge {
448 child_id: child_id.to_owned(),
449 parent_id: edge.parent_id.clone(),
450 edge_type: edge_type.to_owned(),
451 role: role.to_owned(),
452 source_field: edge.source_field.to_owned(),
453 ordinal: edge.ordinal,
454 status: "active".to_owned(),
455 first_seen_at: now.to_owned(),
456 last_seen_at: now.to_owned(),
457 });
458 }
459 }
460
461 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
468 if info.status != ResolveStatus::Resolved
469 && self
470 .resolution_cache
471 .get(child_id)
472 .is_some_and(|entry| entry.status == "resolved")
473 {
474 return;
475 }
476 self.resolution_cache.insert(
477 child_id.to_owned(),
478 CacheEntry {
479 root_id: info.root_id.clone(),
480 status: resolve_status_slug(info.status).to_owned(),
481 algorithm_version: 1,
482 computed_at: now.to_owned(),
483 },
484 );
485 }
486}
487
488fn edge_type_slug(edge_type: EdgeType) -> &'static str {
490 match edge_type {
491 EdgeType::Cover => "cover",
492 EdgeType::Remaster => "remaster",
493 EdgeType::SpeedEdit => "speed_edit",
494 EdgeType::Edit => "edit",
495 EdgeType::Extend => "extend",
496 EdgeType::SectionReplace => "section_replace",
497 EdgeType::Stitch => "stitch",
498 EdgeType::Derived => "derived",
499 EdgeType::Uploaded => "uploaded",
500 }
501}
502
503fn edge_role_slug(role: EdgeRole) -> &'static str {
505 match role {
506 EdgeRole::Primary => "primary",
507 EdgeRole::Secondary => "secondary",
508 }
509}
510
511fn resolve_status_slug(status: ResolveStatus) -> &'static str {
513 match status {
514 ResolveStatus::Resolved => "resolved",
515 ResolveStatus::External => "external",
516 ResolveStatus::Unresolved => "unresolved",
517 ResolveStatus::Cycle => "cycle",
518 }
519}
520
521fn status_from_slug(slug: &str) -> ResolveStatus {
524 match slug {
525 "external" => ResolveStatus::External,
526 "unresolved" => ResolveStatus::Unresolved,
527 "cycle" => ResolveStatus::Cycle,
528 _ => ResolveStatus::Resolved,
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use std::collections::HashMap;
536
537 fn chain_clips() -> Vec<Clip> {
539 vec![
540 Clip {
541 id: "c".into(),
542 title: "Cover".into(),
543 clip_type: "gen".into(),
544 task: "cover".into(),
545 created_at: "t2".into(),
546 cover_clip_id: "b".into(),
547 edited_clip_id: "b".into(),
548 ..Default::default()
549 },
550 Clip {
551 id: "b".into(),
552 title: "Remaster".into(),
553 clip_type: "upsample".into(),
554 task: "upsample".into(),
555 created_at: "t1".into(),
556 upsample_clip_id: "a".into(),
557 edited_clip_id: "a".into(),
558 ..Default::default()
559 },
560 Clip {
561 id: "a".into(),
562 title: "Root".into(),
563 clip_type: "gen".into(),
564 created_at: "t0".into(),
565 ..Default::default()
566 },
567 ]
568 }
569
570 fn chain_resolution() -> Resolution {
572 let mut roots = HashMap::new();
573 for id in ["a", "b", "c"] {
574 roots.insert(
575 id.to_owned(),
576 RootInfo {
577 root_id: "a".into(),
578 root_title: "Root".into(),
579 status: ResolveStatus::Resolved,
580 },
581 );
582 }
583 Resolution {
584 roots,
585 gap_filled: Vec::new(),
586 }
587 }
588
589 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
590 store
591 .edges
592 .iter()
593 .find(|e| e.child_id == child && e.parent_id == parent)
594 .expect("edge should exist")
595 }
596
597 #[test]
598 fn new_store_is_empty_and_versioned() {
599 let store = LineageStore::new();
600 assert!(store.is_empty());
601 assert_eq!(store.len(), 0);
602 assert_eq!(store.schema_version, 1);
603 }
604
605 #[test]
606 fn update_populates_nodes_edges_and_cache() {
607 let mut store = LineageStore::new();
608 store.update(&chain_clips(), &chain_resolution(), "now");
609
610 assert_eq!(store.len(), 3);
612 let cover = store.node("c").unwrap();
613 assert_eq!(cover.title, "Cover");
614 assert_eq!(cover.clip_type, "gen");
615 assert_eq!(cover.task, "cover");
616 assert_eq!(cover.created_at, "t2");
617 assert_eq!(cover.status, "observed");
618 assert!(!cover.is_trashed);
619 assert_eq!(cover.first_seen_at, "now");
620 assert_eq!(cover.last_seen_at, "now");
621
622 assert_eq!(store.edges.len(), 2);
624 let cb = edge(&store, "c", "b");
625 assert_eq!(cb.edge_type, "cover");
626 assert_eq!(cb.role, "primary");
627 assert_eq!(cb.ordinal, 0);
628 assert_eq!(cb.source_field, "cover_clip_id");
629 assert_eq!(cb.status, "active");
630 let ba = edge(&store, "b", "a");
631 assert_eq!(ba.edge_type, "remaster");
632 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
633
634 for id in ["a", "b", "c"] {
636 let cached = store.get_root(id).unwrap();
637 assert_eq!(cached.root_id, "a");
638 assert_eq!(cached.status, "resolved");
639 assert_eq!(cached.algorithm_version, 1);
640 }
641 }
642
643 #[test]
644 fn serde_roundtrip_preserves_a_relational_shape() {
645 let mut store = LineageStore::new();
646 store.update(&chain_clips(), &chain_resolution(), "now");
647
648 let json = serde_json::to_string(&store).unwrap();
649 let back: LineageStore = serde_json::from_str(&json).unwrap();
650 assert_eq!(store, back);
651
652 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
653 assert_eq!(value.get("schema_version").unwrap(), 1);
654 assert!(value.get("nodes").unwrap().is_object());
655 assert!(value.get("edges").unwrap().is_array());
656 assert!(value.get("resolution_cache").unwrap().is_object());
657
658 let node = value.get("nodes").unwrap().get("c").unwrap();
661 assert!(node.get("edges").is_none());
662 assert!(node.get("parent_id").is_none());
663 let first_edge = value.get("edges").unwrap().get(0).unwrap();
664 assert!(first_edge.get("child_id").is_some());
665 assert!(first_edge.get("parent_id").is_some());
666 }
667
668 #[test]
669 fn update_is_idempotent_bar_last_seen() {
670 let clips = chain_clips();
671 let resolution = chain_resolution();
672 let mut store = LineageStore::new();
673 store.update(&clips, &resolution, "first");
674 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
675 let edge_count = store.edges.len();
676
677 store.update(&clips, &resolution, "second");
678
679 assert_eq!(
681 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
682 node_ids
683 );
684 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
685 assert_eq!(store.resolution_cache.len(), 3);
686
687 let cover = store.node("c").unwrap();
689 assert_eq!(cover.first_seen_at, "first");
690 assert_eq!(cover.last_seen_at, "second");
691 let cb = edge(&store, "c", "b");
692 assert_eq!(cb.first_seen_at, "first");
693 assert_eq!(cb.last_seen_at, "second");
694 assert_eq!(store.get_root("c").unwrap().root_id, "a");
696 }
697
698 #[test]
699 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
700 let mut store = LineageStore::new();
701 store.update(&chain_clips(), &chain_resolution(), "first");
702 assert_eq!(store.get_root("c").unwrap().status, "resolved");
703
704 let child = Clip {
707 id: "c".into(),
708 title: "Cover".into(),
709 clip_type: "gen".into(),
710 task: "cover".into(),
711 cover_clip_id: "b".into(),
712 edited_clip_id: "b".into(),
713 ..Default::default()
714 };
715 let mut roots = HashMap::new();
716 roots.insert(
717 "c".to_owned(),
718 RootInfo {
719 root_id: "elsewhere".into(),
720 root_title: String::new(),
721 status: ResolveStatus::External,
722 },
723 );
724 roots.insert(
725 "d".to_owned(),
726 RootInfo {
727 root_id: "boundary".into(),
728 root_title: String::new(),
729 status: ResolveStatus::External,
730 },
731 );
732 let resolution = Resolution {
733 roots,
734 gap_filled: Vec::new(),
735 };
736 store.update(&[child], &resolution, "second");
737
738 let cached = store.get_root("c").unwrap();
740 assert_eq!(cached.root_id, "a");
741 assert_eq!(cached.status, "resolved");
742 assert_eq!(cached.computed_at, "first");
743 let d = store.get_root("d").unwrap();
745 assert_eq!(d.root_id, "boundary");
746 assert_eq!(d.status, "external");
747 }
748
749 #[test]
750 fn gap_filled_trashed_ancestor_is_a_durable_node() {
751 let child = Clip {
755 id: "c".into(),
756 title: "Cover".into(),
757 clip_type: "gen".into(),
758 task: "cover".into(),
759 cover_clip_id: "t".into(),
760 edited_clip_id: "t".into(),
761 ..Default::default()
762 };
763 let trashed = Clip {
764 id: "t".into(),
765 title: "Trashed Original".into(),
766 clip_type: "gen".into(),
767 is_trashed: true,
768 ..Default::default()
769 };
770 let mut roots = HashMap::new();
771 roots.insert(
772 "c".to_owned(),
773 RootInfo {
774 root_id: "t".into(),
775 root_title: "Trashed Original".into(),
776 status: ResolveStatus::Resolved,
777 },
778 );
779 let resolution = Resolution {
780 roots,
781 gap_filled: vec![trashed],
782 };
783 store_update_and_assert_trashed(child, resolution);
784 }
785
786 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
787 let mut store = LineageStore::new();
788 store.update(&[child], &resolution, "now");
789
790 let node = store
791 .node("t")
792 .expect("trashed ancestor should be archived");
793 assert!(node.is_trashed);
794 assert_eq!(node.title, "Trashed Original");
795 assert_eq!(store.get_root("c").unwrap().root_id, "t");
797 }
798
799 #[test]
800 fn partial_json_loads_with_defaults() {
801 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
804 let store: LineageStore = serde_json::from_str(json).unwrap();
805 assert_eq!(store.schema_version, 1);
806 let node = store.node("x").unwrap();
807 assert_eq!(node.title, "Kept");
808 assert_eq!(node.status, "observed");
809 assert_eq!(store.edges[0].status, "active");
810 assert!(store.resolution_cache.is_empty());
811 assert!(store.albums.is_empty());
814 assert!(store.album_art("x").is_none());
815 assert!(store.playlists.is_empty());
819 assert!(store.playlist("x").is_none());
820 }
821
822 #[test]
823 fn album_art_roundtrips_and_reads_by_kind() {
824 let mut store = LineageStore::new();
825 store.albums.insert(
826 "root-1".to_owned(),
827 AlbumArt {
828 folder_jpg: Some(ArtifactState {
829 path: "alice/Album/folder.jpg".to_owned(),
830 hash: "jpg-h".to_owned(),
831 }),
832 folder_webp: Some(ArtifactState {
833 path: "alice/Album/cover.webp".to_owned(),
834 hash: "webp-h".to_owned(),
835 }),
836 },
837 );
838
839 let json = serde_json::to_string(&store).unwrap();
840 let back: LineageStore = serde_json::from_str(&json).unwrap();
841 assert_eq!(store, back);
842
843 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
845 let album = value.get("albums").unwrap().get("root-1").unwrap();
846 assert_eq!(
847 album.get("folder_jpg").unwrap().get("hash").unwrap(),
848 "jpg-h"
849 );
850
851 let art = back.album_art("root-1").unwrap();
852 assert_eq!(
853 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
854 "alice/Album/folder.jpg"
855 );
856 assert_eq!(
857 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
858 "webp-h"
859 );
860 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
862 }
863
864 #[test]
865 fn empty_album_art_omits_slots_when_serialised() {
866 let empty = AlbumArt::default();
869 assert!(empty.is_empty());
870 let value = serde_json::to_value(&empty).unwrap();
871 assert!(value.get("folder_jpg").is_none());
872 assert!(value.get("folder_webp").is_none());
873 let back: AlbumArt = serde_json::from_str("{}").unwrap();
874 assert_eq!(back, empty);
875 }
876
877 #[test]
878 fn set_album_artifact_upserts_then_prunes_when_emptied() {
879 let mut store = LineageStore::new();
880 let jpg = ArtifactState {
881 path: "a/folder.jpg".to_owned(),
882 hash: "h1".to_owned(),
883 };
884 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
885 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
886
887 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
889 assert!(store.album_art("root-1").is_none());
890 assert!(store.albums.is_empty());
891 }
892
893 #[test]
894 fn playlist_state_roundtrips_by_id() {
895 let mut store = LineageStore::new();
896 store.playlists.insert(
897 "pl1".to_owned(),
898 PlaylistState {
899 name: "Road Trip".to_owned(),
900 path: "Road Trip.m3u8".to_owned(),
901 hash: "abc123".to_owned(),
902 },
903 );
904
905 let json = serde_json::to_string(&store).unwrap();
906 let back: LineageStore = serde_json::from_str(&json).unwrap();
907 assert_eq!(store, back);
908
909 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
911 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
912 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
913 assert_eq!(pl.get("hash").unwrap(), "abc123");
914
915 let stored = back.playlist("pl1").unwrap();
916 assert_eq!(stored.name, "Road Trip");
917 assert_eq!(stored.hash, "abc123");
918 }
919
920 #[test]
921 fn set_playlist_upserts_then_clears() {
922 let mut store = LineageStore::new();
923 let state = PlaylistState {
924 name: "Mix".to_owned(),
925 path: "Mix.m3u8".to_owned(),
926 hash: "h1".to_owned(),
927 };
928 store.set_playlist("pl1", Some(state.clone()));
929 assert_eq!(store.playlist("pl1"), Some(&state));
930
931 let renamed = PlaylistState {
933 name: "Mix v2".to_owned(),
934 path: "Mix v2.m3u8".to_owned(),
935 hash: "h2".to_owned(),
936 };
937 store.set_playlist("pl1", Some(renamed.clone()));
938 assert_eq!(store.playlist("pl1"), Some(&renamed));
939
940 store.set_playlist("pl1", None);
942 assert!(store.playlist("pl1").is_none());
943 assert!(store.playlists.is_empty());
944 }
945
946 #[test]
947 fn context_for_roots_a_remix_at_its_stored_ancestor() {
948 let mut store = LineageStore::new();
949 store.update(&chain_clips(), &chain_resolution(), "now");
950
951 let child = &chain_clips()[0]; let ctx = store.context_for(child);
953 assert_eq!(ctx.root_id, "a");
954 assert_eq!(ctx.root_title, "Root");
955 assert_eq!(ctx.parent_id, "b");
956 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
957 assert_eq!(ctx.status, ResolveStatus::Resolved);
958 assert_eq!(ctx.album("Cover"), "Root");
960 }
961
962 #[test]
963 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
964 let mut store = LineageStore::new();
965 store.update(&chain_clips(), &chain_resolution(), "now");
966
967 let root = &chain_clips()[2]; let ctx = store.context_for(root);
969 assert_eq!(ctx.root_id, "a");
970 assert_eq!(ctx.root_title, "Root");
971 assert_eq!(ctx.parent_id, "");
972 assert_eq!(ctx.edge_type, None);
973 assert_eq!(ctx.album("Root"), "Root");
974 }
975
976 #[test]
977 fn context_for_an_unknown_clip_is_self_rooted() {
978 let store = LineageStore::new();
979 let orphan = Clip {
980 id: "z".into(),
981 title: "Lonely".into(),
982 ..Default::default()
983 };
984 let ctx = store.context_for(&orphan);
985 assert_eq!(ctx.root_id, "z");
986 assert_eq!(ctx.root_title, "Lonely");
987 assert_eq!(ctx.parent_id, "");
988 assert_eq!(ctx.status, ResolveStatus::Resolved);
989 }
990
991 #[test]
992 fn context_for_retains_a_purged_ancestor_album() {
993 let child = Clip {
998 id: "c".into(),
999 title: "Cover".into(),
1000 clip_type: "gen".into(),
1001 task: "cover".into(),
1002 cover_clip_id: "t".into(),
1003 edited_clip_id: "t".into(),
1004 ..Default::default()
1005 };
1006 let trashed = Clip {
1007 id: "t".into(),
1008 title: "Trashed Original".into(),
1009 clip_type: "gen".into(),
1010 is_trashed: true,
1011 ..Default::default()
1012 };
1013 let mut roots = HashMap::new();
1014 roots.insert(
1015 "c".to_owned(),
1016 RootInfo {
1017 root_id: "t".into(),
1018 root_title: "Trashed Original".into(),
1019 status: ResolveStatus::Resolved,
1020 },
1021 );
1022 let resolution = Resolution {
1023 roots,
1024 gap_filled: vec![trashed],
1025 };
1026 let mut store = LineageStore::new();
1027 store.update(std::slice::from_ref(&child), &resolution, "now");
1028
1029 let ctx = store.context_for(&child);
1030 assert_eq!(ctx.root_id, "t");
1031 assert_eq!(ctx.root_title, "Trashed Original");
1032 assert_eq!(ctx.album("Cover"), "Trashed Original");
1033 }
1034
1035 #[test]
1036 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1037 let clips = vec![
1040 Clip {
1041 id: "r1".into(),
1042 title: "Break Through".into(),
1043 clip_type: "gen".into(),
1044 ..Default::default()
1045 },
1046 Clip {
1047 id: "r2".into(),
1048 title: "Break Through".into(),
1049 clip_type: "gen".into(),
1050 ..Default::default()
1051 },
1052 Clip {
1053 id: "r3".into(),
1054 title: "Solo".into(),
1055 clip_type: "gen".into(),
1056 ..Default::default()
1057 },
1058 Clip {
1059 id: "c1".into(),
1060 title: "Break Through".into(),
1061 clip_type: "gen".into(),
1062 task: "cover".into(),
1063 cover_clip_id: "r1".into(),
1064 edited_clip_id: "r1".into(),
1065 ..Default::default()
1066 },
1067 ];
1068 let mut roots = HashMap::new();
1069 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1070 let title = if root == "r3" {
1071 "Solo"
1072 } else {
1073 "Break Through"
1074 };
1075 roots.insert(
1076 id.to_owned(),
1077 RootInfo {
1078 root_id: root.into(),
1079 root_title: title.into(),
1080 status: ResolveStatus::Resolved,
1081 },
1082 );
1083 }
1084 let resolution = Resolution {
1085 roots,
1086 gap_filled: Vec::new(),
1087 };
1088 let mut store = LineageStore::new();
1089 store.update(&clips, &resolution, "now");
1090
1091 let colliding = store.colliding_root_titles();
1092 assert!(colliding.contains("Break Through"));
1093 assert!(!colliding.contains("Solo"));
1094 assert_eq!(colliding.len(), 1);
1095 }
1096}