1use core::fmt;
10use core::str::FromStr;
11
12use uuid::Uuid;
13
14use crate::error::IdError;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(transparent))]
20pub struct SiteId(Uuid);
21
22impl SiteId {
23 #[must_use]
25 pub fn new() -> Self {
26 Self(Uuid::now_v7())
27 }
28
29 #[must_use]
31 pub const fn from_uuid(uuid: Uuid) -> Self {
32 Self(uuid)
33 }
34
35 #[must_use]
37 pub const fn as_uuid(&self) -> &Uuid {
38 &self.0
39 }
40}
41
42impl Default for SiteId {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl fmt::Display for SiteId {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 self.0.fmt(f)
51 }
52}
53
54impl FromStr for SiteId {
55 type Err = uuid::Error;
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 Uuid::parse_str(s).map(Self)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64#[cfg_attr(feature = "serde", serde(transparent))]
65pub struct PlanId(Uuid);
66
67impl PlanId {
68 #[must_use]
70 pub fn new() -> Self {
71 Self(Uuid::now_v7())
72 }
73
74 #[must_use]
76 pub const fn as_uuid(&self) -> &Uuid {
77 &self.0
78 }
79}
80
81impl Default for PlanId {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl fmt::Display for PlanId {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 self.0.fmt(f)
90 }
91}
92
93macro_rules! slug_id {
94 ($(#[$meta:meta])* $name:ident) => {
95 $(#[$meta])*
96 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
97 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98 #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
99 pub struct $name(String);
100
101 impl $name {
102 pub fn new(name: impl AsRef<str>) -> Result<Self, IdError> {
112 let name = name.as_ref().trim().to_ascii_lowercase();
113 if name.is_empty() {
114 return Err(IdError::Empty);
115 }
116 if name.chars().count() > 64 {
117 return Err(IdError::TooLong(name.chars().count()));
118 }
119 if let Some(bad) = name
120 .chars()
121 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
122 {
123 return Err(IdError::BadCharacter(bad));
124 }
125 Ok(Self(name))
126 }
127
128 #[must_use]
130 pub fn as_str(&self) -> &str {
131 &self.0
132 }
133 }
134
135 impl fmt::Display for $name {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 f.write_str(&self.0)
138 }
139 }
140
141 impl FromStr for $name {
142 type Err = IdError;
143 fn from_str(s: &str) -> Result<Self, Self::Err> {
144 Self::new(s)
145 }
146 }
147
148 impl TryFrom<String> for $name {
149 type Error = IdError;
150 fn try_from(value: String) -> Result<Self, Self::Error> {
151 Self::new(value)
152 }
153 }
154
155 impl From<$name> for String {
156 fn from(value: $name) -> Self {
157 value.0
158 }
159 }
160
161 impl AsRef<str> for $name {
162 fn as_ref(&self) -> &str {
163 &self.0
164 }
165 }
166 };
167}
168
169slug_id!(
170 AssetId
172);
173slug_id!(
174 CircuitId
176);
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn asset_ids_are_case_folded_and_validated() {
184 assert_eq!(
185 AssetId::new("Wallbox-Garage").unwrap().as_str(),
186 "wallbox-garage"
187 );
188 assert_eq!(AssetId::new(" battery ").unwrap().as_str(), "battery");
189 assert_eq!(AssetId::new(""), Err(IdError::Empty));
190 assert_eq!(AssetId::new("wall box"), Err(IdError::BadCharacter(' ')));
191 assert_eq!(AssetId::new("a".repeat(65)), Err(IdError::TooLong(65)));
192 }
193
194 #[test]
195 fn site_ids_are_time_ordered() {
196 let a = SiteId::new();
197 let b = SiteId::new();
198 assert!(a < b, "v7 UUIDs sort by creation time");
199 }
200}