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}