//! End-of-life (EOL) rule implementation. //! //! This module provides the [`EolRule`] struct which defines rules for automatically //! processing Gmail messages based on their age. Rules can be configured to either //! move messages to trash or permanently delete them after a specified retention period. //! //! # Usage //! //! ```ignore //! use cull_gmail::{Retention, MessageAge, EolAction}; //! use cull_gmail::rules::eol_rule::EolRule; //! //! // Create a new rule to delete messages older than 1 year //! let mut rule = EolRule::new(1); //! let retention = Retention::new(MessageAge::Years(1), true); //! rule.set_retention(retention); //! rule.set_action(&EolAction::Delete); //! //! // Add labels that this rule applies to //! rule.add_label("old-emails"); //! //! println!("Rule description: {}", rule.describe()); //! ``` use std::{collections::BTreeSet, fmt}; use chrono::{DateTime, Datelike, Local, TimeDelta, TimeZone}; use serde::{Deserialize, Serialize}; use crate::{MessageAge, Retention, eol_action::EolAction}; /// A rule that defines end-of-life processing for Gmail messages. /// /// An `EolRule` specifies conditions under which messages should be processed /// (moved to trash or deleted) based on their age and optional label filters. /// Each rule has a unique identifier and can be configured with retention periods, /// target labels, and actions to perform. /// /// # Examples /// /// Creating a basic rule: /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let rule = EolRule::new(42); /// assert_eq!(rule.id(), 42); /// assert!(rule.labels().is_empty()); /// ``` /// /// # Serialization /// /// Rules can be serialized to and from TOML/JSON using serde. #[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct EolRule { id: usize, retention: String, labels: BTreeSet, query: Option, action: String, } impl fmt::Display for EolRule { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if !self.retention.is_empty() { let (action, count, period) = self.get_action_period_count_strings(); write!( f, "Rule #{} is active on `{}` to {action} if it is more than {count} {period} old.", self.id, self.labels .iter() .cloned() .collect::>() .join(", ") ) } else { write!(f, "Complete retention rule not set.") } } } impl EolRule { /// Creates a new end-of-life rule with the specified unique identifier. /// /// The rule is created with default settings: /// - Action: Move to trash (not delete) /// - No retention period set /// - No labels specified /// - No custom query /// /// # Arguments /// /// * `id` - A unique identifier for this rule /// /// # Examples /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let rule = EolRule::new(1); /// assert_eq!(rule.id(), 1); /// assert!(rule.labels().is_empty()); /// ``` pub(crate) fn new(id: usize) -> Self { EolRule { id, action: EolAction::Trash.to_string(), ..Default::default() } } /// Sets the retention period for this rule. /// /// The retention period determines how old messages must be before this rule /// applies to them. If the retention is configured to generate labels, the /// appropriate label will be automatically added to this rule. /// /// # Arguments /// /// * `value` - The retention configuration specifying age and label generation /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge}; /// let mut rule = EolRule::new(1); /// let retention = Retention::new(MessageAge::Months(6), true); /// rule.set_retention(retention); /// /// assert_eq!(rule.retention(), "m:6"); /// assert!(rule.labels().contains(&"retention/6-months".to_string())); /// ``` pub(crate) fn set_retention(&mut self, value: Retention) -> &mut Self { self.retention = value.age().to_string(); if value.generate_label() { self.add_label(&value.age().label()); } self } /// Returns the retention period string for this rule. /// /// The retention string follows the format used by [`MessageAge`], /// such as "d:30" for 30 days or "y:1" for 1 year. /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge}; /// let mut rule = EolRule::new(1); /// let retention = Retention::new(MessageAge::Days(90), false); /// rule.set_retention(retention); /// /// assert_eq!(rule.retention(), "d:90"); /// ``` pub(crate) fn retention(&self) -> &str { &self.retention } /// Adds a label that this rule should apply to. /// /// Labels are used to filter which messages this rule processes. Messages /// must have one of the rule's labels to be affected by the rule. /// Duplicate labels are ignored. /// /// # Arguments /// /// * `value` - The label name to add /// /// # Examples /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let mut rule = EolRule::new(1); /// rule.add_label("newsletter") /// .add_label("promotions"); /// /// let labels = rule.labels(); /// assert!(labels.contains(&"newsletter".to_string())); /// assert!(labels.contains(&"promotions".to_string())); /// assert_eq!(labels.len(), 2); /// ``` pub(crate) fn add_label(&mut self, value: &str) -> &mut Self { self.labels.insert(value.to_string()); self } /// Removes a label from this rule. /// /// If the label is not present, this operation does nothing. /// /// # Arguments /// /// * `value` - The label name to remove /// /// # Examples /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let mut rule = EolRule::new(1); /// rule.add_label("temp-label"); /// assert!(rule.labels().contains(&"temp-label".to_string())); /// /// rule.remove_label("temp-label"); /// assert!(!rule.labels().contains(&"temp-label".to_string())); /// ``` pub(crate) fn remove_label(&mut self, value: &str) { self.labels.remove(value); } /// Returns the unique identifier for this rule. /// /// Each rule has a unique ID that distinguishes it from other rules /// in the same rule set. /// /// # Examples /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let rule = EolRule::new(42); /// assert_eq!(rule.id(), 42); /// ``` pub fn id(&self) -> usize { self.id } /// Returns a list of all labels that this rule applies to. /// /// Labels determine which messages this rule will process. Only messages /// with one of these labels will be affected by the rule. /// /// # Examples /// /// ```ignore /// # use cull_gmail::rules::eol_rule::EolRule; /// let mut rule = EolRule::new(1); /// rule.add_label("spam").add_label("newsletter"); /// /// let labels = rule.labels(); /// assert_eq!(labels.len(), 2); /// assert!(labels.contains(&"spam".to_string())); /// assert!(labels.contains(&"newsletter".to_string())); /// ``` pub fn labels(&self) -> Vec { self.labels.iter().cloned().collect() } /// Sets the action to perform when this rule matches messages. /// /// The action determines what happens to messages that match this rule's /// criteria (age and labels). /// /// # Arguments /// /// * `value` - The action to perform (Trash or Delete) /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, EolAction}; /// let mut rule = EolRule::new(1); /// rule.set_action(&EolAction::Delete); /// /// assert_eq!(rule.action(), Some(EolAction::Delete)); /// ``` pub(crate) fn set_action(&mut self, value: &EolAction) -> &mut Self { self.action = value.to_string(); self } /// Returns the action that will be performed by this rule. /// /// The action determines what happens to messages that match this rule: /// - `Trash`: Move messages to the trash folder /// - `Delete`: Permanently delete messages /// /// Returns `None` if the action string cannot be parsed (should not happen /// with properly constructed rules). /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, EolAction}; /// let mut rule = EolRule::new(1); /// rule.set_action(&EolAction::Trash); /// /// assert_eq!(rule.action(), Some(EolAction::Trash)); /// ``` pub fn action(&self) -> Option { EolAction::parse(&self.action) } /// Returns a human-readable description of what this rule does. /// /// The description includes the rule ID, the action that will be performed, /// and the age threshold for messages. /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge, EolAction}; /// let mut rule = EolRule::new(5); /// let retention = Retention::new(MessageAge::Months(3), false); /// rule.set_retention(retention); /// rule.set_action(&EolAction::Delete); /// /// let description = rule.describe(); /// assert!(description.contains("Rule #5")); /// assert!(description.contains("delete")); /// assert!(description.contains("3 months")); /// ``` pub fn describe(&self) -> String { let (action, count, period) = self.get_action_period_count_strings(); format!( "Rule #{}, to {action} if it is more than {count} {period} old.", self.id, ) } /// Describe the action that will be performed by the rule and its conditions fn get_action_period_count_strings(&self) -> (String, usize, String) { let count = &self.retention[2..]; let count = count.parse::().unwrap_or(0); // Default to 0 if parsing fails let mut period = match self.retention.chars().nth(0) { Some('d') => "day", Some('w') => "week", Some('m') => "month", Some('y') => "year", Some(_) => unreachable!(), None => unreachable!(), } .to_string(); if count > 1 { period.push('s'); } let action = match self.action.to_lowercase().as_str() { "trash" => "move the message to trash", "delete" => "delete the message", _ => unreachable!(), }; (action.to_string(), count, period) } /// Generates a Gmail search query for messages that match this rule's age criteria. /// /// This method calculates the cutoff date based on the rule's retention period /// and returns a Gmail search query string that can be used to find messages /// older than the specified threshold. /// /// Returns `None` if the retention period is not set or cannot be parsed. /// /// # Examples /// /// ```ignore /// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge}; /// let mut rule = EolRule::new(1); /// let retention = Retention::new(MessageAge::Days(30), false); /// rule.set_retention(retention); /// /// if let Some(query) = rule.eol_query() { /// println!("Gmail query: {}", query); /// // Output will be something like "before: 2024-08-15" /// } /// ``` pub(crate) fn eol_query(&self) -> Option { let today = chrono::Local::now(); self.calculate_for_date(today) } fn calculate_for_date(&self, today: DateTime) -> Option { let message_age = MessageAge::parse(&self.retention)?; let deadline = match message_age { MessageAge::Days(c) => { let delta = TimeDelta::days(c); today.checked_sub_signed(delta)? } MessageAge::Weeks(c) => { let delta = TimeDelta::weeks(c); today.checked_sub_signed(delta)? } MessageAge::Months(c) => { let day = today.day(); let month = today.month(); let year = today.year(); let mut years = c as i32 / 12; let months = c % 12; let mut new_month = month - months as u32; if new_month < 1 { years += 1; new_month += 12; } let new_year = year - years; Local .with_ymd_and_hms(new_year, new_month, day, 0, 0, 0) .single()? } MessageAge::Years(c) => { let day = today.day(); let month = today.month(); let year = today.year(); let new_year = year - c as i32; Local .with_ymd_and_hms(new_year, month, day, 0, 0, 0) .single()? } }; Some(format!("before: {}", deadline.format("%Y-%m-%d"))) } } #[cfg(test)] mod test { use chrono::{Local, TimeZone}; use crate::{MessageAge, Retention, rules::eol_rule::EolRule}; fn build_test_rule(age: MessageAge) -> EolRule { let retention = Retention::new(age, true); let mut rule = EolRule::new(1); rule.set_retention(retention); rule } #[test] fn test_display_for_eol_rule_5_years() { let rule = build_test_rule(crate::MessageAge::Years(5)); assert_eq!( "Rule #1 is active on `retention/5-years` to move the message to trash if it is more than 5 years old." .to_string(), rule.to_string() ); } #[test] fn test_display_for_eol_rule_1_month() { let rule = build_test_rule(crate::MessageAge::Months(1)); assert_eq!( "Rule #1 is active on `retention/1-months` to move the message to trash if it is more than 1 month old." .to_string(), rule.to_string() ); } #[test] fn test_display_for_eol_rule_13_weeks() { let rule = build_test_rule(crate::MessageAge::Weeks(13)); assert_eq!( "Rule #1 is active on `retention/13-weeks` to move the message to trash if it is more than 13 weeks old." .to_string(), rule.to_string() ); } #[test] fn test_display_for_eol_rule_365_days() { let rule = build_test_rule(crate::MessageAge::Days(365)); assert_eq!( "Rule #1 is active on `retention/365-days` to move the message to trash if it is more than 365 days old." .to_string(), rule.to_string() ); } #[test] fn test_eol_query_for_eol_rule_5_years() { let rule = build_test_rule(crate::MessageAge::Years(5)); let test_today = Local .with_ymd_and_hms(2025, 9, 15, 0, 0, 0) .single() .unwrap(); let query = rule .calculate_for_date(test_today) .expect("Failed to calculate query"); assert_eq!("before: 2020-09-15", query); } #[test] fn test_eol_query_for_eol_rule_1_month() { let rule = build_test_rule(crate::MessageAge::Months(1)); let test_today = Local .with_ymd_and_hms(2025, 9, 15, 0, 0, 0) .single() .unwrap(); let query = rule .calculate_for_date(test_today) .expect("Failed to calculate query"); assert_eq!("before: 2025-08-15", query); } #[test] fn test_eol_query_for_eol_rule_13_weeks() { let rule = build_test_rule(crate::MessageAge::Weeks(13)); let test_today = Local .with_ymd_and_hms(2025, 9, 15, 0, 0, 0) .single() .unwrap(); let query = rule .calculate_for_date(test_today) .expect("Failed to calculate query"); assert_eq!("before: 2025-06-16", query); } #[test] fn test_eol_query_for_eol_rule_365_days() { let rule = build_test_rule(crate::MessageAge::Days(365)); let test_today = Local .with_ymd_and_hms(2025, 9, 15, 0, 0, 0) .single() .unwrap(); let query = rule .calculate_for_date(test_today) .expect("Failed to calculate query"); assert_eq!("before: 2024-09-15", query); } }