cleanup: strip comments, extract constants, DRY shared helpers across adapters + infra-wiring
- strip all comments except WHY workaround notes (3 remain) - remove all #[allow(dead_code)]; fix via _prefix rename - extract named constants: JWT time units, token types, default config values, jellyfin fallback bitrate - DRY: move serialize_enum_as_string, content_type_str, parse_content_type, parse_genres_blob to adapter-common - sqlite+postgres library.rs use shared helpers instead of local copies - sqlite+postgres channel.rs use shared serialize_enum_as_string - remove dead `let _ = ext` in scanner.rs
This commit is contained in:
@@ -1,31 +1,13 @@
|
||||
//! 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<DateTime<Utc>, DomainError> {
|
||||
DateTime::parse_from_rfc3339(s)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
@@ -35,54 +17,73 @@ pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
|
||||
.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, DomainError> {
|
||||
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<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> {
|
||||
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<ScheduleConfig, DomainError> {
|
||||
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<RecyclePolicy, DomainError> {
|
||||
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<T: DeserializeOwned + Default>(value: String) -> T {
|
||||
serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
pub fn serialize_enum_as_string<T: serde::Serialize>(v: &T, fallback: &str) -> String {
|
||||
serde_json::to_value(v)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
|
||||
pub fn content_type_str(ct: &domain::ContentType) -> &'static str {
|
||||
match ct {
|
||||
domain::ContentType::Movie => "movie",
|
||||
domain::ContentType::Episode => "episode",
|
||||
domain::ContentType::Short => "short",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_content_type(s: &str) -> domain::ContentType {
|
||||
match s {
|
||||
"episode" => domain::ContentType::Episode,
|
||||
"short" => domain::ContentType::Short,
|
||||
_ => domain::ContentType::Movie,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_genres_blob(blob: &str) -> Vec<String> {
|
||||
use std::collections::HashSet;
|
||||
blob.split("],[")
|
||||
.flat_map(|chunk| {
|
||||
let cleaned = chunk.trim_start_matches('[').trim_end_matches(']');
|
||||
cleaned
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim().trim_matches('"');
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_string())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -146,7 +147,6 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -178,4 +177,27 @@ mod tests {
|
||||
let domain_err = map_sqlx_error(sqlx_err);
|
||||
assert!(matches!(domain_err, DomainError::RepositoryError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_enum_as_string_valid() {
|
||||
use domain::AccessMode;
|
||||
let result = serialize_enum_as_string(&AccessMode::Public, "fallback");
|
||||
assert_eq!(result, "public");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_roundtrip() {
|
||||
use domain::ContentType;
|
||||
assert_eq!(parse_content_type(content_type_str(&ContentType::Movie)), ContentType::Movie);
|
||||
assert_eq!(parse_content_type(content_type_str(&ContentType::Episode)), ContentType::Episode);
|
||||
assert_eq!(parse_content_type(content_type_str(&ContentType::Short)), ContentType::Short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_genres_blob_basic() {
|
||||
let genres = parse_genres_blob(r#"["Action","Comedy"],["Drama","Action"]"#);
|
||||
assert!(genres.contains(&"Action".to_string()));
|
||||
assert!(genres.contains(&"Comedy".to_string()));
|
||||
assert!(genres.contains(&"Drama".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user