use serde::{Deserialize, Serialize};
use crate::contract::error::ContractError;
use crate::contract::identity::ProfileIdentity;
use crate::contract::scope::ScopeRequirement;
pub const MAX_TIMEZONE_CHARS: usize = 64;
pub const MAX_PROFILE_NAME_CHARS: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum PreferenceValue {
#[non_exhaustive]
Timezone { value: String },
#[non_exhaustive]
DateGrain { grain: DateGrain },
#[non_exhaustive]
OutputStyle { style: OutputStyle },
#[non_exhaustive]
DefaultProfile { name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DateGrain {
Day,
Week,
Month,
Quarter,
Year,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OutputStyle {
Table,
Compact,
Narrative,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PreferenceScope {
Global,
Profile(ProfileIdentity),
}
impl PreferenceValue {
pub fn kind(&self) -> &'static str {
match self {
Self::Timezone { .. } => "timezone",
Self::DateGrain { .. } => "date_grain",
Self::OutputStyle { .. } => "output_style",
Self::DefaultProfile { .. } => "default_profile",
}
}
pub fn required_scope(&self) -> ScopeRequirement {
match self {
Self::Timezone { .. } | Self::DateGrain { .. } => ScopeRequirement::Profile,
Self::OutputStyle { .. } | Self::DefaultProfile { .. } => ScopeRequirement::Global,
}
}
pub fn matches_scope(&self, scope: &PreferenceScope) -> bool {
self.required_scope().matches(scope)
}
pub fn timezone(value: impl AsRef<str>) -> Result<Self, ContractError> {
let value = value.as_ref();
validate_timezone(value)?;
Ok(Self::Timezone {
value: value.to_owned(),
})
}
pub fn date_grain(grain: DateGrain) -> Self {
Self::DateGrain { grain }
}
pub fn output_style(style: OutputStyle) -> Self {
Self::OutputStyle { style }
}
pub fn default_profile(name: impl AsRef<str>) -> Result<Self, ContractError> {
let name = name.as_ref();
validate_profile_name(name)?;
Ok(Self::DefaultProfile {
name: name.to_owned(),
})
}
pub fn timezone_value(&self) -> Option<&str> {
match self {
Self::Timezone { value } => Some(value),
_ => None,
}
}
pub fn default_profile_name(&self) -> Option<&str> {
match self {
Self::DefaultProfile { name } => Some(name),
_ => None,
}
}
}
fn validate_timezone(value: &str) -> Result<(), ContractError> {
if value.is_empty() {
return Err(ContractError::InvalidTimezone);
}
if value.len() > MAX_TIMEZONE_CHARS {
return Err(ContractError::InvalidTimezone);
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'_' | b'+' | b'-'))
{
return Err(ContractError::InvalidTimezone);
}
Ok(())
}
fn validate_profile_name(value: &str) -> Result<(), ContractError> {
if value.is_empty() {
return Err(ContractError::InvalidProfileName);
}
if value.chars().count() > MAX_PROFILE_NAME_CHARS {
return Err(ContractError::InvalidProfileName);
}
if value.chars().any(|c| c.is_control()) {
return Err(ContractError::InvalidProfileName);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timezone_accepts_well_shaped_real_name() {
let v = PreferenceValue::timezone("Europe/London").unwrap();
assert_eq!(v.kind(), "timezone");
assert_eq!(v.timezone_value(), Some("Europe/London"));
assert_eq!(v.required_scope(), ScopeRequirement::Profile);
}
#[test]
fn timezone_accepts_well_shaped_fictional_name() {
assert!(PreferenceValue::timezone("Mars/Olympus_Mons").is_ok());
assert!(PreferenceValue::timezone("Etc/GMT+5").is_ok());
}
#[test]
fn timezone_rejects_malformed_shapes() {
assert!(PreferenceValue::timezone("").is_err());
assert!(PreferenceValue::timezone("Europe/London!").is_err());
assert!(PreferenceValue::timezone("has space").is_err());
assert!(PreferenceValue::timezone("Europe\\London").is_err());
assert!(PreferenceValue::timezone("Europe\nLondon").is_err());
assert!(PreferenceValue::timezone("x".repeat(MAX_TIMEZONE_CHARS + 1)).is_err());
}
#[test]
fn date_grain_round_trips_and_is_profile_scoped() {
for grain in [
DateGrain::Day,
DateGrain::Week,
DateGrain::Month,
DateGrain::Quarter,
DateGrain::Year,
] {
let v = PreferenceValue::date_grain(grain);
assert_eq!(v.kind(), "date_grain");
assert_eq!(v.required_scope(), ScopeRequirement::Profile);
let json = serde_json::to_string(&v).unwrap();
let back: PreferenceValue = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn output_style_round_trips_and_is_global_scoped() {
for style in [
OutputStyle::Table,
OutputStyle::Compact,
OutputStyle::Narrative,
] {
let v = PreferenceValue::output_style(style);
assert_eq!(v.kind(), "output_style");
assert_eq!(v.required_scope(), ScopeRequirement::Global);
let json = serde_json::to_string(&v).unwrap();
let back: PreferenceValue = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn default_profile_accepts_name_and_is_global_scoped() {
let v = PreferenceValue::default_profile("warehouse").unwrap();
assert_eq!(v.kind(), "default_profile");
assert_eq!(v.default_profile_name(), Some("warehouse"));
assert_eq!(v.required_scope(), ScopeRequirement::Global);
}
#[test]
fn default_profile_rejects_bad_names() {
assert!(PreferenceValue::default_profile("").is_err());
assert!(PreferenceValue::default_profile("name\n").is_err());
assert!(PreferenceValue::default_profile("name\u{0}").is_err());
assert!(PreferenceValue::default_profile("x".repeat(MAX_PROFILE_NAME_CHARS + 1)).is_err());
assert!(PreferenceValue::default_profile("has space").is_ok());
}
#[test]
fn timezone_round_trips_through_serde() {
let v = PreferenceValue::timezone("America/New_York").unwrap();
let json = serde_json::to_string(&v).unwrap();
let back: PreferenceValue = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}