Skip to main content

saya_types/contract/
preference.rs

1//! Scoped user preferences (Plan §12 Phase 5).
2//!
3//! A preference is **not a claim**. A claim is about a database object, bound to
4//! a qualified name and a schema fingerprint, and stale when that object drifts.
5//! "I work in Europe/London" is about a *person* — no object, no fingerprint, no
6//! drift — so it gets its own typed value, not a `ClaimPayload` variant.
7//!
8//! Every variant below is a closed enum or a bounded, shape-validated string.
9//! There is deliberately no free-text variant: a preference must never carry SQL,
10//! secrets, or free-form instructions, and the way to guarantee that is to make
11//! it *unrepresentable* rather than to filter it.
12
13use serde::{Deserialize, Serialize};
14
15use crate::contract::error::ContractError;
16use crate::contract::identity::ProfileIdentity;
17use crate::contract::scope::ScopeRequirement;
18
19/// The maximum length of a timezone string. IANA names are short; 64 is far
20/// above the longest real one and keeps the column cheap.
21pub const MAX_TIMEZONE_CHARS: usize = 64;
22/// The maximum length of a profile *name*. Names come from `connections.toml`,
23/// not the identity; a sensible bound keeps the column cheap.
24pub const MAX_PROFILE_NAME_CHARS: usize = 128;
25
26/// A bounded user preference. Closed enums and shape-validated strings only;
27/// no free-text variant exists by design.
28///
29/// `Deserialize` is hand-rolled, not derived: the string-carrying variants run
30/// their shape validators on deserialization too, so a `{"kind":"timezone",
31/// "value":"SELECT ..."}` row is refused by the *type*, not only by the store's
32/// admission gate. A derived `Deserialize` would populate the field directly
33/// and bypass the constructors — exactly the "validated constructor beside a
34/// publicly-constructible variant" the security standard warns about, and the
35/// spec's "make it unrepresentable, not filtered" rule.
36#[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/// The reporting grain for date-shaped results.
51#[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/// How a result is rendered in the terminal.
63#[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/// What a preference applies to. Presentation choices are global; database-shaped
73/// choices are scoped to one connection profile.
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[non_exhaustive]
76pub enum PreferenceScope {
77    Global,
78    Profile(ProfileIdentity),
79}
80
81impl PreferenceValue {
82    /// The stable discriminator persisted as `preference_kind`, and used as
83    /// part of the store's primary key.
84    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    /// The scope this value must be stored under. A value set at the wrong
94    /// scope is a typed error, never a silent coercion.
95    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    /// True if `scope` is the one this value requires. Convenience over
103    /// `required_scope().matches(scope)`.
104    pub fn matches_scope(&self, scope: &PreferenceScope) -> bool {
105        self.required_scope().matches(scope)
106    }
107
108    /// Construct a timezone preference. Validation is *shape only*: non-empty,
109    /// ≤64 chars, ASCII alphanumeric plus `/`, `_`, `+` and `-`. A wrong-but-
110    /// well-shaped timezone is a user error they can see and fix; bundling an
111    /// IANA list to validate membership would be a maintenance burden that goes
112    /// stale. A fictional-but-well-shaped name is accepted on purpose.
113    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    /// Construct a default-profile preference from a profile *name*. The name is
130    /// validated by the same shape rules as a database object name: non-empty,
131    /// bounded, no control characters. A name is not an identity — it never
132    /// reaches the database bytes as `p-…`, and it is resolved against the live
133    /// `connections.toml` at use time, not stored as a pointer to a profile.
134    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    /// The persisted string for a `Timezone` value, or `None` for other kinds.
143    /// Used only by tests that assert a round-tripped value byte-for-byte.
144    pub fn timezone_value(&self) -> Option<&str> {
145        match self {
146            Self::Timezone { value } => Some(value),
147            _ => None,
148        }
149    }
150
151    /// The persisted name for a `DefaultProfile` value, or `None` for other kinds.
152    pub fn default_profile_name(&self) -> Option<&str> {
153        match self {
154            Self::DefaultProfile { name } => Some(name),
155            _ => None,
156        }
157    }
158}
159
160/// Validates a timezone by shape: non-empty, ≤`MAX_TIMEZONE_CHARS` chars, ASCII
161/// alphanumeric plus `/`, `_`, `+` and `-`. Shape, not membership.
162fn 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
178/// Validates a profile name by shape: non-empty, ≤`MAX_PROFILE_NAME_CHARS`
179/// chars, no control characters. Reuses the same notion of "name" as
180/// `validate_name` in `identity` (which database object names go through),
181/// without the identity's hex profile prefix.
182fn 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        // Shape only: a fictional-but-well-shaped name is a user error they can
210        // see and fix, not a rejection at storage. Bundling an IANA list is out.
211        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        // Profile names come from `connections.toml`, where a name is an
270        // arbitrary map key — config does not reject spaces. A name is rejected
271        // only for being empty, too long, or carrying control characters: the
272        // same shape `validate_name` applies to database object names. A space
273        // is a legitimate character in a profile name, so it is accepted.
274        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}