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        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}