1use chrono::{DateTime, Utc, Duration};
7use serde::{Deserialize, Serialize};
8
9#[cfg(feature = "prost")]
15pub fn prost_timestamp_to_datetime(ts: &prost_types::Timestamp) -> Option<DateTime<Utc>> {
16 DateTime::from_timestamp(ts.seconds, ts.nanos as u32)
17}
18
19#[cfg(feature = "prost")]
21pub fn datetime_to_prost_timestamp(dt: DateTime<Utc>) -> prost_types::Timestamp {
22 prost_types::Timestamp {
23 seconds: dt.timestamp(),
24 nanos: dt.timestamp_subsec_nanos() as i32,
25 }
26}
27
28pub fn now() -> DateTime<Utc> {
30 Utc::now()
31}
32
33pub fn is_past(dt: DateTime<Utc>) -> bool {
35 dt < Utc::now()
36}
37
38pub fn is_future(dt: DateTime<Utc>) -> bool {
40 dt > Utc::now()
41}
42
43pub fn days_ago(days: i64) -> DateTime<Utc> {
45 Utc::now() - Duration::days(days)
46}
47
48pub fn days_from_now(days: i64) -> DateTime<Utc> {
50 Utc::now() + Duration::days(days)
51}
52
53pub fn hours_ago(hours: i64) -> DateTime<Utc> {
55 Utc::now() - Duration::hours(hours)
56}
57
58pub fn hours_from_now(hours: i64) -> DateTime<Utc> {
60 Utc::now() + Duration::hours(hours)
61}
62
63pub fn minutes_from_now(minutes: i64) -> DateTime<Utc> {
65 Utc::now() + Duration::minutes(minutes)
66}
67
68use uuid::Uuid;
73
74pub fn new_id() -> Uuid {
76 Uuid::new_v4()
77}
78
79pub fn new_id_string() -> String {
81 Uuid::new_v4().to_string()
82}
83
84pub fn parse_id(s: &str) -> Option<Uuid> {
86 Uuid::parse_str(s).ok()
87}
88
89pub fn is_valid_uuid(s: &str) -> bool {
91 Uuid::parse_str(s).is_ok()
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct PaginationParams {
101 pub page: u32,
102 pub limit: u32,
103 pub offset: u32,
104}
105
106impl PaginationParams {
107 pub fn new(page: u32, limit: u32) -> Self {
109 let page = if page == 0 { 1 } else { page };
110 let limit = limit.clamp(1, 100); let offset = (page - 1) * limit;
112
113 Self { page, limit, offset }
114 }
115
116 pub fn default_pagination() -> Self {
118 Self::new(1, 20)
119 }
120
121 pub fn total_pages(&self, total_items: u64) -> u32 {
123 ((total_items as f64) / (self.limit as f64)).ceil() as u32
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct PaginationMeta {
130 pub total: u64,
131 pub page: u32,
132 pub limit: u32,
133 pub total_pages: u32,
134 pub has_next: bool,
135 pub has_prev: bool,
136}
137
138impl PaginationMeta {
139 pub fn new(total: u64, page: u32, limit: u32) -> Self {
140 let total_pages = ((total as f64) / (limit as f64)).ceil() as u32;
141 Self {
142 total,
143 page,
144 limit,
145 total_pages,
146 has_next: page < total_pages,
147 has_prev: page > 1,
148 }
149 }
150}
151
152pub fn normalize_string(s: &str) -> String {
158 s.trim().to_lowercase()
159}
160
161pub fn is_valid_email(email: &str) -> bool {
163 let email = email.trim();
164 if email.is_empty() || email.len() > 254 {
165 return false;
166 }
167
168 let parts: Vec<&str> = email.split('@').collect();
169 if parts.len() != 2 {
170 return false;
171 }
172
173 let local = parts[0];
174 let domain = parts[1];
175
176 !local.is_empty()
177 && !domain.is_empty()
178 && domain.contains('.')
179 && !domain.starts_with('.')
180 && !domain.ends_with('.')
181}
182
183pub fn is_valid_username(username: &str) -> bool {
185 let username = username.trim();
186 if username.len() < 3 || username.len() > 50 {
187 return false;
188 }
189
190 username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
191}
192
193#[derive(Debug, Clone, thiserror::Error)]
199pub enum BackboneError {
200 #[error("Entity not found: {entity_type} with id {id}")]
201 NotFound { entity_type: String, id: String },
202
203 #[error("Validation error on field '{field}': {message}")]
204 Validation { field: String, message: String },
205
206 #[error("Conflict: {message}")]
207 Conflict { message: String },
208
209 #[error("Unauthorized: {message}")]
210 Unauthorized { message: String },
211
212 #[error("Forbidden: {message}")]
213 Forbidden { message: String },
214
215 #[error("Internal error: {message}")]
216 Internal { message: String },
217
218 #[error("Database error: {message}")]
219 Database { message: String },
220}
221
222impl BackboneError {
223 pub fn not_found(entity_type: impl Into<String>, id: impl Into<String>) -> Self {
224 Self::NotFound {
225 entity_type: entity_type.into(),
226 id: id.into(),
227 }
228 }
229
230 pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
231 Self::Validation {
232 field: field.into(),
233 message: message.into(),
234 }
235 }
236
237 pub fn conflict(message: impl Into<String>) -> Self {
238 Self::Conflict {
239 message: message.into(),
240 }
241 }
242
243 pub fn internal(message: impl Into<String>) -> Self {
244 Self::Internal {
245 message: message.into(),
246 }
247 }
248
249 pub fn database(message: impl Into<String>) -> Self {
250 Self::Database {
251 message: message.into(),
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_pagination_params() {
262 let params = PaginationParams::new(2, 25);
263 assert_eq!(params.page, 2);
264 assert_eq!(params.limit, 25);
265 assert_eq!(params.offset, 25);
266 }
267
268 #[test]
269 fn test_pagination_meta() {
270 let meta = PaginationMeta::new(100, 2, 20);
271 assert_eq!(meta.total_pages, 5);
272 assert!(meta.has_next);
273 assert!(meta.has_prev);
274 }
275
276 #[test]
277 fn test_email_validation() {
278 assert!(is_valid_email("test@example.com"));
279 assert!(is_valid_email("user.name@domain.co.uk"));
280 assert!(!is_valid_email("invalid"));
281 assert!(!is_valid_email("@domain.com"));
282 assert!(!is_valid_email("user@"));
283 }
284
285 #[test]
286 fn test_username_validation() {
287 assert!(is_valid_username("john_doe"));
288 assert!(is_valid_username("user-123"));
289 assert!(!is_valid_username("ab")); assert!(!is_valid_username("user name")); }
292
293 #[test]
294 fn test_uuid_generation() {
295 let id = new_id();
296 assert!(is_valid_uuid(&id.to_string()));
297 }
298}