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 // Key: (label, action_str) — the same label in a Trash and a Delete rule is
692 // intentional two-stage processing and must not be flagged as a duplicate.
693 let mut seen_label_actions: BTreeMap<(String, String), usize> = BTreeMap::new();
694
695 for rule in self.rules.values() {
696 let id = rule.id();
697
698 if rule.labels().is_empty() {
699 issues.push(ValidationIssue::EmptyLabels { rule_id: id });
700 }
701
702 if MessageAge::parse(rule.retention()).is_none() {
703 issues.push(ValidationIssue::InvalidRetention {
704 rule_id: id,
705 retention: rule.retention().to_string(),
706 });
707 }
708
709 if rule.action().is_none() {
710 issues.push(ValidationIssue::InvalidAction {
711 rule_id: id,
712 action: rule.action_str().to_string(),
713 });
714 }
715
716 for label in rule.labels() {
717 let key = (label.clone(), rule.action_str().to_lowercase());
718 if let Some(&other_id) = seen_label_actions.get(&key) {
719 if other_id != id {
720 issues.push(ValidationIssue::DuplicateLabel {
721 label: label.clone(),
722 });
723 }
724 } else {
725 seen_label_actions.insert(key, id);
726 }
727 }
728 }
729
730 issues
731 }
732}
733
734/// An issue found during rules validation.
735#[derive(Debug, PartialEq)]
736pub enum ValidationIssue {
737 /// A rule has no labels configured.
738 EmptyLabels {
739 /// The ID of the offending rule.
740 rule_id: usize,
741 },
742 /// A rule has a retention string that cannot be parsed as a `MessageAge`.
743 InvalidRetention {
744 /// The ID of the offending rule.
745 rule_id: usize,
746 /// The unparseable retention string.
747 retention: String,
748 },
749 /// A rule has an action string that cannot be parsed as an `EolAction`.
750 InvalidAction {
751 /// The ID of the offending rule.
752 rule_id: usize,
753 /// The unparseable action string.
754 action: String,
755 },
756 /// The same label appears in more than one rule.
757 DuplicateLabel {
758 /// The duplicated label.
759 label: String,
760 },
761}
762
763impl fmt::Display for ValidationIssue {
764 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765 match self {
766 ValidationIssue::EmptyLabels { rule_id } => {
767 write!(f, "Rule #{rule_id}: no labels configured")
768 }
769 ValidationIssue::InvalidRetention { rule_id, retention } => {
770 write!(f, "Rule #{rule_id}: invalid retention '{retention}'")
771 }
772 ValidationIssue::InvalidAction { rule_id, action } => {
773 write!(f, "Rule #{rule_id}: invalid action '{action}'")
774 }
775 ValidationIssue::DuplicateLabel { label } => {
776 write!(f, "Label '{label}' is used in multiple rules")
777 }
778 }
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use crate::test_utils::get_test_logger;
786 use std::fs;
787
788 fn setup_test_environment() {
789 get_test_logger();
790 // Clean up any existing test files
791 let Some(home_dir) = env::home_dir() else {
792 // Skip cleanup if home directory cannot be determined
793 return;
794 };
795 let test_config_dir = home_dir.join(".cull-gmail");
796 let test_rules_file = test_config_dir.join("rules.toml");
797 if test_rules_file.exists() {
798 let _ = fs::remove_file(&test_rules_file);
799 }
800 }
801
802 #[test]
803 fn test_rules_new_creates_default_rules() {
804 setup_test_environment();
805
806 let rules = Rules::new();
807
808 // Should have some default rules
809 let labels = rules.labels();
810 assert!(
811 !labels.is_empty(),
812 "Default rules should create some labels"
813 );
814
815 // Should contain the expected retention labels
816 assert!(labels.iter().any(|l| l.contains("retention/1-years")));
817 assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
818 assert!(labels.iter().any(|l| l.contains("retention/1-months")));
819 assert!(labels.iter().any(|l| l.contains("retention/5-years")));
820 }
821
822 #[test]
823 fn test_rules_default_same_as_new() {
824 setup_test_environment();
825
826 let rules_new = Rules::new();
827 let rules_default = Rules::default();
828
829 // Both should have the same number of rules
830 assert_eq!(rules_new.labels().len(), rules_default.labels().len());
831 }
832
833 #[test]
834 fn test_add_rule_with_label() {
835 setup_test_environment();
836
837 let mut rules = Rules::new();
838 let initial_label_count = rules.labels().len();
839
840 let retention = Retention::new(MessageAge::Days(30), false);
841 rules.add_rule(retention, Some("test-label"), false);
842
843 let labels = rules.labels();
844 assert!(labels.contains(&"test-label".to_string()));
845 assert_eq!(labels.len(), initial_label_count + 1);
846 }
847
848 #[test]
849 fn test_add_rule_without_label() {
850 setup_test_environment();
851
852 let mut rules = Rules::new();
853 let initial_label_count = rules.labels().len();
854
855 let retention = Retention::new(MessageAge::Days(30), false);
856 rules.add_rule(retention, None, false);
857
858 // Should not add any new labels since no label specified and generate_label is false
859 let labels = rules.labels();
860 assert_eq!(labels.len(), initial_label_count);
861 }
862
863 #[test]
864 fn test_add_rule_with_delete_action() {
865 setup_test_environment();
866
867 let mut rules = Rules::new();
868 let retention = Retention::new(MessageAge::Days(7), false);
869 rules.add_rule(retention, Some("delete-test"), true);
870
871 let rules_by_label = rules.get_rules_by_label_for_action(EolAction::Delete);
872 let rule = rules_by_label.get("delete-test").unwrap();
873 assert_eq!(rule.action(), Some(EolAction::Delete));
874 }
875
876 #[test]
877 fn test_add_duplicate_label_warns_and_skips() {
878 setup_test_environment();
879
880 let mut rules = Rules::new();
881 let retention1 = Retention::new(MessageAge::Days(30), false);
882 let retention2 = Retention::new(MessageAge::Days(60), false);
883
884 rules.add_rule(retention1, Some("duplicate"), false);
885 let initial_count = rules.labels().len();
886
887 // Try to add another rule with the same label
888 rules.add_rule(retention2, Some("duplicate"), false);
889
890 // Should not increase the count of labels
891 assert_eq!(rules.labels().len(), initial_count);
892 }
893
894 #[test]
895 fn test_get_rule_existing() {
896 setup_test_environment();
897
898 let rules = Rules::new();
899
900 // Default rules should have ID 1
901 let rule = rules.get_rule(1);
902 assert!(rule.is_some());
903 assert_eq!(rule.unwrap().id(), 1);
904 }
905
906 #[test]
907 fn test_get_rule_nonexistent() {
908 setup_test_environment();
909
910 let rules = Rules::new();
911
912 // ID 999 should not exist
913 let rule = rules.get_rule(999);
914 assert!(rule.is_none());
915 }
916
917 #[test]
918 fn test_labels_returns_all_labels() {
919 setup_test_environment();
920
921 let mut rules = Rules::new();
922 let retention = Retention::new(MessageAge::Days(30), false);
923 rules.add_rule(retention, Some("custom-label"), false);
924
925 let labels = rules.labels();
926 assert!(labels.contains(&"custom-label".to_string()));
927 }
928
929 #[test]
930 fn test_get_rules_by_label() {
931 setup_test_environment();
932
933 let mut rules = Rules::new();
934 let retention = Retention::new(MessageAge::Days(30), false);
935 rules.add_rule(retention, Some("mapped-label"), false);
936
937 let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
938 let rule = label_map.get("mapped-label");
939 assert!(rule.is_some());
940 assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
941 }
942
943 #[test]
944 fn test_remove_rule_by_id_existing() {
945 setup_test_environment();
946
947 let mut rules = Rules::new();
948
949 // Remove a default rule (assuming ID 1 exists)
950 let result = rules.remove_rule_by_id(1);
951 assert!(result.is_ok());
952
953 // Rule should no longer exist
954 assert!(rules.get_rule(1).is_none());
955 }
956
957 #[test]
958 fn test_remove_rule_by_id_nonexistent() {
959 setup_test_environment();
960
961 let mut rules = Rules::new();
962
963 // Removing non-existent rule should still succeed
964 let result = rules.remove_rule_by_id(999);
965 assert!(result.is_ok());
966 }
967
968 #[test]
969 fn test_remove_rule_by_label_existing() {
970 setup_test_environment();
971
972 let mut rules = Rules::new();
973 let retention = Retention::new(MessageAge::Days(30), false);
974 rules.add_rule(retention, Some("remove-me"), false);
975
976 let result = rules.remove_rule_by_label("remove-me");
977 assert!(result.is_ok());
978
979 // Label should no longer exist
980 let labels = rules.labels();
981 assert!(!labels.contains(&"remove-me".to_string()));
982 }
983
984 #[test]
985 fn test_remove_rule_by_label_nonexistent() {
986 setup_test_environment();
987
988 let mut rules = Rules::new();
989
990 let result = rules.remove_rule_by_label("nonexistent-label");
991 assert!(result.is_err());
992
993 match result.unwrap_err() {
994 Error::LabelNotFoundInRules(label) => {
995 assert_eq!(label, "nonexistent-label");
996 }
997 _ => panic!("Expected LabelNotFoundInRules error"),
998 }
999 }
1000
1001 #[test]
1002 fn test_add_label_to_rule_existing_rule() {
1003 setup_test_environment();
1004
1005 let mut rules = Rules::new();
1006
1007 // Add label to existing rule (ID 1)
1008 let result = rules.add_label_to_rule(1, "new-label");
1009 assert!(result.is_ok());
1010
1011 let rule = rules.get_rule(1).unwrap();
1012 assert!(rule.labels().contains(&"new-label".to_string()));
1013 }
1014
1015 #[test]
1016 fn test_add_label_to_rule_nonexistent_rule() {
1017 setup_test_environment();
1018
1019 let mut rules = Rules::new();
1020
1021 let result = rules.add_label_to_rule(999, "new-label");
1022 assert!(result.is_err());
1023
1024 match result.unwrap_err() {
1025 Error::RuleNotFound(id) => {
1026 assert_eq!(id, 999);
1027 }
1028 _ => panic!("Expected RuleNotFound error"),
1029 }
1030 }
1031
1032 #[test]
1033 fn test_remove_label_from_rule_existing() {
1034 setup_test_environment();
1035
1036 let mut rules = Rules::new();
1037
1038 // First add a label
1039 let result = rules.add_label_to_rule(1, "temp-label");
1040 assert!(result.is_ok());
1041
1042 // Then remove it
1043 let result = rules.remove_label_from_rule(1, "temp-label");
1044 assert!(result.is_ok());
1045
1046 let rule = rules.get_rule(1).unwrap();
1047 assert!(!rule.labels().contains(&"temp-label".to_string()));
1048 }
1049
1050 #[test]
1051 fn test_remove_label_from_rule_nonexistent_rule() {
1052 setup_test_environment();
1053
1054 let mut rules = Rules::new();
1055
1056 let result = rules.remove_label_from_rule(999, "any-label");
1057 assert!(result.is_err());
1058
1059 match result.unwrap_err() {
1060 Error::RuleNotFound(id) => {
1061 assert_eq!(id, 999);
1062 }
1063 _ => panic!("Expected RuleNotFound error"),
1064 }
1065 }
1066
1067 #[test]
1068 fn test_set_action_on_rule_existing() {
1069 setup_test_environment();
1070
1071 let mut rules = Rules::new();
1072
1073 // Set action to Delete
1074 let result = rules.set_action_on_rule(1, &EolAction::Delete);
1075 assert!(result.is_ok());
1076
1077 let rule = rules.get_rule(1).unwrap();
1078 assert_eq!(rule.action(), Some(EolAction::Delete));
1079 }
1080
1081 #[test]
1082 fn test_set_action_on_rule_nonexistent() {
1083 setup_test_environment();
1084
1085 let mut rules = Rules::new();
1086
1087 let result = rules.set_action_on_rule(999, &EolAction::Delete);
1088 assert!(result.is_err());
1089
1090 match result.unwrap_err() {
1091 Error::RuleNotFound(id) => {
1092 assert_eq!(id, 999);
1093 }
1094 _ => panic!("Expected RuleNotFound error"),
1095 }
1096 }
1097
1098 #[test]
1099 fn test_list_rules_succeeds() {
1100 setup_test_environment();
1101
1102 let rules = Rules::new();
1103
1104 // Should not panic or return error
1105 let result = rules.list_rules();
1106 assert!(result.is_ok());
1107 }
1108
1109 // --- validate() tests ---
1110
1111 #[test]
1112 fn test_validate_default_rules_are_valid() {
1113 setup_test_environment();
1114 let rules = Rules::new();
1115 let issues = rules.validate();
1116 assert!(
1117 issues.is_empty(),
1118 "Default rules should be valid, got: {issues:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_validate_empty_labels_reported() {
1124 setup_test_environment();
1125 let toml_str = r#"
1126[rules."1"]
1127id = 1
1128retention = "d:30"
1129labels = []
1130action = "Trash"
1131"#;
1132 let rules: Rules = toml::from_str(toml_str).unwrap();
1133 let issues = rules.validate();
1134 assert!(
1135 issues
1136 .iter()
1137 .any(|i| matches!(i, ValidationIssue::EmptyLabels { rule_id: 1 })),
1138 "Expected EmptyLabels for rule #1, got: {issues:?}"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_validate_invalid_retention_reported() {
1144 setup_test_environment();
1145 let toml_str = r#"
1146[rules."1"]
1147id = 1
1148retention = "invalid"
1149labels = ["some-label"]
1150action = "Trash"
1151"#;
1152 let rules: Rules = toml::from_str(toml_str).unwrap();
1153 let issues = rules.validate();
1154 assert!(
1155 issues
1156 .iter()
1157 .any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
1158 "Expected InvalidRetention for rule #1, got: {issues:?}"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_validate_empty_retention_reported() {
1164 setup_test_environment();
1165 let toml_str = r#"
1166[rules."1"]
1167id = 1
1168retention = ""
1169labels = ["some-label"]
1170action = "Trash"
1171"#;
1172 let rules: Rules = toml::from_str(toml_str).unwrap();
1173 let issues = rules.validate();
1174 assert!(
1175 issues
1176 .iter()
1177 .any(|i| matches!(i, ValidationIssue::InvalidRetention { rule_id: 1, .. })),
1178 "Expected InvalidRetention for empty retention in rule #1, got: {issues:?}"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_validate_invalid_action_reported() {
1184 setup_test_environment();
1185 let toml_str = r#"
1186[rules."1"]
1187id = 1
1188retention = "d:30"
1189labels = ["some-label"]
1190action = "invalid-action"
1191"#;
1192 let rules: Rules = toml::from_str(toml_str).unwrap();
1193 let issues = rules.validate();
1194 assert!(
1195 issues
1196 .iter()
1197 .any(|i| matches!(i, ValidationIssue::InvalidAction { rule_id: 1, .. })),
1198 "Expected InvalidAction for rule #1, got: {issues:?}"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_validate_duplicate_label_reported() {
1204 setup_test_environment();
1205 let toml_str = r#"
1206[rules."1"]
1207id = 1
1208retention = "d:30"
1209labels = ["shared-label"]
1210action = "Trash"
1211
1212[rules."2"]
1213id = 2
1214retention = "d:60"
1215labels = ["shared-label"]
1216action = "Trash"
1217"#;
1218 let rules: Rules = toml::from_str(toml_str).unwrap();
1219 let issues = rules.validate();
1220 assert!(
1221 issues.iter().any(|i| matches!(
1222 i,
1223 ValidationIssue::DuplicateLabel { label }
1224 if label == "shared-label"
1225 )),
1226 "Expected DuplicateLabel for 'shared-label', got: {issues:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_validate_same_label_different_actions_not_duplicate() {
1232 setup_test_environment();
1233 // A label in a Trash rule AND a Delete rule is intentional two-stage processing.
1234 let toml_str = r#"
1235[rules."1"]
1236id = 1
1237retention = "w:1"
1238labels = ["Development/Notifications"]
1239action = "Trash"
1240
1241[rules."2"]
1242id = 2
1243retention = "w:2"
1244labels = ["Development/Notifications"]
1245action = "Delete"
1246"#;
1247 let rules: Rules = toml::from_str(toml_str).unwrap();
1248 let issues = rules.validate();
1249 assert!(
1250 !issues
1251 .iter()
1252 .any(|i| matches!(i, ValidationIssue::DuplicateLabel { .. })),
1253 "Same label with different actions should NOT be flagged as duplicate, got: {issues:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_validate_multiple_issues_collected() {
1259 setup_test_environment();
1260 let toml_str = r#"
1261[rules."1"]
1262id = 1
1263retention = ""
1264labels = []
1265action = "bad"
1266"#;
1267 let rules: Rules = toml::from_str(toml_str).unwrap();
1268 let issues = rules.validate();
1269 // All three issues should be present for the one rule
1270 assert!(
1271 issues
1272 .iter()
1273 .any(|i| matches!(i, ValidationIssue::EmptyLabels { .. })),
1274 "Expected EmptyLabels"
1275 );
1276 assert!(
1277 issues
1278 .iter()
1279 .any(|i| matches!(i, ValidationIssue::InvalidRetention { .. })),
1280 "Expected InvalidRetention"
1281 );
1282 assert!(
1283 issues
1284 .iter()
1285 .any(|i| matches!(i, ValidationIssue::InvalidAction { .. })),
1286 "Expected InvalidAction"
1287 );
1288 }
1289
1290 // Integration tests for save/load would require file system setup
1291 // These are marked as ignore to avoid interference with actual config files
1292 #[test]
1293 #[ignore = "Integration test that modifies file system"]
1294 fn test_save_and_load_roundtrip() {
1295 setup_test_environment();
1296
1297 let mut rules = Rules::new();
1298 let retention = Retention::new(MessageAge::Days(30), false);
1299 rules.add_rule(retention, Some("save-test"), false);
1300
1301 // Save to disk
1302 let save_result = rules.save();
1303 assert!(save_result.is_ok());
1304
1305 // Load from disk
1306 let loaded_rules = Rules::load();
1307 assert!(loaded_rules.is_ok());
1308
1309 let loaded_rules = loaded_rules.unwrap();
1310 let labels = loaded_rules.labels();
1311 assert!(labels.contains(&"save-test".to_string()));
1312 }
1313}