Skip to main content

kmp_domain/value_objects/
memory_dimension_identity.rs

1use crate::DomainError;
2
3const PREFIX: &str = "about:";
4const SEPARATOR: &str = ":dimension:";
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
7pub struct MemoryDimensionIdentity {
8    about: String,
9    dimension_id: String,
10}
11
12impl MemoryDimensionIdentity {
13    pub fn new(
14        about: impl Into<String>,
15        dimension_id: impl Into<String>,
16    ) -> Result<Self, DomainError> {
17        let about = normalize_required(about.into(), "memory dimension about")?;
18        let dimension_id = normalize_required(dimension_id.into(), "memory dimension id")?;
19        Ok(Self {
20            about,
21            dimension_id,
22        })
23    }
24
25    pub fn parse(value: &str) -> Option<Self> {
26        let value = value.trim().strip_prefix(PREFIX)?;
27        let (about, dimension_id) = value.split_once(SEPARATOR)?;
28        Self::new(about, dimension_id).ok()
29    }
30
31    /// Resolve `value` against `about`, whether it arrives bare or already
32    /// namespaced. Reads hand out the namespaced form, so both forms reach
33    /// writers; wrapping an already-namespaced id a second time would name a
34    /// different dimension that reads back as the intended one. An id
35    /// namespaced for another about is not this about's to reinterpret.
36    pub fn resolve(about: &str, value: &str) -> Option<Self> {
37        match Self::parse(value) {
38            Some(identity) if identity.about() == about => Some(identity),
39            Some(_) => None,
40            None => Self::new(about, value).ok(),
41        }
42    }
43
44    pub fn node_id(&self) -> String {
45        format!("{PREFIX}{}{SEPARATOR}{}", self.about, self.dimension_id)
46    }
47
48    pub fn about(&self) -> &str {
49        &self.about
50    }
51
52    pub fn dimension_id(&self) -> &str {
53        &self.dimension_id
54    }
55}
56
57fn normalize_required(value: String, field: &'static str) -> Result<String, DomainError> {
58    let value = value.trim();
59    if value.is_empty() {
60        Err(DomainError::EmptyValue(field))
61    } else {
62        Ok(value.to_string())
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::MemoryDimensionIdentity;
69
70    #[test]
71    fn identity_formats_and_parses_namespaced_dimension_node_id() {
72        let identity =
73            MemoryDimensionIdentity::new("question:830ce83f", "timeline").expect("valid identity");
74
75        assert_eq!(
76            identity.node_id(),
77            "about:question:830ce83f:dimension:timeline"
78        );
79        let parsed = MemoryDimensionIdentity::parse(&identity.node_id()).expect("should parse");
80        assert_eq!(parsed.about(), "question:830ce83f");
81        assert_eq!(parsed.dimension_id(), "timeline");
82    }
83
84    #[test]
85    fn resolve_is_idempotent_for_this_about() {
86        let bare = MemoryDimensionIdentity::resolve("project:x", "run").expect("bare id resolves");
87        assert_eq!(bare.node_id(), "about:project:x:dimension:run");
88
89        let namespaced = MemoryDimensionIdentity::resolve("project:x", &bare.node_id())
90            .expect("namespaced id resolves");
91        assert_eq!(namespaced.node_id(), bare.node_id());
92        assert_eq!(namespaced.dimension_id(), "run");
93    }
94
95    #[test]
96    fn resolve_refuses_a_dimension_owned_by_another_about() {
97        assert!(
98            MemoryDimensionIdentity::resolve("project:x", "about:project:y:dimension:run")
99                .is_none()
100        );
101    }
102
103    #[test]
104    fn identity_rejects_empty_parts() {
105        assert!(MemoryDimensionIdentity::new("", "timeline").is_err());
106        assert!(MemoryDimensionIdentity::new("question:830ce83f", " ").is_err());
107    }
108}