//! Shared helpers for database adapter crates (SQLite, PostgreSQL). //! //! Provides error mapping, datetime parsing, UUID parsing, and JSON //! deserialization helpers that are identical across database backends. use chrono::{DateTime, Utc}; use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat}; use serde::de::DeserializeOwned; use uuid::Uuid; // ============================================================================ // Error mapping // ============================================================================ /// Map a [`sqlx::Error`] into a [`DomainError::RepositoryError`]. pub fn map_sqlx_error(err: sqlx::Error) -> DomainError { tracing::error!(error = %err, "database error"); DomainError::RepositoryError(err.to_string()) } // ============================================================================ // Datetime parsing // ============================================================================ /// Parse a datetime string stored in the database. /// /// Tries RFC 3339 first (e.g. `2026-03-19T00:00:00Z`), then falls back to /// the bare SQLite format `%Y-%m-%d %H:%M:%S` (no timezone, assumed UTC). pub fn parse_dt(s: &str) -> Result, DomainError> { DateTime::parse_from_rfc3339(s) .map(|dt| dt.with_timezone(&Utc)) .or_else(|_| { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc()) }) .map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e))) } // ============================================================================ // UUID parsing // ============================================================================ /// Parse a UUID string from the database, wrapping errors in [`DomainError::RepositoryError`]. /// /// The `context` parameter is included in the error message for diagnostics /// (e.g. `"channel id"`, `"slot id"`). pub fn parse_uuid(s: &str, context: &str) -> Result { Uuid::parse_str(s) .map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e))) } // ============================================================================ // JSON deserialization helpers // ============================================================================ /// Deserialize a JSON string from the database into `T`. /// /// The `context` parameter is included in the error message for diagnostics /// (e.g. `"schedule_config"`, `"slot item"`). pub fn parse_json(json: &str, context: &str) -> Result { serde_json::from_str(json) .map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e))) } /// Parse a `schedule_config` JSON column, handling V1/V2 compat migration. pub fn parse_schedule_config(json: &str) -> Result { let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?; Ok(ScheduleConfig::from(compat)) } /// Parse a `recycle_policy` JSON column. pub fn parse_recycle_policy(json: &str) -> Result { parse_json(json, "recycle_policy") } /// Deserialize a string-encoded enum, returning `T::default()` on failure. /// /// Used for columns like `access_mode` and `logo_position` that are stored as /// bare strings (e.g. `"public"`, `"top_left"`) and deserialized via serde. pub fn parse_enum_or_default(value: String) -> T { serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default() } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; use chrono::Datelike; #[test] fn parse_dt_rfc3339() { let dt = parse_dt("2026-03-19T12:30:00Z").unwrap(); assert_eq!(dt.year(), 2026); assert_eq!(dt.month(), 3); assert_eq!(dt.day(), 19); } #[test] fn parse_dt_sqlite_format() { let dt = parse_dt("2026-03-19 12:30:00").unwrap(); assert_eq!(dt.year(), 2026); } #[test] fn parse_dt_invalid() { assert!(parse_dt("not-a-date").is_err()); } #[test] fn parse_uuid_valid() { let u = Uuid::new_v4(); let parsed = parse_uuid(&u.to_string(), "test").unwrap(); assert_eq!(parsed, u); } #[test] fn parse_uuid_invalid() { let err = parse_uuid("not-a-uuid", "channel id").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("channel id"), "error should contain context: {msg}"); } #[test] fn parse_json_valid() { let val: Vec = parse_json("[1,2,3]", "test").unwrap(); assert_eq!(val, vec![1, 2, 3]); } #[test] fn parse_json_invalid() { let err = parse_json::>("not json", "test_field").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("test_field"), "error should contain context: {msg}"); } #[test] fn parse_schedule_config_v2() { let json = r#"{"day_blocks":{}}"#; let cfg = parse_schedule_config(json).unwrap(); assert!(cfg.day_blocks().is_empty()); } #[test] fn parse_schedule_config_v1_compat() { let json = r#"{"blocks":[]}"#; let cfg = parse_schedule_config(json).unwrap(); // V1 promotes blocks to all 7 days assert_eq!(cfg.day_blocks().len(), 7); } #[test] fn parse_recycle_policy_valid() { let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#; let policy = parse_recycle_policy(json).unwrap(); assert_eq!(policy.cooldown_days, Some(7)); } #[test] fn parse_enum_or_default_valid() { use domain::AccessMode; let mode: AccessMode = parse_enum_or_default("public".to_string()); assert!(matches!(mode, AccessMode::Public)); } #[test] fn parse_enum_or_default_fallback() { use domain::AccessMode; let mode: AccessMode = parse_enum_or_default("garbage".to_string()); // Should return default (Public) assert!(matches!(mode, AccessMode::Public)); } #[test] fn map_sqlx_error_produces_repository_error() { let sqlx_err = sqlx::Error::RowNotFound; let domain_err = map_sqlx_error(sqlx_err); assert!(matches!(domain_err, DomainError::RepositoryError(_))); } }