use myko::prelude::*;
use std::collections::{HashMap, HashSet};
use myko::TS;
use myko_macros::myko_item;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
CaptureCollection, CaptureCollectionId, CaptureLibraryId, PixelstreamCollectionPathSegment,
PixelstreamCollectionRef,
};
pub fn new_uuid_v7() -> String {
Uuid::now_v7().to_string()
}
pub fn new_collection_id() -> crate::CollectionId {
crate::CollectionId::from(Uuid::now_v7().to_string())
}
pub(crate) fn is_uuid_v7(value: &str) -> bool {
Uuid::parse_str(value).is_ok_and(|id| id.get_version_num() == 7)
}
#[myko_item]
pub struct Collection {
#[serde(default)]
pub library_id: String,
#[serde(default)]
pub parent_id: String,
pub name: String,
#[serde(default)]
pub sort_order: u32,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct CollectionPathSegment {
pub collection_id: String,
pub name: String,
}
pub fn delivery_path_component(value: &str) -> String {
const MAX_BYTES: usize = 64;
let mut cleaned = String::with_capacity(value.len().min(MAX_BYTES));
let mut replacing = false;
for character in value.trim().chars() {
if character.is_ascii_alphanumeric() {
cleaned.push(character.to_ascii_lowercase());
replacing = false;
} else if !replacing && !cleaned.is_empty() {
cleaned.push('_');
replacing = true;
}
if cleaned.len() >= MAX_BYTES {
break;
}
}
let cleaned = cleaned.trim_matches('_').to_owned();
if cleaned.is_empty() {
"freefly".to_owned()
} else {
cleaned
}
}
pub fn resolve_collection_path(
collection_id: &str,
collections: &[Collection],
) -> Result<Vec<CollectionPathSegment>, String> {
if collection_id.trim().is_empty() {
return Ok(Vec::new());
}
let by_id = collections
.iter()
.map(|collection| (collection.id.to_string(), collection))
.collect::<HashMap<_, _>>();
let mut seen = HashSet::new();
let mut current = collection_id;
let mut reversed = Vec::new();
loop {
if !seen.insert(current.to_owned()) {
return Err(format!(
"collection hierarchy contains a cycle at {current}"
));
}
let collection = by_id
.get(current)
.ok_or_else(|| format!("collection {current} does not exist"))?;
reversed.push(CollectionPathSegment {
collection_id: collection.id.to_string(),
name: collection.name.clone(),
});
if collection.parent_id.trim().is_empty() {
break;
}
current = &collection.parent_id;
}
reversed.reverse();
Ok(reversed)
}
pub fn capture_collection_ref(
collection_id: &str,
collections: &[Collection],
) -> Result<CaptureCollection, String> {
let path = resolve_collection_path(collection_id, collections)?;
let first = path
.first()
.ok_or_else(|| "capture collection path must not be empty".to_owned())?;
let library_id = collections
.iter()
.find(|collection| collection.id.as_ref() == first.collection_id)
.map(|collection| collection.library_id.clone())
.ok_or_else(|| format!("collection {} does not exist", first.collection_id))?;
let library_id = CaptureLibraryId::try_from(library_id).map_err(|error| error.to_string())?;
let path = path
.into_iter()
.map(|segment| {
Ok(PixelstreamCollectionPathSegment {
id: CaptureCollectionId::try_from(segment.collection_id)
.map_err(|error| error.to_string())?,
name: segment.name,
})
})
.collect::<Result<Vec<_>, String>>()?;
PixelstreamCollectionRef::try_new(library_id, path)
.map(CaptureCollection::from)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CollectionId;
fn collection(id: &str, parent_id: &str, name: &str) -> Collection {
Collection {
id: CollectionId::from(id),
library_id: "shared".to_owned(),
parent_id: parent_id.to_owned(),
name: name.to_owned(),
sort_order: 0,
metadata: HashMap::new(),
}
}
#[test]
fn resolves_a_generic_root_to_leaf_path() {
let rows = vec![
collection("deliverable", "campaign", "Launch film"),
collection("campaign", "", "Autumn campaign"),
];
assert_eq!(
resolve_collection_path("deliverable", &rows).unwrap(),
vec![
CollectionPathSegment {
collection_id: "campaign".to_owned(),
name: "Autumn campaign".to_owned(),
},
CollectionPathSegment {
collection_id: "deliverable".to_owned(),
name: "Launch film".to_owned(),
},
]
);
}
#[test]
fn rejects_a_corrupt_cycle() {
let rows = vec![collection("a", "b", "A"), collection("b", "a", "B")];
assert!(resolve_collection_path("a", &rows).is_err());
}
#[test]
fn capture_reference_preserves_library_ids_and_capture_time_names() {
let rows = vec![
collection("moment-2", "cycle-4", "Moment 2"),
collection("cycle-4", "", "Cycle 4"),
];
let CaptureCollection::Pixelstream(reference) =
capture_collection_ref("moment-2", &rows).unwrap()
else {
panic!("Pixelstream selection must create a Pixelstream reference");
};
assert_eq!(reference.library_id.as_str(), "shared");
assert_eq!(reference.path[0].id.as_str(), "cycle-4");
assert_eq!(reference.path[0].name, "Cycle 4");
assert_eq!(reference.path[1].id.as_str(), "moment-2");
assert_eq!(reference.path[1].name, "Moment 2");
}
#[test]
fn new_native_collection_ids_are_uuid_v7() {
let first = new_collection_id();
let second = new_collection_id();
assert!(is_uuid_v7(first.as_ref()));
assert!(is_uuid_v7(second.as_ref()));
assert_ne!(first, second);
}
#[test]
fn delivery_components_match_the_recorder_artifact_contract() {
assert_eq!(delivery_path_component("Cycle 01"), "cycle_01");
assert_eq!(
delivery_path_component("../Moment 03/Final"),
"moment_03_final"
);
assert_eq!(delivery_path_component(" "), "freefly");
}
}