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