From 051507856a3a562d3403933ce193c4e51085cdb3 Mon Sep 17 00:00:00 2001 From: Jeremiah Russell Date: Sun, 19 Oct 2025 07:44:17 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(retention):=20enhance=20messag?= =?UTF-8?q?e=20age=20with=20parsing=20and=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add `TryFrom<&str>` implementation for `MessageAge` - allows creating `MessageAge` from string slices - returns `Error` for invalid formats - improve `MessageAge::new` to return a `Result` - changes error type to `Error` enum - add `Error::InvalidMessageAge` for specific message age errors - mark `MessageAge::parse` and other methods as `must_use` - use byte string literals for character matching in `parse` - update error messages to include the invalid input value - add `#[derive(Hash)]` to `MessageAge` --- src/retention/message_age.rs | 185 ++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 67 deletions(-) diff --git a/src/retention/message_age.rs b/src/retention/message_age.rs index 89b1f44..f7a7b0a 100644 --- a/src/retention/message_age.rs +++ b/src/retention/message_age.rs @@ -1,28 +1,30 @@ use std::fmt::Display; +use crate::{Error, Result}; + /// Message age specification for retention policies. -/// +/// /// Defines different time periods that can be used to specify how old messages /// should be before they are subject to retention actions (trash/delete). -/// +/// /// # Examples -/// +/// /// ``` /// use cull_gmail::MessageAge; -/// +/// /// // Create different message age specifications /// let days = MessageAge::Days(30); /// let weeks = MessageAge::Weeks(4); /// let months = MessageAge::Months(6); /// let years = MessageAge::Years(2); -/// +/// /// // Use with retention policy /// println!("Messages older than {} will be processed", months); /// ``` -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MessageAge { /// Number of days to retain the message - /// + /// /// # Example /// ``` /// use cull_gmail::MessageAge; @@ -31,7 +33,7 @@ pub enum MessageAge { /// ``` Days(i64), /// Number of weeks to retain the message - /// + /// /// # Example /// ``` /// use cull_gmail::MessageAge; @@ -40,7 +42,7 @@ pub enum MessageAge { /// ``` Weeks(i64), /// Number of months to retain the message - /// + /// /// # Example /// ``` /// use cull_gmail::MessageAge; @@ -49,7 +51,7 @@ pub enum MessageAge { /// ``` Months(i64), /// Number of years to retain the message - /// + /// /// # Example /// ``` /// use cull_gmail::MessageAge; @@ -71,109 +73,116 @@ impl Display for MessageAge { } impl MessageAge { - /// Create a new MessageAge from a period string and count. - /// + /// Create a new `MessageAge` from a period string and count. + /// /// # Arguments - /// + /// /// * `period` - The time period ("days", "weeks", "months", "years") /// * `count` - The number of time periods (must be positive) - /// + /// /// # Examples - /// + /// /// ``` /// use cull_gmail::MessageAge; - /// + /// /// let age = MessageAge::new("days", 30).unwrap(); /// assert_eq!(age, MessageAge::Days(30)); - /// + /// /// let age = MessageAge::new("months", 6).unwrap(); /// assert_eq!(age, MessageAge::Months(6)); - /// + /// /// // Invalid period returns an error /// assert!(MessageAge::new("invalid", 1).is_err()); - /// + /// /// // Negative count returns an error /// assert!(MessageAge::new("days", -1).is_err()); /// ``` - /// + /// /// # Errors - /// + /// /// Returns an error if: /// - The period string is not recognized /// - The count is negative or zero - pub fn new(period: &str, count: i64) -> Result { + pub fn new(period: &str, count: i64) -> Result { if count <= 0 { - return Err(format!("Count must be positive, got: {}", count)); + return Err(Error::InvalidMessageAge(format!( + "Count must be positive, got: {count}" + ))); } - + match period.to_lowercase().as_str() { "days" => Ok(MessageAge::Days(count)), "weeks" => Ok(MessageAge::Weeks(count)), "months" => Ok(MessageAge::Months(count)), "years" => Ok(MessageAge::Years(count)), - _ => Err(format!("Unknown period '{}', expected one of: days, weeks, months, years", period)), + _ => Err(Error::InvalidMessageAge(format!( + "Unknown period '{period}', expected one of: days, weeks, months, years" + ))), } } - /// Parse a MessageAge from a string representation (e.g., "d:30", "m:6"). - /// + /// Parse a `MessageAge` from a string representation (e.g., "d:30", "m:6"). + /// /// # Arguments - /// + /// /// * `s` - String in format "`period:count`" where period is d/w/m/y - /// + /// /// # Examples - /// + /// /// ``` /// use cull_gmail::MessageAge; - /// + /// /// let age = MessageAge::parse("d:30").unwrap(); /// assert_eq!(age, MessageAge::Days(30)); - /// + /// /// let age = MessageAge::parse("y:2").unwrap(); /// assert_eq!(age, MessageAge::Years(2)); - /// + /// /// // Invalid format returns None /// assert!(MessageAge::parse("invalid").is_none()); /// assert!(MessageAge::parse("d").is_none()); /// ``` + #[must_use] pub fn parse(s: &str) -> Option { - if s.len() < 3 || s.chars().nth(1) != Some(':') { + let bytes = s.as_bytes(); + if bytes.len() < 3 || bytes[1] != b':' { return None; } - - let period = s.chars().nth(0)?; + + let period = bytes[0]; let count_str = &s[2..]; let count = count_str.parse::().ok()?; - + if count <= 0 { return None; } match period { - 'd' => Some(MessageAge::Days(count)), - 'w' => Some(MessageAge::Weeks(count)), - 'm' => Some(MessageAge::Months(count)), - 'y' => Some(MessageAge::Years(count)), + b'd' => Some(MessageAge::Days(count)), + b'w' => Some(MessageAge::Weeks(count)), + b'm' => Some(MessageAge::Months(count)), + b'y' => Some(MessageAge::Years(count)), _ => None, } } /// Generate a label string for this message age. - /// + /// /// This creates a standardized label that can be used to categorize /// messages based on their retention period. - /// + /// /// # Examples - /// + /// /// ``` /// use cull_gmail::MessageAge; - /// + /// /// let age = MessageAge::Days(30); /// assert_eq!(age.label(), "retention/30-days"); - /// + /// /// let age = MessageAge::Years(1); /// assert_eq!(age.label(), "retention/1-years"); /// ``` + #[must_use] pub fn label(&self) -> String { match self { MessageAge::Days(v) => format!("retention/{v}-days"), @@ -182,39 +191,44 @@ impl MessageAge { MessageAge::Years(v) => format!("retention/{v}-years"), } } - + /// Get the numeric value of this message age. - /// + /// /// # Examples - /// + /// /// ``` /// use cull_gmail::MessageAge; - /// + /// /// let age = MessageAge::Days(30); /// assert_eq!(age.value(), 30); - /// + /// /// let age = MessageAge::Years(2); /// assert_eq!(age.value(), 2); /// ``` + #[must_use] pub fn value(&self) -> i64 { match self { - MessageAge::Days(v) | MessageAge::Weeks(v) | MessageAge::Months(v) | MessageAge::Years(v) => *v, + MessageAge::Days(v) + | MessageAge::Weeks(v) + | MessageAge::Months(v) + | MessageAge::Years(v) => *v, } } - + /// Get the period type as a string. - /// + /// /// # Examples - /// + /// /// ``` /// use cull_gmail::MessageAge; - /// + /// /// let age = MessageAge::Days(30); /// assert_eq!(age.period_type(), "days"); - /// + /// /// let age = MessageAge::Years(2); /// assert_eq!(age.period_type(), "years"); /// ``` + #[must_use] pub fn period_type(&self) -> &'static str { match self { MessageAge::Days(_) => "days", @@ -225,6 +239,30 @@ impl MessageAge { } } +impl TryFrom<&str> for MessageAge { + type Error = Error; + + /// Try to create a `MessageAge` from a string using the parse format. + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::MessageAge; + /// use std::convert::TryFrom; + /// + /// let age = MessageAge::try_from("d:30").unwrap(); + /// assert_eq!(age, MessageAge::Days(30)); + /// + /// let age = MessageAge::try_from("invalid"); + /// assert!(age.is_err()); + /// ``` + fn try_from(value: &str) -> Result { + Self::parse(value).ok_or_else(|| { + Error::InvalidMessageAge(format!("Failed to parse MessageAge from '{value}'")) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -236,7 +274,7 @@ mod tests { assert_eq!(MessageAge::new("weeks", 4).unwrap(), MessageAge::Weeks(4)); assert_eq!(MessageAge::new("months", 6).unwrap(), MessageAge::Months(6)); assert_eq!(MessageAge::new("years", 2).unwrap(), MessageAge::Years(2)); - + // Test case insensitive assert_eq!(MessageAge::new("DAYS", 1).unwrap(), MessageAge::Days(1)); assert_eq!(MessageAge::new("Days", 1).unwrap(), MessageAge::Days(1)); @@ -246,12 +284,12 @@ mod tests { #[test] fn test_message_age_new_invalid_period() { assert!(MessageAge::new("invalid", 1).is_err()); - assert!(MessageAge::new("day", 1).is_err()); // singular form + assert!(MessageAge::new("day", 1).is_err()); // singular form assert!(MessageAge::new("", 1).is_err()); - + // Check error messages let err = MessageAge::new("invalid", 1).unwrap_err(); - assert!(err.contains("Unknown period 'invalid'")); + assert!(err.to_string().contains("Unknown period 'invalid'")); } #[test] @@ -259,10 +297,10 @@ mod tests { assert!(MessageAge::new("days", 0).is_err()); assert!(MessageAge::new("days", -1).is_err()); assert!(MessageAge::new("days", -100).is_err()); - + // Check error messages let err = MessageAge::new("days", -1).unwrap_err(); - assert!(err.contains("Count must be positive")); + assert!(err.to_string().contains("Count must be positive")); } #[test] @@ -271,7 +309,7 @@ mod tests { assert_eq!(MessageAge::parse("w:4").unwrap(), MessageAge::Weeks(4)); assert_eq!(MessageAge::parse("m:6").unwrap(), MessageAge::Months(6)); assert_eq!(MessageAge::parse("y:2").unwrap(), MessageAge::Years(2)); - + // Test large numbers assert_eq!(MessageAge::parse("d:999").unwrap(), MessageAge::Days(999)); } @@ -284,12 +322,12 @@ mod tests { assert!(MessageAge::parse("d:").is_none()); assert!(MessageAge::parse(":30").is_none()); assert!(MessageAge::parse("x:30").is_none()); - + // Invalid count assert!(MessageAge::parse("d:0").is_none()); assert!(MessageAge::parse("d:-1").is_none()); assert!(MessageAge::parse("d:abc").is_none()); - + // Wrong separator assert!(MessageAge::parse("d-30").is_none()); assert!(MessageAge::parse("d 30").is_none()); @@ -347,10 +385,23 @@ mod tests { let serialized = original.to_string(); let parsed = MessageAge::parse(&serialized).unwrap(); assert_eq!(original, parsed); - + let original = MessageAge::Years(5); let serialized = original.to_string(); let parsed = MessageAge::parse(&serialized).unwrap(); assert_eq!(original, parsed); } + + #[test] + fn test_try_from() { + use std::convert::TryFrom; + + assert_eq!(MessageAge::try_from("d:30").unwrap(), MessageAge::Days(30)); + assert_eq!(MessageAge::try_from("w:4").unwrap(), MessageAge::Weeks(4)); + assert_eq!(MessageAge::try_from("m:6").unwrap(), MessageAge::Months(6)); + assert_eq!(MessageAge::try_from("y:2").unwrap(), MessageAge::Years(2)); + + assert!(MessageAge::try_from("invalid").is_err()); + assert!(MessageAge::try_from("d:-1").is_err()); + } }