//! Encoding between model types and their database representation. //! //! Split out from the SQL so the tricky part — turning a `Condition` into //! nullable columns and back without silently losing a field — is testable //! without a database, and so a decode failure has somewhere precise to point. //! //! # Why this is hand-written rather than serde //! //! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires //! stopping to ask before adding one. The encoding is a few hundred lines of //! mechanical match arms, which is a real cost — and it is the cost of not adding //! a dependency without permission. If serde is approved later, this module is the //! only thing that changes. //! //! # Decoding never panics //! //! Every decode failure returns [`DecodeError`]. A database written by a newer //! version, or corrupted by hand, must produce a diagnosable error rather than a //! panic: this is enforcement code, and section 1.5.4 requires failing open. use blocker_core::model::{ Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow, }; /// Why stored bytes could not become a model value. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DecodeError { /// A `mode` column held something other than the two known values. UnknownMode(String), /// A `level` column held something other than the three known values. UnknownLevel(String), /// A `kind` column held something unrecognised. UnknownKind(String), /// A target string was empty. EmptyTarget, /// A `start`/`end` time was not `HH:MM`, or was outside 0..1440. BadTime(String), /// A time of day fell outside 0..1440 minutes. MinuteOutOfRange(i64), /// A `days` list was malformed, or held a value outside 0..=6. BadDays(String), /// A boolean column was neither 0 nor 1. BadFlag(i64), /// A numeric column was negative where only a count is meaningful. NegativeCount(i64), } impl std::fmt::Display for DecodeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"), Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"), Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"), Self::EmptyTarget => write!(f, "target value is empty"), Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"), Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"), Self::BadDays(d) => write!(f, "day list {d:?} is malformed"), Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"), Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"), } } } impl std::error::Error for DecodeError {} /// `ScheduleMode` as stored. pub fn mode_to_str(mode: ScheduleMode) -> &'static str { match mode { ScheduleMode::Blocklist => "Blocklist", ScheduleMode::Allowlist => "Allowlist", } } pub fn mode_from_str(raw: &str) -> Result { match raw { "Blocklist" => Ok(ScheduleMode::Blocklist), "Allowlist" => Ok(ScheduleMode::Allowlist), other => Err(DecodeError::UnknownMode(other.to_owned())), } } /// `LockLevel` as stored. pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str { match level { blocker_core::lock::LockLevel::Normal => "Normal", blocker_core::lock::LockLevel::Strict => "Strict", blocker_core::lock::LockLevel::Stubborn => "Stubborn", } } pub fn level_from_str(raw: &str) -> Result { use blocker_core::lock::LockLevel; match raw { "Normal" => Ok(LockLevel::Normal), "Strict" => Ok(LockLevel::Strict), "Stubborn" => Ok(LockLevel::Stubborn), other => Err(DecodeError::UnknownLevel(other.to_owned())), } } /// A decoded target. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TargetRow { /// Position within the schedule, preserving the user's ordering. pub ordinal: i64, /// Discriminator: `app`, `website`, or `keyword`. pub kind: String, /// The identifier itself. pub value: String, } /// Targets in database form. pub fn encode_targets(targets: &[Target]) -> Vec { targets .iter() .enumerate() .map(|(ordinal, t)| { let (kind, value) = match t { Target::App(v) => ("app", v.clone()), Target::Website(v) => ("website", v.clone()), Target::Keyword(v) => ("keyword", v.clone()), }; TargetRow { ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX), kind: kind.to_owned(), value, } }) .collect() } pub fn decode_targets(rows: &[TargetRow]) -> Result, DecodeError> { let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len()); for row in rows { if row.value.is_empty() { return Err(DecodeError::EmptyTarget); } let target = match row.kind.as_str() { "app" => Target::App(row.value.clone()), "website" => Target::Website(row.value.clone()), "keyword" => Target::Keyword(row.value.clone()), other => return Err(DecodeError::UnknownKind(other.to_owned())), }; out.push((row.ordinal, target)); } // Ordinals come from the database unordered; sorting restores user order, // which matters for a list the user arranged deliberately. out.sort_by_key(|(ordinal, _)| *ordinal); Ok(out.into_iter().map(|(_, t)| t).collect()) } /// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the /// wrong place is caught here rather than becoming a silently defaulted /// condition. #[derive(Debug, Clone, PartialEq)] pub struct ConditionRow { pub ordinal: i64, pub kind: String, /// Comma-separated weekdays, Monday = 0. pub days: Option, pub start_min: Option, pub end_min: Option, pub limit_value: Option, pub window: Option, pub ssid: Option, pub latitude: Option, pub longitude: Option, pub radius_m: Option, pub outside: Option, } pub fn encode_conditions(conditions: &[Condition]) -> Vec { conditions .iter() .enumerate() .map(|(ordinal, c)| { let mut row = ConditionRow { ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX), kind: String::new(), days: None, start_min: None, end_min: None, limit_value: None, window: None, ssid: None, latitude: None, longitude: None, radius_m: None, outside: None, }; match c { Condition::Time(w) => { row.kind = "Time".to_owned(); row.days = Some(w.days.iter().map(u8::to_string).collect::>().join(",")); row.start_min = Some(i64::from(w.start)); row.end_min = Some(i64::from(w.end)); } Condition::UsageLimit { limit_minutes, window } => { row.kind = "UsageLimit".to_owned(); row.limit_value = Some(i64::from(*limit_minutes)); row.window = Some(window_to_str(*window).to_owned()); } Condition::LaunchCount { limit, window } => { row.kind = "LaunchCount".to_owned(); row.limit_value = Some(i64::from(*limit)); row.window = Some(window_to_str(*window).to_owned()); } Condition::WifiSsid(ssid) => { row.kind = "WifiSsid".to_owned(); row.ssid = Some(ssid.clone()); } Condition::Location { latitude, longitude, radius_meters, block_when_outside } => { row.kind = "Location".to_owned(); row.latitude = Some(*latitude); row.longitude = Some(*longitude); row.radius_m = Some(i64::from(*radius_meters)); row.outside = Some(i64::from(*block_when_outside)); } } row }) .collect() } pub fn decode_conditions(rows: &[ConditionRow]) -> Result, DecodeError> { let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len()); for row in rows { out.push((row.ordinal, decode_condition(row)?)); } out.sort_by_key(|(ordinal, _)| *ordinal); Ok(out.into_iter().map(|(_, c)| c).collect()) } fn decode_condition(row: &ConditionRow) -> Result { match row.kind.as_str() { "Time" => { let days = row.days.as_deref().ok_or(DecodeError::BadDays("".into()))?; let start = require_minute(row.start_min)?; let end = require_minute(row.end_min)?; Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end })) } "UsageLimit" => Ok(Condition::UsageLimit { limit_minutes: require_count(row.limit_value)?, window: window_from_str(row.window.as_deref())?, }), "LaunchCount" => Ok(Condition::LaunchCount { limit: require_count(row.limit_value)?, window: window_from_str(row.window.as_deref())?, }), "WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)), "Location" => Ok(Condition::Location { latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?, longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?, radius_meters: require_count(row.radius_m)?, block_when_outside: decode_flag(row.outside.unwrap_or(0))?, }), other => Err(DecodeError::UnknownKind(other.to_owned())), } } fn require_minute(value: Option) -> Result { let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?; u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v)) } fn require_count(value: Option) -> Result { let v = value.ok_or(DecodeError::NegativeCount(-1))?; u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v)) } fn decode_flag(value: i64) -> Result { match value { 0 => Ok(false), 1 => Ok(true), other => Err(DecodeError::BadFlag(other)), } } fn window_to_str(window: UsageWindow) -> &'static str { match window { UsageWindow::Daily => "Daily", UsageWindow::Hourly => "Hourly", } } fn window_from_str(raw: Option<&str>) -> Result { match raw { Some("Daily") => Ok(UsageWindow::Daily), Some("Hourly") => Ok(UsageWindow::Hourly), _ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))), } } fn decode_days(raw: &str) -> Result, DecodeError> { if raw.is_empty() { return Ok(Vec::new()); } raw.split(',') .map(|part| { let trimmed = part.trim(); trimmed .parse::() .ok() .filter(|d| *d < 7) .ok_or_else(|| DecodeError::BadDays(raw.to_owned())) }) .collect() } /// `HH:MM` to minutes since midnight. Used by the golden-table loader, which /// reads times as strings. pub fn parse_hhmm(raw: &str) -> Result { let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?; let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?; let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?; if m >= 60 || h >= 24 { return Err(DecodeError::BadTime(raw.to_owned())); } Ok(h * 60 + m) } /// Reassemble a schedule from its header and its decoded children. /// /// Kept here rather than in `lib.rs` so the ordering contract — children sorted /// by ordinal, attached to the right parent — is tested in one place. pub fn decode_schedule( id: &str, name: &str, icon: Option<&str>, mode: &str, targets: &[TargetRow], conditions: &[ConditionRow], paused: bool, ) -> Result { Ok(Schedule { id: id.to_owned(), name: name.to_owned(), icon: icon.map(str::to_owned), mode: mode_from_str(mode)?, targets: decode_targets(targets)?, conditions: decode_conditions(conditions)?, paused, }) } #[cfg(test)] mod tests;