1use crate::validation::Validate;
2use serde::{Deserialize, Serialize};
3use std::{fmt, str::FromStr};
4
5#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
14pub struct StableKey(String);
15
16impl StableKey {
17 pub fn parse(value: impl AsRef<str>) -> Result<Self, StableKeyError> {
22 validate(value.as_ref())?;
23 Ok(Self(value.as_ref().to_string()))
24 }
25
26 #[must_use]
28 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31
32 #[must_use]
34 pub fn into_string(self) -> String {
35 self.0
36 }
37}
38
39impl Validate for StableKey {
40 type Error = StableKeyError;
41
42 fn validate(&self) -> Result<(), Self::Error> {
43 validate(&self.0)
44 }
45}
46
47impl AsRef<str> for StableKey {
48 fn as_ref(&self) -> &str {
49 self.as_str()
50 }
51}
52
53impl fmt::Display for StableKey {
54 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55 formatter.write_str(self.as_str())
56 }
57}
58
59impl FromStr for StableKey {
60 type Err = StableKeyError;
61
62 fn from_str(value: &str) -> Result<Self, Self::Err> {
63 Self::parse(value)
64 }
65}
66
67#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
72#[error("stable key '{stable_key}' is invalid: {reason}")]
73pub struct StableKeyError {
74 pub stable_key: String,
76 pub reason: &'static str,
78}
79
80fn validate(stable_key: &str) -> Result<(), StableKeyError> {
81 if stable_key.is_empty() {
82 return invalid(stable_key, "must not be empty");
83 }
84 if stable_key.len() > 128 {
85 return invalid(stable_key, "must be at most 128 bytes");
86 }
87 if !stable_key.is_ascii() {
88 return invalid(stable_key, "must be ASCII");
89 }
90 if stable_key.bytes().any(|byte| byte.is_ascii_uppercase()) {
91 return invalid(stable_key, "must be lowercase");
92 }
93 if stable_key.contains(char::is_whitespace) {
94 return invalid(stable_key, "must not contain whitespace");
95 }
96 if stable_key.contains('/') || stable_key.contains('-') {
97 return invalid(stable_key, "must not contain slashes or hyphens");
98 }
99 if stable_key.starts_with('.') || stable_key.ends_with('.') {
100 return invalid(stable_key, "must not start or end with a dot");
101 }
102
103 let Some(version_index) = stable_key.rfind(".v") else {
104 return invalid(stable_key, "must end with .vN");
105 };
106 let version = &stable_key[version_index + 2..];
107 if version.is_empty()
108 || version.starts_with('0')
109 || !version.bytes().all(|byte| byte.is_ascii_digit())
110 {
111 return invalid(stable_key, "version suffix must be nonzero .vN");
112 }
113
114 let prefix = &stable_key[..version_index];
115 if prefix.is_empty() {
116 return invalid(
117 stable_key,
118 "must contain at least one segment before version",
119 );
120 }
121
122 for segment in prefix.split('.') {
123 validate_segment(stable_key, segment)?;
124 }
125
126 Ok(())
127}
128
129fn validate_segment(stable_key: &str, segment: &str) -> Result<(), StableKeyError> {
130 if segment.is_empty() {
131 return invalid(stable_key, "must not contain empty segments");
132 }
133 let mut bytes = segment.bytes();
134 let Some(first) = bytes.next() else {
135 return invalid(stable_key, "must not contain empty segments");
136 };
137 if !first.is_ascii_lowercase() {
138 return invalid(stable_key, "segments must start with a lowercase letter");
139 }
140 if !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') {
141 return invalid(
142 stable_key,
143 "segments may contain only lowercase letters, digits, and underscores",
144 );
145 }
146 Ok(())
147}
148
149fn invalid<T>(stable_key: &str, reason: &'static str) -> Result<T, StableKeyError> {
150 Err(StableKeyError {
151 stable_key: stable_key.to_string(),
152 reason,
153 })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn accepts_canonical_keys() {
162 assert_eq!(
163 StableKey::parse("app.users.primary.v1")
164 .expect("valid key")
165 .as_str(),
166 "app.users.primary.v1"
167 );
168 assert!(StableKey::parse("framework.core.auth_state.v12").is_ok());
169 }
170
171 #[test]
172 fn rejects_noncanonical_keys() {
173 for key in [
174 "",
175 "App.users.v1",
176 "app.users",
177 "app.users.v0",
178 "app..users.v1",
179 ".app.users.v1",
180 "app.users.v1.",
181 "app-users.v1",
182 "app/users.v1",
183 "app.1users.v1",
184 ] {
185 assert!(StableKey::parse(key).is_err(), "{key} should fail");
186 }
187 }
188}