saya_types/contract/
preference.rs1use serde::{Deserialize, Serialize};
14
15use crate::contract::error::ContractError;
16use crate::contract::identity::ProfileIdentity;
17use crate::contract::scope::ScopeRequirement;
18
19pub const MAX_TIMEZONE_CHARS: usize = 64;
22pub const MAX_PROFILE_NAME_CHARS: usize = 128;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(tag = "kind", rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum PreferenceValue {
40 #[non_exhaustive]
41 Timezone { value: String },
42 #[non_exhaustive]
43 DateGrain { grain: DateGrain },
44 #[non_exhaustive]
45 OutputStyle { style: OutputStyle },
46 #[non_exhaustive]
47 DefaultProfile { name: String },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum DateGrain {
55 Day,
56 Week,
57 Month,
58 Quarter,
59 Year,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum OutputStyle {
67 Table,
68 Compact,
69 Narrative,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[non_exhaustive]
76pub enum PreferenceScope {
77 Global,
78 Profile(ProfileIdentity),
79}
80
81impl PreferenceValue {
82 pub fn kind(&self) -> &'static str {
85 match self {
86 Self::Timezone { .. } => "timezone",
87 Self::DateGrain { .. } => "date_grain",
88 Self::OutputStyle { .. } => "output_style",
89 Self::DefaultProfile { .. } => "default_profile",
90 }
91 }
92
93 pub fn required_scope(&self) -> ScopeRequirement {
96 match self {
97 Self::Timezone { .. } | Self::DateGrain { .. } => ScopeRequirement::Profile,
98 Self::OutputStyle { .. } | Self::DefaultProfile { .. } => ScopeRequirement::Global,
99 }
100 }
101
102 pub fn matches_scope(&self, scope: &PreferenceScope) -> bool {
105 self.required_scope().matches(scope)
106 }
107
108 pub fn timezone(value: impl AsRef<str>) -> Result<Self, ContractError> {
114 let value = value.as_ref();
115 validate_timezone(value)?;
116 Ok(Self::Timezone {
117 value: value.to_owned(),
118 })
119 }
120
121 pub fn date_grain(grain: DateGrain) -> Self {
122 Self::DateGrain { grain }
123 }
124
125 pub fn output_style(style: OutputStyle) -> Self {
126 Self::OutputStyle { style }
127 }
128
129 pub fn default_profile(name: impl AsRef<str>) -> Result<Self, ContractError> {
135 let name = name.as_ref();
136 validate_profile_name(name)?;
137 Ok(Self::DefaultProfile {
138 name: name.to_owned(),
139 })
140 }
141
142 pub fn timezone_value(&self) -> Option<&str> {
145 match self {
146 Self::Timezone { value } => Some(value),
147 _ => None,
148 }
149 }
150
151 pub fn default_profile_name(&self) -> Option<&str> {
153 match self {
154 Self::DefaultProfile { name } => Some(name),
155 _ => None,
156 }
157 }
158}
159
160fn validate_timezone(value: &str) -> Result<(), ContractError> {
163 if value.is_empty() {
164 return Err(ContractError::InvalidTimezone);
165 }
166 if value.len() > MAX_TIMEZONE_CHARS {
167 return Err(ContractError::InvalidTimezone);
168 }
169 if !value
170 .bytes()
171 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'_' | b'+' | b'-'))
172 {
173 return Err(ContractError::InvalidTimezone);
174 }
175 Ok(())
176}
177
178fn validate_profile_name(value: &str) -> Result<(), ContractError> {
183 if value.is_empty() {
184 return Err(ContractError::InvalidProfileName);
185 }
186 if value.chars().count() > MAX_PROFILE_NAME_CHARS {
187 return Err(ContractError::InvalidProfileName);
188 }
189 if value.chars().any(|c| c.is_control()) {
190 return Err(ContractError::InvalidProfileName);
191 }
192 Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn timezone_accepts_well_shaped_real_name() {
201 let v = PreferenceValue::timezone("Europe/London").unwrap();
202 assert_eq!(v.kind(), "timezone");
203 assert_eq!(v.timezone_value(), Some("Europe/London"));
204 assert_eq!(v.required_scope(), ScopeRequirement::Profile);
205 }
206
207 #[test]
208 fn timezone_accepts_well_shaped_fictional_name() {
209 assert!(PreferenceValue::timezone("Mars/Olympus_Mons").is_ok());
212 assert!(PreferenceValue::timezone("Etc/GMT+5").is_ok());
213 }
214
215 #[test]
216 fn timezone_rejects_malformed_shapes() {
217 assert!(PreferenceValue::timezone("").is_err());
218 assert!(PreferenceValue::timezone("Europe/London!").is_err());
219 assert!(PreferenceValue::timezone("has space").is_err());
220 assert!(PreferenceValue::timezone("Europe\\London").is_err());
221 assert!(PreferenceValue::timezone("Europe\nLondon").is_err());
222 assert!(PreferenceValue::timezone("x".repeat(MAX_TIMEZONE_CHARS + 1)).is_err());
223 }
224
225 #[test]
226 fn date_grain_round_trips_and_is_profile_scoped() {
227 for grain in [
228 DateGrain::Day,
229 DateGrain::Week,
230 DateGrain::Month,
231 DateGrain::Quarter,
232 DateGrain::Year,
233 ] {
234 let v = PreferenceValue::date_grain(grain);
235 assert_eq!(v.kind(), "date_grain");
236 assert_eq!(v.required_scope(), ScopeRequirement::Profile);
237 let json = serde_json::to_string(&v).unwrap();
238 let back: PreferenceValue = serde_json::from_str(&json).unwrap();
239 assert_eq!(v, back);
240 }
241 }
242
243 #[test]
244 fn output_style_round_trips_and_is_global_scoped() {
245 for style in [
246 OutputStyle::Table,
247 OutputStyle::Compact,
248 OutputStyle::Narrative,
249 ] {
250 let v = PreferenceValue::output_style(style);
251 assert_eq!(v.kind(), "output_style");
252 assert_eq!(v.required_scope(), ScopeRequirement::Global);
253 let json = serde_json::to_string(&v).unwrap();
254 let back: PreferenceValue = serde_json::from_str(&json).unwrap();
255 assert_eq!(v, back);
256 }
257 }
258
259 #[test]
260 fn default_profile_accepts_name_and_is_global_scoped() {
261 let v = PreferenceValue::default_profile("warehouse").unwrap();
262 assert_eq!(v.kind(), "default_profile");
263 assert_eq!(v.default_profile_name(), Some("warehouse"));
264 assert_eq!(v.required_scope(), ScopeRequirement::Global);
265 }
266
267 #[test]
268 fn default_profile_rejects_bad_names() {
269 assert!(PreferenceValue::default_profile("").is_err());
275 assert!(PreferenceValue::default_profile("name\n").is_err());
276 assert!(PreferenceValue::default_profile("name\u{0}").is_err());
277 assert!(PreferenceValue::default_profile("x".repeat(MAX_PROFILE_NAME_CHARS + 1)).is_err());
278 assert!(PreferenceValue::default_profile("has space").is_ok());
279 }
280
281 #[test]
282 fn timezone_round_trips_through_serde() {
283 let v = PreferenceValue::timezone("America/New_York").unwrap();
284 let json = serde_json::to_string(&v).unwrap();
285 let back: PreferenceValue = serde_json::from_str(&json).unwrap();
286 assert_eq!(v, back);
287 }
288}