use std::collections::btree_map::Iter;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
use crate::lineage::{
Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
immediate_parent, lineage_edges,
};
use crate::manifest::ArtifactState;
use crate::model::Clip;
use crate::reconcile::ArtifactKind;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LineageStore {
pub schema_version: u32,
pub nodes: BTreeMap<String, Node>,
pub edges: Vec<StoredEdge>,
pub resolution_cache: BTreeMap<String, CacheEntry>,
pub albums: BTreeMap<String, AlbumArt>,
pub playlists: BTreeMap<String, PlaylistState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<Owner>,
#[serde(skip)]
pub album_overrides: BTreeMap<String, String>,
#[serde(skip)]
eligible_root_ids: HashSet<String>,
}
impl Default for LineageStore {
fn default() -> Self {
Self {
schema_version: 1,
nodes: BTreeMap::new(),
edges: Vec::new(),
resolution_cache: BTreeMap::new(),
albums: BTreeMap::new(),
playlists: BTreeMap::new(),
owner: None,
album_overrides: BTreeMap::new(),
eligible_root_ids: HashSet::new(),
}
}
}
impl PartialEq for LineageStore {
fn eq(&self, other: &Self) -> bool {
self.schema_version == other.schema_version
&& self.nodes == other.nodes
&& self.edges == other.edges
&& self.resolution_cache == other.resolution_cache
&& self.albums == other.albums
&& self.playlists == other.playlists
&& self.owner == other.owner
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Owner {
pub user_id: String,
pub display_name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerCheck {
FirstUse,
Match,
Mismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerGate {
AbortConfigMismatch,
AbortMismatch,
Repin,
Proceed,
FirstUse,
}
impl OwnerGate {
pub fn is_additive(self) -> bool {
matches!(self, OwnerGate::Repin)
}
}
pub fn owner_gate(
store_owner: Option<&Owner>,
configured_id: Option<&str>,
authed_user_id: &str,
allow_change: bool,
) -> OwnerGate {
if let Some(configured) = configured_id
&& configured != authed_user_id
{
return OwnerGate::AbortConfigMismatch;
}
match store_owner {
None => OwnerGate::FirstUse,
Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
Some(_) if allow_change => OwnerGate::Repin,
Some(_) => OwnerGate::AbortMismatch,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdoptDecision {
PinFresh,
PinAdopt,
AdoptForced,
Abort,
SkipPin,
}
impl AdoptDecision {
pub fn is_additive(self) -> bool {
matches!(self, AdoptDecision::AdoptForced)
}
}
pub fn adopt_decision(
listed: &[&str],
owned: &BTreeSet<&str>,
enumerated: bool,
allow_change: bool,
) -> AdoptDecision {
if owned.is_empty() {
return AdoptDecision::PinFresh;
}
if !enumerated {
return AdoptDecision::SkipPin;
}
if listed.iter().any(|id| owned.contains(id)) {
AdoptDecision::PinAdopt
} else if allow_change {
AdoptDecision::AdoptForced
} else {
AdoptDecision::Abort
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumArt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_jpg: Option<ArtifactState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_webp: Option<ArtifactState>,
}
impl AlbumArt {
pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => None,
}
}
pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg = state,
ArtifactKind::FolderWebp => self.folder_webp = state,
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => {}
}
}
pub fn is_empty(&self) -> bool {
self.folder_jpg.is_none() && self.folder_webp.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistState {
pub name: String,
pub path: String,
pub hash: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Node {
pub title: String,
pub created_at: String,
pub clip_type: String,
pub task: String,
pub is_remix: bool,
pub is_trashed: bool,
pub status: String,
pub first_seen_at: String,
pub last_seen_at: String,
}
impl Default for Node {
fn default() -> Self {
Self {
title: String::new(),
created_at: String::new(),
clip_type: String::new(),
task: String::new(),
is_remix: false,
is_trashed: false,
status: "observed".to_owned(),
first_seen_at: String::new(),
last_seen_at: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StoredEdge {
pub child_id: String,
pub parent_id: String,
pub edge_type: String,
pub role: String,
pub source_field: String,
pub ordinal: u32,
pub status: String,
pub first_seen_at: String,
pub last_seen_at: String,
}
impl Default for StoredEdge {
fn default() -> Self {
Self {
child_id: String::new(),
parent_id: String::new(),
edge_type: String::new(),
role: String::new(),
source_field: String::new(),
ordinal: 0,
status: "active".to_owned(),
first_seen_at: String::new(),
last_seen_at: String::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheEntry {
pub root_id: String,
pub status: String,
pub algorithm_version: u32,
pub computed_at: String,
}
impl LineageStore {
pub fn new() -> Self {
Self::default()
}
pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
self.album_overrides = overrides;
}
fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
if !self.eligible_root_ids.contains(root_id) {
return root_title;
}
match self.album_overrides.get(root_id) {
Some(name) if !name.trim().is_empty() => name.clone(),
_ => root_title,
}
}
pub fn refresh_eligible_roots(&mut self) {
self.eligible_root_ids = self
.resolution_cache
.values()
.map(|entry| entry.root_id.as_str())
.filter(|root_id| !root_id.is_empty())
.map(str::to_owned)
.collect();
}
#[cfg(test)]
pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
&self.eligible_root_ids
}
pub fn node(&self, id: &str) -> Option<&Node> {
self.nodes.get(id)
}
pub fn owner(&self) -> Option<&Owner> {
self.owner.as_ref()
}
pub fn owner_check(&self, user_id: &str) -> OwnerCheck {
match &self.owner {
None => OwnerCheck::FirstUse,
Some(owner) if owner.user_id == user_id => OwnerCheck::Match,
Some(_) => OwnerCheck::Mismatch,
}
}
pub fn pin_owner(&mut self, owner: Owner) {
self.owner = Some(owner);
}
pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
match &mut self.owner {
Some(owner) if owner.display_name != display_name => {
owner.display_name = display_name.to_owned();
true
}
_ => false,
}
}
pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
self.resolution_cache.get(id)
}
pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
self.albums.get(root_id)
}
pub fn set_album_artifact(
&mut self,
root_id: &str,
kind: ArtifactKind,
state: Option<ArtifactState>,
) {
match state {
Some(state) => self
.albums
.entry(root_id.to_owned())
.or_default()
.set(kind, Some(state)),
None => {
if let Some(art) = self.albums.get_mut(root_id) {
art.set(kind, None);
if art.is_empty() {
self.albums.remove(root_id);
}
}
}
}
}
pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
self.playlists.get(id)
}
pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
match state {
Some(state) => {
self.playlists.insert(id.to_owned(), state);
}
None => {
self.playlists.remove(id);
}
}
}
pub fn context_for(&self, clip: &Clip) -> LineageContext {
let cached = self.get_root(&clip.id);
let root_id = cached
.map(|entry| entry.root_id.clone())
.filter(|id| !id.is_empty())
.unwrap_or_else(|| clip.id.clone());
let root_title = self
.node(&root_id)
.map(|node| node.title.clone())
.unwrap_or_else(|| clip.title.clone());
let root_title = self.effective_root_title(&root_id, root_title);
let (parent_id, edge_type) = match immediate_parent(clip) {
Some((id, edge)) => (id, Some(edge)),
None => (String::new(), None),
};
let status = cached
.map(|entry| status_from_slug(&entry.status))
.unwrap_or(ResolveStatus::Resolved);
LineageContext {
root_id,
root_title,
parent_id,
edge_type,
status,
}
}
pub fn album_for_id(&self, id: &str) -> String {
let own_title = self
.node(id)
.map(|node| node.title.clone())
.unwrap_or_default();
let root_id = self
.get_root(id)
.map(|entry| entry.root_id.clone())
.filter(|root| !root.is_empty())
.unwrap_or_else(|| id.to_owned());
let root_title = self
.node(&root_id)
.map(|node| node.title.clone())
.unwrap_or_else(|| own_title.clone());
let root_title = self.effective_root_title(&root_id, root_title);
let context = LineageContext {
root_id,
root_title,
parent_id: String::new(),
edge_type: None,
status: ResolveStatus::Resolved,
};
context.album(&own_title)
}
pub fn colliding_root_titles(&self) -> BTreeSet<String> {
let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for root_id in &self.eligible_root_ids {
let node_title = self
.nodes
.get(root_id)
.map(|node| node.title.clone())
.unwrap_or_default();
let effective = self.effective_root_title(root_id, node_title);
let title = effective.trim();
if title.is_empty() {
continue;
}
roots_by_title
.entry(title.to_owned())
.or_default()
.insert(root_id.clone());
}
roots_by_title
.into_iter()
.filter(|(_, roots)| roots.len() > 1)
.map(|(title, _)| title)
.collect()
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn iter(&self) -> Iter<'_, String, Node> {
self.nodes.iter()
}
pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
for clip in clips {
self.upsert_node(clip, now);
}
for clip in &resolution.gap_filled {
self.upsert_node(clip, now);
}
for clip in clips {
for edge in lineage_edges(clip) {
self.upsert_edge(&clip.id, &edge, now);
}
}
self.edges.sort_by(|a, b| {
a.child_id
.cmp(&b.child_id)
.then(a.ordinal.cmp(&b.ordinal))
.then(a.parent_id.cmp(&b.parent_id))
.then(a.edge_type.cmp(&b.edge_type))
.then(a.role.cmp(&b.role))
});
for (child_id, info) in &resolution.roots {
self.upsert_cache(child_id, info, now);
}
self.refresh_eligible_roots();
}
fn upsert_node(&mut self, clip: &Clip, now: &str) {
let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
first_seen_at: now.to_owned(),
..Node::default()
});
node.title = clip.title.clone();
node.created_at = clip.created_at.clone();
node.clip_type = clip.clip_type.clone();
node.task = clip.task.clone();
node.is_remix = clip.is_remix;
node.is_trashed = clip.is_trashed;
node.last_seen_at = now.to_owned();
}
fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
let edge_type = edge_type_slug(edge.edge_type);
let role = edge_role_slug(edge.role);
if let Some(existing) = self.edges.iter_mut().find(|stored| {
stored.child_id == child_id
&& stored.parent_id == edge.parent_id
&& stored.edge_type == edge_type
&& stored.role == role
&& stored.ordinal == edge.ordinal
}) {
existing.source_field = edge.source_field.to_owned();
existing.status = "active".to_owned();
existing.last_seen_at = now.to_owned();
} else {
self.edges.push(StoredEdge {
child_id: child_id.to_owned(),
parent_id: edge.parent_id.clone(),
edge_type: edge_type.to_owned(),
role: role.to_owned(),
source_field: edge.source_field.to_owned(),
ordinal: edge.ordinal,
status: "active".to_owned(),
first_seen_at: now.to_owned(),
last_seen_at: now.to_owned(),
});
}
}
fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
if info.status != ResolveStatus::Resolved
&& self
.resolution_cache
.get(child_id)
.is_some_and(|entry| entry.status == "resolved")
{
return;
}
self.resolution_cache.insert(
child_id.to_owned(),
CacheEntry {
root_id: info.root_id.clone(),
status: resolve_status_slug(info.status).to_owned(),
algorithm_version: 1,
computed_at: now.to_owned(),
},
);
}
}
fn edge_type_slug(edge_type: EdgeType) -> &'static str {
match edge_type {
EdgeType::Cover => "cover",
EdgeType::Remaster => "remaster",
EdgeType::SpeedEdit => "speed_edit",
EdgeType::Edit => "edit",
EdgeType::Extend => "extend",
EdgeType::SectionReplace => "section_replace",
EdgeType::Stitch => "stitch",
EdgeType::Derived => "derived",
EdgeType::Uploaded => "uploaded",
}
}
fn edge_role_slug(role: EdgeRole) -> &'static str {
match role {
EdgeRole::Primary => "primary",
EdgeRole::Secondary => "secondary",
}
}
fn resolve_status_slug(status: ResolveStatus) -> &'static str {
match status {
ResolveStatus::Resolved => "resolved",
ResolveStatus::External => "external",
ResolveStatus::Unresolved => "unresolved",
ResolveStatus::Cycle => "cycle",
}
}
fn status_from_slug(slug: &str) -> ResolveStatus {
match slug {
"external" => ResolveStatus::External,
"unresolved" => ResolveStatus::Unresolved,
"cycle" => ResolveStatus::Cycle,
_ => ResolveStatus::Resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn chain_clips() -> Vec<Clip> {
vec![
Clip {
id: "c".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
created_at: "t2".into(),
cover_clip_id: "b".into(),
edited_clip_id: "b".into(),
..Default::default()
},
Clip {
id: "b".into(),
title: "Remaster".into(),
clip_type: "upsample".into(),
task: "upsample".into(),
created_at: "t1".into(),
upsample_clip_id: "a".into(),
edited_clip_id: "a".into(),
..Default::default()
},
Clip {
id: "a".into(),
title: "Root".into(),
clip_type: "gen".into(),
created_at: "t0".into(),
..Default::default()
},
]
}
fn chain_resolution() -> Resolution {
let mut roots = HashMap::new();
for id in ["a", "b", "c"] {
roots.insert(
id.to_owned(),
RootInfo {
root_id: "a".into(),
root_title: "Root".into(),
status: ResolveStatus::Resolved,
},
);
}
Resolution {
roots,
gap_filled: Vec::new(),
}
}
fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
store
.edges
.iter()
.find(|e| e.child_id == child && e.parent_id == parent)
.expect("edge should exist")
}
#[test]
fn new_store_is_empty_and_versioned() {
let store = LineageStore::new();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
assert_eq!(store.schema_version, 1);
}
#[test]
fn update_populates_nodes_edges_and_cache() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
assert_eq!(store.len(), 3);
let cover = store.node("c").unwrap();
assert_eq!(cover.title, "Cover");
assert_eq!(cover.clip_type, "gen");
assert_eq!(cover.task, "cover");
assert_eq!(cover.created_at, "t2");
assert_eq!(cover.status, "observed");
assert!(!cover.is_trashed);
assert_eq!(cover.first_seen_at, "now");
assert_eq!(cover.last_seen_at, "now");
assert_eq!(store.edges.len(), 2);
let cb = edge(&store, "c", "b");
assert_eq!(cb.edge_type, "cover");
assert_eq!(cb.role, "primary");
assert_eq!(cb.ordinal, 0);
assert_eq!(cb.source_field, "cover_clip_id");
assert_eq!(cb.status, "active");
let ba = edge(&store, "b", "a");
assert_eq!(ba.edge_type, "remaster");
assert!(!store.edges.iter().any(|e| e.child_id == "a"));
for id in ["a", "b", "c"] {
let cached = store.get_root(id).unwrap();
assert_eq!(cached.root_id, "a");
assert_eq!(cached.status, "resolved");
assert_eq!(cached.algorithm_version, 1);
}
}
#[test]
fn album_for_id_matches_context_for_and_handles_unknown() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
assert_eq!(store.album_for_id("c"), "Root");
let cover = &chain_clips()[0];
assert_eq!(
store.album_for_id("c"),
store.context_for(cover).album(&cover.title)
);
assert_eq!(store.album_for_id("a"), "Root");
assert_eq!(store.album_for_id("missing"), "");
}
#[test]
fn serde_roundtrip_preserves_a_relational_shape() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
assert_eq!(value.get("schema_version").unwrap(), 1);
assert!(value.get("nodes").unwrap().is_object());
assert!(value.get("edges").unwrap().is_array());
assert!(value.get("resolution_cache").unwrap().is_object());
let node = value.get("nodes").unwrap().get("c").unwrap();
assert!(node.get("edges").is_none());
assert!(node.get("parent_id").is_none());
let first_edge = value.get("edges").unwrap().get(0).unwrap();
assert!(first_edge.get("child_id").is_some());
assert!(first_edge.get("parent_id").is_some());
}
#[test]
fn album_overrides_are_runtime_only_and_never_persist() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
store.set_album_overrides(
[("a".to_owned(), "Preferred".to_owned())]
.into_iter()
.collect(),
);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
assert!(value.get("album_overrides").is_none());
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert!(back.album_overrides.is_empty());
assert_eq!(back.album_for_id("c"), "Root");
}
#[test]
fn update_is_idempotent_bar_last_seen() {
let clips = chain_clips();
let resolution = chain_resolution();
let mut store = LineageStore::new();
store.update(&clips, &resolution, "first");
let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
let edge_count = store.edges.len();
store.update(&clips, &resolution, "second");
assert_eq!(
store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
node_ids
);
assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
assert_eq!(store.resolution_cache.len(), 3);
let cover = store.node("c").unwrap();
assert_eq!(cover.first_seen_at, "first");
assert_eq!(cover.last_seen_at, "second");
let cb = edge(&store, "c", "b");
assert_eq!(cb.first_seen_at, "first");
assert_eq!(cb.last_seen_at, "second");
assert_eq!(store.get_root("c").unwrap().root_id, "a");
}
#[test]
fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "first");
assert_eq!(store.get_root("c").unwrap().status, "resolved");
let child = Clip {
id: "c".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "b".into(),
edited_clip_id: "b".into(),
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"c".to_owned(),
RootInfo {
root_id: "elsewhere".into(),
root_title: String::new(),
status: ResolveStatus::External,
},
);
roots.insert(
"d".to_owned(),
RootInfo {
root_id: "boundary".into(),
root_title: String::new(),
status: ResolveStatus::External,
},
);
let resolution = Resolution {
roots,
gap_filled: Vec::new(),
};
store.update(&[child], &resolution, "second");
let cached = store.get_root("c").unwrap();
assert_eq!(cached.root_id, "a");
assert_eq!(cached.status, "resolved");
assert_eq!(cached.computed_at, "first");
let d = store.get_root("d").unwrap();
assert_eq!(d.root_id, "boundary");
assert_eq!(d.status, "external");
}
#[test]
fn gap_filled_trashed_ancestor_is_a_durable_node() {
let child = Clip {
id: "c".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "t".into(),
edited_clip_id: "t".into(),
..Default::default()
};
let trashed = Clip {
id: "t".into(),
title: "Trashed Original".into(),
clip_type: "gen".into(),
is_trashed: true,
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"c".to_owned(),
RootInfo {
root_id: "t".into(),
root_title: "Trashed Original".into(),
status: ResolveStatus::Resolved,
},
);
let resolution = Resolution {
roots,
gap_filled: vec![trashed],
};
store_update_and_assert_trashed(child, resolution);
}
fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
let mut store = LineageStore::new();
store.update(&[child], &resolution, "now");
let node = store
.node("t")
.expect("trashed ancestor should be archived");
assert!(node.is_trashed);
assert_eq!(node.title, "Trashed Original");
assert_eq!(store.get_root("c").unwrap().root_id, "t");
}
#[test]
fn partial_json_loads_with_defaults() {
let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
let store: LineageStore = serde_json::from_str(json).unwrap();
assert_eq!(store.schema_version, 1);
let node = store.node("x").unwrap();
assert_eq!(node.title, "Kept");
assert_eq!(node.status, "observed");
assert_eq!(store.edges[0].status, "active");
assert!(store.resolution_cache.is_empty());
assert!(store.albums.is_empty());
assert!(store.album_art("x").is_none());
assert!(store.playlists.is_empty());
assert!(store.playlist("x").is_none());
}
#[test]
fn album_art_roundtrips_and_reads_by_kind() {
let mut store = LineageStore::new();
store.albums.insert(
"root-1".to_owned(),
AlbumArt {
folder_jpg: Some(ArtifactState {
path: "alice/Album/folder.jpg".to_owned(),
hash: "jpg-h".to_owned(),
}),
folder_webp: Some(ArtifactState {
path: "alice/Album/cover.webp".to_owned(),
hash: "webp-h".to_owned(),
}),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let album = value.get("albums").unwrap().get("root-1").unwrap();
assert_eq!(
album.get("folder_jpg").unwrap().get("hash").unwrap(),
"jpg-h"
);
let art = back.album_art("root-1").unwrap();
assert_eq!(
art.artifact(ArtifactKind::FolderJpg).unwrap().path,
"alice/Album/folder.jpg"
);
assert_eq!(
art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
"webp-h"
);
assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
}
#[test]
fn empty_album_art_omits_slots_when_serialised() {
let empty = AlbumArt::default();
assert!(empty.is_empty());
let value = serde_json::to_value(&empty).unwrap();
assert!(value.get("folder_jpg").is_none());
assert!(value.get("folder_webp").is_none());
let back: AlbumArt = serde_json::from_str("{}").unwrap();
assert_eq!(back, empty);
}
#[test]
fn set_album_artifact_upserts_then_prunes_when_emptied() {
let mut store = LineageStore::new();
let jpg = ArtifactState {
path: "a/folder.jpg".to_owned(),
hash: "h1".to_owned(),
};
store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
assert!(store.album_art("root-1").is_none());
assert!(store.albums.is_empty());
}
#[test]
fn playlist_state_roundtrips_by_id() {
let mut store = LineageStore::new();
store.playlists.insert(
"pl1".to_owned(),
PlaylistState {
name: "Road Trip".to_owned(),
path: "Road Trip.m3u8".to_owned(),
hash: "abc123".to_owned(),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let pl = value.get("playlists").unwrap().get("pl1").unwrap();
assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
assert_eq!(pl.get("hash").unwrap(), "abc123");
let stored = back.playlist("pl1").unwrap();
assert_eq!(stored.name, "Road Trip");
assert_eq!(stored.hash, "abc123");
}
#[test]
fn set_playlist_upserts_then_clears() {
let mut store = LineageStore::new();
let state = PlaylistState {
name: "Mix".to_owned(),
path: "Mix.m3u8".to_owned(),
hash: "h1".to_owned(),
};
store.set_playlist("pl1", Some(state.clone()));
assert_eq!(store.playlist("pl1"), Some(&state));
let renamed = PlaylistState {
name: "Mix v2".to_owned(),
path: "Mix v2.m3u8".to_owned(),
hash: "h2".to_owned(),
};
store.set_playlist("pl1", Some(renamed.clone()));
assert_eq!(store.playlist("pl1"), Some(&renamed));
store.set_playlist("pl1", None);
assert!(store.playlist("pl1").is_none());
assert!(store.playlists.is_empty());
}
#[test]
fn context_for_roots_a_remix_at_its_stored_ancestor() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
let child = &chain_clips()[0]; let ctx = store.context_for(child);
assert_eq!(ctx.root_id, "a");
assert_eq!(ctx.root_title, "Root");
assert_eq!(ctx.parent_id, "b");
assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
assert_eq!(ctx.status, ResolveStatus::Resolved);
assert_eq!(ctx.album("Cover"), "Root");
}
#[test]
fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
let mut store = LineageStore::new();
store.update(&chain_clips(), &chain_resolution(), "now");
let root = &chain_clips()[2]; let ctx = store.context_for(root);
assert_eq!(ctx.root_id, "a");
assert_eq!(ctx.root_title, "Root");
assert_eq!(ctx.parent_id, "");
assert_eq!(ctx.edge_type, None);
assert_eq!(ctx.album("Root"), "Root");
}
#[test]
fn context_for_an_unknown_clip_is_self_rooted() {
let store = LineageStore::new();
let orphan = Clip {
id: "z".into(),
title: "Lonely".into(),
..Default::default()
};
let ctx = store.context_for(&orphan);
assert_eq!(ctx.root_id, "z");
assert_eq!(ctx.root_title, "Lonely");
assert_eq!(ctx.parent_id, "");
assert_eq!(ctx.status, ResolveStatus::Resolved);
}
#[test]
fn context_for_retains_a_purged_ancestor_album() {
let child = Clip {
id: "c".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "t".into(),
edited_clip_id: "t".into(),
..Default::default()
};
let trashed = Clip {
id: "t".into(),
title: "Trashed Original".into(),
clip_type: "gen".into(),
is_trashed: true,
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"c".to_owned(),
RootInfo {
root_id: "t".into(),
root_title: "Trashed Original".into(),
status: ResolveStatus::Resolved,
},
);
let resolution = Resolution {
roots,
gap_filled: vec![trashed],
};
let mut store = LineageStore::new();
store.update(std::slice::from_ref(&child), &resolution, "now");
let ctx = store.context_for(&child);
assert_eq!(ctx.root_id, "t");
assert_eq!(ctx.root_title, "Trashed Original");
assert_eq!(ctx.album("Cover"), "Trashed Original");
}
#[test]
fn colliding_root_titles_flags_only_shared_distinct_roots() {
let clips = vec![
Clip {
id: "r1".into(),
title: "Break Through".into(),
clip_type: "gen".into(),
..Default::default()
},
Clip {
id: "r2".into(),
title: "Break Through".into(),
clip_type: "gen".into(),
..Default::default()
},
Clip {
id: "r3".into(),
title: "Solo".into(),
clip_type: "gen".into(),
..Default::default()
},
Clip {
id: "c1".into(),
title: "Break Through".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "r1".into(),
edited_clip_id: "r1".into(),
..Default::default()
},
];
let mut roots = HashMap::new();
for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
let title = if root == "r3" {
"Solo"
} else {
"Break Through"
};
roots.insert(
id.to_owned(),
RootInfo {
root_id: root.into(),
root_title: title.into(),
status: ResolveStatus::Resolved,
},
);
}
let resolution = Resolution {
roots,
gap_filled: Vec::new(),
};
let mut store = LineageStore::new();
store.update(&clips, &resolution, "now");
let colliding = store.colliding_root_titles();
assert!(colliding.contains("Break Through"));
assert!(!colliding.contains("Solo"));
assert_eq!(colliding.len(), 1);
}
fn two_root_store(t1: &str, t2: &str) -> LineageStore {
let clips = vec![
Clip {
id: "r1".into(),
title: t1.into(),
clip_type: "gen".into(),
..Default::default()
},
Clip {
id: "r2".into(),
title: t2.into(),
clip_type: "gen".into(),
..Default::default()
},
];
let mut roots = HashMap::new();
roots.insert(
"r1".to_owned(),
RootInfo {
root_id: "r1".into(),
root_title: t1.into(),
status: ResolveStatus::Resolved,
},
);
roots.insert(
"r2".to_owned(),
RootInfo {
root_id: "r2".into(),
root_title: t2.into(),
status: ResolveStatus::Resolved,
},
);
let mut store = LineageStore::new();
store.update(
&clips,
&Resolution {
roots,
gap_filled: Vec::new(),
},
"now",
);
store
}
#[test]
fn album_override_flows_into_context_tag_hash_and_index() {
let clips = chain_clips();
let mut store = LineageStore::new();
store.update(&clips, &chain_resolution(), "now");
let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
store.set_album_overrides(
[("a".to_owned(), "Preferred Name".to_owned())]
.into_iter()
.collect(),
);
for id in ["a", "b", "c"] {
let clip = clips.iter().find(|c| c.id == id).unwrap();
let ctx = store.context_for(clip);
assert_eq!(ctx.album(&clip.title), "Preferred Name");
assert_eq!(store.album_for_id(id), "Preferred Name");
}
let ctx = store.context_for(cover);
let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
assert_eq!(meta.album, "Preferred Name");
let after_hash = crate::hash::meta_hash(cover, &ctx);
assert_ne!(before_hash, after_hash);
}
#[test]
fn empty_album_override_is_ignored() {
let clips = chain_clips();
let mut store = LineageStore::new();
store.update(&clips, &chain_resolution(), "now");
store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
assert_eq!(store.album_for_id("c"), "Root");
}
#[test]
fn album_override_creates_a_collision_that_disambiguates() {
let mut store = two_root_store("Alpha", "Beta");
assert!(store.colliding_root_titles().is_empty());
store.set_album_overrides(
[("r2".to_owned(), "Alpha".to_owned())]
.into_iter()
.collect(),
);
let colliding = store.colliding_root_titles();
assert!(colliding.contains("Alpha"));
assert_eq!(colliding.len(), 1);
}
#[test]
fn album_override_resolves_a_natural_collision() {
let mut store = two_root_store("Break Through", "Break Through");
assert!(store.colliding_root_titles().contains("Break Through"));
store.set_album_overrides(
[("r2".to_owned(), "Second Wind".to_owned())]
.into_iter()
.collect(),
);
assert!(store.colliding_root_titles().is_empty());
}
fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
store.resolution_cache.insert(
root_id.to_owned(),
CacheEntry {
root_id: root_id.to_owned(),
status: "external".to_owned(),
algorithm_version: 1,
computed_at: "now".to_owned(),
},
);
store.refresh_eligible_roots();
}
#[test]
fn override_on_node_less_root_collides_with_a_real_root() {
let mut store = LineageStore::new();
store.update(
std::slice::from_ref(&Clip {
id: "realroot".into(),
title: "Shared".into(),
clip_type: "gen".into(),
..Default::default()
}),
&Resolution {
roots: [(
"realroot".to_owned(),
RootInfo {
root_id: "realroot".into(),
root_title: "Shared".into(),
status: ResolveStatus::Resolved,
},
)]
.into_iter()
.collect(),
gap_filled: Vec::new(),
},
"now",
);
insert_cache_only_root(&mut store, "extroot");
store.set_album_overrides(
[("extroot".to_owned(), "Shared".to_owned())]
.into_iter()
.collect(),
);
let colliding = store.colliding_root_titles();
assert!(
colliding.contains("Shared"),
"a node-less overridden root must still be seen by collision detection"
);
}
#[test]
fn two_node_less_roots_overridden_to_same_name_collide() {
let mut store = LineageStore::new();
insert_cache_only_root(&mut store, "extone");
insert_cache_only_root(&mut store, "exttwo");
store.set_album_overrides(
[
("extone".to_owned(), "Shared".to_owned()),
("exttwo".to_owned(), "Shared".to_owned()),
]
.into_iter()
.collect(),
);
assert!(store.colliding_root_titles().contains("Shared"));
}
#[test]
fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
let mut store = LineageStore::new();
insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
store.set_album_overrides(
[
("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
]
.into_iter()
.collect(),
);
let colliding = store.colliding_root_titles();
let clip_of = |id: &str| Clip {
id: id.to_owned(),
title: "Track".to_owned(),
display_name: "alice".to_owned(),
image_large_url: "https://art.example/large.jpg".to_owned(),
..Default::default()
};
let ctx_of = |root_id: &str| LineageContext {
root_id: root_id.to_owned(),
root_title: "Shared".to_owned(),
parent_id: String::new(),
edge_type: None,
status: ResolveStatus::Resolved,
};
let clip_a = clip_of("clipaaaa-1111");
let clip_b = clip_of("clipbbbb-2222");
let ctx_a = ctx_of("aaaaaaaa-root-one");
let ctx_b = ctx_of("bbbbbbbb-root-two");
let requests = [
crate::naming::NamingRequest {
clip: &clip_a,
lineage: &ctx_a,
},
crate::naming::NamingRequest {
clip: &clip_b,
lineage: &ctx_b,
},
];
let names = crate::naming::render_clip_names(
&requests,
&crate::naming::NamingConfig::default(),
&colliding,
);
let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
crate::reconcile::Desired {
clip: clip.clone(),
lineage: ctx.clone(),
path: format!("{}.flac", name.relative_path.to_string_lossy()),
format: crate::AudioFormat::Flac,
meta_hash: String::new(),
art_hash: String::new(),
modes: vec![crate::reconcile::SourceMode::Mirror],
trashed: false,
private: false,
artifacts: Vec::new(),
}
};
let desired = vec![
desired_of(&clip_a, &ctx_a, &names[0]),
desired_of(&clip_b, &ctx_b, &names[1]),
];
let albums = crate::reconcile::album_desired(&desired, false);
assert_eq!(albums.len(), 2, "each distinct root is its own album");
let jpg_paths: Vec<String> = albums
.iter()
.filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
.collect();
assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
assert_ne!(
jpg_paths[0], jpg_paths[1],
"colliding roots must not share one folder.jpg path"
);
}
#[test]
fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
let mut store = LineageStore::new();
store.update(
std::slice::from_ref(&Clip {
id: "realroot".into(),
title: "Shared".into(),
clip_type: "gen".into(),
..Default::default()
}),
&Resolution {
roots: [(
"realroot".to_owned(),
RootInfo {
root_id: "realroot".into(),
root_title: "Shared".into(),
status: ResolveStatus::Resolved,
},
)]
.into_iter()
.collect(),
gap_filled: Vec::new(),
},
"now",
);
let new_clip = Clip {
id: "newnewnew-9999".into(),
title: "Solo Track".into(),
display_name: "alice".into(),
image_large_url: "https://art.example/large.jpg".into(),
..Default::default()
};
store.set_album_overrides(
[("newnewnew-9999".to_owned(), "Shared".to_owned())]
.into_iter()
.collect(),
);
let new_ctx = store.context_for(&new_clip);
assert_eq!(new_ctx.root_id, "newnewnew-9999");
assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
assert!(store.colliding_root_titles().is_empty());
let real_clip = Clip {
id: "realroot".into(),
title: "Shared".into(),
display_name: "alice".into(),
image_large_url: "https://art.example/large.jpg".into(),
..Default::default()
};
let real_ctx = store.context_for(&real_clip);
let colliding = store.colliding_root_titles();
let requests = [
crate::naming::NamingRequest {
clip: &real_clip,
lineage: &real_ctx,
},
crate::naming::NamingRequest {
clip: &new_clip,
lineage: &new_ctx,
},
];
let names = crate::naming::render_clip_names(
&requests,
&crate::naming::NamingConfig::default(),
&colliding,
);
let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
crate::reconcile::Desired {
clip: clip.clone(),
lineage: ctx.clone(),
path: format!("{}.flac", name.relative_path.to_string_lossy()),
format: crate::AudioFormat::Flac,
meta_hash: String::new(),
art_hash: String::new(),
modes: vec![crate::reconcile::SourceMode::Mirror],
trashed: false,
private: false,
artifacts: Vec::new(),
}
};
let desired = vec![
desired_of(&real_clip, &real_ctx, &names[0]),
desired_of(&new_clip, &new_ctx, &names[1]),
];
let albums = crate::reconcile::album_desired(&desired, false);
let jpg_paths: Vec<String> = albums
.iter()
.filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
.collect();
assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
assert_ne!(
jpg_paths[0], jpg_paths[1],
"an uncached override must not collapse two albums onto one path"
);
}
#[test]
fn override_on_gap_filled_root_applies_to_children_and_collides() {
let child = Clip {
id: "childclip".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "gaproot".into(),
edited_clip_id: "gaproot".into(),
..Default::default()
};
let other_root = Clip {
id: "otherroot".into(),
title: "Preferred".into(),
clip_type: "gen".into(),
..Default::default()
};
let gap_ancestor = Clip {
id: "gaproot".into(),
title: "Working Title".into(),
clip_type: "gen".into(),
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"childclip".to_owned(),
RootInfo {
root_id: "gaproot".into(),
root_title: "Working Title".into(),
status: ResolveStatus::Resolved,
},
);
roots.insert(
"otherroot".to_owned(),
RootInfo {
root_id: "otherroot".into(),
root_title: "Preferred".into(),
status: ResolveStatus::Resolved,
},
);
let mut store = LineageStore::new();
store.update(
&[child.clone(), other_root],
&Resolution {
roots,
gap_filled: vec![gap_ancestor],
},
"now",
);
assert!(store.node("gaproot").is_some());
assert!(!store.resolution_cache.contains_key("gaproot"));
store.set_album_overrides(
[("gaproot".to_owned(), "Preferred".to_owned())]
.into_iter()
.collect(),
);
assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
assert_eq!(store.album_for_id("childclip"), "Preferred");
assert!(store.colliding_root_titles().contains("Preferred"));
}
#[test]
fn eligible_root_set_is_exactly_the_cache_value_domain() {
let child = Clip {
id: "childclip".into(),
title: "Cover".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "gaproot".into(),
edited_clip_id: "gaproot".into(),
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"childclip".to_owned(),
RootInfo {
root_id: "gaproot".into(),
root_title: "Working Title".into(),
status: ResolveStatus::Resolved,
},
);
let mut store = LineageStore::new();
store.update(
std::slice::from_ref(&child),
&Resolution {
roots,
gap_filled: vec![Clip {
id: "gaproot".into(),
title: "Working Title".into(),
clip_type: "gen".into(),
..Default::default()
}],
},
"now",
);
let expected: std::collections::HashSet<String> = store
.resolution_cache
.values()
.map(|entry| entry.root_id.clone())
.filter(|root_id| !root_id.is_empty())
.collect();
assert_eq!(*store.eligible_root_ids_for_test(), expected);
assert!(store.eligible_root_ids_for_test().contains("gaproot"));
assert!(!store.resolution_cache.contains_key("gaproot"));
}
fn owner(id: &str, name: &str) -> Owner {
Owner {
user_id: id.to_owned(),
display_name: name.to_owned(),
}
}
#[test]
fn owner_check_covers_first_use_match_and_mismatch() {
let mut store = LineageStore::new();
assert_eq!(store.owner_check("user_a"), OwnerCheck::FirstUse);
store.pin_owner(owner("user_a", "Alice"));
assert_eq!(store.owner_check("user_a"), OwnerCheck::Match);
assert_eq!(store.owner_check("user_b"), OwnerCheck::Mismatch);
assert_eq!(store.owner().unwrap().display_name, "Alice");
}
#[test]
fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
let mut store = LineageStore::new();
assert!(!store.refresh_display_name("Alice"));
assert!(store.owner().is_none());
store.pin_owner(owner("user_a", "Alice"));
assert!(!store.refresh_display_name("Alice"));
assert!(store.refresh_display_name("Alice Cooper"));
assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
assert_eq!(store.owner().unwrap().user_id, "user_a");
}
#[test]
fn owner_gate_covers_the_full_matrix() {
let alice = owner("user_a", "Alice");
assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
assert_eq!(
owner_gate(Some(&alice), None, "user_a", false),
OwnerGate::Proceed
);
assert_eq!(
owner_gate(Some(&alice), None, "user_b", false),
OwnerGate::AbortMismatch
);
assert_eq!(
owner_gate(Some(&alice), None, "user_b", true),
OwnerGate::Repin
);
assert_eq!(
owner_gate(Some(&alice), Some("user_c"), "user_a", true),
OwnerGate::AbortConfigMismatch
);
assert_eq!(
owner_gate(None, Some("user_c"), "user_a", true),
OwnerGate::AbortConfigMismatch
);
assert_eq!(
owner_gate(Some(&alice), Some("user_a"), "user_a", false),
OwnerGate::Proceed
);
assert!(OwnerGate::Repin.is_additive());
for gate in [
OwnerGate::AbortConfigMismatch,
OwnerGate::AbortMismatch,
OwnerGate::Proceed,
OwnerGate::FirstUse,
] {
assert!(!gate.is_additive());
}
}
#[test]
fn adopt_decision_covers_every_branch() {
let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
let empty: BTreeSet<&str> = BTreeSet::new();
assert_eq!(
adopt_decision(&["x", "y"], &empty, true, false),
AdoptDecision::PinFresh
);
assert_eq!(
adopt_decision(&["c1"], &owned, false, false),
AdoptDecision::SkipPin
);
assert_eq!(
adopt_decision(&["c1"], &owned, false, true),
AdoptDecision::SkipPin
);
assert_eq!(
adopt_decision(&["c1", "z"], &owned, true, false),
AdoptDecision::PinAdopt
);
assert_eq!(
adopt_decision(&["z1", "z2"], &owned, true, false),
AdoptDecision::Abort
);
assert_eq!(
adopt_decision(&["z1", "z2"], &owned, true, true),
AdoptDecision::AdoptForced
);
assert!(AdoptDecision::AdoptForced.is_additive());
for decision in [
AdoptDecision::PinFresh,
AdoptDecision::PinAdopt,
AdoptDecision::Abort,
AdoptDecision::SkipPin,
] {
assert!(!decision.is_additive());
}
}
#[test]
fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
let json = r#"{"nodes":{},"edges":[]}"#;
let store: LineageStore = serde_json::from_str(json).unwrap();
assert!(store.owner().is_none());
let value = serde_json::to_value(&store).unwrap();
assert!(value.get("owner").is_none());
let mut pinned = LineageStore::new();
pinned.pin_owner(owner("user_a", "Alice"));
let back: LineageStore =
serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
assert_eq!(back, pinned);
assert_eq!(back.owner().unwrap().user_id, "user_a");
}
}