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}