feat(retention): enhance message age with parsing and validation

- 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`
This commit is contained in:
Jeremiah Russell
2025-10-19 07:44:17 +01:00
committed by Jeremiah Russell
parent 5c2124ead4
commit 051507856a

View File

@@ -1,28 +1,30 @@
use std::fmt::Display; use std::fmt::Display;
use crate::{Error, Result};
/// Message age specification for retention policies. /// Message age specification for retention policies.
/// ///
/// Defines different time periods that can be used to specify how old messages /// Defines different time periods that can be used to specify how old messages
/// should be before they are subject to retention actions (trash/delete). /// should be before they are subject to retention actions (trash/delete).
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// // Create different message age specifications /// // Create different message age specifications
/// let days = MessageAge::Days(30); /// let days = MessageAge::Days(30);
/// let weeks = MessageAge::Weeks(4); /// let weeks = MessageAge::Weeks(4);
/// let months = MessageAge::Months(6); /// let months = MessageAge::Months(6);
/// let years = MessageAge::Years(2); /// let years = MessageAge::Years(2);
/// ///
/// // Use with retention policy /// // Use with retention policy
/// println!("Messages older than {} will be processed", months); /// println!("Messages older than {} will be processed", months);
/// ``` /// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MessageAge { pub enum MessageAge {
/// Number of days to retain the message /// Number of days to retain the message
/// ///
/// # Example /// # Example
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
@@ -31,7 +33,7 @@ pub enum MessageAge {
/// ``` /// ```
Days(i64), Days(i64),
/// Number of weeks to retain the message /// Number of weeks to retain the message
/// ///
/// # Example /// # Example
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
@@ -40,7 +42,7 @@ pub enum MessageAge {
/// ``` /// ```
Weeks(i64), Weeks(i64),
/// Number of months to retain the message /// Number of months to retain the message
/// ///
/// # Example /// # Example
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
@@ -49,7 +51,7 @@ pub enum MessageAge {
/// ``` /// ```
Months(i64), Months(i64),
/// Number of years to retain the message /// Number of years to retain the message
/// ///
/// # Example /// # Example
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
@@ -71,109 +73,116 @@ impl Display for MessageAge {
} }
impl MessageAge { impl MessageAge {
/// Create a new MessageAge from a period string and count. /// Create a new `MessageAge` from a period string and count.
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `period` - The time period ("days", "weeks", "months", "years") /// * `period` - The time period ("days", "weeks", "months", "years")
/// * `count` - The number of time periods (must be positive) /// * `count` - The number of time periods (must be positive)
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// let age = MessageAge::new("days", 30).unwrap(); /// let age = MessageAge::new("days", 30).unwrap();
/// assert_eq!(age, MessageAge::Days(30)); /// assert_eq!(age, MessageAge::Days(30));
/// ///
/// let age = MessageAge::new("months", 6).unwrap(); /// let age = MessageAge::new("months", 6).unwrap();
/// assert_eq!(age, MessageAge::Months(6)); /// assert_eq!(age, MessageAge::Months(6));
/// ///
/// // Invalid period returns an error /// // Invalid period returns an error
/// assert!(MessageAge::new("invalid", 1).is_err()); /// assert!(MessageAge::new("invalid", 1).is_err());
/// ///
/// // Negative count returns an error /// // Negative count returns an error
/// assert!(MessageAge::new("days", -1).is_err()); /// assert!(MessageAge::new("days", -1).is_err());
/// ``` /// ```
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if: /// Returns an error if:
/// - The period string is not recognized /// - The period string is not recognized
/// - The count is negative or zero /// - The count is negative or zero
pub fn new(period: &str, count: i64) -> Result<Self, String> { pub fn new(period: &str, count: i64) -> Result<Self> {
if count <= 0 { 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() { match period.to_lowercase().as_str() {
"days" => Ok(MessageAge::Days(count)), "days" => Ok(MessageAge::Days(count)),
"weeks" => Ok(MessageAge::Weeks(count)), "weeks" => Ok(MessageAge::Weeks(count)),
"months" => Ok(MessageAge::Months(count)), "months" => Ok(MessageAge::Months(count)),
"years" => Ok(MessageAge::Years(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 /// # Arguments
/// ///
/// * `s` - String in format "`period:count`" where period is d/w/m/y /// * `s` - String in format "`period:count`" where period is d/w/m/y
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// let age = MessageAge::parse("d:30").unwrap(); /// let age = MessageAge::parse("d:30").unwrap();
/// assert_eq!(age, MessageAge::Days(30)); /// assert_eq!(age, MessageAge::Days(30));
/// ///
/// let age = MessageAge::parse("y:2").unwrap(); /// let age = MessageAge::parse("y:2").unwrap();
/// assert_eq!(age, MessageAge::Years(2)); /// assert_eq!(age, MessageAge::Years(2));
/// ///
/// // Invalid format returns None /// // Invalid format returns None
/// assert!(MessageAge::parse("invalid").is_none()); /// assert!(MessageAge::parse("invalid").is_none());
/// assert!(MessageAge::parse("d").is_none()); /// assert!(MessageAge::parse("d").is_none());
/// ``` /// ```
#[must_use]
pub fn parse(s: &str) -> Option<MessageAge> { pub fn parse(s: &str) -> Option<MessageAge> {
if s.len() < 3 || s.chars().nth(1) != Some(':') { let bytes = s.as_bytes();
if bytes.len() < 3 || bytes[1] != b':' {
return None; return None;
} }
let period = s.chars().nth(0)?; let period = bytes[0];
let count_str = &s[2..]; let count_str = &s[2..];
let count = count_str.parse::<i64>().ok()?; let count = count_str.parse::<i64>().ok()?;
if count <= 0 { if count <= 0 {
return None; return None;
} }
match period { match period {
'd' => Some(MessageAge::Days(count)), b'd' => Some(MessageAge::Days(count)),
'w' => Some(MessageAge::Weeks(count)), b'w' => Some(MessageAge::Weeks(count)),
'm' => Some(MessageAge::Months(count)), b'm' => Some(MessageAge::Months(count)),
'y' => Some(MessageAge::Years(count)), b'y' => Some(MessageAge::Years(count)),
_ => None, _ => None,
} }
} }
/// Generate a label string for this message age. /// Generate a label string for this message age.
/// ///
/// This creates a standardized label that can be used to categorize /// This creates a standardized label that can be used to categorize
/// messages based on their retention period. /// messages based on their retention period.
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// let age = MessageAge::Days(30); /// let age = MessageAge::Days(30);
/// assert_eq!(age.label(), "retention/30-days"); /// assert_eq!(age.label(), "retention/30-days");
/// ///
/// let age = MessageAge::Years(1); /// let age = MessageAge::Years(1);
/// assert_eq!(age.label(), "retention/1-years"); /// assert_eq!(age.label(), "retention/1-years");
/// ``` /// ```
#[must_use]
pub fn label(&self) -> String { pub fn label(&self) -> String {
match self { match self {
MessageAge::Days(v) => format!("retention/{v}-days"), MessageAge::Days(v) => format!("retention/{v}-days"),
@@ -182,39 +191,44 @@ impl MessageAge {
MessageAge::Years(v) => format!("retention/{v}-years"), MessageAge::Years(v) => format!("retention/{v}-years"),
} }
} }
/// Get the numeric value of this message age. /// Get the numeric value of this message age.
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// let age = MessageAge::Days(30); /// let age = MessageAge::Days(30);
/// assert_eq!(age.value(), 30); /// assert_eq!(age.value(), 30);
/// ///
/// let age = MessageAge::Years(2); /// let age = MessageAge::Years(2);
/// assert_eq!(age.value(), 2); /// assert_eq!(age.value(), 2);
/// ``` /// ```
#[must_use]
pub fn value(&self) -> i64 { pub fn value(&self) -> i64 {
match self { 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. /// Get the period type as a string.
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use cull_gmail::MessageAge; /// use cull_gmail::MessageAge;
/// ///
/// let age = MessageAge::Days(30); /// let age = MessageAge::Days(30);
/// assert_eq!(age.period_type(), "days"); /// assert_eq!(age.period_type(), "days");
/// ///
/// let age = MessageAge::Years(2); /// let age = MessageAge::Years(2);
/// assert_eq!(age.period_type(), "years"); /// assert_eq!(age.period_type(), "years");
/// ``` /// ```
#[must_use]
pub fn period_type(&self) -> &'static str { pub fn period_type(&self) -> &'static str {
match self { match self {
MessageAge::Days(_) => "days", 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> {
Self::parse(value).ok_or_else(|| {
Error::InvalidMessageAge(format!("Failed to parse MessageAge from '{value}'"))
})
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -236,7 +274,7 @@ mod tests {
assert_eq!(MessageAge::new("weeks", 4).unwrap(), MessageAge::Weeks(4)); assert_eq!(MessageAge::new("weeks", 4).unwrap(), MessageAge::Weeks(4));
assert_eq!(MessageAge::new("months", 6).unwrap(), MessageAge::Months(6)); assert_eq!(MessageAge::new("months", 6).unwrap(), MessageAge::Months(6));
assert_eq!(MessageAge::new("years", 2).unwrap(), MessageAge::Years(2)); assert_eq!(MessageAge::new("years", 2).unwrap(), MessageAge::Years(2));
// Test case insensitive // Test case insensitive
assert_eq!(MessageAge::new("DAYS", 1).unwrap(), MessageAge::Days(1)); assert_eq!(MessageAge::new("DAYS", 1).unwrap(), MessageAge::Days(1));
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] #[test]
fn test_message_age_new_invalid_period() { fn test_message_age_new_invalid_period() {
assert!(MessageAge::new("invalid", 1).is_err()); 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()); assert!(MessageAge::new("", 1).is_err());
// Check error messages // Check error messages
let err = MessageAge::new("invalid", 1).unwrap_err(); let err = MessageAge::new("invalid", 1).unwrap_err();
assert!(err.contains("Unknown period 'invalid'")); assert!(err.to_string().contains("Unknown period 'invalid'"));
} }
#[test] #[test]
@@ -259,10 +297,10 @@ mod tests {
assert!(MessageAge::new("days", 0).is_err()); assert!(MessageAge::new("days", 0).is_err());
assert!(MessageAge::new("days", -1).is_err()); assert!(MessageAge::new("days", -1).is_err());
assert!(MessageAge::new("days", -100).is_err()); assert!(MessageAge::new("days", -100).is_err());
// Check error messages // Check error messages
let err = MessageAge::new("days", -1).unwrap_err(); 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] #[test]
@@ -271,7 +309,7 @@ mod tests {
assert_eq!(MessageAge::parse("w:4").unwrap(), MessageAge::Weeks(4)); assert_eq!(MessageAge::parse("w:4").unwrap(), MessageAge::Weeks(4));
assert_eq!(MessageAge::parse("m:6").unwrap(), MessageAge::Months(6)); assert_eq!(MessageAge::parse("m:6").unwrap(), MessageAge::Months(6));
assert_eq!(MessageAge::parse("y:2").unwrap(), MessageAge::Years(2)); assert_eq!(MessageAge::parse("y:2").unwrap(), MessageAge::Years(2));
// Test large numbers // Test large numbers
assert_eq!(MessageAge::parse("d:999").unwrap(), MessageAge::Days(999)); 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("d:").is_none());
assert!(MessageAge::parse(":30").is_none()); assert!(MessageAge::parse(":30").is_none());
assert!(MessageAge::parse("x:30").is_none()); assert!(MessageAge::parse("x:30").is_none());
// Invalid count // Invalid count
assert!(MessageAge::parse("d:0").is_none()); assert!(MessageAge::parse("d:0").is_none());
assert!(MessageAge::parse("d:-1").is_none()); assert!(MessageAge::parse("d:-1").is_none());
assert!(MessageAge::parse("d:abc").is_none()); assert!(MessageAge::parse("d:abc").is_none());
// Wrong separator // Wrong separator
assert!(MessageAge::parse("d-30").is_none()); assert!(MessageAge::parse("d-30").is_none());
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 serialized = original.to_string();
let parsed = MessageAge::parse(&serialized).unwrap(); let parsed = MessageAge::parse(&serialized).unwrap();
assert_eq!(original, parsed); assert_eq!(original, parsed);
let original = MessageAge::Years(5); let original = MessageAge::Years(5);
let serialized = original.to_string(); let serialized = original.to_string();
let parsed = MessageAge::parse(&serialized).unwrap(); let parsed = MessageAge::parse(&serialized).unwrap();
assert_eq!(original, parsed); 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());
}
} }