Files
cull-gmail/crates/cull-gmail/src/rules.rs
Jeremiah Russell 12554f2f92 fix: allow same label in trash and delete rules in validate
Signed-off-by: Jeremiah Russell <jerry@jrussell.ie>
2026-03-13 18:05:48 +00:00

1314 lines
40 KiB
Rust

//! 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"), 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"), 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, fmt,
fs::{self, read_to_string},
path::Path,
};
use serde::{Deserialize, Serialize};
mod eol_rule;
pub use eol_rule::EolRule;
use crate::{EolAction, Error, MessageAge, Result, Retention};
/// 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<String, EolRule>,
}
impl Default for Rules {
fn default() -> Self {
let rules = BTreeMap::new();
let mut cfg = Self { rules };
cfg.add_rule(Retention::new(MessageAge::Years(1), true), None, false)
.add_rule(Retention::new(MessageAge::Weeks(1), true), None, false)
.add_rule(Retention::new(MessageAge::Months(1), true), None, false)
.add_rule(Retention::new(MessageAge::Years(5), true), None, false);
cfg
}
}
impl Rules {
/// 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()
}
/// 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<EolRule> {
self.rules.get(&id.to_string()).cloned()
}
/// 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"), 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"), true);
/// ```
pub fn add_rule(
&mut self,
retention: Retention,
label: Option<&str>,
delete: bool,
) -> &mut Self {
let current_labels: Vec<String> =
self.rules.values().flat_map(|rule| rule.labels()).collect();
if let Some(label_ref) = label
&& current_labels.iter().any(|l| l == label_ref)
{
log::warn!("a rule already applies to label {label_ref}");
return self;
}
let id = if let Some((_, max)) = self.rules.iter().max_by_key(|(_, r)| r.id()) {
max.id() + 1
} else {
1
};
let mut rule = EolRule::new(id);
rule.set_retention(retention);
if let Some(l) = label {
rule.add_label(l);
}
if delete {
rule.set_action(&EolAction::Delete);
}
log::info!("added rule: {rule}");
self.rules.insert(rule.id().to_string(), rule);
self
}
/// 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"), false);
///
/// let labels = rules.labels();
/// assert!(labels.len() > 0);
/// println!("Configured labels: {:?}", labels);
/// ```
pub fn labels(&self) -> Vec<String> {
self.rules.values().flat_map(|rule| rule.labels()).collect()
}
/// Find the ids of the rules that contains a label
///
/// A label may have a `trash` and `delete` rule applied to return a
/// maximum of two rules.
///
/// If a label has more than one `trash` or `delete` rules only the id
/// for the last rule will be returned.
fn find_label(&self, label: &str) -> Vec<usize> {
let mut rwl = Vec::new();
if let Some(t) = self.find_label_for_action(label, EolAction::Trash) {
rwl.push(t);
}
if let Some(d) = self.find_label_for_action(label, EolAction::Delete) {
rwl.push(d);
}
rwl
}
/// Find the id of the rule that contains a label
fn find_label_for_action(&self, label: &str, action: EolAction) -> Option<usize> {
let rules_by_label = self.get_rules_by_label_for_action(action);
rules_by_label.get(label).map(|r| r.id())
}
/// 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(())
}
/// 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"), 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();
if !labels.iter().any(|l| l == label) {
return Err(Error::LabelNotFoundInRules(label.to_string()));
}
let rule_ids = self.find_label(label);
if rule_ids.is_empty() {
return Err(Error::NoRuleFoundForLabel(label.to_string()));
}
for id in rule_ids {
self.rules.remove(&id.to_string());
}
log::info!("Rule containing the label `{label}` has been removed.");
Ok(())
}
/// 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, EolAction};
///
/// let mut rules = Rules::new();
/// let retention = Retention::new(MessageAge::Days(30), false);
/// rules.add_rule(retention, Some("test"), false);
///
/// let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
/// if let Some(rule) = label_map.get("test") {
/// println!("Rule for 'test' label: {}", rule.describe());
/// }
/// ```
pub fn get_rules_by_label_for_action(&self, action: EolAction) -> BTreeMap<String, EolRule> {
let mut rbl = BTreeMap::new();
for rule in self.rules.values() {
if rule.action() == Some(action) {
for label in rule.labels() {
rbl.insert(label, rule.clone());
}
}
}
rbl
}
/// 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));
};
rule.add_label(label);
self.save()?;
println!("Label `{label}` added to rule `#{id}`");
Ok(())
}
/// 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));
};
rule.remove_label(label);
self.save()?;
println!("Label `{label}` removed from rule `#{id}`");
Ok(())
}
/// 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));
};
rule.set_action(action);
self.save()?;
println!("Action set to `{action}` on rule `#{id}`");
Ok(())
}
/// 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"), 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<()> {
self.save_to(None)
}
/// Saves the current rule configuration to a specified path.
///
/// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
/// The directory is created if it doesn't exist.
///
/// # Arguments
///
/// * `path` - Optional path where the rules should be saved
///
/// # Examples
///
/// ```ignore
/// use cull_gmail::Rules;
/// use std::path::Path;
///
/// let rules = Rules::new();
/// rules.save_to(Some(Path::new("/custom/path/rules.toml")))
/// .expect("Failed to save");
/// ```
///
/// # Errors
///
/// * TOML serialization errors
/// * IO errors when writing to the file system
/// * File system permission errors
pub fn save_to(&self, path: Option<&Path>) -> Result<()> {
let save_path = if let Some(p) = path {
p.to_path_buf()
} else {
let home_dir = env::home_dir().ok_or_else(|| {
Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
})?;
home_dir.join(".cull-gmail/rules.toml")
};
// Ensure directory exists
if let Some(parent) = save_path.parent() {
fs::create_dir_all(parent)?;
}
let res = toml::to_string(self);
log::trace!("toml conversion result: {res:#?}");
if let Ok(output) = res {
fs::write(&save_path, output)?;
log::trace!("Config saved to {}", save_path.display());
}
Ok(())
}
/// 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<Rules> {
Self::load_from(None)
}
/// Loads rule configuration from a specified path.
///
/// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
///
/// # Arguments
///
/// * `path` - Optional path to load rules from
///
/// # Examples
///
/// ```ignore
/// use cull_gmail::Rules;
/// use std::path::Path;
///
/// let rules = Rules::load_from(Some(Path::new("/custom/path/rules.toml")))
/// .expect("Failed to load rules");
/// ```
///
/// # 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_from(path: Option<&Path>) -> Result<Rules> {
let load_path = if let Some(p) = path {
p.to_path_buf()
} else {
let home_dir = env::home_dir().ok_or_else(|| {
Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
})?;
home_dir.join(".cull-gmail/rules.toml")
};
log::trace!("Loading config from {}", load_path.display());
let input = read_to_string(load_path)?;
let config = toml::from_str::<Rules>(&input)?;
Ok(config)
}
/// 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}");
}
Ok(())
}
/// Validates all rules in the set and returns a list of issues found.
///
/// Checks each rule for:
/// - Non-empty label set
/// - Valid retention period string (parseable as a `MessageAge`)
/// - Valid action string (parseable as an `EolAction`)
///
/// Also checks across rules for duplicate labels (the same label appearing
/// in more than one rule).
///
/// Returns an empty `Vec` if all rules are valid.
///
/// # Examples
///
/// ```
/// use cull_gmail::Rules;
///
/// let rules = Rules::new();
/// let issues = rules.validate();
/// assert!(issues.is_empty(), "Default rules should all be valid");
/// ```
pub fn validate(&self) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
// Key: (label, action_str) — the same label in a Trash and a Delete rule is
// intentional two-stage processing and must not be flagged as a duplicate.
let mut seen_label_actions: BTreeMap<(String, String), usize> = BTreeMap::new();
for rule in self.rules.values() {
let id = rule.id();
if rule.labels().is_empty() {
issues.push(ValidationIssue::EmptyLabels { rule_id: id });
}
if MessageAge::parse(rule.retention()).is_none() {
issues.push(ValidationIssue::InvalidRetention {
rule_id: id,
retention: rule.retention().to_string(),
});
}
if rule.action().is_none() {
issues.push(ValidationIssue::InvalidAction {
rule_id: id,
action: rule.action_str().to_string(),
});
}
for label in rule.labels() {
let key = (label.clone(), rule.action_str().to_lowercase());
if let Some(&other_id) = seen_label_actions.get(&key) {
if other_id != id {
issues.push(ValidationIssue::DuplicateLabel {
label: label.clone(),
});
}
} else {
seen_label_actions.insert(key, id);
}
}
}
issues
}
}
/// An issue found during rules validation.
#[derive(Debug, PartialEq)]
pub enum ValidationIssue {
/// A rule has no labels configured.
EmptyLabels {
/// The ID of the offending rule.
rule_id: usize,
},
/// A rule has a retention string that cannot be parsed as a `MessageAge`.
InvalidRetention {
/// The ID of the offending rule.
rule_id: usize,
/// The unparseable retention string.
retention: String,
},
/// A rule has an action string that cannot be parsed as an `EolAction`.
InvalidAction {
/// The ID of the offending rule.
rule_id: usize,
/// The unparseable action string.
action: String,
},
/// The same label appears in more than one rule.
DuplicateLabel {
/// The duplicated label.
label: String,
},
}
impl fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationIssue::EmptyLabels { rule_id } => {
write!(f, "Rule #{rule_id}: no labels configured")
}
ValidationIssue::InvalidRetention { rule_id, retention } => {
write!(f, "Rule #{rule_id}: invalid retention '{retention}'")
}
ValidationIssue::InvalidAction { rule_id, action } => {
write!(f, "Rule #{rule_id}: invalid action '{action}'")
}
ValidationIssue::DuplicateLabel { label } => {
write!(f, "Label '{label}' is used in multiple rules")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_logger;
use std::fs;
fn setup_test_environment() {
get_test_logger();
// Clean up any existing test files
let Some(home_dir) = env::home_dir() else {
// Skip cleanup if home directory cannot be determined
return;
};
let test_config_dir = home_dir.join(".cull-gmail");
let test_rules_file = test_config_dir.join("rules.toml");
if test_rules_file.exists() {
let _ = fs::remove_file(&test_rules_file);
}
}
#[test]
fn test_rules_new_creates_default_rules() {
setup_test_environment();
let rules = Rules::new();
// Should have some default rules
let labels = rules.labels();
assert!(
!labels.is_empty(),
"Default rules should create some labels"
);
// Should contain the expected retention labels
assert!(labels.iter().any(|l| l.contains("retention/1-years")));
assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
assert!(labels.iter().any(|l| l.contains("retention/1-months")));
assert!(labels.iter().any(|l| l.contains("retention/5-years")));
}
#[test]
fn test_rules_default_same_as_new() {
setup_test_environment();
let rules_new = Rules::new();
let rules_default = Rules::default();
// Both should have the same number of rules
assert_eq!(rules_new.labels().len(), rules_default.labels().len());
}
#[test]
fn test_add_rule_with_label() {
setup_test_environment();
let mut rules = Rules::new();
let initial_label_count = rules.labels().len();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, Some("test-label"), false);
let labels = rules.labels();
assert!(labels.contains(&"test-label".to_string()));
assert_eq!(labels.len(), initial_label_count + 1);
}
#[test]
fn test_add_rule_without_label() {
setup_test_environment();
let mut rules = Rules::new();
let initial_label_count = rules.labels().len();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, None, false);
// Should not add any new labels since no label specified and generate_label is false
let labels = rules.labels();
assert_eq!(labels.len(), initial_label_count);
}
#[test]
fn test_add_rule_with_delete_action() {
setup_test_environment();
let mut rules = Rules::new();
let retention = Retention::new(MessageAge::Days(7), false);
rules.add_rule(retention, Some("delete-test"), true);
let rules_by_label = rules.get_rules_by_label_for_action(EolAction::Delete);
let rule = rules_by_label.get("delete-test").unwrap();
assert_eq!(rule.action(), Some(EolAction::Delete));
}
#[test]
fn test_add_duplicate_label_warns_and_skips() {
setup_test_environment();
let mut rules = Rules::new();
let retention1 = Retention::new(MessageAge::Days(30), false);
let retention2 = Retention::new(MessageAge::Days(60), false);
rules.add_rule(retention1, Some("duplicate"), false);
let initial_count = rules.labels().len();
// Try to add another rule with the same label
rules.add_rule(retention2, Some("duplicate"), false);
// Should not increase the count of labels
assert_eq!(rules.labels().len(), initial_count);
}
#[test]
fn test_get_rule_existing() {
setup_test_environment();
let rules = Rules::new();
// Default rules should have ID 1
let rule = rules.get_rule(1);
assert!(rule.is_some());
assert_eq!(rule.unwrap().id(), 1);
}
#[test]
fn test_get_rule_nonexistent() {
setup_test_environment();
let rules = Rules::new();
// ID 999 should not exist
let rule = rules.get_rule(999);
assert!(rule.is_none());
}
#[test]
fn test_labels_returns_all_labels() {
setup_test_environment();
let mut rules = Rules::new();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, Some("custom-label"), false);
let labels = rules.labels();
assert!(labels.contains(&"custom-label".to_string()));
}
#[test]
fn test_get_rules_by_label() {
setup_test_environment();
let mut rules = Rules::new();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, Some("mapped-label"), false);
let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
let rule = label_map.get("mapped-label");
assert!(rule.is_some());
assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
}
#[test]
fn test_remove_rule_by_id_existing() {
setup_test_environment();
let mut rules = Rules::new();
// Remove a default rule (assuming ID 1 exists)
let result = rules.remove_rule_by_id(1);
assert!(result.is_ok());
// Rule should no longer exist
assert!(rules.get_rule(1).is_none());
}
#[test]
fn test_remove_rule_by_id_nonexistent() {
setup_test_environment();
let mut rules = Rules::new();
// Removing non-existent rule should still succeed
let result = rules.remove_rule_by_id(999);
assert!(result.is_ok());
}
#[test]
fn test_remove_rule_by_label_existing() {
setup_test_environment();
let mut rules = Rules::new();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, Some("remove-me"), false);
let result = rules.remove_rule_by_label("remove-me");
assert!(result.is_ok());
// Label should no longer exist
let labels = rules.labels();
assert!(!labels.contains(&"remove-me".to_string()));
}
#[test]
fn test_remove_rule_by_label_nonexistent() {
setup_test_environment();
let mut rules = Rules::new();
let result = rules.remove_rule_by_label("nonexistent-label");
assert!(result.is_err());
match result.unwrap_err() {
Error::LabelNotFoundInRules(label) => {
assert_eq!(label, "nonexistent-label");
}
_ => panic!("Expected LabelNotFoundInRules error"),
}
}
#[test]
fn test_add_label_to_rule_existing_rule() {
setup_test_environment();
let mut rules = Rules::new();
// Add label to existing rule (ID 1)
let result = rules.add_label_to_rule(1, "new-label");
assert!(result.is_ok());
let rule = rules.get_rule(1).unwrap();
assert!(rule.labels().contains(&"new-label".to_string()));
}
#[test]
fn test_add_label_to_rule_nonexistent_rule() {
setup_test_environment();
let mut rules = Rules::new();
let result = rules.add_label_to_rule(999, "new-label");
assert!(result.is_err());
match result.unwrap_err() {
Error::RuleNotFound(id) => {
assert_eq!(id, 999);
}
_ => panic!("Expected RuleNotFound error"),
}
}
#[test]
fn test_remove_label_from_rule_existing() {
setup_test_environment();
let mut rules = Rules::new();
// First add a label
let result = rules.add_label_to_rule(1, "temp-label");
assert!(result.is_ok());
// Then remove it
let result = rules.remove_label_from_rule(1, "temp-label");
assert!(result.is_ok());
let rule = rules.get_rule(1).unwrap();
assert!(!rule.labels().contains(&"temp-label".to_string()));
}
#[test]
fn test_remove_label_from_rule_nonexistent_rule() {
setup_test_environment();
let mut rules = Rules::new();
let result = rules.remove_label_from_rule(999, "any-label");
assert!(result.is_err());
match result.unwrap_err() {
Error::RuleNotFound(id) => {
assert_eq!(id, 999);
}
_ => panic!("Expected RuleNotFound error"),
}
}
#[test]
fn test_set_action_on_rule_existing() {
setup_test_environment();
let mut rules = Rules::new();
// Set action to Delete
let result = rules.set_action_on_rule(1, &EolAction::Delete);
assert!(result.is_ok());
let rule = rules.get_rule(1).unwrap();
assert_eq!(rule.action(), Some(EolAction::Delete));
}
#[test]
fn test_set_action_on_rule_nonexistent() {
setup_test_environment();
let mut rules = Rules::new();
let result = rules.set_action_on_rule(999, &EolAction::Delete);
assert!(result.is_err());
match result.unwrap_err() {
Error::RuleNotFound(id) => {
assert_eq!(id, 999);
}
_ => panic!("Expected RuleNotFound error"),
}
}
#[test]
fn test_list_rules_succeeds() {
setup_test_environment();
let rules = Rules::new();
// Should not panic or return error
let result = rules.list_rules();
assert!(result.is_ok());
}
// --- validate() tests ---
#[test]
fn test_validate_default_rules_are_valid() {
setup_test_environment();
let rules = Rules::new();
let issues = rules.validate();
assert!(
issues.is_empty(),
"Default rules should be valid, got: {issues:?}"
);
}
#[test]
fn test_validate_empty_labels_reported() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = "d:30"
labels = []
action = "Trash"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::EmptyLabels { rule_id: 1 })),
"Expected EmptyLabels for rule #1, got: {issues:?}"
);
}
#[test]
fn test_validate_invalid_retention_reported() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = "invalid"
labels = ["some-label"]
action = "Trash"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
"Expected InvalidRetention for rule #1, got: {issues:?}"
);
}
#[test]
fn test_validate_empty_retention_reported() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = ""
labels = ["some-label"]
action = "Trash"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
"Expected InvalidRetention for empty retention in rule #1, got: {issues:?}"
);
}
#[test]
fn test_validate_invalid_action_reported() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = "d:30"
labels = ["some-label"]
action = "invalid-action"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::InvalidAction { rule_id: 1, .. })),
"Expected InvalidAction for rule #1, got: {issues:?}"
);
}
#[test]
fn test_validate_duplicate_label_reported() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = "d:30"
labels = ["shared-label"]
action = "Trash"
[rules."2"]
id = 2
retention = "d:60"
labels = ["shared-label"]
action = "Trash"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
issues.iter().any(|i| matches!(
i,
ValidationIssue::DuplicateLabel { label }
if label == "shared-label"
)),
"Expected DuplicateLabel for 'shared-label', got: {issues:?}"
);
}
#[test]
fn test_validate_same_label_different_actions_not_duplicate() {
setup_test_environment();
// A label in a Trash rule AND a Delete rule is intentional two-stage processing.
let toml_str = r#"
[rules."1"]
id = 1
retention = "w:1"
labels = ["Development/Notifications"]
action = "Trash"
[rules."2"]
id = 2
retention = "w:2"
labels = ["Development/Notifications"]
action = "Delete"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
assert!(
!issues
.iter()
.any(|i| matches!(i, ValidationIssue::DuplicateLabel { .. })),
"Same label with different actions should NOT be flagged as duplicate, got: {issues:?}"
);
}
#[test]
fn test_validate_multiple_issues_collected() {
setup_test_environment();
let toml_str = r#"
[rules."1"]
id = 1
retention = ""
labels = []
action = "bad"
"#;
let rules: Rules = toml::from_str(toml_str).unwrap();
let issues = rules.validate();
// All three issues should be present for the one rule
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::EmptyLabels { .. })),
"Expected EmptyLabels"
);
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::InvalidRetention { .. })),
"Expected InvalidRetention"
);
assert!(
issues
.iter()
.any(|i| matches!(i, ValidationIssue::InvalidAction { .. })),
"Expected InvalidAction"
);
}
// Integration tests for save/load would require file system setup
// These are marked as ignore to avoid interference with actual config files
#[test]
#[ignore = "Integration test that modifies file system"]
fn test_save_and_load_roundtrip() {
setup_test_environment();
let mut rules = Rules::new();
let retention = Retention::new(MessageAge::Days(30), false);
rules.add_rule(retention, Some("save-test"), false);
// Save to disk
let save_result = rules.save();
assert!(save_result.is_ok());
// Load from disk
let loaded_rules = Rules::load();
assert!(loaded_rules.is_ok());
let loaded_rules = loaded_rules.unwrap();
let labels = loaded_rules.labels();
assert!(labels.contains(&"save-test".to_string()));
}
}