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,
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};
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(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
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::test_utils::get_test_logger;
673    use std::fs;
674
675    fn setup_test_environment() {
676        get_test_logger();
677        // Clean up any existing test files
678        let Some(home_dir) = env::home_dir() else {
679            // Skip cleanup if home directory cannot be determined
680            return;
681        };
682        let test_config_dir = home_dir.join(".cull-gmail");
683        let test_rules_file = test_config_dir.join("rules.toml");
684        if test_rules_file.exists() {
685            let _ = fs::remove_file(&test_rules_file);
686        }
687    }
688
689    #[test]
690    fn test_rules_new_creates_default_rules() {
691        setup_test_environment();
692
693        let rules = Rules::new();
694
695        // Should have some default rules
696        let labels = rules.labels();
697        assert!(
698            !labels.is_empty(),
699            "Default rules should create some labels"
700        );
701
702        // Should contain the expected retention labels
703        assert!(labels.iter().any(|l| l.contains("retention/1-years")));
704        assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
705        assert!(labels.iter().any(|l| l.contains("retention/1-months")));
706        assert!(labels.iter().any(|l| l.contains("retention/5-years")));
707    }
708
709    #[test]
710    fn test_rules_default_same_as_new() {
711        setup_test_environment();
712
713        let rules_new = Rules::new();
714        let rules_default = Rules::default();
715
716        // Both should have the same number of rules
717        assert_eq!(rules_new.labels().len(), rules_default.labels().len());
718    }
719
720    #[test]
721    fn test_add_rule_with_label() {
722        setup_test_environment();
723
724        let mut rules = Rules::new();
725        let initial_label_count = rules.labels().len();
726
727        let retention = Retention::new(MessageAge::Days(30), false);
728        rules.add_rule(retention, Some("test-label"), false);
729
730        let labels = rules.labels();
731        assert!(labels.contains(&"test-label".to_string()));
732        assert_eq!(labels.len(), initial_label_count + 1);
733    }
734
735    #[test]
736    fn test_add_rule_without_label() {
737        setup_test_environment();
738
739        let mut rules = Rules::new();
740        let initial_label_count = rules.labels().len();
741
742        let retention = Retention::new(MessageAge::Days(30), false);
743        rules.add_rule(retention, None, false);
744
745        // Should not add any new labels since no label specified and generate_label is false
746        let labels = rules.labels();
747        assert_eq!(labels.len(), initial_label_count);
748    }
749
750    #[test]
751    fn test_add_rule_with_delete_action() {
752        setup_test_environment();
753
754        let mut rules = Rules::new();
755        let retention = Retention::new(MessageAge::Days(7), false);
756        rules.add_rule(retention, Some("delete-test"), true);
757
758        let rules_by_label = rules.get_rules_by_label_for_action(EolAction::Delete);
759        let rule = rules_by_label.get("delete-test").unwrap();
760        assert_eq!(rule.action(), Some(EolAction::Delete));
761    }
762
763    #[test]
764    fn test_add_duplicate_label_warns_and_skips() {
765        setup_test_environment();
766
767        let mut rules = Rules::new();
768        let retention1 = Retention::new(MessageAge::Days(30), false);
769        let retention2 = Retention::new(MessageAge::Days(60), false);
770
771        rules.add_rule(retention1, Some("duplicate"), false);
772        let initial_count = rules.labels().len();
773
774        // Try to add another rule with the same label
775        rules.add_rule(retention2, Some("duplicate"), false);
776
777        // Should not increase the count of labels
778        assert_eq!(rules.labels().len(), initial_count);
779    }
780
781    #[test]
782    fn test_get_rule_existing() {
783        setup_test_environment();
784
785        let rules = Rules::new();
786
787        // Default rules should have ID 1
788        let rule = rules.get_rule(1);
789        assert!(rule.is_some());
790        assert_eq!(rule.unwrap().id(), 1);
791    }
792
793    #[test]
794    fn test_get_rule_nonexistent() {
795        setup_test_environment();
796
797        let rules = Rules::new();
798
799        // ID 999 should not exist
800        let rule = rules.get_rule(999);
801        assert!(rule.is_none());
802    }
803
804    #[test]
805    fn test_labels_returns_all_labels() {
806        setup_test_environment();
807
808        let mut rules = Rules::new();
809        let retention = Retention::new(MessageAge::Days(30), false);
810        rules.add_rule(retention, Some("custom-label"), false);
811
812        let labels = rules.labels();
813        assert!(labels.contains(&"custom-label".to_string()));
814    }
815
816    #[test]
817    fn test_get_rules_by_label() {
818        setup_test_environment();
819
820        let mut rules = Rules::new();
821        let retention = Retention::new(MessageAge::Days(30), false);
822        rules.add_rule(retention, Some("mapped-label"), false);
823
824        let label_map = rules.get_rules_by_label_for_action(EolAction::Trash);
825        let rule = label_map.get("mapped-label");
826        assert!(rule.is_some());
827        assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
828    }
829
830    #[test]
831    fn test_remove_rule_by_id_existing() {
832        setup_test_environment();
833
834        let mut rules = Rules::new();
835
836        // Remove a default rule (assuming ID 1 exists)
837        let result = rules.remove_rule_by_id(1);
838        assert!(result.is_ok());
839
840        // Rule should no longer exist
841        assert!(rules.get_rule(1).is_none());
842    }
843
844    #[test]
845    fn test_remove_rule_by_id_nonexistent() {
846        setup_test_environment();
847
848        let mut rules = Rules::new();
849
850        // Removing non-existent rule should still succeed
851        let result = rules.remove_rule_by_id(999);
852        assert!(result.is_ok());
853    }
854
855    #[test]
856    fn test_remove_rule_by_label_existing() {
857        setup_test_environment();
858
859        let mut rules = Rules::new();
860        let retention = Retention::new(MessageAge::Days(30), false);
861        rules.add_rule(retention, Some("remove-me"), false);
862
863        let result = rules.remove_rule_by_label("remove-me");
864        assert!(result.is_ok());
865
866        // Label should no longer exist
867        let labels = rules.labels();
868        assert!(!labels.contains(&"remove-me".to_string()));
869    }
870
871    #[test]
872    fn test_remove_rule_by_label_nonexistent() {
873        setup_test_environment();
874
875        let mut rules = Rules::new();
876
877        let result = rules.remove_rule_by_label("nonexistent-label");
878        assert!(result.is_err());
879
880        match result.unwrap_err() {
881            Error::LabelNotFoundInRules(label) => {
882                assert_eq!(label, "nonexistent-label");
883            }
884            _ => panic!("Expected LabelNotFoundInRules error"),
885        }
886    }
887
888    #[test]
889    fn test_add_label_to_rule_existing_rule() {
890        setup_test_environment();
891
892        let mut rules = Rules::new();
893
894        // Add label to existing rule (ID 1)
895        let result = rules.add_label_to_rule(1, "new-label");
896        assert!(result.is_ok());
897
898        let rule = rules.get_rule(1).unwrap();
899        assert!(rule.labels().contains(&"new-label".to_string()));
900    }
901
902    #[test]
903    fn test_add_label_to_rule_nonexistent_rule() {
904        setup_test_environment();
905
906        let mut rules = Rules::new();
907
908        let result = rules.add_label_to_rule(999, "new-label");
909        assert!(result.is_err());
910
911        match result.unwrap_err() {
912            Error::RuleNotFound(id) => {
913                assert_eq!(id, 999);
914            }
915            _ => panic!("Expected RuleNotFound error"),
916        }
917    }
918
919    #[test]
920    fn test_remove_label_from_rule_existing() {
921        setup_test_environment();
922
923        let mut rules = Rules::new();
924
925        // First add a label
926        let result = rules.add_label_to_rule(1, "temp-label");
927        assert!(result.is_ok());
928
929        // Then remove it
930        let result = rules.remove_label_from_rule(1, "temp-label");
931        assert!(result.is_ok());
932
933        let rule = rules.get_rule(1).unwrap();
934        assert!(!rule.labels().contains(&"temp-label".to_string()));
935    }
936
937    #[test]
938    fn test_remove_label_from_rule_nonexistent_rule() {
939        setup_test_environment();
940
941        let mut rules = Rules::new();
942
943        let result = rules.remove_label_from_rule(999, "any-label");
944        assert!(result.is_err());
945
946        match result.unwrap_err() {
947            Error::RuleNotFound(id) => {
948                assert_eq!(id, 999);
949            }
950            _ => panic!("Expected RuleNotFound error"),
951        }
952    }
953
954    #[test]
955    fn test_set_action_on_rule_existing() {
956        setup_test_environment();
957
958        let mut rules = Rules::new();
959
960        // Set action to Delete
961        let result = rules.set_action_on_rule(1, &EolAction::Delete);
962        assert!(result.is_ok());
963
964        let rule = rules.get_rule(1).unwrap();
965        assert_eq!(rule.action(), Some(EolAction::Delete));
966    }
967
968    #[test]
969    fn test_set_action_on_rule_nonexistent() {
970        setup_test_environment();
971
972        let mut rules = Rules::new();
973
974        let result = rules.set_action_on_rule(999, &EolAction::Delete);
975        assert!(result.is_err());
976
977        match result.unwrap_err() {
978            Error::RuleNotFound(id) => {
979                assert_eq!(id, 999);
980            }
981            _ => panic!("Expected RuleNotFound error"),
982        }
983    }
984
985    #[test]
986    fn test_list_rules_succeeds() {
987        setup_test_environment();
988
989        let rules = Rules::new();
990
991        // Should not panic or return error
992        let result = rules.list_rules();
993        assert!(result.is_ok());
994    }
995
996    // Integration tests for save/load would require file system setup
997    // These are marked as ignore to avoid interference with actual config files
998    #[test]
999    #[ignore = "Integration test that modifies file system"]
1000    fn test_save_and_load_roundtrip() {
1001        setup_test_environment();
1002
1003        let mut rules = Rules::new();
1004        let retention = Retention::new(MessageAge::Days(30), false);
1005        rules.add_rule(retention, Some("save-test"), false);
1006
1007        // Save to disk
1008        let save_result = rules.save();
1009        assert!(save_result.is_ok());
1010
1011        // Load from disk
1012        let loaded_rules = Rules::load();
1013        assert!(loaded_rules.is_ok());
1014
1015        let loaded_rules = loaded_rules.unwrap();
1016        let labels = loaded_rules.labels();
1017        assert!(labels.contains(&"save-test".to_string()));
1018    }
1019}