Skip to main content

max / goingson

7.7 KB · 254 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 /// Build a comma-separated list of `n` SQLite bind placeholders (`?,?,...,?`)
76 /// for an `IN (...)` clause.
77 ///
78 /// Callers guard the empty case before building the query (an empty `IN ()`
79 /// is invalid SQL); this returns `""` for `n == 0` so the guard stays their
80 /// responsibility, matching every existing call site.
81 #[inline]
82 pub fn bind_placeholders(n: usize) -> String {
83 let mut s = "?,".repeat(n);
84 s.pop(); // drop the trailing comma ("" when n == 0)
85 s
86 }
87
88 /// Validate email address format (RFC 5321/5322 compliant).
89 ///
90 /// Validates the basic structure of an email address:
91 /// - Local part: letters, digits, and allowed special chars (.!#$%&'*+/=?^_`{|}~-)
92 /// - Domain: valid hostname with at least one dot
93 /// - No consecutive dots, leading/trailing dots in local part
94 ///
95 /// Note: Does not validate quoted strings or IP address literals for simplicity.
96 #[tracing::instrument(skip_all)]
97 pub fn is_valid_email(email: &str) -> bool {
98 let trimmed = email.trim();
99 if trimmed.is_empty() || trimmed.len() > 254 {
100 return false;
101 }
102
103 let parts: Vec<&str> = trimmed.splitn(2, '@').collect();
104 if parts.len() != 2 {
105 return false;
106 }
107
108 let (local, domain) = (parts[0], parts[1]);
109
110 // Validate local part
111 if local.is_empty() || local.len() > 64 {
112 return false;
113 }
114 if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
115 return false;
116 }
117 if !local.chars().all(|c| {
118 c.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c)
119 }) {
120 return false;
121 }
122
123 // Validate domain
124 if domain.is_empty() || domain.len() > 253 {
125 return false;
126 }
127 if !domain.contains('.') {
128 return false;
129 }
130 if domain.starts_with('.') || domain.ends_with('.') || domain.starts_with('-') {
131 return false;
132 }
133
134 // Validate each domain label
135 for label in domain.split('.') {
136 if label.is_empty() || label.len() > 63 {
137 return false;
138 }
139 if label.starts_with('-') || label.ends_with('-') {
140 return false;
141 }
142 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
143 return false;
144 }
145 }
146
147 true
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::*;
153 use chrono::Timelike;
154
155 #[test]
156 fn test_parse_datetime_rfc3339() {
157 let result = parse_datetime("2024-01-15T10:30:00Z");
158 assert!(result.is_ok());
159 }
160
161 #[test]
162 fn test_bind_placeholders() {
163 assert_eq!(bind_placeholders(0), "");
164 assert_eq!(bind_placeholders(1), "?");
165 assert_eq!(bind_placeholders(3), "?,?,?");
166 // count of placeholders matches n for a realistic IN-clause size
167 assert_eq!(bind_placeholders(10).split(',').count(), 10);
168 }
169
170 #[test]
171 fn test_parse_datetime_sqlite_format() {
172 let result = parse_datetime("2024-01-15 10:30:00");
173 assert!(result.is_ok());
174 }
175
176 #[test]
177 fn test_parse_datetime_date_only() {
178 let result = parse_datetime("2024-01-15");
179 assert!(result.is_ok());
180 let dt = result.unwrap();
181 assert_eq!(dt.hour(), 0);
182 assert_eq!(dt.minute(), 0);
183 }
184
185 #[test]
186 fn test_parse_tags() {
187 let tags = parse_tags(r#"["work", "urgent"]"#);
188 assert_eq!(tags, vec!["work", "urgent"]);
189 }
190
191 #[test]
192 fn test_parse_tags_empty() {
193 let tags = parse_tags("[]");
194 assert!(tags.is_empty());
195 }
196
197 #[test]
198 fn test_parse_tags_invalid() {
199 let tags = parse_tags("not json");
200 assert!(tags.is_empty());
201 }
202
203 #[test]
204 fn test_parse_uuid_valid() {
205 let result = parse_uuid("550e8400-e29b-41d4-a716-446655440000");
206 assert!(result.is_ok());
207 }
208
209 #[test]
210 fn test_parse_uuid_invalid() {
211 let result = parse_uuid("not-a-uuid");
212 assert!(result.is_err());
213 }
214
215 #[test]
216 fn test_parse_uuid_opt_some() {
217 let result = parse_uuid_opt(Some("550e8400-e29b-41d4-a716-446655440000"));
218 assert!(result.is_ok());
219 assert!(result.unwrap().is_some());
220 }
221
222 #[test]
223 fn test_parse_uuid_opt_none() {
224 let result = parse_uuid_opt(None);
225 assert!(result.is_ok());
226 assert!(result.unwrap().is_none());
227 }
228
229 #[test]
230 fn test_is_valid_email() {
231 // Valid emails
232 assert!(is_valid_email("test@example.com"));
233 assert!(is_valid_email(" user@domain.org ")); // Trimmed
234 assert!(is_valid_email("user.name@domain.com"));
235 assert!(is_valid_email("user+tag@domain.com"));
236 assert!(is_valid_email("user_name@sub.domain.com"));
237 assert!(is_valid_email("a@b.co"));
238
239 // Invalid emails
240 assert!(!is_valid_email(""));
241 assert!(!is_valid_email("invalid"));
242 assert!(!is_valid_email("@domain.com"));
243 assert!(!is_valid_email("user@"));
244 assert!(!is_valid_email("user@domain")); // No TLD
245 assert!(!is_valid_email("user@.com"));
246 assert!(!is_valid_email("user@domain."));
247 assert!(!is_valid_email(".user@domain.com")); // Leading dot
248 assert!(!is_valid_email("user.@domain.com")); // Trailing dot
249 assert!(!is_valid_email("user..name@domain.com")); // Consecutive dots
250 assert!(!is_valid_email("user@-domain.com")); // Domain starts with hyphen
251 assert!(!is_valid_email("user@domain-.com")); // Label ends with hyphen
252 }
253 }
254