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::PathBuf,
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        let home_dir = env::home_dir()
496            .ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
497        let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml");
498
499        // Ensure directory exists
500        if let Some(parent) = path.parent() {
501            fs::create_dir_all(parent)?;
502        }
503
504        let res = toml::to_string(self);
505        log::trace!("toml conversion result: {res:#?}");
506
507        if let Ok(output) = res {
508            fs::write(&path, output)?;
509            log::trace!("Config saved to {}", path.display());
510        }
511
512        Ok(())
513    }
514
515    /// Loads rule configuration from disk.
516    ///
517    /// Reads the configuration from `~/.cull-gmail/rules.toml` and deserializes
518    /// it into a `Rules` instance.
519    ///
520    /// # Examples
521    ///
522    /// ```ignore
523    /// use cull_gmail::Rules;
524    ///
525    /// match Rules::load() {
526    ///     Ok(rules) => {
527    ///         println!("Loaded {} rules", rules.labels().len());
528    ///         rules.list_rules().expect("Failed to list rules");
529    ///     }
530    ///     Err(e) => println!("Failed to load rules: {}", e),
531    /// }
532    /// ```
533    ///
534    /// # Errors
535    ///
536    /// * IO errors when reading from the file system
537    /// * TOML parsing errors if the file is malformed
538    /// * File not found errors if the configuration doesn't exist
539    pub fn load() -> Result<Rules> {
540        let home_dir = env::home_dir()
541            .ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
542        let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml");
543        log::trace!("Loading config from {}", path.display());
544
545        let input = read_to_string(path)?;
546        let config = toml::from_str::<Rules>(&input)?;
547        Ok(config)
548    }
549
550    /// Prints all configured rules to standard output.
551    ///
552    /// Each rule is printed on a separate line with its description,
553    /// including the rule ID, action, and age criteria.
554    ///
555    /// # Examples
556    ///
557    /// ```ignore
558    /// use cull_gmail::Rules;
559    ///
560    /// let rules = Rules::new();
561    /// rules.list_rules().expect("Failed to list rules");
562    /// // Output:
563    /// // Rule #1 is active on `retention/1-years` to move the message to trash if it is more than 1 years old.
564    /// // Rule #2 is active on `retention/1-weeks` to move the message to trash if it is more than 1 weeks old.
565    /// // ...
566    /// ```
567    ///
568    /// # Errors
569    ///
570    /// This method currently always returns `Ok(())`, but the return type
571    /// is `Result<()>` for consistency with other methods and future extensibility.
572    pub fn list_rules(&self) -> Result<()> {
573        for rule in self.rules.values() {
574            println!("{rule}");
575        }
576        Ok(())
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::test_utils::get_test_logger;
584    use std::fs;
585
586    fn setup_test_environment() {
587        get_test_logger();
588        // Clean up any existing test files
589        let Some(home_dir) = env::home_dir() else {
590            // Skip cleanup if home directory cannot be determined
591            return;
592        };
593        let test_config_dir = home_dir.join(".cull-gmail");
594        let test_rules_file = test_config_dir.join("rules.toml");
595        if test_rules_file.exists() {
596            let _ = fs::remove_file(&test_rules_file);
597        }
598    }
599
600    #[test]
601    fn test_rules_new_creates_default_rules() {
602        setup_test_environment();
603
604        let rules = Rules::new();
605
606        // Should have some default rules
607        let labels = rules.labels();
608        assert!(
609            !labels.is_empty(),
610            "Default rules should create some labels"
611        );
612
613        // Should contain the expected retention labels
614        assert!(labels.iter().any(|l| l.contains("retention/1-years")));
615        assert!(labels.iter().any(|l| l.contains("retention/1-weeks")));
616        assert!(labels.iter().any(|l| l.contains("retention/1-months")));
617        assert!(labels.iter().any(|l| l.contains("retention/5-years")));
618    }
619
620    #[test]
621    fn test_rules_default_same_as_new() {
622        setup_test_environment();
623
624        let rules_new = Rules::new();
625        let rules_default = Rules::default();
626
627        // Both should have the same number of rules
628        assert_eq!(rules_new.labels().len(), rules_default.labels().len());
629    }
630
631    #[test]
632    fn test_add_rule_with_label() {
633        setup_test_environment();
634
635        let mut rules = Rules::new();
636        let initial_label_count = rules.labels().len();
637
638        let retention = Retention::new(MessageAge::Days(30), false);
639        rules.add_rule(retention, Some("test-label"), false);
640
641        let labels = rules.labels();
642        assert!(labels.contains(&"test-label".to_string()));
643        assert_eq!(labels.len(), initial_label_count + 1);
644    }
645
646    #[test]
647    fn test_add_rule_without_label() {
648        setup_test_environment();
649
650        let mut rules = Rules::new();
651        let initial_label_count = rules.labels().len();
652
653        let retention = Retention::new(MessageAge::Days(30), false);
654        rules.add_rule(retention, None, false);
655
656        // Should not add any new labels since no label specified and generate_label is false
657        let labels = rules.labels();
658        assert_eq!(labels.len(), initial_label_count);
659    }
660
661    #[test]
662    fn test_add_rule_with_delete_action() {
663        setup_test_environment();
664
665        let mut rules = Rules::new();
666        let retention = Retention::new(MessageAge::Days(7), false);
667        rules.add_rule(retention, Some("delete-test"), true);
668
669        let rules_by_label = rules.get_rules_by_label();
670        let rule = rules_by_label.get("delete-test").unwrap();
671        assert_eq!(rule.action(), Some(EolAction::Delete));
672    }
673
674    #[test]
675    fn test_add_duplicate_label_warns_and_skips() {
676        setup_test_environment();
677
678        let mut rules = Rules::new();
679        let retention1 = Retention::new(MessageAge::Days(30), false);
680        let retention2 = Retention::new(MessageAge::Days(60), false);
681
682        rules.add_rule(retention1, Some("duplicate"), false);
683        let initial_count = rules.labels().len();
684
685        // Try to add another rule with the same label
686        rules.add_rule(retention2, Some("duplicate"), false);
687
688        // Should not increase the count of labels
689        assert_eq!(rules.labels().len(), initial_count);
690    }
691
692    #[test]
693    fn test_get_rule_existing() {
694        setup_test_environment();
695
696        let rules = Rules::new();
697
698        // Default rules should have ID 1
699        let rule = rules.get_rule(1);
700        assert!(rule.is_some());
701        assert_eq!(rule.unwrap().id(), 1);
702    }
703
704    #[test]
705    fn test_get_rule_nonexistent() {
706        setup_test_environment();
707
708        let rules = Rules::new();
709
710        // ID 999 should not exist
711        let rule = rules.get_rule(999);
712        assert!(rule.is_none());
713    }
714
715    #[test]
716    fn test_labels_returns_all_labels() {
717        setup_test_environment();
718
719        let mut rules = Rules::new();
720        let retention = Retention::new(MessageAge::Days(30), false);
721        rules.add_rule(retention, Some("custom-label"), false);
722
723        let labels = rules.labels();
724        assert!(labels.contains(&"custom-label".to_string()));
725    }
726
727    #[test]
728    fn test_get_rules_by_label() {
729        setup_test_environment();
730
731        let mut rules = Rules::new();
732        let retention = Retention::new(MessageAge::Days(30), false);
733        rules.add_rule(retention, Some("mapped-label"), false);
734
735        let label_map = rules.get_rules_by_label();
736        let rule = label_map.get("mapped-label");
737        assert!(rule.is_some());
738        assert!(rule.unwrap().labels().contains(&"mapped-label".to_string()));
739    }
740
741    #[test]
742    fn test_remove_rule_by_id_existing() {
743        setup_test_environment();
744
745        let mut rules = Rules::new();
746
747        // Remove a default rule (assuming ID 1 exists)
748        let result = rules.remove_rule_by_id(1);
749        assert!(result.is_ok());
750
751        // Rule should no longer exist
752        assert!(rules.get_rule(1).is_none());
753    }
754
755    #[test]
756    fn test_remove_rule_by_id_nonexistent() {
757        setup_test_environment();
758
759        let mut rules = Rules::new();
760
761        // Removing non-existent rule should still succeed
762        let result = rules.remove_rule_by_id(999);
763        assert!(result.is_ok());
764    }
765
766    #[test]
767    fn test_remove_rule_by_label_existing() {
768        setup_test_environment();
769
770        let mut rules = Rules::new();
771        let retention = Retention::new(MessageAge::Days(30), false);
772        rules.add_rule(retention, Some("remove-me"), false);
773
774        let result = rules.remove_rule_by_label("remove-me");
775        assert!(result.is_ok());
776
777        // Label should no longer exist
778        let labels = rules.labels();
779        assert!(!labels.contains(&"remove-me".to_string()));
780    }
781
782    #[test]
783    fn test_remove_rule_by_label_nonexistent() {
784        setup_test_environment();
785
786        let mut rules = Rules::new();
787
788        let result = rules.remove_rule_by_label("nonexistent-label");
789        assert!(result.is_err());
790
791        match result.unwrap_err() {
792            Error::LabelNotFoundInRules(label) => {
793                assert_eq!(label, "nonexistent-label");
794            }
795            _ => panic!("Expected LabelNotFoundInRules error"),
796        }
797    }
798
799    #[test]
800    fn test_add_label_to_rule_existing_rule() {
801        setup_test_environment();
802
803        let mut rules = Rules::new();
804
805        // Add label to existing rule (ID 1)
806        let result = rules.add_label_to_rule(1, "new-label");
807        assert!(result.is_ok());
808
809        let rule = rules.get_rule(1).unwrap();
810        assert!(rule.labels().contains(&"new-label".to_string()));
811    }
812
813    #[test]
814    fn test_add_label_to_rule_nonexistent_rule() {
815        setup_test_environment();
816
817        let mut rules = Rules::new();
818
819        let result = rules.add_label_to_rule(999, "new-label");
820        assert!(result.is_err());
821
822        match result.unwrap_err() {
823            Error::RuleNotFound(id) => {
824                assert_eq!(id, 999);
825            }
826            _ => panic!("Expected RuleNotFound error"),
827        }
828    }
829
830    #[test]
831    fn test_remove_label_from_rule_existing() {
832        setup_test_environment();
833
834        let mut rules = Rules::new();
835
836        // First add a label
837        let result = rules.add_label_to_rule(1, "temp-label");
838        assert!(result.is_ok());
839
840        // Then remove it
841        let result = rules.remove_label_from_rule(1, "temp-label");
842        assert!(result.is_ok());
843
844        let rule = rules.get_rule(1).unwrap();
845        assert!(!rule.labels().contains(&"temp-label".to_string()));
846    }
847
848    #[test]
849    fn test_remove_label_from_rule_nonexistent_rule() {
850        setup_test_environment();
851
852        let mut rules = Rules::new();
853
854        let result = rules.remove_label_from_rule(999, "any-label");
855        assert!(result.is_err());
856
857        match result.unwrap_err() {
858            Error::RuleNotFound(id) => {
859                assert_eq!(id, 999);
860            }
861            _ => panic!("Expected RuleNotFound error"),
862        }
863    }
864
865    #[test]
866    fn test_set_action_on_rule_existing() {
867        setup_test_environment();
868
869        let mut rules = Rules::new();
870
871        // Set action to Delete
872        let result = rules.set_action_on_rule(1, &EolAction::Delete);
873        assert!(result.is_ok());
874
875        let rule = rules.get_rule(1).unwrap();
876        assert_eq!(rule.action(), Some(EolAction::Delete));
877    }
878
879    #[test]
880    fn test_set_action_on_rule_nonexistent() {
881        setup_test_environment();
882
883        let mut rules = Rules::new();
884
885        let result = rules.set_action_on_rule(999, &EolAction::Delete);
886        assert!(result.is_err());
887
888        match result.unwrap_err() {
889            Error::RuleNotFound(id) => {
890                assert_eq!(id, 999);
891            }
892            _ => panic!("Expected RuleNotFound error"),
893        }
894    }
895
896    #[test]
897    fn test_list_rules_succeeds() {
898        setup_test_environment();
899
900        let rules = Rules::new();
901
902        // Should not panic or return error
903        let result = rules.list_rules();
904        assert!(result.is_ok());
905    }
906
907    // Integration tests for save/load would require file system setup
908    // These are marked as ignore to avoid interference with actual config files
909    #[test]
910    #[ignore = "Integration test that modifies file system"]
911    fn test_save_and_load_roundtrip() {
912        setup_test_environment();
913
914        let mut rules = Rules::new();
915        let retention = Retention::new(MessageAge::Days(30), false);
916        rules.add_rule(retention, Some("save-test"), false);
917
918        // Save to disk
919        let save_result = rules.save();
920        assert!(save_result.is_ok());
921
922        // Load from disk
923        let loaded_rules = Rules::load();
924        assert!(loaded_rules.is_ok());
925
926        let loaded_rules = loaded_rules.unwrap();
927        let labels = loaded_rules.labels();
928        assert!(labels.contains(&"save-test".to_string()));
929    }
930}