Skip to main content

max / goingson

6.9 KB · 232 lines History Blame Raw
1 //! Shared utilities for SQLite repository implementations.
2
3 use chrono::{DateTime, Utc};
4 use goingson_core::CoreError;
5 use uuid::Uuid;
6
7 /// SQLite datetime format string.
8 const SQLITE_DATETIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
9
10 /// Format a `DateTime<Utc>` for SQLite storage.
11 ///
12 /// Uses the standard SQLite datetime format: `YYYY-MM-DD HH:MM:SS`
13 #[inline]
14 #[tracing::instrument(skip_all)]
15 pub fn format_datetime(dt: &DateTime<Utc>) -> String {
16 dt.format(SQLITE_DATETIME_FORMAT).to_string()
17 }
18
19 /// Format a `DateTime<Utc>` for SQLite storage, returning the current time if `None`.
20 #[inline]
21 #[tracing::instrument(skip_all)]
22 pub fn format_datetime_now() -> String {
23 format_datetime(&Utc::now())
24 }
25
26 /// Format an optional `DateTime<Utc>` for SQLite storage.
27 #[inline]
28 #[tracing::instrument(skip_all)]
29 pub fn format_datetime_opt(dt: Option<DateTime<Utc>>) -> Option<String> {
30 dt.map(|d| format_datetime(&d))
31 }
32
33 /// Parse a datetime string from SQLite.
34 /// Supports RFC3339, SQLite datetime, and date-only formats.
35 #[tracing::instrument(skip_all)]
36 pub fn parse_datetime(s: &str) -> Result<DateTime<Utc>, CoreError> {
37 chrono::DateTime::parse_from_rfc3339(s)
38 .map(|dt| dt.with_timezone(&Utc))
39 .or_else(|_| {
40 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
41 .map(|dt| dt.and_utc())
42 })
43 .or_else(|_| {
44 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
45 .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
46 })
47 .map_err(|e| CoreError::database_msg(format!("Invalid date: {}", e)))
48 }
49
50 /// Parse a JSON array string into a `Vec<String>`.
51 /// Returns empty vec on parse failure.
52 #[tracing::instrument(skip_all)]
53 pub fn parse_tags(s: &str) -> Vec<String> {
54 serde_json::from_str(s).unwrap_or_default()
55 }
56
57 /// Parse a UUID string, converting parse errors to CoreError.
58 #[tracing::instrument(skip_all)]
59 pub fn parse_uuid(s: &str) -> Result<Uuid, CoreError> {
60 Uuid::parse_str(s).map_err(|e| CoreError::database_msg(format!("Invalid UUID: {}", e)))
61 }
62
63 /// Parse an optional UUID string.
64 #[tracing::instrument(skip_all)]
65 pub fn parse_uuid_opt(s: Option<&str>) -> Result<Option<Uuid>, CoreError> {
66 s.map(parse_uuid).transpose()
67 }
68
69 /// Escape LIKE wildcards in a value to prevent unintended pattern matching.
70 #[inline]
71 pub fn escape_like(value: &str) -> String {
72 value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
73 }
74
75 /// Validate email address format (RFC 5321/5322 compliant).
76 ///
77 /// Validates the basic structure of an email address:
78 /// - Local part: letters, digits, and allowed special chars (.!#$%&'*+/=?^_`{|}~-)
79 /// - Domain: valid hostname with at least one dot
80 /// - No consecutive dots, leading/trailing dots in local part
81 ///
82 /// Note: Does not validate quoted strings or IP address literals for simplicity.
83 #[tracing::instrument(skip_all)]
84 pub fn is_valid_email(email: &str) -> bool {
85 let trimmed = email.trim();
86 if trimmed.is_empty() || trimmed.len() > 254 {
87 return false;
88 }
89
90 let parts: Vec<&str> = trimmed.splitn(2, '@').collect();
91 if parts.len() != 2 {
92 return false;
93 }
94
95 let (local, domain) = (parts[0], parts[1]);
96
97 // Validate local part
98 if local.is_empty() || local.len() > 64 {
99 return false;
100 }
101 if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
102 return false;
103 }
104 if !local.chars().all(|c| {
105 c.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c)
106 }) {
107 return false;
108 }
109
110 // Validate domain
111 if domain.is_empty() || domain.len() > 253 {
112 return false;
113 }
114 if !domain.contains('.') {
115 return false;
116 }
117 if domain.starts_with('.') || domain.ends_with('.') || domain.starts_with('-') {
118 return false;
119 }
120
121 // Validate each domain label
122 for label in domain.split('.') {
123 if label.is_empty() || label.len() > 63 {
124 return false;
125 }
126 if label.starts_with('-') || label.ends_with('-') {
127 return false;
128 }
129 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
130 return false;
131 }
132 }
133
134 true
135 }
136
137 #[cfg(test)]
138 mod tests {
139 use super::*;
140 use chrono::Timelike;
141
142 #[test]
143 fn test_parse_datetime_rfc3339() {
144 let result = parse_datetime("2024-01-15T10:30:00Z");
145 assert!(result.is_ok());
146 }
147
148 #[test]
149 fn test_parse_datetime_sqlite_format() {
150 let result = parse_datetime("2024-01-15 10:30:00");
151 assert!(result.is_ok());
152 }
153
154 #[test]
155 fn test_parse_datetime_date_only() {
156 let result = parse_datetime("2024-01-15");
157 assert!(result.is_ok());
158 let dt = result.unwrap();
159 assert_eq!(dt.hour(), 0);
160 assert_eq!(dt.minute(), 0);
161 }
162
163 #[test]
164 fn test_parse_tags() {
165 let tags = parse_tags(r#"["work", "urgent"]"#);
166 assert_eq!(tags, vec!["work", "urgent"]);
167 }
168
169 #[test]
170 fn test_parse_tags_empty() {
171 let tags = parse_tags("[]");
172 assert!(tags.is_empty());
173 }
174
175 #[test]
176 fn test_parse_tags_invalid() {
177 let tags = parse_tags("not json");
178 assert!(tags.is_empty());
179 }
180
181 #[test]
182 fn test_parse_uuid_valid() {
183 let result = parse_uuid("550e8400-e29b-41d4-a716-446655440000");
184 assert!(result.is_ok());
185 }
186
187 #[test]
188 fn test_parse_uuid_invalid() {
189 let result = parse_uuid("not-a-uuid");
190 assert!(result.is_err());
191 }
192
193 #[test]
194 fn test_parse_uuid_opt_some() {
195 let result = parse_uuid_opt(Some("550e8400-e29b-41d4-a716-446655440000"));
196 assert!(result.is_ok());
197 assert!(result.unwrap().is_some());
198 }
199
200 #[test]
201 fn test_parse_uuid_opt_none() {
202 let result = parse_uuid_opt(None);
203 assert!(result.is_ok());
204 assert!(result.unwrap().is_none());
205 }
206
207 #[test]
208 fn test_is_valid_email() {
209 // Valid emails
210 assert!(is_valid_email("test@example.com"));
211 assert!(is_valid_email(" user@domain.org ")); // Trimmed
212 assert!(is_valid_email("user.name@domain.com"));
213 assert!(is_valid_email("user+tag@domain.com"));
214 assert!(is_valid_email("user_name@sub.domain.com"));
215 assert!(is_valid_email("a@b.co"));
216
217 // Invalid emails
218 assert!(!is_valid_email(""));
219 assert!(!is_valid_email("invalid"));
220 assert!(!is_valid_email("@domain.com"));
221 assert!(!is_valid_email("user@"));
222 assert!(!is_valid_email("user@domain")); // No TLD
223 assert!(!is_valid_email("user@.com"));
224 assert!(!is_valid_email("user@domain."));
225 assert!(!is_valid_email(".user@domain.com")); // Leading dot
226 assert!(!is_valid_email("user.@domain.com")); // Trailing dot
227 assert!(!is_valid_email("user..name@domain.com")); // Consecutive dots
228 assert!(!is_valid_email("user@-domain.com")); // Domain starts with hyphen
229 assert!(!is_valid_email("user@domain-.com")); // Label ends with hyphen
230 }
231 }
232