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