pulse_pixelstream_types/
collection.rs1use myko::prelude::*;
2use std::collections::{HashMap, HashSet};
3
4use myko::TS;
5use myko_macros::myko_item;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::{
10 CaptureCollection, CaptureCollectionId, CaptureLibraryId, PixelstreamCollectionPathSegment,
11 PixelstreamCollectionRef,
12};
13
14pub fn new_uuid_v7() -> String {
19 Uuid::now_v7().to_string()
20}
21
22pub fn new_collection_id() -> crate::CollectionId {
23 crate::CollectionId::from(Uuid::now_v7().to_string())
24}
25
26pub(crate) fn is_uuid_v7(value: &str) -> bool {
27 Uuid::parse_str(value).is_ok_and(|id| id.get_version_num() == 7)
28}
29
30#[myko_item]
37pub struct Collection {
38 #[serde(default)]
39 pub library_id: String,
40 #[serde(default)]
41 pub parent_id: String,
42 pub name: String,
43 #[serde(default)]
44 pub sort_order: u32,
45 #[serde(default)]
48 pub metadata: HashMap<String, String>,
49}
50
51#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
52#[serde(rename_all = "camelCase")]
53pub struct CollectionPathSegment {
54 pub collection_id: String,
55 pub name: String,
56}
57
58pub fn delivery_path_component(value: &str) -> String {
62 const MAX_BYTES: usize = 64;
63 let mut cleaned = String::with_capacity(value.len().min(MAX_BYTES));
64 let mut replacing = false;
65 for character in value.trim().chars() {
66 if character.is_ascii_alphanumeric() {
67 cleaned.push(character.to_ascii_lowercase());
68 replacing = false;
69 } else if !replacing && !cleaned.is_empty() {
70 cleaned.push('_');
71 replacing = true;
72 }
73 if cleaned.len() >= MAX_BYTES {
74 break;
75 }
76 }
77 let cleaned = cleaned.trim_matches('_').to_owned();
78 if cleaned.is_empty() {
79 "freefly".to_owned()
80 } else {
81 cleaned
82 }
83}
84
85pub fn resolve_collection_path(
87 collection_id: &str,
88 collections: &[Collection],
89) -> Result<Vec<CollectionPathSegment>, String> {
90 if collection_id.trim().is_empty() {
91 return Ok(Vec::new());
92 }
93 let by_id = collections
94 .iter()
95 .map(|collection| (collection.id.to_string(), collection))
96 .collect::<HashMap<_, _>>();
97 let mut seen = HashSet::new();
98 let mut current = collection_id;
99 let mut reversed = Vec::new();
100 loop {
101 if !seen.insert(current.to_owned()) {
102 return Err(format!(
103 "collection hierarchy contains a cycle at {current}"
104 ));
105 }
106 let collection = by_id
107 .get(current)
108 .ok_or_else(|| format!("collection {current} does not exist"))?;
109 reversed.push(CollectionPathSegment {
110 collection_id: collection.id.to_string(),
111 name: collection.name.clone(),
112 });
113 if collection.parent_id.trim().is_empty() {
114 break;
115 }
116 current = &collection.parent_id;
117 }
118 reversed.reverse();
119 Ok(reversed)
120}
121
122pub fn capture_collection_ref(
125 collection_id: &str,
126 collections: &[Collection],
127) -> Result<CaptureCollection, String> {
128 let path = resolve_collection_path(collection_id, collections)?;
129 let first = path
130 .first()
131 .ok_or_else(|| "capture collection path must not be empty".to_owned())?;
132 let library_id = collections
133 .iter()
134 .find(|collection| collection.id.as_ref() == first.collection_id)
135 .map(|collection| collection.library_id.clone())
136 .ok_or_else(|| format!("collection {} does not exist", first.collection_id))?;
137 let library_id = CaptureLibraryId::try_from(library_id).map_err(|error| error.to_string())?;
138 let path = path
139 .into_iter()
140 .map(|segment| {
141 Ok(PixelstreamCollectionPathSegment {
142 id: CaptureCollectionId::try_from(segment.collection_id)
143 .map_err(|error| error.to_string())?,
144 name: segment.name,
145 })
146 })
147 .collect::<Result<Vec<_>, String>>()?;
148 PixelstreamCollectionRef::try_new(library_id, path)
149 .map(CaptureCollection::from)
150 .map_err(|error| error.to_string())
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::CollectionId;
157
158 fn collection(id: &str, parent_id: &str, name: &str) -> Collection {
159 Collection {
160 id: CollectionId::from(id),
161 library_id: "shared".to_owned(),
162 parent_id: parent_id.to_owned(),
163 name: name.to_owned(),
164 sort_order: 0,
165 metadata: HashMap::new(),
166 }
167 }
168
169 #[test]
170 fn resolves_a_generic_root_to_leaf_path() {
171 let rows = vec![
172 collection("deliverable", "campaign", "Launch film"),
173 collection("campaign", "", "Autumn campaign"),
174 ];
175 assert_eq!(
176 resolve_collection_path("deliverable", &rows).unwrap(),
177 vec![
178 CollectionPathSegment {
179 collection_id: "campaign".to_owned(),
180 name: "Autumn campaign".to_owned(),
181 },
182 CollectionPathSegment {
183 collection_id: "deliverable".to_owned(),
184 name: "Launch film".to_owned(),
185 },
186 ]
187 );
188 }
189
190 #[test]
191 fn rejects_a_corrupt_cycle() {
192 let rows = vec![collection("a", "b", "A"), collection("b", "a", "B")];
193 assert!(resolve_collection_path("a", &rows).is_err());
194 }
195
196 #[test]
197 fn capture_reference_preserves_library_ids_and_capture_time_names() {
198 let rows = vec![
199 collection("moment-2", "cycle-4", "Moment 2"),
200 collection("cycle-4", "", "Cycle 4"),
201 ];
202 let CaptureCollection::Pixelstream(reference) =
203 capture_collection_ref("moment-2", &rows).unwrap()
204 else {
205 panic!("Pixelstream selection must create a Pixelstream reference");
206 };
207 assert_eq!(reference.library_id.as_str(), "shared");
208 assert_eq!(reference.path[0].id.as_str(), "cycle-4");
209 assert_eq!(reference.path[0].name, "Cycle 4");
210 assert_eq!(reference.path[1].id.as_str(), "moment-2");
211 assert_eq!(reference.path[1].name, "Moment 2");
212 }
213
214 #[test]
215 fn new_native_collection_ids_are_uuid_v7() {
216 let first = new_collection_id();
217 let second = new_collection_id();
218 assert!(is_uuid_v7(first.as_ref()));
219 assert!(is_uuid_v7(second.as_ref()));
220 assert_ne!(first, second);
221 }
222
223 #[test]
224 fn delivery_components_match_the_recorder_artifact_contract() {
225 assert_eq!(delivery_path_component("Cycle 01"), "cycle_01");
226 assert_eq!(
227 delivery_path_component("../Moment 03/Final"),
228 "moment_03_final"
229 );
230 assert_eq!(delivery_path_component(" "), "freefly");
231 }
232}