cull_gmail/rules.rs
1//! Rules management for Gmail message retention and cleanup.
2//!
3//! This module provides the [`Rules`] struct which manages a collection of end-of-life (EOL)
4//! rules for automatically processing Gmail messages. Rules define when and how messages
5//! should be processed based on their age and labels.
6//!
7//! # Overview
8//!
9//! The rules system allows you to:
10//! - Create rules with specific retention periods (days, weeks, months, years)
11//! - Target specific Gmail labels or apply rules globally
12//! - Choose between moving to trash or permanent deletion
13//! - Save and load rule configurations from disk
14//! - Manage rules individually by ID or label
15//!
16//! # Usage
17//!
18//! ```
19//! use cull_gmail::{Rules, Retention, MessageAge, EolAction};
20//!
21//! // Create a new rule set
22//! let mut rules = Rules::new();
23//!
24//! // Add a rule to delete old newsletters after 6 months
25//! let newsletter_retention = Retention::new(MessageAge::Months(6), true);
26//! rules.add_rule(newsletter_retention, Some("newsletter"), true);
27//!
28//! // Add a rule to trash spam after 30 days
29//! let spam_retention = Retention::new(MessageAge::Days(30), false);
30//! rules.add_rule(spam_retention, Some("spam"), false);
31//!
32//! // Save the rules to disk
33//! rules.save().expect("Failed to save rules");
34//!
35//! // List all configured rules
36//! rules.list_rules().expect("Failed to list rules");
37//! ```
38//!
39//! # Persistence
40//!
41//! Rules are automatically saved to `~/.cull-gmail/rules.toml` and can be loaded
42//! using [`Rules::load()`]. The configuration uses TOML format for human readability.
43
44use std::{
45 collections::BTreeMap,
46 env, fmt,
47 fs::{self, read_to_string},
48 path::Path,
49};
50
51use serde::{Deserialize, Serialize};
52
53mod eol_rule;
54
55pub use eol_rule::EolRule;
56
57use crate::{EolAction, Error, MessageAge, Result, Retention};
58
59/// A collection of end-of-life rules for Gmail message processing.
60///
61/// `Rules` manages a set of end-of-life rule instances that define how Gmail messages
62/// should be processed based on their age and labels. Rules can move messages to
63/// trash or delete them permanently when they exceed specified retention periods.
64///
65/// # Structure
66///
67/// Each rule has:
68/// - A unique ID for identification
69/// - A retention period (age threshold)
70/// - Optional target labels
71/// - An action (trash or delete)
72///
73/// # Default Rules
74///
75/// When created with [`Rules::new()`] or [`Rules::default()`], the following
76/// default rules are automatically added:
77/// - 1 year retention with auto-generated label
78/// - 1 week retention with auto-generated label
79/// - 1 month retention with auto-generated label
80/// - 5 year retention with auto-generated label
81///
82/// # Examples
83///
84/// ```
85/// use cull_gmail::{Rules, Retention, MessageAge};
86///
87/// let rules = Rules::new();
88/// // Default rules are automatically created
89/// assert!(!rules.labels().is_empty());
90/// ```
91///
92/// # Serialization
93///
94/// Rules can be serialized to and from TOML format for persistence.
95#[derive(Debug, Serialize, Deserialize)]
96pub struct Rules {
97 rules: BTreeMap<String, EolRule>,
98}
99
100impl Default for Rules {
101 fn default() -> Self {
102 let rules = BTreeMap::new();
103
104 let mut cfg = Self { rules };
105
106 cfg.add_rule(Retention::new(MessageAge::Years(1), true), None, false)
107 .add_rule(Retention::new(MessageAge::Weeks(1), true), None, false)
108 .add_rule(Retention::new(MessageAge::Months(1), true), None, false)
109 .add_rule(Retention::new(MessageAge::Years(5), true), None, false);
110
111 cfg
112 }
113}
114
115impl Rules {
116 /// Creates a new Rules instance with default retention rules.
117 ///
118 /// This creates the same configuration as [`Rules::default()`], including
119 /// several pre-configured rules with common retention periods.
120 ///
121 /// # Examples
122 ///
123 /// ```
124 /// use cull_gmail::Rules;
125 ///
126 /// let rules = Rules::new();
127 /// // Default rules are automatically created
128 /// let labels = rules.labels();
129 /// assert!(!labels.is_empty());
130 /// ```
131 pub fn new() -> Self {
132 Rules::default()
133 }
134
135 /// Retrieves a rule by its unique ID.
136 ///
137 /// Returns a cloned copy of the rule if found, or `None` if no rule
138 /// exists with the specified ID.
139 ///
140 /// # Arguments
141 ///
142 /// * `id` - The unique identifier of the rule to retrieve
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use cull_gmail::{Rules, Retention, MessageAge};
148 ///
149 /// let mut rules = Rules::new();
150 /// let retention = Retention::new(MessageAge::Days(30), false);
151 /// rules.add_rule(retention, None, false);
152 ///
153 /// // Retrieve a rule (exact ID depends on existing rules)
154 /// if let Some(rule) = rules.get_rule(1) {
155 /// println!("Found rule: {}", rule.describe());
156 /// }
157 /// ```
158 pub fn get_rule(&self, id: usize) -> Option<EolRule> {
159 self.rules.get(&id.to_string()).cloned()
160 }
161
162 /// Adds a new rule to the rule set with the specified retention settings.
163 ///
164 /// Creates a new rule with an automatically assigned unique ID. If a label
165 /// is specified and another rule already targets that label, a warning is
166 /// logged and the rule is not added.
167 ///
168 /// # Arguments
169 ///
170 /// * `retention` - The retention configuration (age and label generation)
171 /// * `label` - Optional label that this rule should target
172 /// * `delete` - If `true`, messages are permanently deleted; if `false`, moved to trash
173 ///
174 /// # Returns
175 ///
176 /// Returns a mutable reference to self for method chaining.
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use cull_gmail::{Rules, Retention, MessageAge, EolAction};
182 ///
183 /// let mut rules = Rules::new();
184 ///
185 /// // Add a rule to trash newsletters after 3 months
186 /// let retention = Retention::new(MessageAge::Months(3), false);
187 /// rules.add_rule(retention, Some("newsletter"), false);
188 ///
189 /// // Add a rule to delete spam after 7 days
190 /// let spam_retention = Retention::new(MessageAge::Days(7), false);
191 /// rules.add_rule(spam_retention, Some("spam"), true);
192 /// ```
193 pub fn add_rule(
194 &mut self,
195 retention: Retention,
196 label: Option<&str>,
197 delete: bool,
198 ) -> &mut Self {
199 let current_labels: Vec<String> =
200 self.rules.values().flat_map(|rule| rule.labels()).collect();
201
202 if let Some(label_ref) = label
203 && current_labels.iter().any(|l| l == label_ref)
204 {
205 log::warn!("a rule already applies to label {label_ref}");
206 return self;
207 }
208
209 let id = if let Some((_, max)) = self.rules.iter().max_by_key(|(_, r)| r.id()) {
210 max.id() + 1
211 } else {
212 1
213 };
214
215 let mut rule = EolRule::new(id);
216 rule.set_retention(retention);
217 if let Some(l) = label {
218 rule.add_label(l);
219 }
220 if delete {
221 rule.set_action(&EolAction::Delete);
222 }
223 log::info!("added rule: {rule}");
224 self.rules.insert(rule.id().to_string(), rule);
225 self
226 }
227
228 /// Returns all labels targeted by the current rules.
229 ///
230 /// This method collects labels from all rules in the set and returns
231 /// them as a single vector. Duplicate labels are not removed.
232 ///
233 /// # Examples
234 ///
235 /// ```
236 /// use cull_gmail::{Rules, Retention, MessageAge};
237 ///
238 /// let mut rules = Rules::new();
239 /// let retention = Retention::new(MessageAge::Days(30), false);
240 /// rules.add_rule(retention, Some("test-label"), false);
241 ///
242 /// let labels = rules.labels();
243 /// assert!(labels.len() > 0);
244 /// println!("Configured labels: {:?}", labels);
245 /// ```
246 pub fn labels(&self) -> Vec<String> {
247 self.rules.values().flat_map(|rule| rule.labels()).collect()
248 }
249
250 /// Find the ids of the rules that contains a label
251 ///
252 /// A label may have a `trash` and `delete` rule applied to return a
253 /// maximum of two rules.
254 ///
255 /// If a label has more than one `trash` or `delete` rules only the id
256 /// for the last rule will be returned.
257 fn find_label(&self, label: &str) -> Vec<usize> {
258 let mut rwl = Vec::new();
259
260 if let Some(t) = self.find_label_for_action(label, EolAction::Trash) {
261 rwl.push(t);
262 }
263
264 if let Some(d) = self.find_label_for_action(label, EolAction::Delete) {
265 rwl.push(d);
266 }
267
268 rwl
269 }
270
271 /// Find the id of the rule that contains a label
272 fn find_label_for_action(&self, label: &str, action: EolAction) -> Option<usize> {
273 let rules_by_label = self.get_rules_by_label_for_action(action);
274
275 rules_by_label.get(label).map(|r| r.id())
276 }
277
278 /// Removes a rule from the set by its unique ID.
279 ///
280 /// If the rule exists, it is removed and a confirmation message is printed.
281 /// If the rule doesn't exist, the operation completes successfully without error.
282 ///
283 /// # Arguments
284 ///
285 /// * `id` - The unique identifier of the rule to remove
286 ///
287 /// # Examples
288 ///
289 /// ```
290 /// use cull_gmail::{Rules, Retention, MessageAge};
291 ///
292 /// let mut rules = Rules::new();
293 /// // Assume rule ID 1 exists from defaults
294 /// rules.remove_rule_by_id(1).expect("Failed to remove rule");
295 /// ```
296 ///
297 /// # Errors
298 ///
299 /// This method currently always returns `Ok(())`, but the return type
300 /// is `Result<()>` for future extensibility.
301 pub fn remove_rule_by_id(&mut self, id: usize) -> crate::Result<()> {
302 self.rules.remove(&id.to_string());
303 println!("Rule `{id}` has been removed.");
304 Ok(())
305 }
306
307 /// Removes a rule from the set by targeting one of its labels.
308 ///
309 /// Finds the rule that contains the specified label and removes it.
310 /// If multiple rules target the same label, only one is removed.
311 ///
312 /// # Arguments
313 ///
314 /// * `label` - The label to search for in existing rules
315 ///
316 /// # Examples
317 ///
318 /// ```ignore
319 /// use cull_gmail::{Rules, Retention, MessageAge};
320 ///
321 /// let mut rules = Rules::new();
322 /// let retention = Retention::new(MessageAge::Days(30), false);
323 /// rules.add_rule(retention, Some("newsletter"), false);
324 ///
325 /// // Remove the rule targeting the newsletter label
326 /// rules.remove_rule_by_label("newsletter")
327 /// .expect("Failed to remove rule");
328 /// ```
329 ///
330 /// # Errors
331 ///
332 /// * [`Error::LabelNotFoundInRules`] if no rule contains the specified label
333 /// * [`Error::NoRuleFoundForLabel`] if the label exists but no rule is found
334 /// (should not happen under normal conditions)
335 pub fn remove_rule_by_label(&mut self, label: &str) -> crate::Result<()> {
336 let labels = self.labels();
337
338 if !labels.iter().any(|l| l == label) {
339 return Err(Error::LabelNotFoundInRules(label.to_string()));
340 }
341
342 let rule_ids = self.find_label(label);
343 if rule_ids.is_empty() {
344 return Err(Error::NoRuleFoundForLabel(label.to_string()));
345 }
346
347 for id in rule_ids {
348 self.rules.remove(&id.to_string());
349 }
350
351 log::info!("Rule containing the label `{label}` has been removed.");
352 Ok(())
353 }
354
355 /// Returns a mapping from labels to rules that target them.
356 ///
357 /// Creates a `BTreeMap` where each key is a label and each value is a cloned
358 /// copy of the rule that targets that label. If multiple rules target the
359 /// same label, only one will be present in the result (the last one processed).
360 ///
361 /// # Examples
362 ///
363 /// ```
364 /// use cull_gmail::{Rules, Retention, MessageAge, EolAction};
365 ///
366 /// let mut rules = Rules::new();
367 /// let retention = Retention::new(MessageAge::Days(30), false);
368 /// rules.add_rule(retention, Some("test"), false);
369 ///
370 /// let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
371 /// if let Some(rule) = label_map.get("test") {
372 /// println!("Rule for 'test' label: {}", rule.describe());
373 /// }
374 /// ```
375 pub fn get_rules_by_label_for_action(&self, action: EolAction) -> BTreeMap<String, EolRule> {
376 let mut rbl = BTreeMap::new();
377
378 for rule in self.rules.values() {
379 if rule.action() == Some(action) {
380 for label in rule.labels() {
381 rbl.insert(label, rule.clone());
382 }
383 }
384 }
385
386 rbl
387 }
388
389 /// Adds a label to an existing rule and saves the configuration.
390 ///
391 /// Finds the rule with the specified ID and adds the given label to it.
392 /// The configuration is automatically saved to disk after the change.
393 ///
394 /// # Arguments
395 ///
396 /// * `id` - The unique identifier of the rule to modify
397 /// * `label` - The label to add to the rule
398 ///
399 /// # Examples
400 ///
401 /// ```ignore
402 /// use cull_gmail::Rules;
403 ///
404 /// let mut rules = Rules::load().expect("Failed to load rules");
405 /// rules.add_label_to_rule(1, "new-label")
406 /// .expect("Failed to add label");
407 /// ```
408 ///
409 /// # Errors
410 ///
411 /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
412 /// * IO errors from saving the configuration file
413 pub fn add_label_to_rule(&mut self, id: usize, label: &str) -> Result<()> {
414 let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
415 return Err(Error::RuleNotFound(id));
416 };
417 rule.add_label(label);
418 self.save()?;
419 println!("Label `{label}` added to rule `#{id}`");
420
421 Ok(())
422 }
423
424 /// Removes a label from an existing rule and saves the configuration.
425 ///
426 /// Finds the rule with the specified ID and removes the given label from it.
427 /// The configuration is automatically saved to disk after the change.
428 ///
429 /// # Arguments
430 ///
431 /// * `id` - The unique identifier of the rule to modify
432 /// * `label` - The label to remove from the rule
433 ///
434 /// # Examples
435 ///
436 /// ```ignore
437 /// use cull_gmail::Rules;
438 ///
439 /// let mut rules = Rules::load().expect("Failed to load rules");
440 /// rules.remove_label_from_rule(1, "old-label")
441 /// .expect("Failed to remove label");
442 /// ```
443 ///
444 /// # Errors
445 ///
446 /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
447 /// * IO errors from saving the configuration file
448 pub fn remove_label_from_rule(&mut self, id: usize, label: &str) -> Result<()> {
449 let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
450 return Err(Error::RuleNotFound(id));
451 };
452 rule.remove_label(label);
453 self.save()?;
454 println!("Label `{label}` removed from rule `#{id}`");
455
456 Ok(())
457 }
458
459 /// Sets the action for an existing rule and saves the configuration.
460 ///
461 /// Finds the rule with the specified ID and updates its action (trash or delete).
462 /// The configuration is automatically saved to disk after the change.
463 ///
464 /// # Arguments
465 ///
466 /// * `id` - The unique identifier of the rule to modify
467 /// * `action` - The new action to set (`Trash` or `Delete`)
468 ///
469 /// # Examples
470 ///
471 /// ```ignore
472 /// use cull_gmail::{Rules, EolAction};
473 ///
474 /// let mut rules = Rules::load().expect("Failed to load rules");
475 /// rules.set_action_on_rule(1, &EolAction::Delete)
476 /// .expect("Failed to set action");
477 /// ```
478 ///
479 /// # Errors
480 ///
481 /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
482 /// * IO errors from saving the configuration file
483 pub fn set_action_on_rule(&mut self, id: usize, action: &EolAction) -> Result<()> {
484 let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
485 return Err(Error::RuleNotFound(id));
486 };
487 rule.set_action(action);
488 self.save()?;
489 println!("Action set to `{action}` on rule `#{id}`");
490
491 Ok(())
492 }
493
494 /// Saves the current rule configuration to disk.
495 ///
496 /// The configuration is saved as TOML format to `~/.cull-gmail/rules.toml`.
497 /// The directory is created if it doesn't exist.
498 ///
499 /// # Examples
500 ///
501 /// ```ignore
502 /// use cull_gmail::{Rules, Retention, MessageAge};
503 ///
504 /// let mut rules = Rules::new();
505 /// let retention = Retention::new(MessageAge::Days(30), false);
506 /// rules.add_rule(retention, Some("test"), false);
507 ///
508 /// rules.save().expect("Failed to save configuration");
509 /// ```
510 ///
511 /// # Errors
512 ///
513 /// * TOML serialization errors
514 /// * IO errors when writing to the file system
515 /// * File system permission errors
516 pub fn save(&self) -> Result<()> {
517 self.save_to(None)
518 }
519
520 /// Saves the current rule configuration to a specified path.
521 ///
522 /// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
523 /// The directory is created if it doesn't exist.
524 ///
525 /// # Arguments
526 ///
527 /// * `path` - Optional path where the rules should be saved
528 ///
529 /// # Examples
530 ///
531 /// ```ignore
532 /// use cull_gmail::Rules;
533 /// use std::path::Path;
534 ///
535 /// let rules = Rules::new();
536 /// rules.save_to(Some(Path::new("/custom/path/rules.toml")))
537 /// .expect("Failed to save");
538 /// ```
539 ///
540 /// # Errors
541 ///
542 /// * TOML serialization errors
543 /// * IO errors when writing to the file system
544 /// * File system permission errors
545 pub fn save_to(&self, path: Option<&Path>) -> Result<()> {
546 let save_path = if let Some(p) = path {
547 p.to_path_buf()
548 } else {
549 let home_dir = env::home_dir().ok_or_else(|| {
550 Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
551 })?;
552 home_dir.join(".cull-gmail/rules.toml")
553 };
554
555 // Ensure directory exists
556 if let Some(parent) = save_path.parent() {
557 fs::create_dir_all(parent)?;
558 }
559
560 let res = toml::to_string(self);
561 log::trace!("toml conversion result: {res:#?}");
562
563 if let Ok(output) = res {
564 fs::write(&save_path, output)?;
565 log::trace!("Config saved to {}", save_path.display());
566 }
567
568 Ok(())
569 }
570
571 /// Loads rule configuration from disk.
572 ///
573 /// Reads the configuration from `~/.cull-gmail/rules.toml` and deserializes
574 /// it into a `Rules` instance.
575 ///
576 /// # Examples
577 ///
578 /// ```ignore
579 /// use cull_gmail::Rules;
580 ///
581 /// match Rules::load() {
582 /// Ok(rules) => {
583 /// println!("Loaded {} rules", rules.labels().len());
584 /// rules.list_rules().expect("Failed to list rules");
585 /// }
586 /// Err(e) => println!("Failed to load rules: {}", e),
587 /// }
588 /// ```
589 ///
590 /// # Errors
591 ///
592 /// * IO errors when reading from the file system
593 /// * TOML parsing errors if the file is malformed
594 /// * File not found errors if the configuration doesn't exist
595 pub fn load() -> Result<Rules> {
596 Self::load_from(None)
597 }
598
599 /// Loads rule configuration from a specified path.
600 ///
601 /// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
602 ///
603 /// # Arguments
604 ///
605 /// * `path` - Optional path to load rules from
606 ///
607 /// # Examples
608 ///
609 /// ```ignore
610 /// use cull_gmail::Rules;
611 /// use std::path::Path;
612 ///
613 /// let rules = Rules::load_from(Some(Path::new("/custom/path/rules.toml")))
614 /// .expect("Failed to load rules");
615 /// ```
616 ///
617 /// # Errors
618 ///
619 /// * IO errors when reading from the file system
620 /// * TOML parsing errors if the file is malformed
621 /// * File not found errors if the configuration doesn't exist
622 pub fn load_from(path: Option<&Path>) -> Result<Rules> {
623 let load_path = if let Some(p) = path {
624 p.to_path_buf()
625 } else {
626 let home_dir = env::home_dir().ok_or_else(|| {
627 Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
628 })?;
629 home_dir.join(".cull-gmail/rules.toml")
630 };
631
632 log::trace!("Loading config from {}", load_path.display());
633
634 let input = read_to_string(load_path)?;
635 let config = toml::from_str::<Rules>(&input)?;
636 Ok(config)
637 }
638
639 /// Prints all configured rules to standard output.
640 ///
641 /// Each rule is printed on a separate line with its description,
642 /// including the rule ID, action, and age criteria.
643 ///
644 /// # Examples
645 ///
646 /// ```ignore
647 /// use cull_gmail::Rules;
648 ///
649 /// let rules = Rules::new();
650 /// rules.list_rules().expect("Failed to list rules");
651 /// // Output:
652 /// // Rule #1 is active on `retention/1-years` to move the message to trash if it is more than 1 years old.
653 /// // Rule #2 is active on `retention/1-weeks` to move the message to trash if it is more than 1 weeks old.
654 /// // ...
655 /// ```
656 ///
657 /// # Errors
658 ///
659 /// This method currently always returns `Ok(())`, but the return type
660 /// is `Result<()>` for consistency with other methods and future extensibility.
661 pub fn list_rules(&self) -> Result<()> {
662 for rule in self.rules.values() {
663 println!("{rule}");
664 }
665 Ok(())
666 }
667
668 /// Validates all rules in the set and returns a list of issues found.
669 ///
670 /// Checks each rule for:
671 /// - Non-empty label set
672 /// - Valid retention period string (parseable as a `MessageAge`)
673 /// - Valid action string (parseable as an `EolAction`)
674 ///
675 /// Also checks across rules for duplicate labels (the same label appearing
676 /// in more than one rule).
677 ///
678 /// Returns an empty `Vec` if all rules are valid.
679 ///
680 /// # Examples
681 ///
682 /// ```
683 /// use cull_gmail::Rules;
684 ///
685 /// let rules = Rules::new();
686 /// let issues = rules.validate();
687 /// assert!(issues.is_empty(), "Default rules should all be valid");
688 /// ```
689 pub fn validate(&self) -> Vec<ValidationIssue> {
690 let mut issues = Vec::new();
691 let mut seen_labels: BTreeMap<String, usize> = BTreeMap::new();
692
693 for rule in self.rules.values() {
694 let id = rule.id();
695
696 if rule.labels().is_empty() {
697 issues.push(ValidationIssue::EmptyLabels { rule_id: id });
698 }
699
700 if MessageAge::parse(rule.retention()).is_none() {
701 issues.push(ValidationIssue::InvalidRetention {
702 rule_id: id,
703 retention: rule.retention().to_string(),
704 });
705 }
706
707 if rule.action().is_none() {
708 issues.push(ValidationIssue::InvalidAction {
709 rule_id: id,
710 action: rule.action_str().to_string(),
711 });
712 }
713
714 for label in rule.labels() {
715 if let Some(&other_id) = seen_labels.get(&label) {
716 if other_id != id {
717 issues.push(ValidationIssue::DuplicateLabel {
718 label: label.clone(),
719 });
720 }
721 } else {
722 seen_labels.insert(label, id);
723 }
724 }
725 }
726
727 issues
728 }
729}
730
731/// An issue found during rules validation.
732#[derive(Debug, PartialEq)]
733pub enum ValidationIssue {
734 /// A rule has no labels configured.
735 EmptyLabels {
736 /// The ID of the offending rule.
737 rule_id: usize,
738 },
739 /// A rule has a retention string that cannot be parsed as a `MessageAge`.
740 InvalidRetention {
741 /// The ID of the offending rule.
742 rule_id: usize,
743 /// The unparseable retention string.
744 retention: String,
745 },
746 /// A rule has an action string that cannot be parsed as an `EolAction`.
747 InvalidAction {
748 /// The ID of the offending rule.
749 rule_id: usize,
750 /// The unparseable action string.
751 action: String,
752 },
753 /// The same label appears in more than one rule.
754 DuplicateLabel {
755 /// The duplicated label.
756 label: String,
757 },
758}
759
760impl fmt::Display for ValidationIssue {
761 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762 match self {
763 ValidationIssue::EmptyLabels { rule_id } => {
764 write!(f, "Rule #{rule_id}: no labels configured")
765 }
766 ValidationIssue::InvalidRetention { rule_id, retention } => {
767 write!(f, "Rule #{rule_id}: invalid retention '{retention}'")
768 }
769 ValidationIssue::InvalidAction { rule_id, action } => {
770 write!(f, "Rule #{rule_id}: invalid action '{action}'")
771 }
772 ValidationIssue::DuplicateLabel { label } => {
773 write!(f, "Label '{label}' is used in multiple rules")
774 }
775 }
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::test_utils::get_test_logger;
783 use std::fs;
784
785 fn setup_test_environment() {
786 get_test_logger();
787 // Clean up any existing test files
788 let Some(home_dir) = env::home_dir() else {
789 // Skip cleanup if home directory cannot be determined
790 return;
791 };
792 let test_config_dir = home_dir.join(".cull-gmail");
793 let test_rules_file = test_config_dir.join("rules.toml");
794 if test_rules_file.exists() {
795 let _ = fs::remove_file(&test_rules_file);
796 }
797 }
798
799 #[test]
800 fn test_rules_new_creates_default_rules() {
801 setup_test_environment();
802
803 let rules = Rules::new();
804
805 // Should have some default rules
806 let labels = rules.labels();
807 assert!(
808 !labels.is_empty(),
809 "Default rules should create some labels"
810 );
811
812 // Should contain the expected retention labels
813 assert!(labels.iter().any(|l| l.contains("retention/1-years")));
814 assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
815 assert!(labels.iter().any(|l| l.contains("retention/1-months")));
816 assert!(labels.iter().any(|l| l.contains("retention/5-years")));
817 }
818
819 #[test]
820 fn test_rules_default_same_as_new() {
821 setup_test_environment();
822
823 let rules_new = Rules::new();
824 let rules_default = Rules::default();
825
826 // Both should have the same number of rules
827 assert_eq!(rules_new.labels().len(), rules_default.labels().len());
828 }
829
830 #[test]
831 fn test_add_rule_with_label() {
832 setup_test_environment();
833
834 let mut rules = Rules::new();
835 let initial_label_count = rules.labels().len();
836
837 let retention = Retention::new(MessageAge::Days(30), false);
838 rules.add_rule(retention, Some("test-label"), false);
839
840 let labels = rules.labels();
841 assert!(labels.contains(&"test-label".to_string()));
842 assert_eq!(labels.len(), initial_label_count + 1);
843 }
844
845 #[test]
846 fn test_add_rule_without_label() {
847 setup_test_environment();
848
849 let mut rules = Rules::new();
850 let initial_label_count = rules.labels().len();
851
852 let retention = Retention::new(MessageAge::Days(30), false);
853 rules.add_rule(retention, None, false);
854
855 // Should not add any new labels since no label specified and generate_label is false
856 let labels = rules.labels();
857 assert_eq!(labels.len(), initial_label_count);
858 }
859
860 #[test]
861 fn test_add_rule_with_delete_action() {
862 setup_test_environment();
863
864 let mut rules = Rules::new();
865 let retention = Retention::new(MessageAge::Days(7), false);
866 rules.add_rule(retention, Some("delete-test"), true);
867
868 let rules_by_label = rules.get_rules_by_label_for_action(EolAction::Delete);
869 let rule = rules_by_label.get("delete-test").unwrap();
870 assert_eq!(rule.action(), Some(EolAction::Delete));
871 }
872
873 #[test]
874 fn test_add_duplicate_label_warns_and_skips() {
875 setup_test_environment();
876
877 let mut rules = Rules::new();
878 let retention1 = Retention::new(MessageAge::Days(30), false);
879 let retention2 = Retention::new(MessageAge::Days(60), false);
880
881 rules.add_rule(retention1, Some("duplicate"), false);
882 let initial_count = rules.labels().len();
883
884 // Try to add another rule with the same label
885 rules.add_rule(retention2, Some("duplicate"), false);
886
887 // Should not increase the count of labels
888 assert_eq!(rules.labels().len(), initial_count);
889 }
890
891 #[test]
892 fn test_get_rule_existing() {
893 setup_test_environment();
894
895 let rules = Rules::new();
896
897 // Default rules should have ID 1
898 let rule = rules.get_rule(1);
899 assert!(rule.is_some());
900 assert_eq!(rule.unwrap().id(), 1);
901 }
902
903 #[test]
904 fn test_get_rule_nonexistent() {
905 setup_test_environment();
906
907 let rules = Rules::new();
908
909 // ID 999 should not exist
910 let rule = rules.get_rule(999);
911 assert!(rule.is_none());
912 }
913
914 #[test]
915 fn test_labels_returns_all_labels() {
916 setup_test_environment();
917
918 let mut rules = Rules::new();
919 let retention = Retention::new(MessageAge::Days(30), false);
920 rules.add_rule(retention, Some("custom-label"), false);
921
922 let labels = rules.labels();
923 assert!(labels.contains(&"custom-label".to_string()));
924 }
925
926 #[test]
927 fn test_get_rules_by_label() {
928 setup_test_environment();
929
930 let mut rules = Rules::new();
931 let retention = Retention::new(MessageAge::Days(30), false);
932 rules.add_rule(retention, Some("mapped-label"), false);
933
934 let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
935 let rule = label_map.get("mapped-label");
936 assert!(rule.is_some());
937 assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
938 }
939
940 #[test]
941 fn test_remove_rule_by_id_existing() {
942 setup_test_environment();
943
944 let mut rules = Rules::new();
945
946 // Remove a default rule (assuming ID 1 exists)
947 let result = rules.remove_rule_by_id(1);
948 assert!(result.is_ok());
949
950 // Rule should no longer exist
951 assert!(rules.get_rule(1).is_none());
952 }
953
954 #[test]
955 fn test_remove_rule_by_id_nonexistent() {
956 setup_test_environment();
957
958 let mut rules = Rules::new();
959
960 // Removing non-existent rule should still succeed
961 let result = rules.remove_rule_by_id(999);
962 assert!(result.is_ok());
963 }
964
965 #[test]
966 fn test_remove_rule_by_label_existing() {
967 setup_test_environment();
968
969 let mut rules = Rules::new();
970 let retention = Retention::new(MessageAge::Days(30), false);
971 rules.add_rule(retention, Some("remove-me"), false);
972
973 let result = rules.remove_rule_by_label("remove-me");
974 assert!(result.is_ok());
975
976 // Label should no longer exist
977 let labels = rules.labels();
978 assert!(!labels.contains(&"remove-me".to_string()));
979 }
980
981 #[test]
982 fn test_remove_rule_by_label_nonexistent() {
983 setup_test_environment();
984
985 let mut rules = Rules::new();
986
987 let result = rules.remove_rule_by_label("nonexistent-label");
988 assert!(result.is_err());
989
990 match result.unwrap_err() {
991 Error::LabelNotFoundInRules(label) => {
992 assert_eq!(label, "nonexistent-label");
993 }
994 _ => panic!("Expected LabelNotFoundInRules error"),
995 }
996 }
997
998 #[test]
999 fn test_add_label_to_rule_existing_rule() {
1000 setup_test_environment();
1001
1002 let mut rules = Rules::new();
1003
1004 // Add label to existing rule (ID 1)
1005 let result = rules.add_label_to_rule(1, "new-label");
1006 assert!(result.is_ok());
1007
1008 let rule = rules.get_rule(1).unwrap();
1009 assert!(rule.labels().contains(&"new-label".to_string()));
1010 }
1011
1012 #[test]
1013 fn test_add_label_to_rule_nonexistent_rule() {
1014 setup_test_environment();
1015
1016 let mut rules = Rules::new();
1017
1018 let result = rules.add_label_to_rule(999, "new-label");
1019 assert!(result.is_err());
1020
1021 match result.unwrap_err() {
1022 Error::RuleNotFound(id) => {
1023 assert_eq!(id, 999);
1024 }
1025 _ => panic!("Expected RuleNotFound error"),
1026 }
1027 }
1028
1029 #[test]
1030 fn test_remove_label_from_rule_existing() {
1031 setup_test_environment();
1032
1033 let mut rules = Rules::new();
1034
1035 // First add a label
1036 let result = rules.add_label_to_rule(1, "temp-label");
1037 assert!(result.is_ok());
1038
1039 // Then remove it
1040 let result = rules.remove_label_from_rule(1, "temp-label");
1041 assert!(result.is_ok());
1042
1043 let rule = rules.get_rule(1).unwrap();
1044 assert!(!rule.labels().contains(&"temp-label".to_string()));
1045 }
1046
1047 #[test]
1048 fn test_remove_label_from_rule_nonexistent_rule() {
1049 setup_test_environment();
1050
1051 let mut rules = Rules::new();
1052
1053 let result = rules.remove_label_from_rule(999, "any-label");
1054 assert!(result.is_err());
1055
1056 match result.unwrap_err() {
1057 Error::RuleNotFound(id) => {
1058 assert_eq!(id, 999);
1059 }
1060 _ => panic!("Expected RuleNotFound error"),
1061 }
1062 }
1063
1064 #[test]
1065 fn test_set_action_on_rule_existing() {
1066 setup_test_environment();
1067
1068 let mut rules = Rules::new();
1069
1070 // Set action to Delete
1071 let result = rules.set_action_on_rule(1, &EolAction::Delete);
1072 assert!(result.is_ok());
1073
1074 let rule = rules.get_rule(1).unwrap();
1075 assert_eq!(rule.action(), Some(EolAction::Delete));
1076 }
1077
1078 #[test]
1079 fn test_set_action_on_rule_nonexistent() {
1080 setup_test_environment();
1081
1082 let mut rules = Rules::new();
1083
1084 let result = rules.set_action_on_rule(999, &EolAction::Delete);
1085 assert!(result.is_err());
1086
1087 match result.unwrap_err() {
1088 Error::RuleNotFound(id) => {
1089 assert_eq!(id, 999);
1090 }
1091 _ => panic!("Expected RuleNotFound error"),
1092 }
1093 }
1094
1095 #[test]
1096 fn test_list_rules_succeeds() {
1097 setup_test_environment();
1098
1099 let rules = Rules::new();
1100
1101 // Should not panic or return error
1102 let result = rules.list_rules();
1103 assert!(result.is_ok());
1104 }
1105
1106 // --- validate() tests ---
1107
1108 #[test]
1109 fn test_validate_default_rules_are_valid() {
1110 setup_test_environment();
1111 let rules = Rules::new();
1112 let issues = rules.validate();
1113 assert!(
1114 issues.is_empty(),
1115 "Default rules should be valid, got: {issues:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn test_validate_empty_labels_reported() {
1121 setup_test_environment();
1122 let toml_str = r#"
1123[rules."1"]
1124id = 1
1125retention = "d:30"
1126labels = []
1127action = "Trash"
1128"#;
1129 let rules: Rules = toml::from_str(toml_str).unwrap();
1130 let issues = rules.validate();
1131 assert!(
1132 issues
1133 .iter()
1134 .any(|i| matches!(i, ValidationIssue::EmptyLabels { rule_id: 1 })),
1135 "Expected EmptyLabels for rule #1, got: {issues:?}"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_validate_invalid_retention_reported() {
1141 setup_test_environment();
1142 let toml_str = r#"
1143[rules."1"]
1144id = 1
1145retention = "invalid"
1146labels = ["some-label"]
1147action = "Trash"
1148"#;
1149 let rules: Rules = toml::from_str(toml_str).unwrap();
1150 let issues = rules.validate();
1151 assert!(
1152 issues
1153 .iter()
1154 .any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
1155 "Expected InvalidRetention for rule #1, got: {issues:?}"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_validate_empty_retention_reported() {
1161 setup_test_environment();
1162 let toml_str = r#"
1163[rules."1"]
1164id = 1
1165retention = ""
1166labels = ["some-label"]
1167action = "Trash"
1168"#;
1169 let rules: Rules = toml::from_str(toml_str).unwrap();
1170 let issues = rules.validate();
1171 assert!(
1172 issues
1173 .iter()
1174 .any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
1175 "Expected InvalidRetention for empty retention in rule #1, got: {issues:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_validate_invalid_action_reported() {
1181 setup_test_environment();
1182 let toml_str = r#"
1183[rules."1"]
1184id = 1
1185retention = "d:30"
1186labels = ["some-label"]
1187action = "invalid-action"
1188"#;
1189 let rules: Rules = toml::from_str(toml_str).unwrap();
1190 let issues = rules.validate();
1191 assert!(
1192 issues
1193 .iter()
1194 .any(|i| matches!(i, ValidationIssue::InvalidAction { rule_id: 1, .. })),
1195 "Expected InvalidAction for rule #1, got: {issues:?}"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_validate_duplicate_label_reported() {
1201 setup_test_environment();
1202 let toml_str = r#"
1203[rules."1"]
1204id = 1
1205retention = "d:30"
1206labels = ["shared-label"]
1207action = "Trash"
1208
1209[rules."2"]
1210id = 2
1211retention = "d:60"
1212labels = ["shared-label"]
1213action = "Trash"
1214"#;
1215 let rules: Rules = toml::from_str(toml_str).unwrap();
1216 let issues = rules.validate();
1217 assert!(
1218 issues.iter().any(|i| matches!(
1219 i,
1220 ValidationIssue::DuplicateLabel { label }
1221 if label == "shared-label"
1222 )),
1223 "Expected DuplicateLabel for 'shared-label', got: {issues:?}"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_validate_multiple_issues_collected() {
1229 setup_test_environment();
1230 let toml_str = r#"
1231[rules."1"]
1232id = 1
1233retention = ""
1234labels = []
1235action = "bad"
1236"#;
1237 let rules: Rules = toml::from_str(toml_str).unwrap();
1238 let issues = rules.validate();
1239 // All three issues should be present for the one rule
1240 assert!(
1241 issues
1242 .iter()
1243 .any(|i| matches!(i, ValidationIssue::EmptyLabels { .. })),
1244 "Expected EmptyLabels"
1245 );
1246 assert!(
1247 issues
1248 .iter()
1249 .any(|i| matches!(i, ValidationIssue::InvalidRetention { .. })),
1250 "Expected InvalidRetention"
1251 );
1252 assert!(
1253 issues
1254 .iter()
1255 .any(|i| matches!(i, ValidationIssue::InvalidAction { .. })),
1256 "Expected InvalidAction"
1257 );
1258 }
1259
1260 // Integration tests for save/load would require file system setup
1261 // These are marked as ignore to avoid interference with actual config files
1262 #[test]
1263 #[ignore = "Integration test that modifies file system"]
1264 fn test_save_and_load_roundtrip() {
1265 setup_test_environment();
1266
1267 let mut rules = Rules::new();
1268 let retention = Retention::new(MessageAge::Days(30), false);
1269 rules.add_rule(retention, Some("save-test"), false);
1270
1271 // Save to disk
1272 let save_result = rules.save();
1273 assert!(save_result.is_ok());
1274
1275 // Load from disk
1276 let loaded_rules = Rules::load();
1277 assert!(loaded_rules.is_ok());
1278
1279 let loaded_rules = loaded_rules.unwrap();
1280 let labels = loaded_rules.labels();
1281 assert!(labels.contains(&"save-test".to_string()));
1282 }
1283}