Skip to main content

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}