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 id of the rule that contains a label
251    fn find_label(&self, label: &str) -> usize {
252        let rules_by_label = self.get_rules_by_label();
253        if let Some(rule) = rules_by_label.get(label) {
254            rule.id()
255        } else {
256            0
257        }
258    }
259
260    /// Removes a rule from the set by its unique ID.
261    ///
262    /// If the rule exists, it is removed and a confirmation message is printed.
263    /// If the rule doesn't exist, the operation completes successfully without error.
264    ///
265    /// # Arguments
266    ///
267    /// * `id` - The unique identifier of the rule to remove
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use cull_gmail::{Rules, Retention, MessageAge};
273    ///
274    /// let mut rules = Rules::new();
275    /// // Assume rule ID 1 exists from defaults
276    /// rules.remove_rule_by_id(1).expect("Failed to remove rule");
277    /// ```
278    ///
279    /// # Errors
280    ///
281    /// This method currently always returns `Ok(())`, but the return type
282    /// is `Result<()>` for future extensibility.
283    pub fn remove_rule_by_id(&mut self, id: usize) -> crate::Result<()> {
284        self.rules.remove(&id.to_string());
285        println!("Rule `{id}` has been removed.");
286        Ok(())
287    }
288
289    /// Removes a rule from the set by targeting one of its labels.
290    ///
291    /// Finds the rule that contains the specified label and removes it.
292    /// If multiple rules target the same label, only one is removed.
293    ///
294    /// # Arguments
295    ///
296    /// * `label` - The label to search for in existing rules
297    ///
298    /// # Examples
299    ///
300    /// ```ignore
301    /// use cull_gmail::{Rules, Retention, MessageAge};
302    ///
303    /// let mut rules = Rules::new();
304    /// let retention = Retention::new(MessageAge::Days(30), false);
305    /// rules.add_rule(retention, Some("newsletter"), false);
306    ///
307    /// // Remove the rule targeting the newsletter label
308    /// rules.remove_rule_by_label("newsletter")
309    ///      .expect("Failed to remove rule");
310    /// ```
311    ///
312    /// # Errors
313    ///
314    /// * [`Error::LabelNotFoundInRules`] if no rule contains the specified label
315    /// * [`Error::NoRuleFoundForLabel`] if the label exists but no rule is found
316    ///   (should not happen under normal conditions)
317    pub fn remove_rule_by_label(&mut self, label: &str) -> crate::Result<()> {
318        let labels = self.labels();
319
320        if !labels.iter().any(|l| l == label) {
321            return Err(Error::LabelNotFoundInRules(label.to_string()));
322        }
323
324        let rule_id = self.find_label(label);
325        if rule_id == 0 {
326            return Err(Error::NoRuleFoundForLabel(label.to_string()));
327        }
328
329        self.rules.remove(&rule_id.to_string());
330
331        log::info!("Rule containing the label `{label}` has been removed.");
332        Ok(())
333    }
334
335    /// Returns a mapping from labels to rules that target them.
336    ///
337    /// Creates a `BTreeMap` where each key is a label and each value is a cloned
338    /// copy of the rule that targets that label. If multiple rules target the
339    /// same label, only one will be present in the result (the last one processed).
340    ///
341    /// # Examples
342    ///
343    /// ```
344    /// use cull_gmail::{Rules, Retention, MessageAge};
345    ///
346    /// let mut rules = Rules::new();
347    /// let retention = Retention::new(MessageAge::Days(30), false);
348    /// rules.add_rule(retention, Some("test"), false);
349    ///
350    /// let label_map = rules.get_rules_by_label();
351    /// if let Some(rule) = label_map.get("test") {
352    ///     println!("Rule for 'test' label: {}", rule.describe());
353    /// }
354    /// ```
355    pub fn get_rules_by_label(&self) -> BTreeMap<String, EolRule> {
356        let mut rbl = BTreeMap::new();
357
358        for rule in self.rules.values() {
359            for label in rule.labels() {
360                rbl.insert(label, rule.clone());
361            }
362        }
363
364        rbl
365    }
366
367    /// Adds a label to an existing rule and saves the configuration.
368    ///
369    /// Finds the rule with the specified ID and adds the given label to it.
370    /// The configuration is automatically saved to disk after the change.
371    ///
372    /// # Arguments
373    ///
374    /// * `id` - The unique identifier of the rule to modify
375    /// * `label` - The label to add to the rule
376    ///
377    /// # Examples
378    ///
379    /// ```ignore
380    /// use cull_gmail::Rules;
381    ///
382    /// let mut rules = Rules::load().expect("Failed to load rules");
383    /// rules.add_label_to_rule(1, "new-label")
384    ///      .expect("Failed to add label");
385    /// ```
386    ///
387    /// # Errors
388    ///
389    /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
390    /// * IO errors from saving the configuration file
391    pub fn add_label_to_rule(&mut self, id: usize, label: &str) -> Result<()> {
392        let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
393            return Err(Error::RuleNotFound(id));
394        };
395        rule.add_label(label);
396        self.save()?;
397        println!("Label `{label}` added to rule `#{id}`");
398
399        Ok(())
400    }
401
402    /// Removes a label from an existing rule and saves the configuration.
403    ///
404    /// Finds the rule with the specified ID and removes the given label from it.
405    /// The configuration is automatically saved to disk after the change.
406    ///
407    /// # Arguments
408    ///
409    /// * `id` - The unique identifier of the rule to modify
410    /// * `label` - The label to remove from the rule
411    ///
412    /// # Examples
413    ///
414    /// ```ignore
415    /// use cull_gmail::Rules;
416    ///
417    /// let mut rules = Rules::load().expect("Failed to load rules");
418    /// rules.remove_label_from_rule(1, "old-label")
419    ///      .expect("Failed to remove label");
420    /// ```
421    ///
422    /// # Errors
423    ///
424    /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
425    /// * IO errors from saving the configuration file
426    pub fn remove_label_from_rule(&mut self, id: usize, label: &str) -> Result<()> {
427        let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
428            return Err(Error::RuleNotFound(id));
429        };
430        rule.remove_label(label);
431        self.save()?;
432        println!("Label `{label}` removed from rule `#{id}`");
433
434        Ok(())
435    }
436
437    /// Sets the action for an existing rule and saves the configuration.
438    ///
439    /// Finds the rule with the specified ID and updates its action (trash or delete).
440    /// The configuration is automatically saved to disk after the change.
441    ///
442    /// # Arguments
443    ///
444    /// * `id` - The unique identifier of the rule to modify
445    /// * `action` - The new action to set (`Trash` or `Delete`)
446    ///
447    /// # Examples
448    ///
449    /// ```ignore
450    /// use cull_gmail::{Rules, EolAction};
451    ///
452    /// let mut rules = Rules::load().expect("Failed to load rules");
453    /// rules.set_action_on_rule(1, &EolAction::Delete)
454    ///      .expect("Failed to set action");
455    /// ```
456    ///
457    /// # Errors
458    ///
459    /// * [`Error::RuleNotFound`] if no rule exists with the specified ID
460    /// * IO errors from saving the configuration file
461    pub fn set_action_on_rule(&mut self, id: usize, action: &EolAction) -> Result<()> {
462        let Some(rule) = self.rules.get_mut(id.to_string().as_str()) else {
463            return Err(Error::RuleNotFound(id));
464        };
465        rule.set_action(action);
466        self.save()?;
467        println!("Action set to `{action}` on rule `#{id}`");
468
469        Ok(())
470    }
471
472    /// Saves the current rule configuration to disk.
473    ///
474    /// The configuration is saved as TOML format to `~/.cull-gmail/rules.toml`.
475    /// The directory is created if it doesn't exist.
476    ///
477    /// # Examples
478    ///
479    /// ```ignore
480    /// use cull_gmail::{Rules, Retention, MessageAge};
481    ///
482    /// let mut rules = Rules::new();
483    /// let retention = Retention::new(MessageAge::Days(30), false);
484    /// rules.add_rule(retention, Some("test"), false);
485    ///
486    /// rules.save().expect("Failed to save configuration");
487    /// ```
488    ///
489    /// # Errors
490    ///
491    /// * TOML serialization errors
492    /// * IO errors when writing to the file system
493    /// * File system permission errors
494    pub fn save(&self) -> Result<()> {
495        self.save_to(None)
496    }
497
498    /// Saves the current rule configuration to a specified path.
499    ///
500    /// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
501    /// The directory is created if it doesn't exist.
502    ///
503    /// # Arguments
504    ///
505    /// * `path` - Optional path where the rules should be saved
506    ///
507    /// # Examples
508    ///
509    /// ```ignore
510    /// use cull_gmail::Rules;
511    /// use std::path::Path;
512    ///
513    /// let rules = Rules::new();
514    /// rules.save_to(Some(Path::new("/custom/path/rules.toml")))
515    ///      .expect("Failed to save");
516    /// ```
517    ///
518    /// # Errors
519    ///
520    /// * TOML serialization errors
521    /// * IO errors when writing to the file system
522    /// * File system permission errors
523    pub fn save_to(&self, path: Option<&Path>) -> Result<()> {
524        let save_path = if let Some(p) = path {
525            p.to_path_buf()
526        } else {
527            let home_dir = env::home_dir().ok_or_else(|| {
528                Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
529            })?;
530            home_dir.join(".cull-gmail/rules.toml")
531        };
532
533        // Ensure directory exists
534        if let Some(parent) = save_path.parent() {
535            fs::create_dir_all(parent)?;
536        }
537
538        let res = toml::to_string(self);
539        log::trace!("toml conversion result: {res:#?}");
540
541        if let Ok(output) = res {
542            fs::write(&save_path, output)?;
543            log::trace!("Config saved to {}", save_path.display());
544        }
545
546        Ok(())
547    }
548
549    /// Loads rule configuration from disk.
550    ///
551    /// Reads the configuration from `~/.cull-gmail/rules.toml` and deserializes
552    /// it into a `Rules` instance.
553    ///
554    /// # Examples
555    ///
556    /// ```ignore
557    /// use cull_gmail::Rules;
558    ///
559    /// match Rules::load() {
560    ///     Ok(rules) => {
561    ///         println!("Loaded {} rules", rules.labels().len());
562    ///         rules.list_rules().expect("Failed to list rules");
563    ///     }
564    ///     Err(e) => println!("Failed to load rules: {}", e),
565    /// }
566    /// ```
567    ///
568    /// # Errors
569    ///
570    /// * IO errors when reading from the file system
571    /// * TOML parsing errors if the file is malformed
572    /// * File not found errors if the configuration doesn't exist
573    pub fn load() -> Result<Rules> {
574        Self::load_from(None)
575    }
576
577    /// Loads rule configuration from a specified path.
578    ///
579    /// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
580    ///
581    /// # Arguments
582    ///
583    /// * `path` - Optional path to load rules from
584    ///
585    /// # Examples
586    ///
587    /// ```ignore
588    /// use cull_gmail::Rules;
589    /// use std::path::Path;
590    ///
591    /// let rules = Rules::load_from(Some(Path::new("/custom/path/rules.toml")))
592    ///     .expect("Failed to load rules");
593    /// ```
594    ///
595    /// # Errors
596    ///
597    /// * IO errors when reading from the file system
598    /// * TOML parsing errors if the file is malformed
599    /// * File not found errors if the configuration doesn't exist
600    pub fn load_from(path: Option<&Path>) -> Result<Rules> {
601        let load_path = if let Some(p) = path {
602            p.to_path_buf()
603        } else {
604            let home_dir = env::home_dir().ok_or_else(|| {
605                Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string())
606            })?;
607            home_dir.join(".cull-gmail/rules.toml")
608        };
609
610        log::trace!("Loading config from {}", load_path.display());
611
612        let input = read_to_string(load_path)?;
613        let config = toml::from_str::<Rules>(&input)?;
614        Ok(config)
615    }
616
617    /// Prints all configured rules to standard output.
618    ///
619    /// Each rule is printed on a separate line with its description,
620    /// including the rule ID, action, and age criteria.
621    ///
622    /// # Examples
623    ///
624    /// ```ignore
625    /// use cull_gmail::Rules;
626    ///
627    /// let rules = Rules::new();
628    /// rules.list_rules().expect("Failed to list rules");
629    /// // Output:
630    /// // Rule #1 is active on `retention/1-years` to move the message to trash if it is more than 1 years old.
631    /// // Rule #2 is active on `retention/1-weeks` to move the message to trash if it is more than 1 weeks old.
632    /// // ...
633    /// ```
634    ///
635    /// # Errors
636    ///
637    /// This method currently always returns `Ok(())`, but the return type
638    /// is `Result<()>` for consistency with other methods and future extensibility.
639    pub fn list_rules(&self) -> Result<()> {
640        for rule in self.rules.values() {
641            println!("{rule}");
642        }
643        Ok(())
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::test_utils::get_test_logger;
651    use std::fs;
652
653    fn setup_test_environment() {
654        get_test_logger();
655        // Clean up any existing test files
656        let Some(home_dir) = env::home_dir() else {
657            // Skip cleanup if home directory cannot be determined
658            return;
659        };
660        let test_config_dir = home_dir.join(".cull-gmail");
661        let test_rules_file = test_config_dir.join("rules.toml");
662        if test_rules_file.exists() {
663            let _ = fs::remove_file(&test_rules_file);
664        }
665    }
666
667    #[test]
668    fn test_rules_new_creates_default_rules() {
669        setup_test_environment();
670
671        let rules = Rules::new();
672
673        // Should have some default rules
674        let labels = rules.labels();
675        assert!(
676            !labels.is_empty(),
677            "Default rules should create some labels"
678        );
679
680        // Should contain the expected retention labels
681        assert!(labels.iter().any(|l| l.contains("retention/1-years")));
682        assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
683        assert!(labels.iter().any(|l| l.contains("retention/1-months")));
684        assert!(labels.iter().any(|l| l.contains("retention/5-years")));
685    }
686
687    #[test]
688    fn test_rules_default_same_as_new() {
689        setup_test_environment();
690
691        let rules_new = Rules::new();
692        let rules_default = Rules::default();
693
694        // Both should have the same number of rules
695        assert_eq!(rules_new.labels().len(), rules_default.labels().len());
696    }
697
698    #[test]
699    fn test_add_rule_with_label() {
700        setup_test_environment();
701
702        let mut rules = Rules::new();
703        let initial_label_count = rules.labels().len();
704
705        let retention = Retention::new(MessageAge::Days(30), false);
706        rules.add_rule(retention, Some("test-label"), false);
707
708        let labels = rules.labels();
709        assert!(labels.contains(&"test-label".to_string()));
710        assert_eq!(labels.len(), initial_label_count + 1);
711    }
712
713    #[test]
714    fn test_add_rule_without_label() {
715        setup_test_environment();
716
717        let mut rules = Rules::new();
718        let initial_label_count = rules.labels().len();
719
720        let retention = Retention::new(MessageAge::Days(30), false);
721        rules.add_rule(retention, None, false);
722
723        // Should not add any new labels since no label specified and generate_label is false
724        let labels = rules.labels();
725        assert_eq!(labels.len(), initial_label_count);
726    }
727
728    #[test]
729    fn test_add_rule_with_delete_action() {
730        setup_test_environment();
731
732        let mut rules = Rules::new();
733        let retention = Retention::new(MessageAge::Days(7), false);
734        rules.add_rule(retention, Some("delete-test"), true);
735
736        let rules_by_label = rules.get_rules_by_label();
737        let rule = rules_by_label.get("delete-test").unwrap();
738        assert_eq!(rule.action(), Some(EolAction::Delete));
739    }
740
741    #[test]
742    fn test_add_duplicate_label_warns_and_skips() {
743        setup_test_environment();
744
745        let mut rules = Rules::new();
746        let retention1 = Retention::new(MessageAge::Days(30), false);
747        let retention2 = Retention::new(MessageAge::Days(60), false);
748
749        rules.add_rule(retention1, Some("duplicate"), false);
750        let initial_count = rules.labels().len();
751
752        // Try to add another rule with the same label
753        rules.add_rule(retention2, Some("duplicate"), false);
754
755        // Should not increase the count of labels
756        assert_eq!(rules.labels().len(), initial_count);
757    }
758
759    #[test]
760    fn test_get_rule_existing() {
761        setup_test_environment();
762
763        let rules = Rules::new();
764
765        // Default rules should have ID 1
766        let rule = rules.get_rule(1);
767        assert!(rule.is_some());
768        assert_eq!(rule.unwrap().id(), 1);
769    }
770
771    #[test]
772    fn test_get_rule_nonexistent() {
773        setup_test_environment();
774
775        let rules = Rules::new();
776
777        // ID 999 should not exist
778        let rule = rules.get_rule(999);
779        assert!(rule.is_none());
780    }
781
782    #[test]
783    fn test_labels_returns_all_labels() {
784        setup_test_environment();
785
786        let mut rules = Rules::new();
787        let retention = Retention::new(MessageAge::Days(30), false);
788        rules.add_rule(retention, Some("custom-label"), false);
789
790        let labels = rules.labels();
791        assert!(labels.contains(&"custom-label".to_string()));
792    }
793
794    #[test]
795    fn test_get_rules_by_label() {
796        setup_test_environment();
797
798        let mut rules = Rules::new();
799        let retention = Retention::new(MessageAge::Days(30), false);
800        rules.add_rule(retention, Some("mapped-label"), false);
801
802        let label_map = rules.get_rules_by_label();
803        let rule = label_map.get("mapped-label");
804        assert!(rule.is_some());
805        assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
806    }
807
808    #[test]
809    fn test_remove_rule_by_id_existing() {
810        setup_test_environment();
811
812        let mut rules = Rules::new();
813
814        // Remove a default rule (assuming ID 1 exists)
815        let result = rules.remove_rule_by_id(1);
816        assert!(result.is_ok());
817
818        // Rule should no longer exist
819        assert!(rules.get_rule(1).is_none());
820    }
821
822    #[test]
823    fn test_remove_rule_by_id_nonexistent() {
824        setup_test_environment();
825
826        let mut rules = Rules::new();
827
828        // Removing non-existent rule should still succeed
829        let result = rules.remove_rule_by_id(999);
830        assert!(result.is_ok());
831    }
832
833    #[test]
834    fn test_remove_rule_by_label_existing() {
835        setup_test_environment();
836
837        let mut rules = Rules::new();
838        let retention = Retention::new(MessageAge::Days(30), false);
839        rules.add_rule(retention, Some("remove-me"), false);
840
841        let result = rules.remove_rule_by_label("remove-me");
842        assert!(result.is_ok());
843
844        // Label should no longer exist
845        let labels = rules.labels();
846        assert!(!labels.contains(&"remove-me".to_string()));
847    }
848
849    #[test]
850    fn test_remove_rule_by_label_nonexistent() {
851        setup_test_environment();
852
853        let mut rules = Rules::new();
854
855        let result = rules.remove_rule_by_label("nonexistent-label");
856        assert!(result.is_err());
857
858        match result.unwrap_err() {
859            Error::LabelNotFoundInRules(label) => {
860                assert_eq!(label, "nonexistent-label");
861            }
862            _ => panic!("Expected LabelNotFoundInRules error"),
863        }
864    }
865
866    #[test]
867    fn test_add_label_to_rule_existing_rule() {
868        setup_test_environment();
869
870        let mut rules = Rules::new();
871
872        // Add label to existing rule (ID 1)
873        let result = rules.add_label_to_rule(1, "new-label");
874        assert!(result.is_ok());
875
876        let rule = rules.get_rule(1).unwrap();
877        assert!(rule.labels().contains(&"new-label".to_string()));
878    }
879
880    #[test]
881    fn test_add_label_to_rule_nonexistent_rule() {
882        setup_test_environment();
883
884        let mut rules = Rules::new();
885
886        let result = rules.add_label_to_rule(999, "new-label");
887        assert!(result.is_err());
888
889        match result.unwrap_err() {
890            Error::RuleNotFound(id) => {
891                assert_eq!(id, 999);
892            }
893            _ => panic!("Expected RuleNotFound error"),
894        }
895    }
896
897    #[test]
898    fn test_remove_label_from_rule_existing() {
899        setup_test_environment();
900
901        let mut rules = Rules::new();
902
903        // First add a label
904        let result = rules.add_label_to_rule(1, "temp-label");
905        assert!(result.is_ok());
906
907        // Then remove it
908        let result = rules.remove_label_from_rule(1, "temp-label");
909        assert!(result.is_ok());
910
911        let rule = rules.get_rule(1).unwrap();
912        assert!(!rule.labels().contains(&"temp-label".to_string()));
913    }
914
915    #[test]
916    fn test_remove_label_from_rule_nonexistent_rule() {
917        setup_test_environment();
918
919        let mut rules = Rules::new();
920
921        let result = rules.remove_label_from_rule(999, "any-label");
922        assert!(result.is_err());
923
924        match result.unwrap_err() {
925            Error::RuleNotFound(id) => {
926                assert_eq!(id, 999);
927            }
928            _ => panic!("Expected RuleNotFound error"),
929        }
930    }
931
932    #[test]
933    fn test_set_action_on_rule_existing() {
934        setup_test_environment();
935
936        let mut rules = Rules::new();
937
938        // Set action to Delete
939        let result = rules.set_action_on_rule(1, &EolAction::Delete);
940        assert!(result.is_ok());
941
942        let rule = rules.get_rule(1).unwrap();
943        assert_eq!(rule.action(), Some(EolAction::Delete));
944    }
945
946    #[test]
947    fn test_set_action_on_rule_nonexistent() {
948        setup_test_environment();
949
950        let mut rules = Rules::new();
951
952        let result = rules.set_action_on_rule(999, &EolAction::Delete);
953        assert!(result.is_err());
954
955        match result.unwrap_err() {
956            Error::RuleNotFound(id) => {
957                assert_eq!(id, 999);
958            }
959            _ => panic!("Expected RuleNotFound error"),
960        }
961    }
962
963    #[test]
964    fn test_list_rules_succeeds() {
965        setup_test_environment();
966
967        let rules = Rules::new();
968
969        // Should not panic or return error
970        let result = rules.list_rules();
971        assert!(result.is_ok());
972    }
973
974    // Integration tests for save/load would require file system setup
975    // These are marked as ignore to avoid interference with actual config files
976    #[test]
977    #[ignore = "Integration test that modifies file system"]
978    fn test_save_and_load_roundtrip() {
979        setup_test_environment();
980
981        let mut rules = Rules::new();
982        let retention = Retention::new(MessageAge::Days(30), false);
983        rules.add_rule(retention, Some("save-test"), false);
984
985        // Save to disk
986        let save_result = rules.save();
987        assert!(save_result.is_ok());
988
989        // Load from disk
990        let loaded_rules = Rules::load();
991        assert!(loaded_rules.is_ok());
992
993        let loaded_rules = loaded_rules.unwrap();
994        let labels = loaded_rules.labels();
995        assert!(labels.contains(&"save-test".to_string()));
996    }
997}