Skip to main content

fizzy_sdk/
types.rs

1//! The scalar types the model speaks that Rust has no single spelling for.
2
3use std::fmt;
4
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8/// An instant Fizzy reports, always with its offset.
9pub type DateTime = chrono::DateTime<Utc>;
10
11/// Reads a required field that arrived as `null` as its type's default. Reach for it with
12/// `#[serde(default, deserialize_with = "crate::types::null_as_default::deserialize")]`,
13/// which the generator puts on every required field of a type that has a zero value.
14///
15/// Fizzy writes `null` where it has nothing for a field the model calls required, and Go's
16/// `encoding/json` reads that into a non-pointer as a no-op — the field keeps its zero
17/// value. `#[serde(default)]` alone only covers the field being absent, so without this a
18/// `null` fails the whole response where Go reads it as `""` or `0`.
19pub mod null_as_default {
20    use serde::{Deserialize, Deserializer};
21
22    /// Reads `null` as `T::default()`.
23    pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de> + Default>(
24        deserializer: D,
25    ) -> Result<T, D::Error> {
26        Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
27    }
28}
29
30/// A string that must not end up in logs: an email address, a person's name, a token. It
31/// prints as `[REDACTED]`; call [`SensitiveString::expose`] to read it.
32#[derive(Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct SensitiveString(String);
35
36impl SensitiveString {
37    /// Wraps a value.
38    pub fn new(value: impl Into<String>) -> SensitiveString {
39        SensitiveString(value.into())
40    }
41
42    /// The value itself.
43    pub fn expose(&self) -> &str {
44        &self.0
45    }
46
47    /// The value itself, owned.
48    pub fn into_inner(self) -> String {
49        self.0
50    }
51
52    /// Whether there is nothing to hide.
53    pub fn is_empty(&self) -> bool {
54        self.0.is_empty()
55    }
56}
57
58impl From<String> for SensitiveString {
59    fn from(value: String) -> SensitiveString {
60        SensitiveString(value)
61    }
62}
63
64impl From<&str> for SensitiveString {
65    fn from(value: &str) -> SensitiveString {
66        SensitiveString(value.to_string())
67    }
68}
69
70impl fmt::Debug for SensitiveString {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        if self.0.is_empty() {
73            f.write_str("\"\"")
74        } else {
75            f.write_str("[REDACTED]")
76        }
77    }
78}
79
80impl fmt::Display for SensitiveString {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        if self.0.is_empty() {
83            Ok(())
84        } else {
85            f.write_str("[REDACTED]")
86        }
87    }
88}
89
90#[cfg(test)]
91#[allow(clippy::unwrap_used)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn sensitive_strings_hide_their_value() {
97        let secret = SensitiveString::new("jane@example.com");
98        assert_eq!(format!("{secret:?}"), "[REDACTED]");
99        assert_eq!(secret.to_string(), "[REDACTED]");
100        assert_eq!(secret.expose(), "jane@example.com");
101        assert_eq!(
102            serde_json::to_string(&secret).unwrap(),
103            "\"jane@example.com\""
104        );
105    }
106
107    #[test]
108    fn timestamps_read_with_any_offset() {
109        let utc: DateTime = serde_json::from_str("\"2026-01-01T00:00:00Z\"").unwrap();
110        let offset: DateTime = serde_json::from_str("\"2025-12-31T19:00:00-05:00\"").unwrap();
111        assert_eq!(utc, offset);
112    }
113}