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