1use std::fmt;
3use std::str::FromStr;
4
5use bitcoin::base64::engine::general_purpose;
6use bitcoin::base64::Engine as _;
7use serde::{de, Deserialize, Deserializer, Serialize};
8use thiserror::Error;
9use uuid::Uuid;
10
11#[derive(Debug, Error)]
13pub enum QuoteIdError {
14 #[error("invalid UUID: {0}")]
16 Uuid(#[from] uuid::Error),
17 #[error("invalid base64")]
19 Base64,
20 #[error("neither a valid UUID nor a valid base64 string")]
22 InvalidQuoteId,
23}
24
25#[derive(Serialize, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)]
27#[serde(untagged)]
28pub enum QuoteId {
29 BASE64(String),
31 UUID(Uuid),
33}
34
35impl QuoteId {
36 pub fn new() -> Self {
38 Self::UUID(Uuid::now_v7())
39 }
40}
41
42impl Default for QuoteId {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl From<Uuid> for QuoteId {
49 fn from(uuid: Uuid) -> Self {
50 Self::UUID(uuid)
51 }
52}
53
54impl fmt::Display for QuoteId {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 QuoteId::BASE64(s) => write!(f, "{s}"),
58 QuoteId::UUID(u) => write!(f, "{}", u.hyphenated()),
59 }
60 }
61}
62
63impl FromStr for QuoteId {
64 type Err = QuoteIdError;
65
66 fn from_str(s: &str) -> Result<Self, Self::Err> {
67 if let Ok(u) = Uuid::parse_str(s) {
69 return Ok(QuoteId::UUID(u));
70 }
71
72 match general_purpose::URL_SAFE.decode(s) {
76 Ok(_bytes) => Ok(QuoteId::BASE64(s.to_string())),
77 Err(_) => Err(QuoteIdError::InvalidQuoteId),
78 }
79 }
80}
81
82impl<'de> Deserialize<'de> for QuoteId {
83 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84 where
85 D: Deserializer<'de>,
86 {
87 let s = String::deserialize(deserializer)?;
89
90 if let Ok(u) = Uuid::parse_str(&s) {
92 return Ok(QuoteId::UUID(u));
93 }
94
95 if general_purpose::URL_SAFE.decode(&s).is_ok() {
96 return Ok(QuoteId::BASE64(s));
97 }
98
99 Err(de::Error::custom(format!(
101 "QuoteId must be either a UUID (e.g. {}) or a valid base64 string; got: {}",
102 Uuid::nil(),
103 s
104 )))
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_quote_id_display_uuid() {
114 let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
116 let quote_id = QuoteId::UUID(uuid);
117 let displayed = quote_id.to_string();
118 assert_eq!(displayed, "550e8400-e29b-41d4-a716-446655440000");
119 assert!(!displayed.is_empty());
120
121 let parsed: QuoteId = displayed.parse().unwrap();
123 assert_eq!(quote_id, parsed);
124 }
125
126 #[test]
127 fn test_quote_id_new_uses_uuid_v7() {
128 let QuoteId::UUID(uuid) = QuoteId::new() else {
129 panic!("new should create a UUID quote ID");
130 };
131
132 assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand));
133 }
134
135 #[test]
136 fn test_quote_id_from_uuid_preserves_uuid() {
137 let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
138
139 assert_eq!(QuoteId::from(uuid), QuoteId::UUID(uuid));
140 }
141
142 #[test]
143 fn test_quote_id_display_base64() {
144 let base64_str = "SGVsbG8gV29ybGQh"; let base64_id = QuoteId::BASE64(base64_str.to_string());
147 let displayed = base64_id.to_string();
148 assert_eq!(displayed, base64_str);
149 assert!(!displayed.is_empty());
150
151 let parsed: QuoteId = displayed.parse().unwrap();
153 assert_eq!(base64_id, parsed);
154 }
155
156 #[test]
157 fn test_quote_id_deserialize_uuid_preserves_uuid() {
158 let quote_id: QuoteId =
159 serde_json::from_str(r#""550e8400-e29b-41d4-a716-446655440000""#).unwrap();
160
161 assert_eq!(
162 quote_id,
163 QuoteId::UUID(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap())
164 );
165 }
166
167 #[test]
168 fn test_quote_id_deserialize_base64_preserves_base64() {
169 let quote_id: QuoteId = serde_json::from_str(r#""SGVsbG8gV29ybGQh""#).unwrap();
170
171 assert_eq!(quote_id, QuoteId::BASE64("SGVsbG8gV29ybGQh".to_string()));
172 }
173
174 #[test]
175 fn test_quote_id_deserialize_rejects_invalid_id() {
176 let err = serde_json::from_str::<QuoteId>(r#""not a quote id""#).unwrap_err();
177
178 assert!(err.to_string().contains("QuoteId must be either a UUID"));
179 }
180}