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:
2026-07-12 04:21:21 +02:00
parent eff14228af
commit 25b33b6a0e
38 changed files with 188 additions and 630 deletions

View File

@@ -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()));
}
}