//! Input validation shared by the REST handlers, the sync push checks and the importer (SPEC §8.6, §9.6, §9.7). //! //! Strings are trimmed of leading and trailing whitespace and their length is counted in Unicode code points after //! trimming; the trimmed value is what the server stores. use chrono::{DateTime, Datelike, Utc}; use kvitt_domain::Period; use crate::error::AppError; use crate::time::STOCKHOLM; /// Category icon tokens (SPEC §9.7, exhaustive; `key`, `shield` and `account_balance` added by D-054). pub const ICONS: [&str; 33] = [ "shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train", "local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center", "medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard", "celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance", ]; /// Colour tokens (SPEC §9.7, exhaustive; `slate` and `amber` added by D-054). pub const COLORS: [&str; 14] = [ "green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime", "slate", "amber", ]; /// Account name: 1 to 60 code points (SPEC §9.6). pub const ACCOUNT_NAME_MAX: usize = 60; /// Category name: 1 to 40 code points. pub const CATEGORY_NAME_MAX: usize = 40; /// User display name: 1 to 40 code points. pub const DISPLAY_NAME_MAX: usize = 40; /// Device name: 1 to 60 code points. pub const DEVICE_NAME_MAX: usize = 60; /// Expense and template description: 1 to 100 code points. pub const DESCRIPTION_MAX: usize = 100; /// Expense and template notes: 0 to 1000 code points. pub const NOTES_MAX: usize = 1000; /// How far back a template's `start_period` may lie, in months (SPEC §9.6, AC-9.8). pub const START_PERIOD_BACKFILL_MONTHS: u32 = 12; /// Trims `value` and checks its length in code points is within `min..=max`. Returns the trimmed value. pub fn text(field: &str, value: &str, min: usize, max: usize) -> Result { let trimmed = value.trim(); let len = trimmed.chars().count(); if len < min || len > max { return Err(AppError::validation( field, format!("{field} must be {min} to {max} characters after trimming"), )); } Ok(trimmed.to_owned()) } /// Notes: trimmed, 0 to `NOTES_MAX` code points, no control characters other than newline (SPEC §8.6). pub fn notes(field: &str, value: &str) -> Result { let trimmed = text(field, value, 0, NOTES_MAX)?; if trimmed.chars().any(|c| c.is_control() && c != '\n') { return Err(AppError::validation( field, format!("{field} may not contain control characters other than newline"), )); } Ok(trimmed) } /// An icon token of SPEC §9.7. pub fn icon(value: &str) -> Result { if ICONS.contains(&value) { Ok(value.to_owned()) } else { Err(AppError::validation( "icon", "icon is not a token of SPEC §9.7", )) } } /// A colour token of SPEC §9.7. pub fn color(value: &str) -> Result { if COLORS.contains(&value) { Ok(value.to_owned()) } else { Err(AppError::validation( "color", "color is not a token of SPEC §9.7", )) } } /// `amount_ore` within 1 to 1 000 000 000 (SPEC §9.6). pub fn amount(field: &str, amount_ore: i64) -> Result { if kvitt_domain::amount_in_range(amount_ore) { Ok(amount_ore) } else { Err(AppError::validation(field, "amount_out_of_range")) } } /// A period `YYYY-MM`. pub fn period(field: &str, value: &str) -> Result { value .parse::() .map_err(|_| AppError::validation(field, format!("{field} must be YYYY-MM"))) } /// The current month in Europe/Stockholm. pub fn current_period(now: DateTime) -> Result { let local = now.with_timezone(&STOCKHOLM); let year = u16::try_from(local.year()).map_err(AppError::internal)?; let month = u8::try_from(local.month()).map_err(AppError::internal)?; Period::new(year, month).ok_or_else(|| AppError::internal("current month out of range")) } /// `period` minus `months` months, or `None` before year 1. pub fn months_before(period: Period, months: u32) -> Option { let index = i64::from(period.year()) * 12 + i64::from(period.month()) - 1 - i64::from(months); let year = u16::try_from(index.div_euclid(12)).ok()?; let month = u8::try_from(index.rem_euclid(12) + 1).ok()?; Period::new(year, month) } /// A template `start_period`: `YYYY-MM`, not earlier than 12 months before the current month (SPEC §9.6). pub fn start_period(value: &str, now: DateTime) -> Result { let p = period("start_period", value)?; let earliest = months_before(current_period(now)?, START_PERIOD_BACKFILL_MONTHS); if earliest.is_some_and(|earliest| p < earliest) { return Err(AppError::validation( "start_period", "start_period may be at most 12 months before the current month", )); } Ok(p) } /// `day_of_month` within 1 to 31. pub fn day_of_month(value: i64) -> Result { u8::try_from(value) .ok() .filter(|d| (1..=31).contains(d)) .ok_or_else(|| AppError::validation("day_of_month", "day_of_month must be 1 to 31")) } #[cfg(test)] mod tests { use super::*; #[test] fn text_counts_code_points_after_trimming() { assert_eq!(text("name", " Åsa ", 1, 3).unwrap(), "Åsa"); assert!(text("name", " ", 1, 3).is_err()); assert!(text("name", "Åsaa", 1, 3).is_err()); } #[test] fn notes_allow_newline_only() { assert_eq!(notes("notes", "a\nb").unwrap(), "a\nb"); assert!(notes("notes", "a\tb").is_err()); assert!(notes("notes", "a\u{7}b").is_err()); } #[test] fn months_before_crosses_years() { let p: Period = "2026-10".parse().unwrap(); assert_eq!(months_before(p, 12).unwrap().to_string(), "2025-10"); assert_eq!(months_before(p, 10).unwrap().to_string(), "2025-12"); assert_eq!(months_before(p, 9).unwrap().to_string(), "2026-01"); } #[test] fn tokens() { assert!(icon("pets").is_ok()); assert!(icon("dog").is_err()); assert!(color("lime").is_ok()); assert!(color("black").is_err()); } #[test] fn tokens_added_by_schema_2() { for token in ["key", "shield", "account_balance"] { assert!(icon(token).is_ok(), "{token}"); } for token in ["slate", "amber"] { assert!(color(token).is_ok(), "{token}"); } assert_eq!(ICONS.len(), 33); assert_eq!(COLORS.len(), 14); let mut icons = ICONS.to_vec(); icons.sort_unstable(); icons.dedup(); assert_eq!(icons.len(), ICONS.len(), "icon tokens are distinct"); let mut colors = COLORS.to_vec(); colors.sort_unstable(); colors.dedup(); assert_eq!(colors.len(), COLORS.len(), "colour tokens are distinct"); } }