Skip to main content

phoxal_model/
asset.rs

1//! Canonical logical asset identities.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ModelError;
6
7/// A normalized, forward-slash logical asset identifier.
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct AssetId(String);
10
11impl AssetId {
12    /// Validate and construct a logical asset identifier.
13    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
14        let value = value.into();
15        if value.is_empty()
16            || value.starts_with('/')
17            || value.contains('\\')
18            || value
19                .split('/')
20                .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
21        {
22            return Err(ModelError::Invalid(format!(
23                "invalid logical asset id '{value}'"
24            )));
25        }
26        Ok(Self(value))
27    }
28
29    /// The normalized forward-slash representation.
30    #[must_use]
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36impl Serialize for AssetId {
37    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
38        serializer.serialize_str(self.as_str())
39    }
40}
41
42impl<'de> Deserialize<'de> for AssetId {
43    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
44        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn validates_normalized_ids() {
54        for invalid in ["", "/a", "../a", "a/../b", "a\\b", "a//b"] {
55            assert!(AssetId::new(invalid).is_err(), "{invalid}");
56        }
57        assert_eq!(
58            AssetId::new("meshes/base.stl").unwrap().as_str(),
59            "meshes/base.stl"
60        );
61    }
62}