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}