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