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