Skip to main content

pulse_pixelstream_types/
collection.rs

1use 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
14/// Generate the canonical identity for a newly authored Pixelstream collection.
15/// UUIDv7 is globally unique, standardized, and naturally ordered by creation time.
16/// Fresh UUIDv7 for client-minted, time-sortable ids (capture ids, command
17/// ids). Same identity discipline as native collection ids.
18pub 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/// A persistent, user-defined organizational bin.
31///
32/// Collections deliberately have no fixed production taxonomy. Every level and
33/// label is user-authored; Pulse stores only parent/child organization.
34/// `parent_id` forms the hierarchy while timeline membership remains a
35/// separate many-to-many entity.
36#[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    /// Namespaced, lossless interchange metadata. The first UI intentionally
46    /// edits only the collection name and hierarchy.
47    #[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
58/// Portable lowercase path component used by the recording delivery contract.
59/// Keep this byte-for-byte aligned with the recorder's artifact naming so UI
60/// destination previews describe the path that will actually be delivered.
61pub 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
85/// Resolve a collection's stable root-to-leaf path and reject corrupt cycles.
86pub 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
122/// Freeze a selected Pixelstream collection into the cross-service capture
123/// contract without reducing its stable identity to a display-name slug.
124pub 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}