pulse-pixelstream-types 0.14.0

Shared Myko entity and command types for the Pulse Pixelstream recording cell.
Documentation
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,
};

/// Generate the canonical identity for a newly authored Pixelstream collection.
/// UUIDv7 is globally unique, standardized, and naturally ordered by creation time.
/// Fresh UUIDv7 for client-minted, time-sortable ids (capture ids, command
/// ids). Same identity discipline as native collection ids.
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)
}

/// A persistent, user-defined organizational bin.
///
/// Collections deliberately have no fixed production taxonomy. Every level and
/// label is user-authored; Pulse stores only parent/child organization.
/// `parent_id` forms the hierarchy while timeline membership remains a
/// separate many-to-many entity.
#[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,
    /// Namespaced, lossless interchange metadata. The first UI intentionally
    /// edits only the collection name and hierarchy.
    #[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,
}

/// Portable lowercase path component used by the recording delivery contract.
/// Keep this byte-for-byte aligned with the recorder's artifact naming so UI
/// destination previews describe the path that will actually be delivered.
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
    }
}

/// Resolve a collection's stable root-to-leaf path and reject corrupt cycles.
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)
}

/// Freeze a selected Pixelstream collection into the cross-service capture
/// contract without reducing its stable identity to a display-name slug.
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");
    }
}