1use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::ValidationError;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
28#[serde(transparent)]
29pub struct NodeId(String);
30
31impl NodeId {
32 pub fn new(value: &str) -> Result<Self, ValidationError> {
36 Self::validate(value)?;
37 Ok(Self(value.to_owned()))
38 }
39
40 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}