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(&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(&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(&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(&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(&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        log::trace!("{batch_request:#?}");
381
382        let _res = self
383            .hub()
384            .users()
385            .messages_batch_delete(batch_request, "me")
386            .add_scope(GMAIL_MODIFY_SCOPE)
387            .doit()
388            .await
389            .map_err(Box::new)?;
390
391        for m in self.messages() {
392            log::info!("Message with subject `{}` permanently deleted", m.subject());
393        }
394
395        Ok(())
396    }
397    /// Moves all prepared messages to Gmail's trash folder using batch modify API.
398    ///
399    /// This is a recoverable operation - messages can be restored from trash within
400    /// 30 days via the Gmail web interface or API calls. After 30 days, Gmail
401    /// automatically purges trashed messages permanently.
402    ///
403    /// The operation adds the TRASH label and removes any existing labels that were
404    /// used to filter the messages, effectively moving them out of their current
405    /// folders into the trash.
406    ///
407    /// # API Scope Requirements
408    ///
409    /// Uses `https://www.googleapis.com/auth/gmail.modify` scope for secure,
410    /// minimal privilege access to Gmail message modification operations.
411    async fn batch_trash(&self) -> Result<()> {
412        let message_ids = MessageList::message_ids(self);
413
414        // Early return if no messages to trash, avoiding unnecessary API calls
415        if message_ids.is_empty() {
416            log::info!("No messages to trash - skipping batch trash operation");
417            return Ok(());
418        }
419
420        let add_label_ids = Some(vec![TRASH_LABEL.to_string()]);
421        let ids = Some(message_ids);
422        let remove_label_ids = Some(MessageList::label_ids(self));
423
424        let batch_request = BatchModifyMessagesRequest {
425            add_label_ids,
426            ids,
427            remove_label_ids,
428        };
429
430        log::trace!("{batch_request:#?}");
431
432        let _res = self
433            .hub()
434            .users()
435            .messages_batch_modify(batch_request, "me")
436            .add_scope(GMAIL_MODIFY_SCOPE)
437            .doit()
438            .await
439            .map_err(Box::new)?;
440
441        for m in self.messages() {
442            log::info!("Message with subject `{}` moved to trash", m.subject());
443        }
444
445        Ok(())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::{EolAction, Error, rules::EolRule};
453    use std::sync::{Arc, Mutex};
454
455    /// Test helper to create a simple EolRule with or without a query
456    fn create_test_rule(id: usize, has_query: bool) -> EolRule {
457        use crate::{MessageAge, Retention};
458
459        let mut rule = EolRule::new(id);
460
461        if has_query {
462            // Create a rule that will generate a query (using retention days)
463            let retention = Retention::new(MessageAge::Days(30), false);
464            rule.set_retention(retention);
465            rule.add_label("test-label");
466        }
467        // For rules without query, we just return the basic rule with no retention set
468
469        rule
470    }
471
472    /// Fake client implementation for testing the orchestration logic
473    struct FakeClient {
474        labels: Vec<String>,
475        label_ids: Vec<String>,
476        query: String,
477        messages_prepared: bool,
478        prepare_call_count: u32,
479        batch_trash_call_count: Arc<Mutex<u32>>, // Use Arc<Mutex> for thread safety
480        should_fail_add_labels: bool,
481        should_fail_prepare: bool,
482        should_fail_batch_trash: bool,
483        simulate_missing_labels: bool, // Flag to simulate labels not being found
484    }
485
486    impl Default for FakeClient {
487        fn default() -> Self {
488            Self {
489                labels: Vec::new(),
490                label_ids: Vec::new(),
491                query: String::new(),
492                messages_prepared: false,
493                prepare_call_count: 0,
494                batch_trash_call_count: Arc::new(Mutex::new(0)),
495                should_fail_add_labels: false,
496                should_fail_prepare: false,
497                should_fail_batch_trash: false,
498                simulate_missing_labels: false,
499            }
500        }
501    }
502
503    impl FakeClient {
504        fn new() -> Self {
505            Self::default()
506        }
507
508        /// Create a client that simulates missing labels (add_labels succeeds but no label_ids)
509        fn with_missing_labels() -> Self {
510            Self {
511                simulate_missing_labels: true,
512                ..Default::default()
513            }
514        }
515
516        fn with_labels(label_ids: Vec<String>) -> Self {
517            Self {
518                label_ids,
519                ..Default::default()
520            }
521        }
522
523        fn with_failure(failure_type: &str) -> Self {
524            match failure_type {
525                "add_labels" => Self {
526                    should_fail_add_labels: true,
527                    ..Default::default()
528                },
529                "prepare" => Self {
530                    should_fail_prepare: true,
531                    ..Default::default()
532                },
533                "batch_trash" => Self {
534                    should_fail_batch_trash: true,
535                    ..Default::default()
536                },
537                _ => Self::default(),
538            }
539        }
540
541        fn get_batch_trash_call_count(&self) -> u32 {
542            *self.batch_trash_call_count.lock().unwrap()
543        }
544    }
545
546    impl MailOperations for FakeClient {
547        fn add_labels(&mut self, labels: &[String]) -> Result<()> {
548            if self.should_fail_add_labels {
549                return Err(Error::DirectoryUnset); // Use a valid error variant
550            }
551            self.labels.extend(labels.iter().cloned());
552            // Only populate label_ids if we're not simulating missing labels
553            if !self.simulate_missing_labels && !labels.is_empty() {
554                self.label_ids = labels.to_vec();
555            }
556            // When simulate_missing_labels is true, label_ids stays empty
557            Ok(())
558        }
559
560        fn label_ids(&self) -> Vec<String> {
561            self.label_ids.clone()
562        }
563
564        fn set_query(&mut self, query: &str) {
565            self.query = query.to_owned();
566        }
567
568        async fn prepare(&mut self, _pages: u32) -> Result<()> {
569            // Always increment the counter to track that prepare was called
570            self.prepare_call_count += 1;
571
572            if self.should_fail_prepare {
573                return Err(Error::NoLabelsFound); // Use a valid error variant
574            }
575            self.messages_prepared = true;
576            Ok(())
577        }
578
579        async fn batch_trash(&self) -> Result<()> {
580            // Always increment the counter to track that batch_trash was called
581            *self.batch_trash_call_count.lock().unwrap() += 1;
582
583            if self.should_fail_batch_trash {
584                return Err(Error::InvalidPagingMode); // Use a valid error variant
585            }
586            Ok(())
587        }
588    }
589
590    #[tokio::test]
591    async fn test_errors_when_label_missing() {
592        let mut client = FakeClient::with_missing_labels(); // Simulate labels not being found
593        let rule = create_test_rule(1, true);
594        let label = "missing-label";
595
596        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
597
598        assert!(matches!(result, Err(Error::LabelNotFoundInMailbox(_))));
599        assert_eq!(client.prepare_call_count, 0);
600        assert_eq!(client.get_batch_trash_call_count(), 0);
601    }
602
603    #[tokio::test]
604    async fn test_errors_when_rule_has_no_query() {
605        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
606        let rule = create_test_rule(2, false); // Rule without query
607        let label = "test-label";
608
609        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
610
611        assert!(matches!(result, Err(Error::NoQueryStringCalculated(2))));
612        assert_eq!(client.prepare_call_count, 0);
613        assert_eq!(client.get_batch_trash_call_count(), 0);
614    }
615
616    #[tokio::test]
617    async fn test_dry_run_does_not_trash() {
618        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
619        let rule = create_test_rule(3, true);
620        let label = "test-label";
621
622        let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
623
624        assert!(result.is_ok());
625        assert_eq!(client.prepare_call_count, 1);
626        assert_eq!(client.get_batch_trash_call_count(), 0); // Should not trash in dry-run mode
627        assert!(client.messages_prepared);
628        assert!(!client.query.is_empty()); // Query should be set
629    }
630
631    #[tokio::test]
632    async fn test_execute_trashes_messages_once() {
633        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
634        let rule = create_test_rule(4, true);
635        let label = "test-label";
636
637        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
638
639        assert!(result.is_ok());
640        assert_eq!(client.prepare_call_count, 1);
641        assert_eq!(client.get_batch_trash_call_count(), 1); // Should trash when execute=true
642        assert!(client.messages_prepared);
643        assert!(!client.query.is_empty());
644    }
645
646    #[tokio::test]
647    async fn test_propagates_prepare_error() {
648        // Create a client that will fail on prepare but has valid labels
649        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
650        client.should_fail_prepare = true; // Set the failure flag directly
651
652        let rule = create_test_rule(5, true);
653        let label = "test-label";
654
655        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
656
657        assert!(result.is_err());
658        assert_eq!(client.prepare_call_count, 1); // prepare should be called once
659        assert_eq!(client.get_batch_trash_call_count(), 0); // Should not reach trash due to error
660    }
661
662    #[tokio::test]
663    async fn test_propagates_batch_trash_error() {
664        // Create a client that will fail on batch_trash but has valid labels
665        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
666        client.should_fail_batch_trash = true; // Set the failure flag directly
667
668        let rule = create_test_rule(6, true);
669        let label = "test-label";
670
671        let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
672
673        assert!(result.is_err());
674        assert_eq!(client.prepare_call_count, 1);
675        assert_eq!(client.get_batch_trash_call_count(), 1); // Should attempt trash but fail
676    }
677
678    #[tokio::test]
679    async fn test_pages_parameter_passed_correctly() {
680        let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
681        let rule = create_test_rule(7, true);
682        let label = "test-label";
683        let pages = 5;
684
685        let result = process_label_with_rule(&mut client, &rule, label, pages, false).await;
686
687        assert!(result.is_ok());
688        assert_eq!(client.prepare_call_count, 1);
689        // Note: In a more sophisticated test, we'd verify pages parameter is passed to prepare
690        // but our simple FakeClient doesn't track this. In practice, you might want to enhance it.
691    }
692
693    /// Test the rule processor trait setters and getters
694    #[test]
695    fn test_rule_processor_setters_and_getters() {
696        // Note: This test would need a mock GmailClient implementation
697        // For now, we'll create a simple struct that implements RuleProcessor
698
699        struct MockProcessor {
700            rule: Option<EolRule>,
701            execute: bool,
702        }
703
704        impl RuleProcessor for MockProcessor {
705            fn set_rule(&mut self, rule: EolRule) {
706                self.rule = Some(rule);
707            }
708
709            fn set_execute(&mut self, value: bool) {
710                self.execute = value;
711            }
712
713            fn action(&self) -> Option<EolAction> {
714                self.rule.as_ref().and_then(|r| r.action())
715            }
716
717            async fn find_rule_and_messages_for_label(&mut self, _label: &str) -> Result<()> {
718                Ok(())
719            }
720
721            async fn prepare(&mut self, _pages: u32) -> Result<()> {
722                Ok(())
723            }
724
725            async fn batch_delete(&self) -> Result<()> {
726                Ok(())
727            }
728
729            async fn batch_trash(&self) -> Result<()> {
730                Ok(())
731            }
732        }
733
734        let mut processor = MockProcessor {
735            rule: None,
736            execute: false,
737        };
738
739        // Test initial state
740        assert!(processor.action().is_none());
741        assert!(!processor.execute);
742
743        // Test rule setting
744        let rule = create_test_rule(8, true);
745        processor.set_rule(rule);
746        assert!(processor.action().is_some());
747        assert_eq!(processor.action(), Some(EolAction::Trash));
748
749        // Test execute flag setting
750        processor.set_execute(true);
751        assert!(processor.execute);
752
753        processor.set_execute(false);
754        assert!(!processor.execute);
755    }
756}