Skip to main content

dendryform_core/
id.rs

1//! Node identity type with slug validation.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::ValidationError;
8
9/// A validated node identifier.
10///
11/// Node IDs must be valid slugs: start with a lowercase letter or digit,
12/// followed by lowercase alphanumeric characters, hyphens, underscores,
13/// or dots. For example: `"web-app"`, `"auth.service"`, `"db-01"`.
14///
15/// # Examples
16///
17/// ```
18/// use dendryform_core::NodeId;
19///
20/// let id = NodeId::new("web-app").unwrap();
21/// assert_eq!(id.as_str(), "web-app");
22///
23/// assert!(NodeId::new("").is_err());
24/// assert!(NodeId::new("Has Spaces").is_err());
25/// assert!(NodeId::new("UPPERCASE").is_err());
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
28#[serde(transparent)]
29pub struct NodeId(String);
30
31impl NodeId {
32    /// Creates a new `NodeId` after validating the slug format.
33    ///
34    /// Valid slugs match `[a-z0-9][a-z0-9._-]*`.
35    pub fn new(value: &str) -> Result<Self, ValidationError> {
36        Self::validate(value)?;
37        Ok(Self(value.to_owned()))
38    }
39
40    /// Returns the ID as a string slice.
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44
45    fn validate(value: &str) -> Result<(), ValidationError> {
46        if value.is_empty() {
47            return Err(ValidationError::InvalidNodeId {
48                value: value.to_owned(),
49                reason: "node ID cannot be empty",
50            });
51        }
52
53        let first = value.as_bytes()[0];
54        if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
55            return Err(ValidationError::InvalidNodeId {
56                value: value.to_owned(),
57                reason: "must start with a lowercase letter or digit",
58            });
59        }
60
61        if let Some(pos) = value.bytes().position(|b| {
62            !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'-' && b != b'_' && b != b'.'
63        }) {
64            let _ = pos;
65            return Err(ValidationError::InvalidNodeId {
66                value: value.to_owned(),
67                reason: "must contain only lowercase alphanumeric, hyphens, underscores, or dots",
68            });
69        }
70
71        Ok(())
72    }
73}
74
75impl fmt::Display for NodeId {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81impl<'de> Deserialize<'de> for NodeId {
82    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83    where
84        D: serde::Deserializer<'de>,
85    {
86        let s = String::deserialize(deserializer)?;
87        NodeId::new(&s).map_err(serde::de::Error::custom)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_valid_node_ids() {
97        assert!(NodeId::new("web-app").is_ok());
98        assert!(NodeId::new("auth.service").is_ok());
99        assert!(NodeId::new("db-01").is_ok());
100        assert!(NodeId::new("a").is_ok());
101        assert!(NodeId::new("1password").is_ok());
102        assert!(NodeId::new("my_component").is_ok());
103    }
104
105    #[test]
106    fn test_empty_rejected() {
107        assert!(NodeId::new("").is_err());
108    }
109
110    #[test]
111    fn test_uppercase_rejected() {
112        assert!(NodeId::new("WebApp").is_err());
113        assert!(NodeId::new("ALLCAPS").is_err());
114    }
115
116    #[test]
117    fn test_spaces_rejected() {
118        assert!(NodeId::new("web app").is_err());
119        assert!(NodeId::new(" leading").is_err());
120    }
121
122    #[test]
123    fn test_special_chars_rejected() {
124        assert!(NodeId::new("web@app").is_err());
125        assert!(NodeId::new("web/app").is_err());
126        assert!(NodeId::new("web#app").is_err());
127    }
128
129    #[test]
130    fn test_starts_with_hyphen_rejected() {
131        assert!(NodeId::new("-web").is_err());
132    }
133
134    #[test]
135    fn test_display() {
136        let id = NodeId::new("web-app").unwrap();
137        assert_eq!(format!("{id}"), "web-app");
138    }
139
140    #[test]
141    fn test_serde_round_trip() {
142        let id = NodeId::new("web-app").unwrap();
143        let json = serde_json::to_string(&id).unwrap();
144        assert_eq!(json, "\"web-app\"");
145        let deserialized: NodeId = serde_json::from_str(&json).unwrap();
146        assert_eq!(id, deserialized);
147    }
148
149    #[test]
150    fn test_serde_rejects_invalid() {
151        let result: Result<NodeId, _> = serde_json::from_str("\"Has Spaces\"");
152        assert!(result.is_err());
153    }
154
155    #[test]
156    fn test_as_str() {
157        let id = NodeId::new("my-node").unwrap();
158        assert_eq!(id.as_str(), "my-node");
159    }
160
161    #[test]
162    fn test_clone_and_hash() {
163        use std::collections::HashSet;
164        let id = NodeId::new("x").unwrap();
165        let cloned = id.clone();
166        assert_eq!(id, cloned);
167
168        let mut set = HashSet::new();
169        set.insert(id);
170        set.insert(cloned);
171        assert_eq!(set.len(), 1);
172    }
173
174    #[test]
175    fn test_dots_and_underscores_valid() {
176        assert!(NodeId::new("a.b.c").is_ok());
177        assert!(NodeId::new("a_b_c").is_ok());
178        assert!(NodeId::new("a-b_c.d").is_ok());
179    }
180}