cull_gmail/
rule_processor.rs

1//! Rule Processing Module
2//!
3//! This module provides the [`RuleProcessor`] trait and its implementation for processing
4//! Gmail messages according to configured end-of-life (EOL) rules. It handles the complete
5//! workflow of finding messages, applying filters based on rules, and executing actions
6//! such as moving messages to trash or permanently deleting them.
7//!
8//! ## Safety Considerations
9//!
10//! - **Destructive Operations**: The [`RuleProcessor::batch_delete`] method permanently
11//!   removes messages from Gmail and cannot be undone.
12//! - **Recoverable Operations**: The [`RuleProcessor::batch_trash`] method moves messages
13//!   to the Gmail trash folder, from which they can be recovered within 30 days.
14//! - **Execute Flag**: All destructive operations are gated by an execute flag that must
15//!   be explicitly set to `true`. When `false`, operations run in "dry-run" mode.
16//!
17//! ## Workflow
18//!
19//! 1. Set a rule using [`RuleProcessor::set_rule`]
20//! 2. Configure the execute flag with [`RuleProcessor::set_execute`]
21//! 3. Process messages for a label with [`RuleProcessor::find_rule_and_messages_for_label`]
22//! 4. The processor will automatically:
23//!    - Find messages matching the rule's query
24//!    - Prepare the message list via [`RuleProcessor::prepare`]
25//!    - Execute the rule's action (trash) if execute flag is true
26//!
27//! ## Example
28//!
29//! ```text
30//! use cull_gmail::{GmailClient, RuleProcessor, ClientConfig};
31//!
32//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
33//!     // Configure Gmail client with credentials
34//!     let config = ClientConfig::builder()
35//!         .with_client_id("your-client-id")
36//!         .with_client_secret("your-client-secret")
37//!         .build();
38//!     let mut client = GmailClient::new_with_config(config).await?;
39//!     
40//!     // Rules would typically be loaded from configuration
41//!     // let rule = load_rule_from_config("old-emails");
42//!     // client.set_rule(rule);
43//!     
44//!     client.set_execute(true); // Set to false for dry-run
45//!     
46//!     // Process all messages with the "old-emails" label according to the rule
47//!     client.find_rule_and_messages_for_label("old-emails").await?;
48//!     Ok(())
49//! }
50//! ```
51
52use google_gmail1::api::{BatchDeleteMessagesRequest, BatchModifyMessagesRequest};
53
54use crate::{EolAction, Error, GmailClient, Result, message_list::MessageList, rules::EolRule};
55
56/// Gmail label name for the trash folder.
57///
58/// This constant ensures consistent usage of the TRASH label throughout the module.
59const TRASH_LABEL: &str = "TRASH";
60
61/// Gmail API scope for modifying messages (recommended scope for most operations).
62///
63/// This scope allows adding/removing labels, moving messages to trash, and other
64/// modification operations. Preferred over broader scopes for security.
65const GMAIL_MODIFY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
66
67/// Internal trait defining the minimal operations needed for rule processing.
68///
69/// This trait is used internally to enable unit testing of orchestration logic
70/// without requiring network calls or real Gmail API access. It abstracts the
71/// core operations that the rule processor needs from the Gmail client.
72#[doc(hidden)]
73pub(crate) trait MailOperations {
74    /// Add labels to the client for filtering
75    fn add_labels(&mut self, labels: &[String]) -> Result<()>;
76
77    /// Get the current label IDs
78    fn label_ids(&self) -> Vec<String>;
79
80    /// Set the query string for message filtering
81    fn set_query(&mut self, query: &str);
82
83    /// Prepare messages by fetching from Gmail API
84    fn prepare(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
85
86    /// Execute trash operation on prepared messages
87    fn batch_trash(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
88}
89
90/// Internal orchestration function for rule processing that can be unit tested.
91///
92/// This function contains the core rule processing logic extracted from the trait
93/// implementation to enable testing without network dependencies.
94async fn process_label_with_rule<T: MailOperations>(
95    client: &mut T,
96    rule: &EolRule,
97    label: &str,
98    pages: u32,
99    execute: bool,
100) -> Result<()> {
101    // Add the label for filtering
102    client.add_labels(&[label.to_owned()])?;
103
104    // Validate label exists in mailbox
105    if client.label_ids().is_empty() {
106        return Err(Error::LabelNotFoundInMailbox(label.to_owned()));
107    }
108
109    // Get query from rule
110    let Some(query) = rule.eol_query() else {
111        return Err(Error::NoQueryStringCalculated(rule.id()));
112    };
113
114    // Set the query and prepare messages
115    client.set_query(&query);
116    log::info!("Ready to process messages for label: {label}");
117    client.prepare(pages).await?;
118
119    // Execute or dry-run based on execute flag
120    if execute {
121        log::info!("Execute mode: applying rule action to messages");
122        client.batch_trash().await
123    } else {
124        log::info!("Dry-run mode: no changes made to messages");
125        Ok(())
126    }
127}
128
129/// Implement the internal mail operations trait for GmailClient.
130impl MailOperations for GmailClient {
131    fn add_labels(&mut self, labels: &[String]) -> Result<()> {
132        MessageList::add_labels(self, labels)
133    }
134
135    fn label_ids(&self) -> Vec<String> {
136        MessageList::label_ids(self)
137    }
138
139    fn set_query(&mut self, query: &str) {
140        MessageList::set_query(self, query);
141    }
142
143    async fn prepare(&mut self, pages: u32) -> Result<()> {
144        self.get_messages(pages).await
145    }
146
147    async fn batch_trash(&mut self) -> Result<()> {
148        RuleProcessor::batch_trash(self).await
149    }
150}
151
152/// Trait for processing Gmail messages according to configured end-of-life rules.
153///
154/// This trait defines the interface for finding, filtering, and acting upon Gmail messages
155/// based on retention rules. Implementations should handle the complete workflow from
156/// rule application to message processing.
157pub trait RuleProcessor {
158    /// Processes all messages for a specific Gmail label according to the configured rule.
159    ///
160    /// This is the main entry point for rule processing. It coordinates the entire workflow:
161    /// 1. Validates that the label exists in the mailbox
162    /// 2. Applies the rule's query to find matching messages
163    /// 3. Prepares the message list for processing
164    /// 4. Executes the rule's action (if execute flag is true) or runs in dry-run mode
165    ///
166    /// # Arguments
167    ///
168    /// * `label` - The Gmail label name to process (e.g., "INBOX", "old-emails")
169    ///
170    /// # Returns
171    ///
172    /// * `Ok(())` - Processing completed successfully
173    /// * `Err(Error::LabelNotFoundInMailbox)` - The specified label doesn't exist
174    /// * `Err(Error::RuleNotFound)` - No rule has been set via [`set_rule`](Self::set_rule)
175    /// * `Err(Error::NoQueryStringCalculated)` - The rule doesn't provide a valid query
176    ///
177    /// # Side Effects
178    ///
179    /// When execute flag is true, messages may be moved to trash or permanently deleted.
180    /// When execute flag is false, runs in dry-run mode with no destructive actions.
181    fn find_rule_and_messages_for_label(
182        &mut self,
183        label: &str,
184    ) -> impl std::future::Future<Output = Result<()>> + Send;
185
186    /// Sets the execution mode for destructive operations.
187    ///
188    /// # Arguments
189    ///
190    /// * `value` - `true` to enable destructive operations, `false` for dry-run mode
191    ///
192    /// # Safety
193    ///
194    /// When set to `true`, subsequent calls to processing methods will perform actual
195    /// destructive operations on Gmail messages. Always verify your rules and queries
196    /// in dry-run mode (`false`) before enabling execution.
197    fn set_execute(&mut self, value: bool);
198
199    /// Configures the end-of-life rule to apply during processing.
200    ///
201    /// # Arguments
202    ///
203    /// * `rule` - The `EolRule` containing query criteria and action to perform
204    ///
205    /// # Example
206    ///
207    /// ```text
208    /// use cull_gmail::{GmailClient, RuleProcessor, ClientConfig};
209    ///
210    /// async fn example() -> Result<(), Box<dyn std::error::Error>> {
211    ///     let config = ClientConfig::builder()
212    ///         .with_client_id("your-client-id")
213    ///         .with_client_secret("your-client-secret")
214    ///         .build();
215    ///     let mut client = GmailClient::new_with_config(config).await?;
216    ///     
217    ///     // Rules would typically be loaded from configuration
218    ///     // let rule = load_rule_from_config();
219    ///     // client.set_rule(rule);
220    ///     Ok(())
221    /// }
222    /// ```
223    fn set_rule(&mut self, rule: EolRule);
224
225    /// Returns the action that will be performed by the currently configured rule.
226    ///
227    /// # Returns
228    ///
229    /// * `Some(EolAction)` - The action (e.g., `EolAction::Trash`) if a rule is set
230    /// * `None` - If no rule has been configured via [`set_rule`](Self::set_rule)
231    fn action(&self) -> Option<EolAction>;
232
233    /// Prepares the list of messages for processing by fetching them from Gmail.
234    ///
235    /// This method queries the Gmail API to retrieve messages matching the current
236    /// query and label filters, up to the specified number of pages.
237    ///
238    /// # Arguments
239    ///
240    /// * `pages` - Maximum number of result pages to fetch (0 = all pages)
241    ///
242    /// # Returns
243    ///
244    /// * `Ok(())` - Messages successfully retrieved and prepared
245    /// * `Err(_)` - Gmail API error or network failure
246    ///
247    /// # Side Effects
248    ///
249    /// Makes API calls to Gmail to retrieve message metadata. No messages are
250    /// modified by this operation.
251    fn prepare(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
252
253    /// Permanently deletes all prepared messages from Gmail.
254    ///
255    /// # Returns
256    ///
257    /// * `Ok(())` - All messages successfully deleted
258    /// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
259    ///
260    /// # Safety
261    ///
262    /// ⚠️ **DESTRUCTIVE OPERATION** - This permanently removes messages from Gmail.
263    /// Deleted messages cannot be recovered. Use [`batch_trash`](Self::batch_trash)
264    /// for recoverable deletion.
265    ///
266    /// # Gmail API Requirements
267    ///
268    /// Requires the `https://www.googleapis.com/auth/gmail.modify` scope or broader.
269    fn batch_delete(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
270
271    /// Moves all prepared messages to the Gmail trash folder.
272    ///
273    /// Messages moved to trash can be recovered within 30 days through the Gmail
274    /// web interface or API calls.
275    ///
276    /// # Returns
277    ///
278    /// * `Ok(())` - All messages successfully moved to trash
279    /// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
280    ///
281    /// # Recovery
282    ///
283    /// Messages can be recovered from trash within 30 days. After 30 days,
284    /// Gmail automatically purges trashed messages.
285    ///
286    /// # Gmail API Requirements
287    ///
288    /// Requires the `https://www.googleapis.com/auth/gmail.modify` scope.
289    fn batch_trash(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
290}
291
292impl RuleProcessor for GmailClient {
293    /// Configures the end-of-life rule for this Gmail client.
294    ///
295    /// The rule defines which messages to target and what action to perform on them.
296    /// This must be called before processing any labels.
297    fn set_rule(&mut self, value: EolRule) {
298        self.rule = Some(value);
299    }
300
301    /// Controls whether destructive operations are actually executed.
302    ///
303    /// When `false` (dry-run mode), all operations are simulated but no actual
304    /// changes are made to Gmail messages. When `true`, destructive operations
305    /// like moving to trash or deleting will be performed.
306    ///
307    /// **Default is `false` for safety.**
308    fn set_execute(&mut self, value: bool) {
309        self.execute = value;
310    }
311
312    /// Returns the action that will be performed by the current rule.
313    ///
314    /// This is useful for logging and verification before executing destructive operations.
315    fn action(&self) -> Option<EolAction> {
316        if let Some(rule) = &self.rule {
317            return rule.action();
318        }
319        None
320    }
321
322    /// Orchestrates the complete rule processing workflow for a Gmail label.
323    ///
324    /// This method implements the main processing logic by delegating to the internal
325    /// orchestration function, which enables better testability while maintaining
326    /// the same external behaviour.
327    ///
328    /// The method respects the execute flag - when `false`, it runs in dry-run mode
329    /// and only logs what would be done without making any changes.
330    async fn find_rule_and_messages_for_label(&mut self, label: &str) -> Result<()> {
331        // Ensure we have a rule configured and clone it to avoid borrow conflicts
332        let Some(rule) = self.rule.clone() else {
333            return Err(Error::RuleNotFound(0));
334        };
335
336        let execute = self.execute;
337
338        // Delegate to internal orchestration function
339        process_label_with_rule(self, &rule, label, 0, execute).await
340    }
341
342    /// Fetches messages from Gmail API based on current query and label filters.
343    ///
344    /// This is a read-only operation that retrieves message metadata from Gmail
345    /// without modifying any messages. The results are cached internally for
346    /// subsequent batch operations.
347    ///
348    /// # Arguments
349    ///
350    /// * `pages` - Number of result pages to fetch (0 = all available pages)
351    async fn prepare(&mut self, pages: u32) -> Result<()> {
352        self.get_messages(pages).await
353    }
354
355    /// Permanently deletes all prepared messages using Gmail's batch delete API.
356    ///
357    /// ⚠️ **DESTRUCTIVE OPERATION** - This action cannot be undone!
358    ///
359    /// This method uses the Gmail API's batch delete functionality to permanently
360    /// remove messages from the user's mailbox. Once deleted, messages cannot be
361    /// recovered through any means.
362    ///
363    /// # API Scope Requirements
364    ///
365    /// Uses `https://www.googleapis.com/auth/gmail.modify` scope for secure,
366    /// minimal privilege access. This scope provides sufficient permissions
367    /// for message deletion while following security best practices.
368    async fn batch_delete(&mut self) -> Result<()> {
369        let message_ids = MessageList::message_ids(self);
370
371        // Early return if no messages to delete, avoiding unnecessary API calls
372        if message_ids.is_empty() {
373            log::info!("No messages to delete - skipping batch delete operation");
374            return Ok(());
375        }
376
377        let ids = Some(message_ids);
378        let batch_request = BatchDeleteMessagesRequest { ids };
379
380        self.log_messages("Message with subject `", "` permanently deleted")
381            .await?;
382
383        log::trace!("{batch_request:#?}");
384
385        let _res = self
386            .hub()
387            .users()
388            .messages_batch_delete(batch_request, "me")
389            .add_scope(GMAIL_MODIFY_SCOPE)
390            .doit()
391            .await
392            .map_err(Box::new)?;
393
394        Ok(())
395    }
396    /// Moves all prepared messages to Gmail's trash folder using batch modify API.
397    ///
398    /// This is a recoverable operation - messages can be restored from trash within
399    /// 30 days via the Gmail web interface or API calls. After 30 days, Gmail
400    /// automatically purges trashed messages permanently.
401    ///
402    /// The operation adds the TRASH label and removes any existing labels that were
403    /// used to filter the messages, effectively moving them out of their current
404    /// folders into the trash.
405    ///
406    /// # API Scope Requirements
407    ///
408    /// Uses `https://www.googleapis.com/auth/gmail.modify` scope for secure,
409    /// minimal privilege access to Gmail message modification operations.
410    async fn batch_trash(&mut self) -> Result<()> {
411        let message_ids = MessageList::message_ids(self);
412
413        // Early return if no messages to trash, avoiding unnecessary API calls
414        if message_ids.is_empty() {
415            log::info!("No messages to trash - skipping batch trash operation");
416            return Ok(());
417        }
418
419        let add_label_ids = Some(vec![TRASH_LABEL.to_string()]);
420        let ids = Some(message_ids);
421        let remove_label_ids = Some(MessageList::label_ids(self));
422
423        let batch_request = BatchModifyMessagesRequest {
424            add_label_ids,
425            ids,
426            remove_label_ids,
427        };
428
429        self.log_messages("Message with subject `", "` moved to trash")
430            .await?;
431
432        log::trace!("{batch_request:#?}");
433
434        let _res = self
435            .hub()
436            .users()
437            .messages_batch_modify(batch_request, "me")
438            .add_scope(GMAIL_MODIFY_SCOPE)
439            .doit()
440            .await
441            .map_err(Box::new)?;
442
443        Ok(())
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::{EolAction, Error, rules::EolRule};
451    use std::sync::{Arc, Mutex};
452
453    /// Test helper to create a simple EolRule with or without a query
454    fn create_test_rule(id: usize, has_query: bool) -> EolRule {
455        use crate::{MessageAge, Retention};
456
457        let mut rule = EolRule::new(id);
458
459        if has_query {
460            // Create a rule that will generate a query (using retention days)
461            let retention = Retention::new(MessageAge::Days(30), false);
462            rule.set_retention(retention);
463            rule.add_label("test-label");
464        }
465        // For rules without query, we just return the basic rule with no retention set
466
467        rule
468    }
469
470    /// Fake client implementation for testing the orchestration logic
471    struct FakeClient {
472        labels: Vec<String>,
473        label_ids: Vec<String>,
474        query: String,
475        messages_prepared: bool,
476        prepare_call_count: u32,
477        batch_trash_call_count: Arc<Mutex<u32>>, // Use Arc<Mutex> for thread safety
478        should_fail_add_labels: bool,
479        should_fail_prepare: bool,
480        should_fail_batch_trash: bool,
481        simulate_missing_labels: bool, // Flag to simulate labels not being found
482    }
483
484    impl Default for FakeClient {
485        fn default() -> Self {
486            Self {
487                labels: Vec::new(),
488                label_ids: Vec::new(),
489                query: String::new(),
490                messages_prepared: false,
491                prepare_call_count: 0,
492                batch_trash_call_count: Arc::new(Mutex::new(0)),
493                should_fail_add_labels: false,
494                should_fail_prepare: false,
495                should_fail_batch_trash: false,
496                simulate_missing_labels: false,
497            }
498        }
499    }
500
501    impl FakeClient {
502        fn new() -> Self {
503            Self::default()
504        }
505
506        /// Create a client that simulates missing labels (add_labels succeeds but no label_ids)
507        fn with_missing_labels() -> Self {
508            Self {
509                simulate_missing_labels: true,
510                ..Default::default()
511            }
512        }
513
514        fn with_labels(label_ids: Vec<String>) -> Self {
515            Self {
516                label_ids,
517                ..Default::default()
518            }
519        }
520
521        fn with_failure(failure_type: &str) -> Self {
522            match failure_type {
523                "add_labels" => Self {
524                    should_fail_add_labels: true,
525                    ..Default::default()
526                },
527                "prepare" => Self {
528                    should_fail_prepare: true,
529                    ..Default::default()
530                },
531                "batch_trash" => Self {
532                    should_fail_batch_trash: true,
533                    ..Default::default()
534                },
535                _ => Self::default(),
536            }
537        }
538
539        fn get_batch_trash_call_count(&self) -> u32 {
540            *self.batch_trash_call_count.lock().unwrap()
541        }
542    }
543
544    impl MailOperations for FakeClient {
545        fn add_labels(&mut self, labels: &[String]) -> Result<()> {
546            if self.should_fail_add_labels {
547                return Err(Error::DirectoryUnset); // Use a valid error variant
548            }
549            self.labels.extend(labels.iter().cloned());
550            // Only populate label_ids if we're not simulating missing labels
551            if !self.simulate_missing_labels && !labels.is_empty() {
552                self.label_ids = labels.to_vec();
553            }
554            // When simulate_missing_labels is true, label_ids stays empty
555            Ok(())
556        }
557
558        fn label_ids(&self) -> Vec<String> {
559            self.label_ids.clone()
560        }
561
562        fn set_query(&mut self, query: &str) {
563            self.query = query.to_owned();
564        }
565
566        async fn prepare(&mut self, _pages: u32) -> Result<()> {
567            // Always increment the counter to track that prepare was called
568            self.prepare_call_count += 1;
569
570            if self.should_fail_prepare {
571                return Err(Error::NoLabelsFound); // Use a valid error variant
572            }
573            self.messages_prepared = true;
574            Ok(())
575        }
576
577        async fn batch_trash(&mut self) -> Result<()> {
578            // Always increment the counter to track that batch_trash was called
579            *self.batch_trash_call_count.lock().unwrap() += 1;
580
581            if self.should_fail_batch_trash {
582                return Err(Error::InvalidPagingMode); // Use a valid error variant
583            }
584            Ok(())
585        }
586    }
587
588    #[tokio::test]
589    async fn test_errors_when_label_missing() {
590        let mut client = FakeClient::with_missing_labels(); // Simulate labels not being found
591        let rule = create_test_rule(1, true);
592        let label = "missing-label";
593
594        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
595
596        assert!(matches!(result, Err(Error::LabelNotFoundInMailbox(_))));
597        assert_eq!(client.prepare_call_count, 0);
598        assert_eq!(client.get_batch_trash_call_count(), 0);
599    }
600
601    #[tokio::test]
602    async fn test_errors_when_rule_has_no_query() {
603        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
604        let rule = create_test_rule(2, false); // Rule without query
605        let label = "test-label";
606
607        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
608
609        assert!(matches!(result, Err(Error::NoQueryStringCalculated(2))));
610        assert_eq!(client.prepare_call_count, 0);
611        assert_eq!(client.get_batch_trash_call_count(), 0);
612    }
613
614    #[tokio::test]
615    async fn test_dry_run_does_not_trash() {
616        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
617        let rule = create_test_rule(3, true);
618        let label = "test-label";
619
620        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
621
622        assert!(result.is_ok());
623        assert_eq!(client.prepare_call_count, 1);
624        assert_eq!(client.get_batch_trash_call_count(), 0); // Should not trash in dry-run mode
625        assert!(client.messages_prepared);
626        assert!(!client.query.is_empty()); // Query should be set
627    }
628
629    #[tokio::test]
630    async fn test_execute_trashes_messages_once() {
631        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
632        let rule = create_test_rule(4, true);
633        let label = "test-label";
634
635        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
636
637        assert!(result.is_ok());
638        assert_eq!(client.prepare_call_count, 1);
639        assert_eq!(client.get_batch_trash_call_count(), 1); // Should trash when execute=true
640        assert!(client.messages_prepared);
641        assert!(!client.query.is_empty());
642    }
643
644    #[tokio::test]
645    async fn test_propagates_prepare_error() {
646        // Create a client that will fail on prepare but has valid labels
647        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
648        client.should_fail_prepare = true; // Set the failure flag directly
649
650        let rule = create_test_rule(5, true);
651        let label = "test-label";
652
653        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
654
655        assert!(result.is_err());
656        assert_eq!(client.prepare_call_count, 1); // prepare should be called once
657        assert_eq!(client.get_batch_trash_call_count(), 0); // Should not reach trash due to error
658    }
659
660    #[tokio::test]
661    async fn test_propagates_batch_trash_error() {
662        // Create a client that will fail on batch_trash but has valid labels
663        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
664        client.should_fail_batch_trash = true; // Set the failure flag directly
665
666        let rule = create_test_rule(6, true);
667        let label = "test-label";
668
669        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
670
671        assert!(result.is_err());
672        assert_eq!(client.prepare_call_count, 1);
673        assert_eq!(client.get_batch_trash_call_count(), 1); // Should attempt trash but fail
674    }
675
676    #[tokio::test]
677    async fn test_pages_parameter_passed_correctly() {
678        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
679        let rule = create_test_rule(7, true);
680        let label = "test-label";
681        let pages = 5;
682
683        let result = process_label_with_rule(&mut client, &rule, label, pages, false).await;
684
685        assert!(result.is_ok());
686        assert_eq!(client.prepare_call_count, 1);
687        // Note: In a more sophisticated test, we'd verify pages parameter is passed to prepare
688        // but our simple FakeClient doesn't track this. In practice, you might want to enhance it.
689    }
690
691    /// Test the rule processor trait setters and getters
692    #[test]
693    fn test_rule_processor_setters_and_getters() {
694        // Note: This test would need a mock GmailClient implementation
695        // For now, we'll create a simple struct that implements RuleProcessor
696
697        struct MockProcessor {
698            rule: Option<EolRule>,
699            execute: bool,
700        }
701
702        impl RuleProcessor for MockProcessor {
703            fn set_rule(&mut self, rule: EolRule) {
704                self.rule = Some(rule);
705            }
706
707            fn set_execute(&mut self, value: bool) {
708                self.execute = value;
709            }
710
711            fn action(&self) -> Option<EolAction> {
712                self.rule.as_ref().and_then(|r| r.action())
713            }
714
715            async fn find_rule_and_messages_for_label(&mut self, _label: &str) -> Result<()> {
716                Ok(())
717            }
718
719            async fn prepare(&mut self, _pages: u32) -> Result<()> {
720                Ok(())
721            }
722
723            async fn batch_delete(&mut self) -> Result<()> {
724                Ok(())
725            }
726
727            async fn batch_trash(&mut self) -> Result<()> {
728                Ok(())
729            }
730        }
731
732        let mut processor = MockProcessor {
733            rule: None,
734            execute: false,
735        };
736
737        // Test initial state
738        assert!(processor.action().is_none());
739        assert!(!processor.execute);
740
741        // Test rule setting
742        let rule = create_test_rule(8, true);
743        processor.set_rule(rule);
744        assert!(processor.action().is_some());
745        assert_eq!(processor.action(), Some(EolAction::Trash));
746
747        // Test execute flag setting
748        processor.set_execute(true);
749        assert!(processor.execute);
750
751        processor.set_execute(false);
752        assert!(!processor.execute);
753    }
754}