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