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}