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};
#[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 {
pub fn new() -> Self {
Rules::default()
}
pub fn get_rule(&self, id: usize) -> Option<EolRule> {
self.rules.get(&id.to_string()).cloned()
}
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
}
pub fn labels(&self) -> Vec<String> {
self.rules.values().flat_map(|rule| rule.labels()).collect()
}
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
}
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())
}
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(())
}
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(())
}
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
}
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(())
}
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(())
}
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(())
}
pub fn save(&self) -> Result<()> {
self.save_to(None)
}
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")
};
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(())
}
pub fn load() -> Result<Rules> {
Self::load_from(None)
}
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)
}
pub fn list_rules(&self) -> Result<()> {
for rule in self.rules.values() {
println!("{rule}");
}
Ok(())
}
pub fn validate(&self) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
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
}
}
#[derive(Debug, PartialEq)]
pub enum ValidationIssue {
EmptyLabels {
rule_id: usize,
},
InvalidRetention {
rule_id: usize,
retention: String,
},
InvalidAction {
rule_id: usize,
action: String,
},
DuplicateLabel {
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();
let Some(home_dir) = env::home_dir() else {
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();
let labels = rules.labels();
assert!(
!labels.is_empty(),
"Default rules should create some 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();
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);
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();
rules.add_rule(retention2, Some("duplicate"), false);
assert_eq!(rules.labels().len(), initial_count);
}
#[test]
fn test_get_rule_existing() {
setup_test_environment();
let rules = Rules::new();
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();
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();
let result = rules.remove_rule_by_id(1);
assert!(result.is_ok());
assert!(rules.get_rule(1).is_none());
}
#[test]
fn test_remove_rule_by_id_nonexistent() {
setup_test_environment();
let mut rules = Rules::new();
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());
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();
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();
let result = rules.add_label_to_rule(1, "temp-label");
assert!(result.is_ok());
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();
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();
let result = rules.list_rules();
assert!(result.is_ok());
}
#[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();
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();
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"
);
}
#[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);
let save_result = rules.save();
assert!(save_result.is_ok());
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()));
}
}