diff --git a/src/rules.rs b/src/rules.rs index 79f68dc..c99afbc 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -1,3 +1,46 @@ +//! Rules management for Gmail message retention and cleanup. +//! +//! This module provides the [`Rules`] struct which manages a collection of end-of-life (EOL) +//! rules for automatically processing Gmail messages. Rules define when and how messages +//! should be processed based on their age and labels. +//! +//! # Overview +//! +//! The rules system allows you to: +//! - Create rules with specific retention periods (days, weeks, months, years) +//! - Target specific Gmail labels or apply rules globally +//! - Choose between moving to trash or permanent deletion +//! - Save and load rule configurations from disk +//! - Manage rules individually by ID or label +//! +//! # Usage +//! +//! ``` +//! use cull_gmail::{Rules, Retention, MessageAge, EolAction}; +//! +//! // Create a new rule set +//! let mut rules = Rules::new(); +//! +//! // Add a rule to delete old newsletters after 6 months +//! let newsletter_retention = Retention::new(MessageAge::Months(6), true); +//! rules.add_rule(newsletter_retention, Some(&"newsletter".to_string()), true); +//! +//! // Add a rule to trash spam after 30 days +//! let spam_retention = Retention::new(MessageAge::Days(30), false); +//! rules.add_rule(spam_retention, Some(&"spam".to_string()), false); +//! +//! // Save the rules to disk +//! rules.save().expect("Failed to save rules"); +//! +//! // List all configured rules +//! rules.list_rules().expect("Failed to list rules"); +//! ``` +//! +//! # Persistence +//! +//! Rules are automatically saved to `~/.cull-gmail/rules.toml` and can be loaded +//! using [`Rules::load()`]. The configuration uses TOML format for human readability. + use std::{ collections::BTreeMap, env, @@ -13,7 +56,42 @@ pub use eol_rule::EolRule; use crate::{EolAction, Error, MessageAge, Result, Retention}; -/// Configuration file for the program +/// A collection of end-of-life rules for Gmail message processing. +/// +/// `Rules` manages a set of end-of-life rule instances that define how Gmail messages +/// should be processed based on their age and labels. Rules can move messages to +/// trash or delete them permanently when they exceed specified retention periods. +/// +/// # Structure +/// +/// Each rule has: +/// - A unique ID for identification +/// - A retention period (age threshold) +/// - Optional target labels +/// - An action (trash or delete) +/// +/// # Default Rules +/// +/// When created with [`Rules::new()`] or [`Rules::default()`], the following +/// default rules are automatically added: +/// - 1 year retention with auto-generated label +/// - 1 week retention with auto-generated label +/// - 1 month retention with auto-generated label +/// - 5 year retention with auto-generated label +/// +/// # Examples +/// +/// ``` +/// use cull_gmail::{Rules, Retention, MessageAge}; +/// +/// let rules = Rules::new(); +/// // Default rules are automatically created +/// assert!(!rules.labels().is_empty()); +/// ``` +/// +/// # Serialization +/// +/// Rules can be serialized to and from TOML format for persistence. #[derive(Debug, Serialize, Deserialize)] pub struct Rules { rules: BTreeMap, @@ -35,17 +113,83 @@ impl Default for Rules { } impl Rules { - /// Create a new configuration file + /// Creates a new Rules instance with default retention rules. + /// + /// This creates the same configuration as [`Rules::default()`], including + /// several pre-configured rules with common retention periods. + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::Rules; + /// + /// let rules = Rules::new(); + /// // Default rules are automatically created + /// let labels = rules.labels(); + /// assert!(!labels.is_empty()); + /// ``` pub fn new() -> Self { Rules::default() } - /// Get the contents of an existing rule + /// Retrieves a rule by its unique ID. + /// + /// Returns a cloned copy of the rule if found, or `None` if no rule + /// exists with the specified ID. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the rule to retrieve + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// let retention = Retention::new(MessageAge::Days(30), false); + /// rules.add_rule(retention, None, false); + /// + /// // Retrieve a rule (exact ID depends on existing rules) + /// if let Some(rule) = rules.get_rule(1) { + /// println!("Found rule: {}", rule.describe()); + /// } + /// ``` pub fn get_rule(&self, id: usize) -> Option { self.rules.get(&id.to_string()).cloned() } - /// Add a new rule to the rule set by setting the retention age + /// Adds a new rule to the rule set with the specified retention settings. + /// + /// Creates a new rule with an automatically assigned unique ID. If a label + /// is specified and another rule already targets that label, a warning is + /// logged and the rule is not added. + /// + /// # Arguments + /// + /// * `retention` - The retention configuration (age and label generation) + /// * `label` - Optional label that this rule should target + /// * `delete` - If `true`, messages are permanently deleted; if `false`, moved to trash + /// + /// # Returns + /// + /// Returns a mutable reference to self for method chaining. + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::{Rules, Retention, MessageAge, EolAction}; + /// + /// let mut rules = Rules::new(); + /// + /// // Add a rule to trash newsletters after 3 months + /// let retention = Retention::new(MessageAge::Months(3), false); + /// rules.add_rule(retention, Some(&"newsletter".to_string()), false); + /// + /// // Add a rule to delete spam after 7 days + /// let spam_retention = Retention::new(MessageAge::Days(7), false); + /// rules.add_rule(spam_retention, Some(&"spam".to_string()), true); + /// ``` pub fn add_rule( &mut self, retention: Retention, @@ -82,7 +226,24 @@ impl Rules { self } - /// Get the labels from the rules + /// Returns all labels targeted by the current rules. + /// + /// This method collects labels from all rules in the set and returns + /// them as a single vector. Duplicate labels are not removed. + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// let retention = Retention::new(MessageAge::Days(30), false); + /// rules.add_rule(retention, Some(&"test-label".to_string()), false); + /// + /// let labels = rules.labels(); + /// assert!(labels.len() > 0); + /// println!("Configured labels: {:?}", labels); + /// ``` pub fn labels(&self) -> Vec { let mut labels = Vec::new(); for rule in self.rules.values() { @@ -101,14 +262,63 @@ impl Rules { } } - /// Remove a rule by the ID specified + /// Removes a rule from the set by its unique ID. + /// + /// If the rule exists, it is removed and a confirmation message is printed. + /// If the rule doesn't exist, the operation completes successfully without error. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the rule to remove + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// // Assume rule ID 1 exists from defaults + /// rules.remove_rule_by_id(1).expect("Failed to remove rule"); + /// ``` + /// + /// # Errors + /// + /// This method currently always returns `Ok(())`, but the return type + /// is `Result<()>` for future extensibility. pub fn remove_rule_by_id(&mut self, id: usize) -> crate::Result<()> { self.rules.remove(&id.to_string()); println!("Rule `{id}` has been removed."); Ok(()) } - /// Remove a rule by the Label specified + /// Removes a rule from the set by targeting one of its labels. + /// + /// Finds the rule that contains the specified label and removes it. + /// If multiple rules target the same label, only one is removed. + /// + /// # Arguments + /// + /// * `label` - The label to search for in existing rules + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// let retention = Retention::new(MessageAge::Days(30), false); + /// rules.add_rule(retention, Some(&"newsletter".to_string()), false); + /// + /// // Remove the rule targeting the newsletter label + /// rules.remove_rule_by_label("newsletter") + /// .expect("Failed to remove rule"); + /// ``` + /// + /// # Errors + /// + /// * [`Error::LabelNotFoundInRules`] if no rule contains the specified label + /// * [`Error::NoRuleFoundForLabel`] if the label exists but no rule is found + /// (should not happen under normal conditions) pub fn remove_rule_by_label(&mut self, label: &str) -> crate::Result<()> { let labels = self.labels(); @@ -127,7 +337,26 @@ impl Rules { Ok(()) } - /// Get a map of the rules indexed by labels + /// Returns a mapping from labels to rules that target them. + /// + /// Creates a `BTreeMap` where each key is a label and each value is a cloned + /// copy of the rule that targets that label. If multiple rules target the + /// same label, only one will be present in the result (the last one processed). + /// + /// # Examples + /// + /// ``` + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// let retention = Retention::new(MessageAge::Days(30), false); + /// rules.add_rule(retention, Some(&"test".to_string()), false); + /// + /// let label_map = rules.get_rules_by_label(); + /// if let Some(rule) = label_map.get("test") { + /// println!("Rule for 'test' label: {}", rule.describe()); + /// } + /// ``` pub fn get_rules_by_label(&self) -> BTreeMap { let mut rbl = BTreeMap::new(); @@ -140,7 +369,30 @@ impl Rules { rbl } - /// Add a label to the rule identified by the id + /// Adds a label to an existing rule and saves the configuration. + /// + /// Finds the rule with the specified ID and adds the given label to it. + /// The configuration is automatically saved to disk after the change. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the rule to modify + /// * `label` - The label to add to the rule + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::Rules; + /// + /// let mut rules = Rules::load().expect("Failed to load rules"); + /// rules.add_label_to_rule(1, "new-label") + /// .expect("Failed to add label"); + /// ``` + /// + /// # Errors + /// + /// * [`Error::RuleNotFound`] if no rule exists with the specified ID + /// * IO errors from saving the configuration file pub fn add_label_to_rule(&mut self, id: usize, label: &str) -> Result<()> { let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else { return Err(Error::RuleNotFound(id)); @@ -152,7 +404,30 @@ impl Rules { Ok(()) } - /// Remove a label from the rule identified by the id + /// Removes a label from an existing rule and saves the configuration. + /// + /// Finds the rule with the specified ID and removes the given label from it. + /// The configuration is automatically saved to disk after the change. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the rule to modify + /// * `label` - The label to remove from the rule + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::Rules; + /// + /// let mut rules = Rules::load().expect("Failed to load rules"); + /// rules.remove_label_from_rule(1, "old-label") + /// .expect("Failed to remove label"); + /// ``` + /// + /// # Errors + /// + /// * [`Error::RuleNotFound`] if no rule exists with the specified ID + /// * IO errors from saving the configuration file pub fn remove_label_from_rule(&mut self, id: usize, label: &str) -> Result<()> { let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else { return Err(Error::RuleNotFound(id)); @@ -164,7 +439,30 @@ impl Rules { Ok(()) } - /// Set the action on the rule identified by the id + /// Sets the action for an existing rule and saves the configuration. + /// + /// Finds the rule with the specified ID and updates its action (trash or delete). + /// The configuration is automatically saved to disk after the change. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the rule to modify + /// * `action` - The new action to set (`Trash` or `Delete`) + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::{Rules, EolAction}; + /// + /// let mut rules = Rules::load().expect("Failed to load rules"); + /// rules.set_action_on_rule(1, &EolAction::Delete) + /// .expect("Failed to set action"); + /// ``` + /// + /// # Errors + /// + /// * [`Error::RuleNotFound`] if no rule exists with the specified ID + /// * IO errors from saving the configuration file pub fn set_action_on_rule(&mut self, id: usize, action: &EolAction) -> Result<()> { let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else { return Err(Error::RuleNotFound(id)); @@ -176,7 +474,28 @@ impl Rules { Ok(()) } - /// Save the current configuration to the file + /// Saves the current rule configuration to disk. + /// + /// The configuration is saved as TOML format to `~/.cull-gmail/rules.toml`. + /// The directory is created if it doesn't exist. + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::{Rules, Retention, MessageAge}; + /// + /// let mut rules = Rules::new(); + /// let retention = Retention::new(MessageAge::Days(30), false); + /// rules.add_rule(retention, Some(&"test".to_string()), false); + /// + /// rules.save().expect("Failed to save configuration"); + /// ``` + /// + /// # Errors + /// + /// * TOML serialization errors + /// * IO errors when writing to the file system + /// * File system permission errors pub fn save(&self) -> Result<()> { let home_dir = env::home_dir().unwrap(); let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml"); @@ -192,7 +511,30 @@ impl Rules { Ok(()) } - /// Load the current configuration + /// Loads rule configuration from disk. + /// + /// Reads the configuration from `~/.cull-gmail/rules.toml` and deserializes + /// it into a `Rules` instance. + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::Rules; + /// + /// match Rules::load() { + /// Ok(rules) => { + /// println!("Loaded {} rules", rules.labels().len()); + /// rules.list_rules().expect("Failed to list rules"); + /// } + /// Err(e) => println!("Failed to load rules: {}", e), + /// } + /// ``` + /// + /// # Errors + /// + /// * IO errors when reading from the file system + /// * TOML parsing errors if the file is malformed + /// * File not found errors if the configuration doesn't exist pub fn load() -> Result { let home_dir = env::home_dir().unwrap(); let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml"); @@ -203,7 +545,28 @@ impl Rules { Ok(config) } - /// List the end of life rules set in the configuration + /// Prints all configured rules to standard output. + /// + /// Each rule is printed on a separate line with its description, + /// including the rule ID, action, and age criteria. + /// + /// # Examples + /// + /// ```ignore + /// use cull_gmail::Rules; + /// + /// let rules = Rules::new(); + /// rules.list_rules().expect("Failed to list rules"); + /// // Output: + /// // Rule #1 is active on `retention/1-years` to move the message to trash if it is more than 1 years old. + /// // Rule #2 is active on `retention/1-weeks` to move the message to trash if it is more than 1 weeks old. + /// // ... + /// ``` + /// + /// # Errors + /// + /// This method currently always returns `Ok(())`, but the return type + /// is `Result<()>` for consistency with other methods and future extensibility. pub fn list_rules(&self) -> Result<()> { for rule in self.rules.values() { println!("{rule}");