chore: migrate to workspace structure and 3-file CI pipeline at toolkit 4.9.6
Signed-off-by: Jeremiah Russell <jerry@jrussell.ie>
This commit is contained in:
694
crates/cull-gmail/CHANGELOG.md
Normal file
694
crates/cull-gmail/CHANGELOG.md
Normal file
@@ -0,0 +1,694 @@
|
||||
<!-- LTex: Enabled=false -->
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.4] - 2026-02-14
|
||||
|
||||
Summary: Chore[1], Continuous Integration[2]
|
||||
|
||||
## [0.1.3] - 2026-02-14
|
||||
|
||||
Summary: Chore[3], Continuous Integration[4]
|
||||
|
||||
## [0.1.2] - 2026-02-14
|
||||
|
||||
Summary: Chore[4]
|
||||
|
||||
## [0.1.1] - 2026-02-13
|
||||
|
||||
Summary: Chore[2], Continuous Integration[2]
|
||||
|
||||
## [0.1.0] - 2026-02-13
|
||||
|
||||
Summary: Added[6], Build[1], Changed[1], Chore[50], Continuous Integration[12], Fixed[62]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: add security improvements to CI (#142)
|
||||
- feat: add security improvements to CI
|
||||
- feat!: migrate to circleci-toolkit v4.2.1
|
||||
- ✨ feat(cli): restructure rules config CLI
|
||||
- ✨ feat(cli): enhance rules configuration
|
||||
- ✨ feat(cli): add optional rules path argument to cli
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix(deps): update rust crate toml to v1 (#153)
|
||||
- fix(deps): update rust crate toml to v1
|
||||
- fix(deps): update rust crate tempfile to 3.25.0 (#152)
|
||||
- fix(deps): update rust crate tempfile to 3.25.0
|
||||
- fix(deps): update rust crate lazy-regex to 3.6.0 (#151)
|
||||
- fix(deps): update rust crate lazy-regex to 3.6.0
|
||||
- fix(deps): update dependency toolkit to v4.4.2 (#150)
|
||||
- fix(deps): update dependency toolkit to v4.4.2
|
||||
- fix(deps): update rust crate toml to 0.9.12 (#149)
|
||||
- fix(deps): update rust crate toml to 0.9.12
|
||||
- fix(deps): update rust crate predicates to 3.1.4 (#148)
|
||||
- fix(deps): update rust crate predicates to 3.1.4
|
||||
- fix(deps): update rust crate httpmock to 0.8.3 (#147)
|
||||
- fix(deps): update rust crate httpmock to 0.8.3
|
||||
- fix(deps): update rust crate clap to 4.5.58 (#144)
|
||||
- fix(deps): update rust crate clap to 4.5.58
|
||||
- fix(deps): update rust crate env_logger to 0.11.9 (#145)
|
||||
- fix(deps): update rust crate env_logger to 0.11.9
|
||||
- fix(deps): update rust crate flate2 to 1.1.9 (#146)
|
||||
- fix(deps): update rust crate flate2 to 1.1.9
|
||||
- fix(deps): resolve rustls crypto provider conflict (#143)
|
||||
- fix(deps): resolve rustls crypto provider conflict
|
||||
- fix(deps): update bytes and time for security
|
||||
- fix(deps): update rust crate thiserror to 2.0.18 (#141)
|
||||
- fix(deps): update rust crate thiserror to 2.0.18
|
||||
- fix(deps): update rust crate lazy-regex to 3.5.1 (#140)
|
||||
- fix(deps): update rust crate lazy-regex to 3.5.1
|
||||
- fix(deps): update rust crate hyper-rustls to 0.27.7 (#139)
|
||||
- fix(deps): update rust crate hyper-rustls to 0.27.7
|
||||
- fix(deps): update rust crate flate2 to 1.1.8 (#138)
|
||||
- fix(deps): update rust crate flate2 to 1.1.8
|
||||
- fix(deps): update rust crate clap to 4.5.54 (#137)
|
||||
- fix(deps): update rust crate clap to 4.5.54
|
||||
- fix(deps): update rust crate chrono to 0.4.43 (#136)
|
||||
- fix(deps): update rust crate chrono to 0.4.43
|
||||
- fix(deps): update tokio packages (#132)
|
||||
- fix(deps): update tokio packages
|
||||
- fix(deps): update rust crate tempfile to 3.24.0 (#131)
|
||||
- fix(deps): update rust crate tempfile to 3.24.0
|
||||
- fix(deps): update rust crate dialoguer to 0.12.0 (#130)
|
||||
- fix(deps): update rust crate dialoguer to 0.12.0
|
||||
- fix(deps): update rust crate toml to 0.9.11 (#127)
|
||||
- fix(deps): update rust crate toml to 0.9.11
|
||||
- fix(deps): update rust crate assert_cmd to 2.1.2 (#129)
|
||||
- fix(deps): update rust crate assert_cmd to 2.1.2
|
||||
- fix(deps): update rust crate serde_json to 1.0.149 (#126)
|
||||
- fix(deps): update rust crate serde_json to 1.0.149
|
||||
- fix(deps): update rust crate log to 0.4.29 (#125)
|
||||
- fix(deps): update rust crate log to 0.4.29
|
||||
- fix: replace deprecated cargo_bin method with macro
|
||||
- fix: upgrade google-gmail1 to v7 to resolve security advisory
|
||||
- fix(deps): update rust crate temp-env to 0.3.6
|
||||
- fix(deps): update rust crate predicates to 3.1.3
|
||||
- fix(deps): update rust crate lazy-regex to 3.4.2
|
||||
- fix(deps): update rust crate httpmock to 0.8.2
|
||||
- fix(deps): update rust crate futures to 0.3.31
|
||||
- fix(deps): update rust crate flate2 to 1.1.5
|
||||
- fix(deps): update rust crate config to 0.15.19
|
||||
- fix(deps): update rust crate clap to 4.5.53
|
||||
- fix(deps): update rust crate base64 to 0.22.1
|
||||
- fix(deps): update rust crate assert_fs to 1.1.3
|
||||
- 🐛 fix(client): fix config root parsing
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(cli): rename rule management subcommands for clarity
|
||||
|
||||
## [0.0.16] - 2025-10-30
|
||||
|
||||
Summary: Added[3], Changed[4], Chore[10], Documentation[2], Fixed[7]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(rules): support multiple actions per label
|
||||
- ✨ feat(rule_processor): implement batch operations for message deletion and trashing
|
||||
- ✨ feat(rule_processor): add initialise_message_list to processor
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(rule_processor): enhance logging for chunk processing
|
||||
- 🐛 fix(cli): correct rule execution order for trash and delete
|
||||
- 🐛 fix(gmail): use GMAIL_DELETE_SCOPE for batch delete
|
||||
- 🐛 fix(cli): correct logging level
|
||||
- 🐛 fix(eol_rule): correct calculate_for_date and add logging
|
||||
- 🐛 fix(rules): correct grammar and improve date calculation
|
||||
- 🐛 fix(gmail): handle batch delete errors
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(rules): execute rules by action
|
||||
- ♻️ refactor(gmail): consolidate batch processing logic
|
||||
- ♻️ refactor(rule_processor): enhance Gmail message handling with chunk processing
|
||||
- ♻️ refactor(core): rename initialise_message_list to initialise_lists
|
||||
|
||||
## [0.0.15] - 2025-10-26
|
||||
|
||||
Summary: Changed[1], Chore[2], Documentation[1], Fixed[3]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(cli): fix log messages with empty arguments
|
||||
- 🐛 fix(cli): prevent dry-run from crashing
|
||||
- 🐛 fix(rule_processor): fix batch_trash and batch_delete signatures
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(message_list): allow pre/post text in log_messages
|
||||
|
||||
## [0.0.14] - 2025-10-23
|
||||
|
||||
Summary: Added[2], Chore[7], Fixed[2]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): add token and auth uri config options
|
||||
- ✨ feat(config): load application secret with logging
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(config): reduce log verbosity
|
||||
- 🐛 fix(config): improve config logging format
|
||||
|
||||
## [0.0.13] - 2025-10-22
|
||||
|
||||
Summary: Added[1], Chore[2], Fixed[5]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): enhance configuration loading with logging
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(cli): load config file only if it exists
|
||||
- 🐛 fix(cli): fix config file loading
|
||||
- 🐛 fix(client_config): print config for debugging
|
||||
- 🐛 fix(cli): correct spelling errors in documentation
|
||||
- 🐛 fix(cli): load config file as optional
|
||||
|
||||
## [0.0.12] - 2025-10-22
|
||||
|
||||
Summary: Added[6], Build[1], Changed[2], Chore[7], Documentation[1], Fixed[6], Testing[2]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat: integrate configurable rules path throughout CLI
|
||||
- ✨ feat: add get_rules_from() to load rules from custom path
|
||||
- ✨ feat: add configurable rules directory support to Rules and InitCli
|
||||
- 🏗️ feat(init): implement plan and apply operations
|
||||
- ✨ feat(cli): scaffold InitCli subcommand and clap wiring
|
||||
- 🔐 feat: Add token export/import for ephemeral environments
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(ci): correct default test runner value
|
||||
- 🔧 fix: address clippy warnings after refactoring
|
||||
- 🐛 fix: allow init command to run without existing config file
|
||||
- 🐛 fix: replace hardcoded paths in tests with temp directories for CI compatibility
|
||||
- 🔧 fix: address clippy warnings and improve code formatting
|
||||
- 🔧 fix: Resolve clippy warnings and formatting issues
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor: reduce cognitive complexity of plan_operations and execute_operation
|
||||
- ♻️ refactor: extract mock credential file creation into helper function
|
||||
|
||||
## [0.0.11] - 2025-10-20
|
||||
|
||||
Summary: Added[7], Changed[7], Chore[13], Continuous Integration[5], Documentation[24], Fixed[7], Testing[12]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(test): add junit report
|
||||
- ✨ feat(ci): introduce nextest test runner
|
||||
- ✨ feat(retention): enhance message age with parsing and validation
|
||||
- ✨ feat(retention): implement retention policy configuration
|
||||
- ✨ feat(error): add invalid message age error
|
||||
- ✨ feat(retention): introduce message age specification
|
||||
- ✨ feat(retention): enhance retention policy configuration
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(rule_processor): correct spelling of "behaviour"
|
||||
- ✅ fix(message-list): improve idioms (avoid redundant clone, extend labels, safer message extraction)
|
||||
- ✅ fix(clippy): move tests module to file end to satisfy items_after_test_module lint
|
||||
- 🐛 fix(retention): fix debug string formatting in retention struct
|
||||
- 🐛 fix(cli): correct error mapping in add_cli
|
||||
- 🐛 fix(rules): handle message age creation error
|
||||
- 🐛 fix(build): correct readme generation script
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor: remove redundant credential module
|
||||
- ♻️ refactor(message-list): introduce GmailService abstraction and refactor to use it; fix borrows and lifetimes
|
||||
- ♻️ refactor(message-list): extract helper to append messages from ListMessagesResponse and add unit test
|
||||
- ♻️ refactor(rule_processor): extract process_label and add internal ops trait for unit testing
|
||||
- ♻️ refactor(rule_processor): add TRASH_LABEL, correct Gmail scopes, early returns, and improve idioms
|
||||
- refactor(rules): apply idiomatic patterns and resolve clippy warnings
|
||||
- refactor(rules): replace unwrap() with explicit error handling and propagate errors safely
|
||||
|
||||
## [0.0.10] - 2025-10-16
|
||||
|
||||
Summary: Added[11], Changed[15], Chore[12], Fixed[3]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): add default subcommand for rule execution
|
||||
- ✨ feat(config): implement config builder pattern for ClientConfig
|
||||
- ✨ feat(cli): load configurations from toml file
|
||||
- ✨ feat(client_config): add config root parsing with regex
|
||||
- ✨ feat(utils): add test utils module
|
||||
- ✨ feat(deps): add lazy-regex crate
|
||||
- ✨ feat(dependencies): add lazy-regex dependency
|
||||
- ✨ feat(config): add ConfigRoot enum for flexible path handling
|
||||
- ✨ feat(core): add client config
|
||||
- ✨ feat(config): introduce client configuration
|
||||
- ✨ feat(cli): add config file support
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(gmail): fix token persistence path
|
||||
- 🐛 fix(config): resolve credential file path issue
|
||||
- 🐛 fix(rule_processor): update Gmail API scope
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(cli): extract action execution into a function
|
||||
- ♻️ refactor(cli): rename get_config to get_rules
|
||||
- ♻️ refactor(cli): extract rule execution to separate function
|
||||
- ♻️ refactor(config): improve ConfigRoot to handle different root types
|
||||
- ♻️ refactor(utils): improve config directory creation
|
||||
- ♻️ refactor(cli): use ClientConfig struct for gmail client
|
||||
- ♻️ refactor(gmail): use client config for gmail client
|
||||
- ♻️ refactor(rules): remove credentials config
|
||||
- ♻️ refactor(cli): remove config from run args
|
||||
- ♻️ refactor(eol_rule): improve labels handling
|
||||
- ♻️ refactor(cli): remove redundant Rules import
|
||||
- ♻️ refactor: rename Config to Rules
|
||||
- ♻️ refactor(cli): restructure cli commands for better organization
|
||||
- ♻️ refactor(message_list): rename messages_list to list_messages
|
||||
- ♻️ refactor(rule_processor): remove unused delete functions
|
||||
|
||||
## [0.0.9] - 2025-10-14
|
||||
|
||||
Summary: Added[5], Changed[3], Chore[2], Fixed[2]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(gmail_client): add date to message summary
|
||||
- ✨ feat(gmail): enhance message metadata retrieval
|
||||
- ✨ feat(cli): enhance cli subcommand ordering and grouping
|
||||
- ✨ feat(cli): add message list subcommand
|
||||
- ✨ feat(cli): add configuration options for message listing
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(gmail_client): resolve ownership issue in message summary
|
||||
- 🐛 fix(gmail): display message date and subject
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(cli): rename run_cli to rules_cli
|
||||
- ♻️ refactor(cli): consolidate message handling and remove delete command
|
||||
- ♻️ refactor(cli): refactor message handling and remove trash command
|
||||
|
||||
## [0.0.8] - 2025-10-14
|
||||
|
||||
Summary: Added[14], Changed[42], Chore[3], Documentation[2], Fixed[5]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): create message trait to share list parameters
|
||||
- ✨ feat(cli): add message trait for cli subcommands
|
||||
- ✨ feat(cli): implement batch actions for trashing and deleting
|
||||
- ✨ feat(rule_processor): implement rule processing for Gmail
|
||||
- ✨ feat(gmail_client): add execute flag and EolRule
|
||||
- ✨ feat(processor): add execute flag to GmailClient
|
||||
- ✨ feat(gmail_client): add rule field to GmailClient struct - Add rule field to GmailClient struct to store EolAction.
|
||||
- ✨ feat(eol_action): add clone derive to eolaction enum
|
||||
- ✨ feat(message_list): enhance message list trait with documentation and functionalities
|
||||
- ✨ feat(core): add message management structs
|
||||
- ✨ feat(gmail_client): integrate message summary
|
||||
- ✨ feat(gmail): create gmail client struct
|
||||
- ✨ feat(gmail): add get messages functionality
|
||||
- ✨ feat(error): add NoLabelsFound error
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(cli): correct label adding to use non-async function
|
||||
- 🐛 fix(rule_processor): fix label creation and message retrieval
|
||||
- 🐛 fix(cli): fix rule execution and client handling
|
||||
- 🐛 fix(trash): fix trash command with new gmail client
|
||||
- 🐛 fix(cli): fix delete command
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(cli): streamline message retrieval and parameter setting
|
||||
- ♻️ refactor(cli): extract parameter setting logic
|
||||
- ♻️ refactor(message_list): rename run to get_messages
|
||||
- ♻️ refactor(cli): remove unused `Delete` import
|
||||
- ♻️ refactor(cli): remove unused Delete, Trash trait - Remove Delete and Trash traits from cull_gmail - Use RuleProcessor instead of Delete and Trash traits
|
||||
- ♻️ refactor(core): remove processor.rs
|
||||
- ♻️ refactor(message): remove delete functionality
|
||||
- ♻️ refactor(core): restructure modules for clarity
|
||||
- ♻️ refactor(processor): implement RuleProcessor trait for GmailClient
|
||||
- ♻️ refactor(cli): rename Processor to RuleProcessor
|
||||
- ♻️ refactor(cli): use mutable client for subcommands
|
||||
- ♻️ refactor(core): rename Processor to RuleProcessor
|
||||
- ♻️ refactor(message_cli): simplify message processing
|
||||
- ♻️ refactor(delete): streamline delete command execution
|
||||
- ♻️ refactor(gmail_client): change MessageSummary's visibility
|
||||
- ♻️ refactor(processor): simplify trash_messages function
|
||||
- ♻️ refactor(core): remove unused trash module
|
||||
- ♻️ refactor(trash): refactor trash module to trait implementation
|
||||
- ♻️ refactor(message_list): remove client parameter from add_labels
|
||||
- ♻️ refactor(delete): restructure delete functionality
|
||||
- ♻️ refactor(core): remove unused Delete module - Delete module is no longer needed.
|
||||
- ♻️ refactor(processor): consolidate message operations in GmailClient
|
||||
- ♻️ refactor(gmail_client): move message_summary to gmail_client
|
||||
- ♻️ refactor(message_list): implement MessageList trait for GmailClient
|
||||
- ♻️ refactor(cli): use GmailClient instead of credential file
|
||||
- ♻️ refactor(cli): use client for trash subcommand
|
||||
- ♻️ refactor(cli): use gmail client in run_cli
|
||||
- ♻️ refactor(cli): pass client to run command
|
||||
- ♻️ refactor(processor): use reference for GmailClient in processor builder
|
||||
- ♻️ refactor(cli): use client instance for message subcommand
|
||||
- ♻️ refactor(cli): use GmailClient for MessageList
|
||||
- ♻️ refactor(cli): use GmailClient in delete_cli
|
||||
- ♻️ refactor(cli): use gmail client for label operations
|
||||
- ♻️ refactor(trash): use GmailClient instead of credential string
|
||||
- ♻️ refactor(delete): use GmailClient for message list creation
|
||||
- ♻️ refactor(message_list): update add_labels function to accept &GmailClient
|
||||
- ♻️ refactor(gmail): improve gmail client structure
|
||||
- ♻️ refactor(processor): use GmailClient instead of credential_file
|
||||
- ♻️ refactor(cli): remove unused credential file
|
||||
- ♻️ refactor(message_list): use gmail client for label retrieval
|
||||
- ♻️ refactor(core): rename labels module to gmail_client
|
||||
- ♻️ refactor(gmail): rename labels.rs to gmail_client.rs
|
||||
|
||||
## [0.0.7] - 2025-10-12
|
||||
|
||||
Summary: Added[23], Build[1], Changed[8], Chore[5], Documentation[3], Fixed[10]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(processor): introduce processor builder
|
||||
- ✨ feat(cli): add execute option to processor
|
||||
- ✨ feat(processor): add execute flag for dry run
|
||||
- ✨ feat(cli): add execute flag to run action
|
||||
- ✨ feat(message_list): increase default max results
|
||||
- ✨ feat(cli): add skip action flags to cli
|
||||
- ✨ feat(cli): add skip-delete flag to cli
|
||||
- ✨ feat(cli): add option to skip trash actions
|
||||
- ✨ feat(config): add date calculation for EOL queries
|
||||
- ✨ feat(config): add retention period to eol rule
|
||||
- ✨ feat(processor): add label existence check before processing
|
||||
- ✨ feat(processor): add trash and delete message functionality
|
||||
- ✨ feat(cli): implement trash and delete actions
|
||||
- ✨ feat(processor): implement message deletion functionality
|
||||
- ✨ feat(config): add eol query function
|
||||
- ✨ feat(cli): add chrono crate as a dependency
|
||||
- ✨ feat(core): introduce message processor module
|
||||
- ✨ feat(processor): implement rule processor
|
||||
- ✨ feat(eol_rule): add describe function for eol rule
|
||||
- ✨ feat(cli): implement rule execution logic
|
||||
- ✨ feat(eol_action): add parse method to EolAction
|
||||
- ✨ feat(cli): add run command to execute rules
|
||||
- ✨ feat(cli): add run cli command
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(utils): correct string elision boundary calculation
|
||||
- 🐛 fix(utils): correct string elision boundary calculation
|
||||
- 🐛 fix(error): correct spelling error in error message
|
||||
- 🐛 fix(processor): correct typo in error message
|
||||
- 🐛 fix(processor): execute delete messages
|
||||
- 🐛 fix(message_age): correct data type for message age count
|
||||
- 🐛 fix(cli): correct count type in add_cli
|
||||
- 🐛 fix(processor): handle None query in eol_query
|
||||
- 🐛 fix(error): add error type for no query string calculated
|
||||
- 🐛 fix(error): add specific error for missing label in mailbox - add `LableNotFoundInMailbox` error to handle cases where a label is not found in the mailbox
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(utils): remove unused `get_start_boundary` function
|
||||
- ♻️ refactor(cli): extract action execution to separate function
|
||||
- ♻️ refactor(config): extract common logic to reduce duplication
|
||||
- ♻️ refactor(eol_rule): simplify eol_rule tests
|
||||
- ♻️ refactor(trash): refactor trash command
|
||||
- ♻️ refactor(trash): separate trash preparation and execution
|
||||
- ♻️ refactor(config): make EolRule public
|
||||
- ♻️ refactor(cli): inject config into run command
|
||||
|
||||
## [0.0.6] - 2025-10-09
|
||||
|
||||
Summary: Added[23], Changed[26], Chore[12], Fixed[7]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): add delete subcommand
|
||||
- ✨ feat(cli): add delete subcommand
|
||||
- ✨ feat(gh-release): add delete module
|
||||
- ✨ feat(delete): implement batch delete functionality
|
||||
- ✨ feat(message_list): add label support
|
||||
- ✨ feat(message): add label support to message listing
|
||||
- ✨ feat(rules_cli): implement add command for managing retention rules
|
||||
- ✨ feat(cli): add remove label subcommand
|
||||
- ✨ feat(cli): add list labels subcommand
|
||||
- ✨ feat(label): implement add label command
|
||||
- ✨ feat(config): add functionality to set action on rule
|
||||
- ✨ feat(cli): add action subcommand
|
||||
- ✨ feat(config_cli): implement action subcommand
|
||||
- ✨ feat(config): add remove label from rule
|
||||
- ✨ feat(config): add label functionality to rules
|
||||
- ✨ feat(error): add RuleNotFound error
|
||||
- ✨ feat(config): add get_rule function to retrieve existing rules
|
||||
- ✨ feat(cli): implement commands dispatching
|
||||
- ✨ feat(label_cli): implement remove label subcommand
|
||||
- ✨ feat(label_cli): implement label listing subcommand
|
||||
- ✨ feat(label): implement add label subcommand
|
||||
- ✨ feat(cli): implement label subcommand
|
||||
- ✨ feat(config): add cli config - introduce cli config with clap - add subcommand rules and label
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(config): correct typo in eol_cmd module name
|
||||
- 🐛 fix(eol_rule): correct grammar in rule descriptions
|
||||
- 🐛 fix(config): correct grammar in EolRule display
|
||||
- 🐛 fix(remove_cli): handle rule not found when removing label
|
||||
- 🐛 fix(label_cli): fix add label logic
|
||||
- 🐛 fix(cli): correct output format for label list
|
||||
- 🐛 fix(label_cli): display labels by rule id
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(trash): encapsulate message list operations
|
||||
- ♻️ refactor(cli): improve delete command structure
|
||||
- ♻️ refactor(trash): encapsulate message list
|
||||
- ♻️ refactor(delete): rename struct and methods for deleting messages
|
||||
- ♻️ refactor(trash): streamline label handling in trash listing
|
||||
- ♻️ refactor(utils): improve config directory handling
|
||||
- ♻️ refactor(labels): simplify error handling in labels module
|
||||
- ♻️ refactor(trash): simplify error handling and label management
|
||||
- ♻️ refactor(cli): move rm_cli to new directory
|
||||
- ♻️ refactor(cli): move rules_cli to config_cli
|
||||
- ♻️ refactor(cli): rename label_cli module
|
||||
- ♻️ refactor(cli): rename action_cli module
|
||||
- ♻️ refactor(cli): rename trash_cli to cli
|
||||
- ♻️ refactor(cli): rename message_cli to cli
|
||||
- ♻️ refactor(cli): move label_cli to cli directory
|
||||
- ♻️ refactor(cli): move config_cli to cli directory
|
||||
- ♻️ refactor(cli): move main.rs to cli folder - move main.rs to cli folder for better structure
|
||||
- ♻️ refactor(project): move main.rs to cli directory
|
||||
- ♻️ refactor(cli): rename command to sub_command for clarity
|
||||
- ♻️ refactor(core): rename eol_cmd module to eol_action
|
||||
- ♻️ refactor(core): rename eol_cmd to eol_action - clarifies the file's purpose as defining actions related to EOL handling rather than just commands
|
||||
- ♻️ refactor(config): make EolRule fields public
|
||||
- ♻️ refactor(cli): restructure rules CLI
|
||||
- ♻️ refactor(cli): rename add_cli to rules_cli
|
||||
- ♻️ refactor(cli): rename rm_cli to rules_cli
|
||||
- ♻️ refactor(cli): consolidate rules and labels under config subcommand
|
||||
|
||||
## [0.0.5] - 2025-10-08
|
||||
|
||||
Summary: Added[28], Build[1], Changed[6], Chore[16], Documentation[5], Fixed[10]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(cli): implement trace logging for configuration
|
||||
- ✨ feat(rules_cli): implement rule removal
|
||||
- ✨ feat(lib): introduce Result type alias for error handling
|
||||
- ✨ feat(error): add custom error types for rule selection
|
||||
- ✨ feat(config): enhance rule management and label handling
|
||||
- ✨ feat(rules_cli): implement rm_cli subcommand
|
||||
- ✨ feat(rules_cli): add remove command to rules cli
|
||||
- ✨ feat(rules_cli): add option to immediately delete rules
|
||||
- ✨ feat(config): add delete flag for retention rules
|
||||
- ✨ feat(rules_cli): add optional label for retention rules
|
||||
- ✨ feat(config): add labels method to EolRule
|
||||
- ✨ feat(config): add support for labels to retention rules
|
||||
- ✨ feat(config): add retention attribute to EolRule
|
||||
- ✨ feat(config): enhance rule management with BTreeMap
|
||||
- ✨ feat(rules_cli): implement add command
|
||||
- ✨ feat(retention): add message age enum creation
|
||||
- ✨ feat(rules): add subcommand for rule management
|
||||
- ✨ feat(config): add result type to list_rules function
|
||||
- ✨ feat(config): implement display for eolrule struct
|
||||
- ✨ feat(config): add function to list rules
|
||||
- ✨ feat(config): implement configuration file management
|
||||
- ✨ feat(retention): introduce message age enum
|
||||
- ✨ feat(config): add EolRule struct for managing end-of-life rules
|
||||
- ✨ feat(retention): implement data retention policy
|
||||
- ✨ feat(cli): load configuration for message command
|
||||
- ✨ feat(lib): add config and retention modules
|
||||
- ✨ feat(eol_cmd): introduce EolCmd enum for message disposal
|
||||
- ✨ feat(build): add toml dependency
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(rm_cli): rule removal save
|
||||
- 🐛 fix(config): improve rule removal and logging
|
||||
- 🐛 fix(error): improve error message for missing labels
|
||||
- 🐛 fix(error): refine error message for rule selector
|
||||
- 🐛 fix(eol_rule): correct rule description in to_string method
|
||||
- 🐛 fix(rules): fix config_cli.run to return a Result
|
||||
- 🐛 fix(config): correct pluralization of time periods in EolRule display
|
||||
- 🐛 fix(message_age): correct retention label formatting
|
||||
- 🐛 fix(ui): correct grammar errors in eol command and trash messages
|
||||
- 🐛 fix(error): refine error handling with granular variants
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(config): use string keys for rules in config
|
||||
- ♻️ refactor(config): enhance EolRule for label management
|
||||
- ♻️ refactor(config): rename EolCmd to EolAction for clarity
|
||||
- ♻️ refactor(core): rename EolCmd to EolAction
|
||||
- ♻️ refactor(cli): restructure cli commands and config handling
|
||||
- ♻️ refactor(cli): rename config_cli to rules_cli
|
||||
|
||||
## [0.0.4] - 2025-10-07
|
||||
|
||||
Summary: Added[9], Changed[7], Chore[8]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(message_list): create message summary struct
|
||||
- ✨ feat(utils): implement string elision trait
|
||||
- ✨ feat(message_list): improve message handling and logging
|
||||
- ✨ feat(trash): implement trash functionality
|
||||
- ✨ feat(trash): add trash cli
|
||||
- ✨ feat(cli): add trash command
|
||||
- ✨ feat(message_list): enhance message list functionality and debugging
|
||||
- ✨ feat(lib): add trash module for moving messages to trash
|
||||
- ✨ feat(message_list): add message_ids to MessageList struct
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(trash): improve trash operation logging
|
||||
- ♻️ refactor(message): rename Message to MessageList
|
||||
- ♻️ refactor(core): rename message module to message_list
|
||||
- ♻️ refactor(message): rename message to message_list
|
||||
- ♻️ refactor(labels): remove unused code
|
||||
- ♻️ refactor(labels): improve label listing and mapping
|
||||
- ♻️ refactor(message): improve subject logging with early returns
|
||||
|
||||
## [0.0.3] - 2025-10-04
|
||||
|
||||
Summary: Added[7], Changed[6], Chore[5], Fixed[1]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(message): implement message listing functionality
|
||||
- ✨ feat(cli): add label listing subcommand
|
||||
- ✨ feat(labels): add show option to display labels
|
||||
- ✨ feat(cli): add label command-line interface
|
||||
- ✨ feat(cli): add query option to list command
|
||||
- ✨ feat(list): add query support to list messages - allow users to filter messages using a query string - implement set_query method to set the query - add query parameter to the Gmail API call
|
||||
- ✨ feat(list): add label filtering to list command
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(list): fix label creation logic
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(cli): rename list subcommand to message
|
||||
- ♻️ refactor(cli): rename list_cli to message_cli
|
||||
- 🔥 refactor(core): remove list module
|
||||
- ♻️ refactor(core): rename list module to message
|
||||
- ♻️ refactor(labels): simplify labels struct initialization
|
||||
- ♻️ refactor(labels): simplify and optimize label retrieval - rename function name `add_label` to `add_labels` - add the function of adding multiple labels at once - optimize code for streamlined operation
|
||||
|
||||
## [0.0.2] - 2025-10-03
|
||||
|
||||
Summary: Added[26], Build[6], Changed[6], Chore[17], Continuous Integration[1], Documentation[1], Fixed[3], Security[1]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(list): add label filtering to list command
|
||||
- ✨ feat(list): add label filtering capability
|
||||
- ✨ feat(core): add Labels struct
|
||||
- ✨ feat(labels): create labels module to manage Gmail labels
|
||||
- ✨ feat(list): add pagination to list command
|
||||
- ✨ feat(list): add pagination support for listing messages
|
||||
- ✨ feat(error): add error type for invalid paging mode
|
||||
- ✨ feat(list): add max results option to list command
|
||||
- ✨ feat(list): export DEFAULT_MAX_RESULTS constant
|
||||
- ✨ feat(error): enhance error handling for configuration issues
|
||||
- ✨ feat(core): add utils module
|
||||
- ✨ feat(utils): create assure_config_dir_exists function
|
||||
- ✨ feat(gmail): implement list functionality for Gmail API
|
||||
- ✨ feat(lib): add error module and export it
|
||||
- ✨ feat(error): introduce custom error enum for cull-gmail
|
||||
- ✨ feat(list): implement list api to retrieve gmail messages
|
||||
- ✨ feat(list): integrate List struct for message listing
|
||||
- ✨ feat(list): export List struct in lib.rs
|
||||
- ✨ feat(cli): add list subcommand
|
||||
- ✨ feat(core): add client and credential modules
|
||||
- ✨ feat(list): add list module - creates a new list module
|
||||
- ✨ feat(credential): implement credential loading and conversion
|
||||
- ✨ feat(gmail): add gmail client
|
||||
- ✨ feat(cli): implement list subcommand
|
||||
- ✨ feat(cli): add command line interface with logging
|
||||
- ✨ feat(main): add initial main function with hello world
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 fix(main): exit process with error code on failure
|
||||
- 🐛 fix(list): remove debug print statement
|
||||
- 🐛 fix(credential): fix the config directory
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️ refactor(list): improve max results handling
|
||||
- ♻️ refactor(gmail): remove unused client file
|
||||
- ♻️ refactor(lib): restructure module exports and visibility
|
||||
- ♻️ refactor(list): improve error handling and config loading
|
||||
- ♻️ refactor(list): refactor list command to accept credential file
|
||||
- ♻️ refactor(main): improve error handling and logging
|
||||
|
||||
### Security
|
||||
|
||||
- 🔧 chore(deps): remove unused dependencies
|
||||
|
||||
## [0.0.1] - 2025-09-30
|
||||
|
||||
Summary: Added[4], Build[3], Chore[21], Continuous Integration[4], Documentation[7]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ feat(lib): add addition function with test
|
||||
- ✨ feat(assets): add new logo and splash screen
|
||||
- ✨ feat(vscode): add custom dictionary entry for ltex
|
||||
- ✨ feat(project): add initial Cargo.toml for cull-gmail tool
|
||||
|
||||
[Unreleased]: https://github.com/jerus-org/cull-gmail/compare/v0.1.3...HEAD
|
||||
[0.1.3]: https://github.com/jerus-org/cull-gmail/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/jerus-org/cull-gmail/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/jerus-org/cull-gmail/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/jerus-org/cull-gmail/compare/v0.0.16...v0.1.0
|
||||
[0.0.16]: https://github.com/jerus-org/cull-gmail/compare/v0.0.15...v0.0.16
|
||||
[0.0.15]: https://github.com/jerus-org/cull-gmail/compare/v0.0.14...v0.0.15
|
||||
[0.0.14]: https://github.com/jerus-org/cull-gmail/compare/v0.0.13...v0.0.14
|
||||
[0.0.13]: https://github.com/jerus-org/cull-gmail/compare/v0.0.12...v0.0.13
|
||||
[0.0.12]: https://github.com/jerus-org/cull-gmail/compare/v0.0.11...v0.0.12
|
||||
[0.0.11]: https://github.com/jerus-org/cull-gmail/compare/v0.0.10...v0.0.11
|
||||
[0.0.10]: https://github.com/jerus-org/cull-gmail/compare/v0.0.9...v0.0.10
|
||||
[0.0.9]: https://github.com/jerus-org/cull-gmail/compare/v0.0.8...v0.0.9
|
||||
[0.0.8]: https://github.com/jerus-org/cull-gmail/compare/v0.0.7...v0.0.8
|
||||
[0.0.7]: https://github.com/jerus-org/cull-gmail/compare/v0.0.6...v0.0.7
|
||||
[0.0.6]: https://github.com/jerus-org/cull-gmail/compare/v0.0.5...v0.0.6
|
||||
[0.0.5]: https://github.com/jerus-org/cull-gmail/compare/v0.0.4...v0.0.5
|
||||
[0.0.4]: https://github.com/jerus-org/cull-gmail/compare/v0.0.3...v0.0.4
|
||||
[0.0.3]: https://github.com/jerus-org/cull-gmail/compare/v0.0.2...v0.0.3
|
||||
[0.0.2]: https://github.com/jerus-org/cull-gmail/compare/v0.0.1...v0.0.2
|
||||
[0.0.1]: https://github.com/jerus-org/cull-gmail/releases/tag/v0.0.1
|
||||
|
||||
62
crates/cull-gmail/Cargo.toml
Normal file
62
crates/cull-gmail/Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "cull-gmail"
|
||||
description = "Cull emails from a gmail account using the gmail API"
|
||||
version = "0.1.4"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
readme = "README.md"
|
||||
include = [
|
||||
"**/*.rs",
|
||||
"Cargo.toml",
|
||||
"README.md",
|
||||
"LICENSE-MIT",
|
||||
"LICENSE-APACHE",
|
||||
"CHANGELOG.md",
|
||||
"docs",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
clap-verbosity-flag.workspace = true
|
||||
config.workspace = true
|
||||
dialoguer.workspace = true
|
||||
env_logger.workspace = true
|
||||
flate2.workspace = true
|
||||
google-gmail1.workspace = true
|
||||
hyper-rustls.workspace = true
|
||||
indicatif.workspace = true
|
||||
lazy-regex.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
assert_fs.workspace = true
|
||||
futures.workspace = true
|
||||
httpmock.workspace = true
|
||||
predicates.workspace = true
|
||||
temp-env.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio-test.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
name = "cull_gmail"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "cull-gmail"
|
||||
path = "src/cli/main.rs"
|
||||
258
crates/cull-gmail/docs/lib/lib.md
Normal file
258
crates/cull-gmail/docs/lib/lib.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# cull-gmail Library Documentation
|
||||
|
||||
The `cull-gmail` library provides a Rust API for managing Gmail messages through the Gmail API. It enables programmatic email culling operations including authentication, message querying, filtering, and batch operations (trash/delete).
|
||||
|
||||
## Installation
|
||||
|
||||
Add the library to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
cull-gmail = "0.0.10"
|
||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example to get started:
|
||||
|
||||
```rust
|
||||
# // This is a compile-only test since it requires OAuth credentials
|
||||
use cull_gmail::{ClientConfig, GmailClient, MessageList, Result};
|
||||
|
||||
// Example of how to set up the client (requires valid OAuth credentials)
|
||||
async fn setup_client() -> Result<GmailClient> {
|
||||
let config = ClientConfig::builder()
|
||||
.with_client_id("your-client-id")
|
||||
.with_client_secret("your-client-secret")
|
||||
.build();
|
||||
|
||||
let mut client = GmailClient::new_with_config(config).await?;
|
||||
|
||||
// Configure message listing
|
||||
client.set_max_results(10);
|
||||
// client.get_messages(1).await?;
|
||||
// client.log_messages().await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
```
|
||||
|
||||
## Core Types
|
||||
|
||||
### GmailClient
|
||||
|
||||
The main client for interacting with Gmail API:
|
||||
|
||||
```rust
|
||||
# use cull_gmail::Result;
|
||||
# use cull_gmail::{GmailClient, MessageList, ClientConfig};
|
||||
#
|
||||
# async fn example() -> Result<()> {
|
||||
# let config = ClientConfig::builder().with_client_id("test").with_client_secret("test").build();
|
||||
|
||||
// Create client with configuration
|
||||
let mut client = GmailClient::new_with_config(config).await?;
|
||||
|
||||
// Query messages with Gmail search syntax
|
||||
client.set_query("older_than:1y label:promotions");
|
||||
client.add_labels(&["INBOX".to_string()])?;
|
||||
client.set_max_results(200);
|
||||
|
||||
// Get messages (0 = all pages, 1 = first page only)
|
||||
// client.get_messages(0).await?;
|
||||
|
||||
// Access message data
|
||||
let messages = client.messages();
|
||||
let message_ids = client.message_ids();
|
||||
# Ok(())
|
||||
# }
|
||||
# fn main() {}
|
||||
```
|
||||
|
||||
### ClientConfig
|
||||
|
||||
Handles authentication and configuration:
|
||||
|
||||
```rust
|
||||
use cull_gmail::ClientConfig;
|
||||
|
||||
// From individual OAuth2 parameters (recommended for doctests)
|
||||
let config = ClientConfig::builder()
|
||||
.with_client_id("your-client-id")
|
||||
.with_client_secret("your-client-secret")
|
||||
.with_auth_uri("https://accounts.google.com/o/oauth2/auth")
|
||||
.with_token_uri("https://oauth2.googleapis.com/token")
|
||||
.add_redirect_uri("http://localhost:8080")
|
||||
.build();
|
||||
|
||||
// Configuration with file paths (requires actual files)
|
||||
// let config = ClientConfig::builder()
|
||||
// .with_credential_file("path/to/credential.json")
|
||||
// .with_config_path(".cull-gmail")
|
||||
// .build();
|
||||
```
|
||||
|
||||
### Rules and Retention Policies
|
||||
|
||||
Define automated message lifecycle rules:
|
||||
|
||||
```rust
|
||||
use cull_gmail::{Rules, Retention, MessageAge, EolAction};
|
||||
|
||||
# use cull_gmail::Result;
|
||||
# fn main() -> Result<()> {
|
||||
|
||||
// Create a rule set
|
||||
let mut rules = Rules::new();
|
||||
|
||||
// Add retention rules
|
||||
rules.add_rule(
|
||||
Retention::new(MessageAge::Years(1), true),
|
||||
Some(&"old-emails".to_string()),
|
||||
false // false = trash, true = delete
|
||||
);
|
||||
|
||||
rules.add_rule(
|
||||
Retention::new(MessageAge::Months(6), true),
|
||||
Some(&"promotions".to_string()),
|
||||
false
|
||||
);
|
||||
|
||||
# let home = std::env::home_dir().unwrap();
|
||||
# let dir = home.join(".cull-gmail");
|
||||
# let _ = std::fs::create_dir(dir);
|
||||
|
||||
// Save rules to file
|
||||
rules.save()?;
|
||||
|
||||
// Load existing rules
|
||||
let loaded_rules = Rules::load()?;
|
||||
# Ok(())
|
||||
# }
|
||||
```
|
||||
|
||||
### Message Operations
|
||||
|
||||
Batch operations on messages:
|
||||
|
||||
```rust
|
||||
# use cull_gmail::{RuleProcessor, EolAction, GmailClient, Rules, ClientConfig, MessageAge, Retention, Result, MessageList};
|
||||
#
|
||||
# async fn example() -> Result<()> {
|
||||
# let config = ClientConfig::builder().with_client_id("test").with_client_secret("test").build();
|
||||
# let mut client = GmailClient::new_with_config(config).await?;
|
||||
# let mut rules = Rules::new();
|
||||
# rules.add_rule(Retention::new(MessageAge::Years(1), true), Some(&"test".to_string()), false);
|
||||
use cull_gmail::{RuleProcessor, EolAction};
|
||||
|
||||
// Set up rule and dry-run mode
|
||||
client.set_execute(false); // Dry run - no actual changes
|
||||
let rule = rules.get_rule(1).unwrap();
|
||||
client.set_rule(rule);
|
||||
|
||||
// Find messages matching rule for a label would require network access
|
||||
// client.find_rule_and_messages_for_label("promotions").await?;
|
||||
|
||||
// Check what action would be performed
|
||||
if let Some(action) = client.action() {
|
||||
match action {
|
||||
EolAction::Trash => println!("Would move {} messages to trash", client.messages().len()),
|
||||
EolAction::Delete => println!("Would delete {} messages permanently", client.messages().len()),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute operations (commented out for doctest)
|
||||
// client.set_execute(true);
|
||||
// match client.action() {
|
||||
// Some(EolAction::Trash) => client.batch_trash().await?,
|
||||
// Some(EolAction::Delete) => client.batch_delete().await?,
|
||||
// None => println!("No action specified"),
|
||||
// }
|
||||
# Ok(())
|
||||
# }
|
||||
# fn main() {}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### OAuth2 Setup
|
||||
|
||||
1. Create OAuth2 credentials in [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Download the credential JSON file
|
||||
3. Configure the client:
|
||||
|
||||
```rust
|
||||
use cull_gmail::ClientConfig;
|
||||
|
||||
// Build config with OAuth parameters (recommended for tests)
|
||||
let config = ClientConfig::builder()
|
||||
.with_client_id("your-client-id")
|
||||
.with_client_secret("your-client-secret")
|
||||
.build();
|
||||
|
||||
// Or from credential file (requires actual file)
|
||||
// let config = ClientConfig::builder()
|
||||
// .with_credential_file("path/to/credential.json")
|
||||
// .build();
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
The library supports TOML configuration files (default: `~/.cull-gmail/cull-gmail.toml`):
|
||||
|
||||
```toml
|
||||
credentials = "credential.json"
|
||||
config_root = "~/.cull-gmail"
|
||||
rules = "rules.toml"
|
||||
execute = false
|
||||
|
||||
# Alternative: direct OAuth2 parameters
|
||||
# client_id = "your-client-id"
|
||||
# client_secret = "your-client-secret"
|
||||
# token_uri = "https://oauth2.googleapis.com/token"
|
||||
# auth_uri = "https://accounts.google.com/o/oauth2/auth"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Override configuration with environment variables:
|
||||
|
||||
```bash
|
||||
export APP_CREDENTIALS="/path/to/credential.json"
|
||||
export APP_EXECUTE="true"
|
||||
export APP_CLIENT_ID="your-client-id"
|
||||
export APP_CLIENT_SECRET="your-client-secret"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The library uses a comprehensive error type:
|
||||
|
||||
Common error types:
|
||||
- `NoLabelsFound`: Mailbox has no labels
|
||||
- `LabelNotFoundInMailbox(String)`: Specific label not found
|
||||
- `RuleNotFound(usize)`: Rule ID doesn't exist
|
||||
- `GoogleGmail1(Box<google_gmail1::Error>)`: Gmail API errors
|
||||
- `StdIO(std::io::Error)`: File I/O errors
|
||||
- `Config(config::ConfigError)`: Configuration errors
|
||||
|
||||
## Async Runtime
|
||||
|
||||
The library requires an async runtime (Tokio recommended):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> cull_gmail::Result<()> {
|
||||
// Your code here
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
16
crates/cull-gmail/release-hook.sh
Executable file
16
crates/cull-gmail/release-hook.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Build an updated README
|
||||
cat ../../docs/readme/head.md > ../../README.md
|
||||
# shellcheck disable=SC2129
|
||||
cat ../../docs/main.md >> ../../README.md
|
||||
cat ../../docs/lib.md >> ../../README.md
|
||||
cat ../../docs/readme/tail.md >> ../../README.md
|
||||
|
||||
# Build Changelog
|
||||
gen-changelog generate \
|
||||
--display-summaries \
|
||||
--name "CHANGELOG.md" \
|
||||
--package "cull-gmail" \
|
||||
--repository-dir "../.." \
|
||||
--next-version "${NEW_VERSION:-${SEMVER}}"
|
||||
7
crates/cull-gmail/release.toml
Normal file
7
crates/cull-gmail/release.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
pre-release-commit-message = "chore: Release cull-gmail v{{version}}"
|
||||
tag-message = "{{tag_name}}"
|
||||
tag-name = "cull-gmail-v{{version}}"
|
||||
pre-release-hook = ["./release-hook.sh"]
|
||||
pre-release-replacements = [
|
||||
{ file = "../../docs/lib.md", search = "cull-gmail = \"\\d+\\.\\d+\\.\\d+\"", replace = "cull-gmail = \"{{version}}\"", exactly = 1 },
|
||||
]
|
||||
1097
crates/cull-gmail/src/cli/init_cli.rs
Normal file
1097
crates/cull-gmail/src/cli/init_cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
476
crates/cull-gmail/src/cli/init_cli/tests.rs
Normal file
476
crates/cull-gmail/src/cli/init_cli/tests.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
//! Unit tests for init CLI functionality.
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_tests {
|
||||
use super::super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Test helper to create a mock credential file
|
||||
fn create_mock_credential_file(dir: &Path) -> std::io::Result<()> {
|
||||
let credential_content = r#"{
|
||||
"installed": {
|
||||
"client_id": "test-client-id.googleusercontent.com",
|
||||
"client_secret": "test-client-secret",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": ["http://localhost"]
|
||||
}
|
||||
}"#;
|
||||
fs::write(dir.join("credential.json"), credential_content)
|
||||
}
|
||||
|
||||
/// Test helper to create a default InitCli instance
|
||||
fn create_test_init_cli() -> InitCli {
|
||||
InitCli {
|
||||
rules_dir: None,
|
||||
config_dir: "test".to_string(),
|
||||
credential_file: None,
|
||||
force: false,
|
||||
dry_run: false,
|
||||
interactive: false,
|
||||
skip_rules: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper to create an InitCli instance with force enabled
|
||||
fn create_test_init_cli_with_force() -> InitCli {
|
||||
InitCli {
|
||||
rules_dir: None,
|
||||
config_dir: "test".to_string(),
|
||||
credential_file: None,
|
||||
force: true,
|
||||
dry_run: false,
|
||||
interactive: false,
|
||||
skip_rules: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_root_home() {
|
||||
let result = parse_config_root("h:.test-config");
|
||||
let home = env::home_dir().unwrap_or_default();
|
||||
assert_eq!(result, home.join(".test-config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_root_current() {
|
||||
let result = parse_config_root("c:.test-config");
|
||||
let current = env::current_dir().unwrap_or_default();
|
||||
assert_eq!(result, current.join(".test-config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_root_root() {
|
||||
let result = parse_config_root("r:etc/cull-gmail");
|
||||
assert_eq!(result, std::path::PathBuf::from("/etc/cull-gmail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_root_no_prefix() {
|
||||
let result = parse_config_root("/absolute/path");
|
||||
assert_eq!(result, std::path::PathBuf::from("/absolute/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_defaults() {
|
||||
assert_eq!(InitDefaults::credential_filename(), "credential.json");
|
||||
assert_eq!(InitDefaults::config_filename(), "cull-gmail.toml");
|
||||
assert_eq!(InitDefaults::rules_filename(), "rules.toml");
|
||||
assert_eq!(InitDefaults::token_dir_name(), "gmail1");
|
||||
|
||||
// Test that config content contains expected keys
|
||||
let config_content = InitDefaults::CONFIG_FILE_CONTENT;
|
||||
assert!(config_content.contains("credential_file = \"credential.json\""));
|
||||
assert!(config_content.contains("config_root = \"h:.cull-gmail\""));
|
||||
assert!(config_content.contains("execute = false"));
|
||||
|
||||
// Test that rules content is a valid template
|
||||
let rules_content = InitDefaults::RULES_FILE_CONTENT;
|
||||
assert!(rules_content.contains("# Example rules for cull-gmail"));
|
||||
assert!(rules_content.contains("older_than:30d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_file_success() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
create_mock_credential_file(temp_dir.path()).unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let credential_path = temp_dir.path().join("credential.json");
|
||||
let result = init_cli.validate_credential_file(&credential_path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_file_not_found() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let nonexistent_path = temp_dir.path().join("nonexistent.json");
|
||||
let result = init_cli.validate_credential_file(&nonexistent_path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_file_invalid_json() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let credential_path = temp_dir.path().join("invalid.json");
|
||||
fs::write(&credential_path, "invalid json content").unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let result = init_cli.validate_credential_file(&credential_path);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Invalid credential file format")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_new_setup() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("new-config");
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let operations = init_cli.plan_operations(&config_path, None).unwrap();
|
||||
|
||||
// Should have: CreateDir, WriteFile (config), WriteFile (rules), EnsureTokenDir
|
||||
assert_eq!(operations.len(), 4);
|
||||
|
||||
match &operations[0] {
|
||||
Operation::CreateDir { path, .. } => {
|
||||
assert_eq!(path, &config_path);
|
||||
}
|
||||
_ => panic!("Expected CreateDir operation"),
|
||||
}
|
||||
|
||||
match &operations[1] {
|
||||
Operation::WriteFile { path, contents, .. } => {
|
||||
assert_eq!(path, &config_path.join("cull-gmail.toml"));
|
||||
assert!(contents.contains("credential_file = \"credential.json\""));
|
||||
}
|
||||
_ => panic!("Expected WriteFile operation for config"),
|
||||
}
|
||||
|
||||
match &operations[2] {
|
||||
Operation::WriteFile { path, contents, .. } => {
|
||||
assert_eq!(path, &config_path.join("rules.toml"));
|
||||
assert!(contents.contains("# Example rules for cull-gmail"));
|
||||
}
|
||||
_ => panic!("Expected WriteFile operation for rules"),
|
||||
}
|
||||
|
||||
match &operations[3] {
|
||||
Operation::EnsureTokenDir { path, .. } => {
|
||||
assert_eq!(path, &config_path.join("gmail1"));
|
||||
}
|
||||
_ => panic!("Expected EnsureTokenDir operation"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_with_credential_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("new-config");
|
||||
let cred_path = temp_dir.path().join("cred.json");
|
||||
create_mock_credential_file(temp_dir.path()).unwrap();
|
||||
fs::rename(temp_dir.path().join("credential.json"), &cred_path).unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let operations = init_cli
|
||||
.plan_operations(&config_path, Some(&cred_path))
|
||||
.unwrap();
|
||||
|
||||
// Should have: CreateDir, CopyFile (credential), WriteFile (config), WriteFile (rules), EnsureTokenDir, RunOAuth2
|
||||
assert_eq!(operations.len(), 6);
|
||||
|
||||
// Check that CopyFile operation exists
|
||||
let copy_op = operations
|
||||
.iter()
|
||||
.find(|op| matches!(op, Operation::CopyFile { .. }));
|
||||
assert!(copy_op.is_some());
|
||||
|
||||
// Check that RunOAuth2 operation exists
|
||||
let oauth_op = operations
|
||||
.iter()
|
||||
.find(|op| matches!(op, Operation::RunOAuth2 { .. }));
|
||||
assert!(oauth_op.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_existing_config_no_force() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("existing-config");
|
||||
fs::create_dir_all(&config_path).unwrap();
|
||||
|
||||
// Create existing config file
|
||||
fs::write(config_path.join("cull-gmail.toml"), "existing config").unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let result = init_cli.plan_operations(&config_path, None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_existing_config_with_force() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("existing-config");
|
||||
fs::create_dir_all(&config_path).unwrap();
|
||||
|
||||
// Create existing config file
|
||||
fs::write(config_path.join("cull-gmail.toml"), "existing config").unwrap();
|
||||
fs::write(config_path.join("rules.toml"), "existing rules").unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli_with_force();
|
||||
|
||||
let operations = init_cli.plan_operations(&config_path, None).unwrap();
|
||||
|
||||
// Should succeed and plan backup operations
|
||||
let config_op = operations.iter().find(|op| {
|
||||
if let Operation::WriteFile {
|
||||
path,
|
||||
backup_if_exists,
|
||||
..
|
||||
} = op
|
||||
{
|
||||
path.file_name().unwrap() == "cull-gmail.toml" && *backup_if_exists
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(config_op.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_backup() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
fs::write(&test_file, "test content").unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let result = init_cli.create_backup(&test_file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Check that a backup file was created
|
||||
let backup_files: Vec<_> = fs::read_dir(temp_dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("test.bak-") {
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(backup_files.len(), 1);
|
||||
|
||||
// Verify backup content
|
||||
let backup_path = temp_dir.path().join(&backup_files[0]);
|
||||
let backup_content = fs::read_to_string(backup_path).unwrap();
|
||||
assert_eq!(backup_content, "test content");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_set_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
fs::write(&test_file, "test content").unwrap();
|
||||
|
||||
let init_cli = create_test_init_cli();
|
||||
|
||||
let result = init_cli.set_permissions(&test_file, 0o600);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let metadata = fs::metadata(&test_file).unwrap();
|
||||
let permissions = metadata.permissions();
|
||||
assert_eq!(permissions.mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_display() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_path = temp_dir.path().join("test");
|
||||
|
||||
let create_dir_op = Operation::CreateDir {
|
||||
path: temp_path.clone(),
|
||||
#[cfg(unix)]
|
||||
mode: Some(0o755),
|
||||
};
|
||||
assert_eq!(
|
||||
format!("{create_dir_op}"),
|
||||
format!("Create directory: {}", temp_path.display())
|
||||
);
|
||||
|
||||
let copy_file_op = Operation::CopyFile {
|
||||
from: temp_path.clone(),
|
||||
to: temp_path.join("dest"),
|
||||
#[cfg(unix)]
|
||||
mode: Some(0o600),
|
||||
backup_if_exists: false,
|
||||
};
|
||||
assert_eq!(
|
||||
format!("{copy_file_op}"),
|
||||
format!(
|
||||
"Copy file: {} → {}",
|
||||
temp_path.display(),
|
||||
temp_path.join("dest").display()
|
||||
)
|
||||
);
|
||||
|
||||
let write_file_op = Operation::WriteFile {
|
||||
path: temp_path.clone(),
|
||||
contents: "content".to_string(),
|
||||
#[cfg(unix)]
|
||||
mode: Some(0o644),
|
||||
backup_if_exists: false,
|
||||
};
|
||||
assert_eq!(
|
||||
format!("{write_file_op}"),
|
||||
format!("Write file: {}", temp_path.display())
|
||||
);
|
||||
|
||||
let oauth_op = Operation::RunOAuth2 {
|
||||
config_root: "h:.config".to_string(),
|
||||
credential_file: Some("cred.json".to_string()),
|
||||
};
|
||||
assert_eq!(format!("{oauth_op}"), "Run OAuth2 authentication flow");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_operation_get_mode() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_path = temp_dir.path().join("test");
|
||||
|
||||
let create_dir_op = Operation::CreateDir {
|
||||
path: temp_path.clone(),
|
||||
mode: Some(0o755),
|
||||
};
|
||||
assert_eq!(create_dir_op.get_mode(), Some(0o755));
|
||||
|
||||
let oauth_op = Operation::RunOAuth2 {
|
||||
config_root: "h:.config".to_string(),
|
||||
credential_file: Some("cred.json".to_string()),
|
||||
};
|
||||
assert_eq!(oauth_op.get_mode(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_with_skip_rules_no_rules_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("new-config");
|
||||
|
||||
let init_cli = InitCli {
|
||||
rules_dir: None,
|
||||
config_dir: "test".to_string(),
|
||||
credential_file: None,
|
||||
force: false,
|
||||
dry_run: false,
|
||||
interactive: false,
|
||||
skip_rules: true,
|
||||
};
|
||||
|
||||
let operations = init_cli.plan_operations(&config_path, None).unwrap();
|
||||
|
||||
// Should have: CreateDir, WriteFile (config only, no rules), EnsureTokenDir
|
||||
assert_eq!(operations.len(), 3);
|
||||
|
||||
// Verify no WriteFile operation for rules.toml
|
||||
let has_rules_write = operations.iter().any(|op| {
|
||||
if let Operation::WriteFile { path, .. } = op {
|
||||
path.file_name().unwrap() == "rules.toml"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
!has_rules_write,
|
||||
"rules.toml should not be written when skip_rules is true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_operations_with_skip_rules_and_rules_dir() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config");
|
||||
|
||||
let init_cli = InitCli {
|
||||
rules_dir: Some("c:rules".to_string()),
|
||||
config_dir: "test".to_string(),
|
||||
credential_file: None,
|
||||
force: false,
|
||||
dry_run: false,
|
||||
interactive: false,
|
||||
skip_rules: true,
|
||||
};
|
||||
|
||||
let operations = init_cli.plan_operations(&config_path, None).unwrap();
|
||||
|
||||
// Should have: CreateDir (config), CreateDir (rules), WriteFile (config only), EnsureTokenDir
|
||||
// The rules directory should still be created even though the file isn't
|
||||
let rules_dir_created = operations.iter().any(|op| {
|
||||
if let Operation::CreateDir { path, .. } = op {
|
||||
path.ends_with("rules")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(rules_dir_created, "Rules directory should still be created");
|
||||
|
||||
// Verify no WriteFile operation for rules.toml
|
||||
let has_rules_write = operations.iter().any(|op| {
|
||||
if let Operation::WriteFile { path, .. } = op {
|
||||
path.file_name().unwrap() == "rules.toml"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
!has_rules_write,
|
||||
"rules.toml should not be written when skip_rules is true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_content_has_skip_rules_comment() {
|
||||
// Test that config content includes skip-rules comment
|
||||
let content_with_skip = InitDefaults::config_content_with_skip_rules("rules.toml");
|
||||
|
||||
assert!(
|
||||
content_with_skip
|
||||
.contains("NOTE: rules.toml creation was skipped via --skip-rules flag")
|
||||
);
|
||||
assert!(content_with_skip.contains("expected to be provided externally"));
|
||||
assert!(content_with_skip.contains("rules = \"rules.toml\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_content_skip_rules_with_custom_path() {
|
||||
let custom_path = "/mnt/rules/rules.toml";
|
||||
let content_with_skip = InitDefaults::config_content_with_skip_rules(custom_path);
|
||||
|
||||
assert!(
|
||||
content_with_skip
|
||||
.contains("NOTE: rules.toml creation was skipped via --skip-rules flag")
|
||||
);
|
||||
assert!(content_with_skip.contains(&format!("rules = \"{custom_path}\"")));
|
||||
}
|
||||
}
|
||||
125
crates/cull-gmail/src/cli/labels_cli.rs
Normal file
125
crates/cull-gmail/src/cli/labels_cli.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! # Gmail Labels CLI Module
|
||||
//!
|
||||
//! This module provides command-line interface functionality for inspecting and displaying
|
||||
//! Gmail labels. It enables users to list all available labels in their Gmail account
|
||||
//! along with their internal Gmail IDs and display names.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//!
|
||||
//! The labels command is essential for:
|
||||
//! - Understanding the structure of Gmail labels in an account
|
||||
//! - Finding correct label names for use in message queries
|
||||
//! - Inspecting label IDs for advanced Gmail API usage
|
||||
//! - Verifying label availability before creating rules
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cull-gmail labels
|
||||
//! ```
|
||||
//!
|
||||
//! ## Output Format
|
||||
//!
|
||||
//! The command displays labels in a human-readable format showing:
|
||||
//! - **Label Name**: User-visible label name
|
||||
//! - **Label ID**: Internal Gmail identifier
|
||||
//!
|
||||
//! Example output:
|
||||
//! ```text
|
||||
//! INBOX: INBOX
|
||||
//! IMPORTANT: IMPORTANT
|
||||
//! promotions: Label_1234567890
|
||||
//! newsletters: Label_0987654321
|
||||
//! ```
|
||||
//!
|
||||
//! ## Integration
|
||||
//!
|
||||
//! This module integrates with:
|
||||
//! - **GmailClient**: For Gmail API communication and authentication
|
||||
//! - **Main CLI**: As a subcommand in the primary CLI application
|
||||
//! - **Error handling**: Using the unified crate error types
|
||||
|
||||
use clap::Parser;
|
||||
use cull_gmail::{Error, GmailClient};
|
||||
|
||||
/// Command-line interface for Gmail label inspection and display.
|
||||
///
|
||||
/// This structure represents the `labels` subcommand, which provides functionality
|
||||
/// to list and inspect all Gmail labels available in the user's account. The command
|
||||
/// requires no additional arguments and displays comprehensive label information.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **Complete label listing**: Shows all labels including system and user-created labels
|
||||
/// - **ID mapping**: Displays both human-readable names and internal Gmail IDs
|
||||
/// - **Simple usage**: No configuration or arguments required
|
||||
/// - **Authentication handling**: Automatic OAuth2 authentication through GmailClient
|
||||
///
|
||||
/// # Usage Context
|
||||
///
|
||||
/// This command is typically used:
|
||||
/// 1. **Before creating queries**: To understand available labels for message filtering
|
||||
/// 2. **Before configuring rules**: To verify target labels exist
|
||||
/// 3. **For debugging**: To inspect label structure and IDs
|
||||
/// 4. **For exploration**: To understand Gmail organization structure
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use cull_gmail::cli::labels_cli::LabelsCli;
|
||||
/// use cull_gmail::GmailClient;
|
||||
///
|
||||
/// # async fn example(client: GmailClient) -> Result<(), cull_gmail::Error> {
|
||||
/// let labels_cli = LabelsCli {};
|
||||
/// labels_cli.run(client).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct LabelsCli {}
|
||||
|
||||
impl LabelsCli {
|
||||
/// Executes the labels command to display Gmail label information.
|
||||
///
|
||||
/// This method coordinates the label inspection workflow by utilizing the
|
||||
/// Gmail client to retrieve and display all available labels in the user's account.
|
||||
/// The output includes both system labels (like INBOX, SENT) and user-created labels.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Authenticated Gmail client for API communication
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<(), Error>` indicating success or failure of the operation.
|
||||
///
|
||||
/// # Operation Details
|
||||
///
|
||||
/// The function performs the following steps:
|
||||
/// 1. **Label Retrieval**: Fetches all labels from the Gmail API
|
||||
/// 2. **Format Processing**: Organizes labels for display
|
||||
/// 3. **Display Output**: Shows labels with names and IDs
|
||||
///
|
||||
/// # Output Format
|
||||
///
|
||||
/// Labels are displayed in the format:
|
||||
/// ```text
|
||||
/// <Label Name>: <Label ID>
|
||||
/// ```
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Possible errors include:
|
||||
/// - **Authentication failures**: OAuth2 token issues or expired credentials
|
||||
/// - **API communication errors**: Network issues or Gmail API unavailability
|
||||
/// - **Permission errors**: Insufficient OAuth2 scopes for label access
|
||||
///
|
||||
/// # Side Effects
|
||||
///
|
||||
/// This function produces output to stdout showing the label information.
|
||||
/// No Gmail account modifications are performed.
|
||||
pub async fn run(&self, client: GmailClient) -> Result<(), Error> {
|
||||
client.show_label();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
694
crates/cull-gmail/src/cli/main.rs
Normal file
694
crates/cull-gmail/src/cli/main.rs
Normal file
@@ -0,0 +1,694 @@
|
||||
//! # Gmail Message Cull CLI Application
|
||||
//!
|
||||
//! A command-line interface for managing Gmail messages with automated retention rules.
|
||||
//! This CLI provides powerful tools for querying, filtering, and managing Gmail messages
|
||||
//! based on labels, age, and custom rules with built-in safety features like dry-run mode.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The CLI is built around three main command categories:
|
||||
//!
|
||||
//! - **Labels**: List and inspect Gmail labels for message organization
|
||||
//! - **Messages**: Query, filter, and perform batch operations on Gmail messages
|
||||
//! - **Rules**: Configure and execute automated message lifecycle management rules
|
||||
//!
|
||||
//! ## Authentication
|
||||
//!
|
||||
//! The CLI uses OAuth2 for Gmail API authentication with the following configuration:
|
||||
//!
|
||||
//! - **Configuration file**: `~/.cull-gmail/cull-gmail.toml`
|
||||
//! - **Credential file**: OAuth2 credentials from Google Cloud Platform
|
||||
//! - **Token storage**: Automatic token caching in `~/.cull-gmail/gmail1/`
|
||||
//!
|
||||
//! ## Command Structure
|
||||
//!
|
||||
//! ```bash
|
||||
//! cull-gmail [OPTIONS] [COMMAND]
|
||||
//! ```
|
||||
//!
|
||||
//! ### Global Options
|
||||
//!
|
||||
//! - `-v, --verbose...`: Increase logging verbosity (can be used multiple times)
|
||||
//! - `-q, --quiet...`: Decrease logging verbosity
|
||||
//! - `-h, --help`: Show help information
|
||||
//! - `-V, --version`: Show version information
|
||||
//!
|
||||
//! ### Commands
|
||||
//!
|
||||
//! 1. **`labels`**: List all available Gmail labels
|
||||
//! 2. **`messages`**: Query and operate on Gmail messages
|
||||
//! 3. **`rules`**: Configure and execute retention rules
|
||||
//!
|
||||
//! ## Configuration File Format
|
||||
//!
|
||||
//! The CLI expects a TOML configuration file at `~/.cull-gmail/cull-gmail.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! # OAuth2 credential file (required)
|
||||
//! credential_file = "client_secret.json"
|
||||
//!
|
||||
//! # Configuration root directory
|
||||
//! config_root = "h:.cull-gmail"
|
||||
//!
|
||||
//! # Rules configuration file
|
||||
//! rules = "rules.toml"
|
||||
//!
|
||||
//! # Default execution mode (false = dry-run, true = execute)
|
||||
//! execute = false
|
||||
//! ```
|
||||
//!
|
||||
//! ## Safety Features
|
||||
//!
|
||||
//! - **Dry-run mode**: Default behaviour prevents accidental data loss
|
||||
//! - **Comprehensive logging**: Detailed operation tracking with multiple verbosity levels
|
||||
//! - **Error handling**: Graceful error recovery with meaningful error messages
|
||||
//! - **Confirmation prompts**: For destructive operations
|
||||
//!
|
||||
//! ## Usage Examples
|
||||
//!
|
||||
//! ### List Gmail Labels
|
||||
//! ```bash
|
||||
//! cull-gmail labels
|
||||
//! ```
|
||||
//!
|
||||
//! ### Query Messages
|
||||
//! ```bash
|
||||
//! # List recent messages
|
||||
//! cull-gmail messages -m 10 list
|
||||
//!
|
||||
//! # Find old promotional emails
|
||||
//! cull-gmail messages -Q "label:promotions older_than:1y" list
|
||||
//! ```
|
||||
//!
|
||||
//! ### Execute Rules
|
||||
//! ```bash
|
||||
//! # Preview rule execution (dry-run)
|
||||
//! cull-gmail rules run
|
||||
//!
|
||||
//! # Execute rules for real
|
||||
//! cull-gmail rules run --execute
|
||||
//! ```
|
||||
//!
|
||||
//! ## Error Handling
|
||||
//!
|
||||
//! The CLI returns the following exit codes:
|
||||
//! - **0**: Success
|
||||
//! - **101**: Error (check stderr and logs for details)
|
||||
//!
|
||||
//! ## Logging
|
||||
//!
|
||||
//! Logging is controlled through command-line verbosity flags and environment variables:
|
||||
//!
|
||||
//! - **Default**: Info level logging for the cull-gmail crate
|
||||
//! - **Verbose (`-v`)**: Debug level logging
|
||||
//! - **Very Verbose (`-vv`)**: Trace level logging
|
||||
//! - **Quiet (`-q`)**: Error level logging only
|
||||
//!
|
||||
//! Environment variable override:
|
||||
//! ```bash
|
||||
//! export RUST_LOG=cull_gmail=debug
|
||||
//! ```
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod init_cli;
|
||||
mod labels_cli;
|
||||
mod messages_cli;
|
||||
mod rules_cli;
|
||||
mod token_cli;
|
||||
|
||||
use config::Config;
|
||||
use cull_gmail::{ClientConfig, EolAction, GmailClient, MessageList, Result, RuleProcessor, Rules};
|
||||
use std::{env, error::Error as stdError};
|
||||
|
||||
use init_cli::InitCli;
|
||||
use labels_cli::LabelsCli;
|
||||
use messages_cli::MessagesCli;
|
||||
use rules_cli::RulesCli;
|
||||
use token_cli::{TokenCli, restore_tokens_from_string};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Main CLI application structure defining global options and subcommands.
|
||||
///
|
||||
/// This struct represents the root of the command-line interface, providing
|
||||
/// global configuration options and dispatching to specific subcommands for
|
||||
/// labels, messages, and rules management.
|
||||
///
|
||||
/// # Global Options
|
||||
///
|
||||
/// - **Logging**: Configurable verbosity levels for operation visibility
|
||||
/// - **Subcommands**: Optional command selection (defaults to rule execution)
|
||||
///
|
||||
/// # Default behaviour
|
||||
///
|
||||
/// When no subcommand is provided, the CLI executes the default rule processing
|
||||
/// workflow, loading rules from the configuration file and executing them
|
||||
/// according to the current execution mode (dry-run or live).
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Logging verbosity control.
|
||||
///
|
||||
/// Use `-q` for quiet (errors only), default for info level,
|
||||
/// `-v` for debug level, `-vv` for trace level.
|
||||
#[clap(flatten)]
|
||||
logging: clap_verbosity_flag::Verbosity,
|
||||
|
||||
/// Optional subcommand selection.
|
||||
///
|
||||
/// If not provided, the CLI will execute the default rule processing workflow.
|
||||
#[command(subcommand)]
|
||||
sub_command: Option<SubCmds>,
|
||||
}
|
||||
|
||||
/// Available CLI subcommands for Gmail message management.
|
||||
///
|
||||
/// Each subcommand provides specialized functionality for different aspects
|
||||
/// of Gmail message lifecycle management, from inspection to automated processing.
|
||||
///
|
||||
/// # Command Categories
|
||||
///
|
||||
/// - **Messages**: Direct message querying, filtering, and batch operations
|
||||
/// - **Labels**: Gmail label inspection and management
|
||||
/// - **Rules**: Automated message lifecycle rule configuration and execution
|
||||
///
|
||||
/// # Display Order
|
||||
///
|
||||
/// Commands are ordered by typical usage workflow: inspect labels first,
|
||||
/// then query specific messages, and finally configure automated rules.
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SubCmds {
|
||||
/// Initialize cull-gmail configuration, credentials, and OAuth2 tokens.
|
||||
///
|
||||
/// Sets up the complete cull-gmail environment including configuration directory,
|
||||
/// OAuth2 credentials, default configuration files, and initial authentication flow.
|
||||
#[clap(name = "init", display_order = 1)]
|
||||
Init(InitCli),
|
||||
|
||||
/// Query, filter, and perform batch operations on Gmail messages.
|
||||
///
|
||||
/// Supports advanced Gmail query syntax, label filtering, and batch actions
|
||||
/// including trash and permanent deletion with safety controls.
|
||||
#[clap(name = "messages", display_order = 3, next_help_heading = "Labels")]
|
||||
Message(MessagesCli),
|
||||
|
||||
/// List and inspect available Gmail labels.
|
||||
///
|
||||
/// Displays all labels in your Gmail account with their internal IDs,
|
||||
/// useful for understanding label structure before creating queries or rules.
|
||||
#[clap(name = "labels", display_order = 2, next_help_heading = "Rules")]
|
||||
Labels(LabelsCli),
|
||||
|
||||
/// Configure and execute automated message retention rules.
|
||||
///
|
||||
/// Provides rule-based message lifecycle management with configurable
|
||||
/// retention periods, label targeting, and automated actions.
|
||||
#[clap(name = "rules", display_order = 2)]
|
||||
Rules(RulesCli),
|
||||
|
||||
/// Export and import OAuth2 tokens for ephemeral environments.
|
||||
///
|
||||
/// Supports token export to compressed strings and automatic import from
|
||||
/// environment variables for container deployments and CI/CD pipelines.
|
||||
#[clap(name = "token", display_order = 4)]
|
||||
Token(TokenCli),
|
||||
}
|
||||
|
||||
/// CLI application entry point with comprehensive error handling and logging setup.
|
||||
///
|
||||
/// This function initializes the async runtime, parses command-line arguments,
|
||||
/// configures logging based on user preferences, and orchestrates the main
|
||||
/// application workflow with proper error handling and exit code management.
|
||||
///
|
||||
/// # Process Flow
|
||||
///
|
||||
/// 1. **Argument Parsing**: Parse command-line arguments using clap
|
||||
/// 2. **Logging Setup**: Initialize logging with user-specified verbosity
|
||||
/// 3. **Application Execution**: Run the main application logic
|
||||
/// 4. **Error Handling**: Handle errors with detailed reporting
|
||||
/// 5. **Exit Code**: Return appropriate exit codes for shell integration
|
||||
///
|
||||
/// # Exit Codes
|
||||
///
|
||||
/// - **0**: Successful execution
|
||||
/// - **101**: Error occurred (details logged and printed to stderr)
|
||||
///
|
||||
/// # Error Reporting
|
||||
///
|
||||
/// Errors are reported through multiple channels:
|
||||
/// - **Logging**: Structured error logging for debugging
|
||||
/// - **stderr**: User-friendly error messages
|
||||
/// - **Exit codes**: Shell-scriptable status reporting
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Cli::parse();
|
||||
|
||||
let mut logging = get_logging(args.logging.log_level_filter());
|
||||
logging.init();
|
||||
log::info!("Logging started.");
|
||||
|
||||
std::process::exit(match run(args).await {
|
||||
Ok(_) => 0,
|
||||
Err(e) => {
|
||||
if let Some(src) = e.source() {
|
||||
log::error!("{e}: {src}");
|
||||
eprintln!("{e}: {src}");
|
||||
} else {
|
||||
log::error!("{e}");
|
||||
eprintln!("{e}");
|
||||
}
|
||||
101
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Main application logic dispatcher handling subcommand execution and default behaviour.
|
||||
///
|
||||
/// This function orchestrates the core application workflow by:
|
||||
/// 1. Loading configuration from files and environment
|
||||
/// 2. Initializing the Gmail API client with OAuth2 authentication
|
||||
/// 3. Dispatching to appropriate subcommands or executing default rule processing
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `args` - Parsed command-line arguments containing global options and subcommands
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the operation.
|
||||
///
|
||||
/// # Default behaviour
|
||||
///
|
||||
/// When no subcommand is specified, the function executes the default rule processing
|
||||
/// workflow, loading rules from configuration and executing them based on the
|
||||
/// current execution mode setting.
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Errors can occur during:
|
||||
/// - Configuration loading and parsing
|
||||
/// - Gmail client initialization and authentication
|
||||
/// - Subcommand execution
|
||||
/// - Rule processing operations
|
||||
async fn run(args: Cli) -> Result<()> {
|
||||
// Handle init command first, before trying to load config
|
||||
if let Some(SubCmds::Init(init_cli)) = args.sub_command {
|
||||
// Init commands don't need existing config since they set up the config
|
||||
return init_cli.run().await;
|
||||
}
|
||||
|
||||
// For all other commands, load config normally
|
||||
let (config, client_config) = get_config()?;
|
||||
|
||||
// Check for token restoration before client initialization
|
||||
restore_tokens_if_available(&config, &client_config)?;
|
||||
|
||||
let mut client = GmailClient::new_with_config(client_config).await?;
|
||||
|
||||
// Get configured rules path
|
||||
let rules_path = get_rules_path(&config)?;
|
||||
|
||||
let Some(sub_command) = args.sub_command else {
|
||||
let rules = rules_cli::get_rules_from(rules_path.as_deref())?;
|
||||
let execute = config.get_bool("execute").unwrap_or(false);
|
||||
return run_rules(&mut client, rules, execute).await;
|
||||
};
|
||||
|
||||
match sub_command {
|
||||
SubCmds::Init(_) => {
|
||||
// This should never be reached due to early return above
|
||||
unreachable!("Init command should have been handled earlier");
|
||||
}
|
||||
SubCmds::Message(messages_cli) => messages_cli.run(&mut client).await,
|
||||
SubCmds::Labels(labels_cli) => labels_cli.run(client).await,
|
||||
SubCmds::Rules(rules_cli) => {
|
||||
rules_cli
|
||||
.run_with_rules_path(&mut client, rules_path.as_deref())
|
||||
.await
|
||||
}
|
||||
SubCmds::Token(token_cli) => {
|
||||
// Token commands don't need an initialized client, just the config
|
||||
// We need to get a fresh client_config since the original was moved
|
||||
let (_, token_client_config) = get_config()?;
|
||||
token_cli.run(&token_client_config).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and configures a logging builder with appropriate verbosity levels.
|
||||
///
|
||||
/// This function sets up structured logging for the application with:
|
||||
/// - Minimum info-level logging for user-facing information
|
||||
/// - Configurable verbosity based on command-line flags
|
||||
/// - Timestamp formatting for operation tracking
|
||||
/// - Focused logging on the cull-gmail crate to reduce noise
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `level` - Desired log level filter from command-line verbosity flags
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a configured `env_logger::Builder` ready for initialization.
|
||||
///
|
||||
/// # Logging Levels
|
||||
///
|
||||
/// - **Error**: Critical failures and unrecoverable errors
|
||||
/// - **Warn**: Non-fatal issues, dry-run notifications, missing resources
|
||||
/// - **Info**: General operation progress, message counts, rule execution
|
||||
/// - **Debug**: Detailed operation info, API calls, configuration values
|
||||
/// - **Trace**: Very detailed debugging information
|
||||
///
|
||||
/// # Default behaviour
|
||||
///
|
||||
/// The function enforces a minimum of Info-level logging to ensure users
|
||||
/// receive adequate feedback about application operations, even when
|
||||
/// verbosity is not explicitly requested.
|
||||
fn get_logging(level: log::LevelFilter) -> env_logger::Builder {
|
||||
// let level = if level > log::LevelFilter::Info {
|
||||
// level
|
||||
// } else {
|
||||
// log::LevelFilter::Info
|
||||
// };
|
||||
|
||||
let mut builder = env_logger::Builder::new();
|
||||
|
||||
builder.filter(Some("cull_gmail"), level);
|
||||
// TODO: Provide an option to set wider filter allowing all crates to report
|
||||
|
||||
builder.format_timestamp_secs().format_module_path(false);
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
/// Loads and parses application configuration from multiple sources.
|
||||
///
|
||||
/// This function implements a hierarchical configuration loading strategy:
|
||||
/// 1. **Default values**: Sensible defaults for all configuration options
|
||||
/// 2. **Configuration file**: User-specific settings from `~/.cull-gmail/cull-gmail.toml`
|
||||
/// 3. **Environment variables**: Runtime overrides with `APP_` prefix
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a tuple containing:
|
||||
/// - **Config**: Raw configuration for general application settings
|
||||
/// - **ClientConfig**: Processed Gmail client configuration with OAuth2 setup
|
||||
///
|
||||
/// # Configuration Hierarchy
|
||||
///
|
||||
/// Settings are applied in this order (later sources override earlier ones):
|
||||
/// 1. Built-in defaults
|
||||
/// 2. Configuration file values
|
||||
/// 3. Environment variable overrides
|
||||
///
|
||||
/// # Configuration Parameters
|
||||
///
|
||||
/// ## Default Values:
|
||||
/// - `credentials`: "credential.json" - OAuth2 credential file name
|
||||
/// - `config_root`: "h:.cull-gmail" - Configuration directory (home-relative)
|
||||
/// - `rules`: "rules.toml" - Rules configuration file name
|
||||
/// - `execute`: true - Default execution mode (can be overridden for safety)
|
||||
///
|
||||
/// ## Environment Variables:
|
||||
/// - `APP_CREDENTIALS`: Override credential file name
|
||||
/// - `APP_CONFIG_ROOT`: Override configuration directory
|
||||
/// - `APP_RULES`: Override rules file name
|
||||
/// - `APP_EXECUTE`: Override execution mode (true/false)
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Configuration errors can occur due to:
|
||||
/// - Missing or inaccessible configuration files
|
||||
/// - Invalid TOML syntax in configuration files
|
||||
/// - Missing OAuth2 credential files
|
||||
/// - Invalid OAuth2 credential format or structure
|
||||
fn get_config() -> Result<(Config, ClientConfig)> {
|
||||
let home_dir = env::home_dir().unwrap();
|
||||
let path = home_dir.join(".cull-gmail/cull-gmail.toml");
|
||||
log::info!("Loading config from {}", path.display());
|
||||
|
||||
let mut config_builder = config::Config::builder()
|
||||
.set_default("credential_file", "credential.json")?
|
||||
.set_default("config_root", "h:.cull-gmail")?
|
||||
.set_default("rules", "rules.toml")?
|
||||
.set_default("execute", true)?
|
||||
.set_default("token_uri", "https://oauth2.googleapis.com/token")?
|
||||
.set_default("auth_uri", "https://accounts.google.com/o/oauth2/auth")?
|
||||
.set_default("token_cache_env", "CULL_GMAIL_TOKEN_CACHE")?;
|
||||
|
||||
if path.exists() {
|
||||
let config_file = config::File::with_name(path.to_path_buf().to_str().unwrap());
|
||||
log::info!("Config file {config_file:?}");
|
||||
config_builder = config_builder.add_source(config_file);
|
||||
}
|
||||
let configurations = config_builder
|
||||
.add_source(config::Environment::with_prefix("APP"))
|
||||
.build()?;
|
||||
|
||||
Ok((
|
||||
configurations.clone(),
|
||||
ClientConfig::new_from_configuration(configurations)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Executes automated message retention rules across Gmail labels by action.
|
||||
///
|
||||
/// This function orchestrates the rule-based message processing workflow by:
|
||||
/// 1. Executing rules by action: `Delete` first, then `Trash`
|
||||
/// 2. Organizing rules by their target labels
|
||||
/// 3. Processing each label according to its configured rule
|
||||
/// 4. Executing or simulating actions based on execution mode
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client for API operations
|
||||
/// * `rules` - Loaded rules configuration containing all retention policies
|
||||
/// * `execute` - Whether to actually perform actions (true) or dry-run (false)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the rule processing.
|
||||
///
|
||||
/// # Rule Processing Flow
|
||||
///
|
||||
/// For each configured label:
|
||||
/// 1. **Rule Lookup**: Find the retention rule for the label
|
||||
/// 2. **Rule Application**: Apply rule criteria to find matching messages
|
||||
/// 3. **Action Determination**: Determine appropriate action (trash/delete)
|
||||
/// 4. **Execution**: Execute action or simulate for dry-run
|
||||
///
|
||||
/// # Safety Features
|
||||
///
|
||||
/// - **Dry-run mode**: When `execute` is false, actions are logged but not performed
|
||||
/// - **Error isolation**: Errors for individual labels don't stop processing of other labels
|
||||
/// - **Detailed logging**: Comprehensive logging of rule execution and results
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The function continues processing even if individual rules fail, logging
|
||||
/// warnings for missing rules, processing errors, or action failures.
|
||||
async fn run_rules(client: &mut GmailClient, rules: Rules, execute: bool) -> Result<()> {
|
||||
run_rules_for_action(client, &rules, execute, EolAction::Delete).await?;
|
||||
run_rules_for_action(client, &rules, execute, EolAction::Trash).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes automated message retention rules across Gmail labels for an action.
|
||||
///
|
||||
/// This function orchestrates the rule-based message processing workflow by:
|
||||
/// 1. Organizing rules by their target labels
|
||||
/// 2. Processing each label according to its configured rule
|
||||
/// 3. Executing or simulating actions based on execution mode
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client for API operations
|
||||
/// * `rules` - Loaded rules configuration containing all retention policies
|
||||
/// * `execute` - Whether to actually perform actions (true) or dry-run (false)
|
||||
/// * `action` - The action the rule will execute
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the rule processing.
|
||||
///
|
||||
/// # Rule Processing Flow
|
||||
///
|
||||
/// For each configured label:
|
||||
/// 1. **Rule Lookup**: Find the retention rule for the label
|
||||
/// 2. **Rule Application**: Apply rule criteria to find matching messages
|
||||
/// 3. **Action Determination**: Determine appropriate action (trash/delete)
|
||||
/// 4. **Execution**: Execute action or simulate for dry-run
|
||||
///
|
||||
/// # Safety Features
|
||||
///
|
||||
/// - **Dry-run mode**: When `execute` is false, actions are logged but not performed
|
||||
/// - **Error isolation**: Errors for individual labels don't stop processing of other labels
|
||||
/// - **Detailed logging**: Comprehensive logging of rule execution and results
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The function continues processing even if individual rules fail, logging
|
||||
/// warnings for missing rules, processing errors, or action failures.
|
||||
async fn run_rules_for_action(
|
||||
client: &mut GmailClient,
|
||||
rules: &Rules,
|
||||
execute: bool,
|
||||
action: EolAction,
|
||||
) -> Result<()> {
|
||||
let rules_by_labels = rules.get_rules_by_label_for_action(action);
|
||||
|
||||
for label in rules.labels() {
|
||||
let Some(rule) = rules_by_labels.get(&label) else {
|
||||
log::warn!("no rule found for label `{label}`");
|
||||
continue;
|
||||
};
|
||||
|
||||
log::info!("Executing rule `#{}` for label `{label}`", rule.describe());
|
||||
client.initialise_lists();
|
||||
client.set_rule(rule.clone());
|
||||
client.set_execute(execute);
|
||||
if let Err(e) = client.find_rule_and_messages_for_label(&label).await {
|
||||
log::warn!("Nothing to process for label `{label}` as {e}");
|
||||
continue;
|
||||
}
|
||||
let Some(action) = client.action() else {
|
||||
log::warn!("no valid action specified for rule #{}", rule.id());
|
||||
continue;
|
||||
};
|
||||
|
||||
if execute {
|
||||
execute_action(action, client, &label).await;
|
||||
} else {
|
||||
client.log_messages("", "").await?;
|
||||
log::warn!("Execution stopped for dry run");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores OAuth2 tokens from environment variable if available.
|
||||
///
|
||||
/// This function checks if the token cache environment variable is set and,
|
||||
/// if found, restores the token files before client initialization to enable
|
||||
/// ephemeral environment workflows.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Application configuration containing token environment variable name
|
||||
/// * `client_config` - Client configuration containing token persistence path
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure. Non-critical errors
|
||||
/// (like missing environment variables) are logged but don't cause failure.
|
||||
///
|
||||
/// # Process
|
||||
///
|
||||
/// 1. **Check Environment**: Look for configured token cache environment variable
|
||||
/// 2. **Skip if Missing**: Continue normally if environment variable not set
|
||||
/// 3. **Restore Tokens**: Decode and restore token files if variable present
|
||||
/// 4. **Log Results**: Report restoration success or failures
|
||||
///
|
||||
/// This function enables seamless token restoration for:
|
||||
/// - Container deployments with injected token environment variables
|
||||
/// - CI/CD pipelines with stored token secrets
|
||||
/// - Ephemeral compute environments requiring periodic Gmail access
|
||||
fn restore_tokens_if_available(config: &Config, client_config: &ClientConfig) -> Result<()> {
|
||||
let token_env_var = config
|
||||
.get_string("token_cache_env")
|
||||
.unwrap_or_else(|_| "CULL_GMAIL_TOKEN_CACHE".to_string());
|
||||
|
||||
if let Ok(token_data) = env::var(&token_env_var) {
|
||||
log::info!("Found {token_env_var} environment variable, restoring tokens");
|
||||
restore_tokens_from_string(&token_data, client_config.persist_path())?;
|
||||
log::info!("Tokens successfully restored from environment variable");
|
||||
} else {
|
||||
log::debug!(
|
||||
"No {token_env_var} environment variable found, proceeding with normal token flow"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the rules file path from configuration.
|
||||
///
|
||||
/// Reads the `rules` configuration value and resolves it using path prefixes.
|
||||
/// Supports h:, c:, r: prefixes for home, current, and root directories.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Application configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the resolved rules file path, or None if using default location.
|
||||
fn get_rules_path(config: &Config) -> Result<Option<PathBuf>> {
|
||||
let rules_config = config
|
||||
.get_string("rules")
|
||||
.unwrap_or_else(|_| "rules.toml".to_string());
|
||||
|
||||
// If it's just "rules.toml" (the default), return None to use default location
|
||||
if rules_config == "rules.toml" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Otherwise, parse the path with prefix support
|
||||
let path = init_cli::parse_config_root(&rules_config);
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
/// Executes the specified end-of-life action on messages for a Gmail label.
|
||||
///
|
||||
/// This function performs the actual message operations (trash or delete) based on
|
||||
/// the rule configuration and execution mode. It handles both recoverable (trash)
|
||||
/// and permanent (delete) operations with appropriate logging and error handling.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `action` - The end-of-life action to perform (Trash or Delete)
|
||||
/// * `client` - Gmail client configured with messages to process
|
||||
/// * `label` - Label name for context in logging and error reporting
|
||||
///
|
||||
/// # Actions
|
||||
///
|
||||
/// ## Trash
|
||||
/// - **Operation**: Moves messages to Gmail's Trash folder
|
||||
/// - **Reversibility**: Messages can be recovered from Trash for ~30 days
|
||||
/// - **Safety**: Relatively safe operation with recovery options
|
||||
///
|
||||
/// ## Delete
|
||||
/// - **Operation**: Permanently deletes messages from Gmail
|
||||
/// - **Reversibility**: **IRREVERSIBLE** - messages cannot be recovered
|
||||
/// - **Safety**: High-risk operation requiring careful consideration
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The function logs errors but does not propagate them, allowing rule processing
|
||||
/// to continue for other labels even if one action fails. Errors are reported through:
|
||||
/// - **Warning logs**: Structured logging for debugging
|
||||
/// - **Label context**: Error messages include label name for traceability
|
||||
///
|
||||
/// # Safety Considerations
|
||||
///
|
||||
/// This function should only be called when execute mode is enabled and after
|
||||
/// appropriate user confirmation for destructive operations.
|
||||
async fn execute_action(action: EolAction, client: &mut GmailClient, label: &str) {
|
||||
match action {
|
||||
EolAction::Trash => {
|
||||
log::info!("***executing trash messages***");
|
||||
if client.batch_trash().await.is_err() {
|
||||
log::warn!("Move to trash failed for label `{label}`");
|
||||
}
|
||||
}
|
||||
EolAction::Delete => {
|
||||
log::info!("***executing final delete messages***");
|
||||
if client.batch_delete().await.is_err() {
|
||||
log::warn!("Delete failed for label `{label}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
405
crates/cull-gmail/src/cli/messages_cli.rs
Normal file
405
crates/cull-gmail/src/cli/messages_cli.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
//! # Gmail Messages CLI Module
|
||||
//!
|
||||
//! This module provides comprehensive command-line interface functionality for querying,
|
||||
//! filtering, and performing batch operations on Gmail messages. It supports advanced
|
||||
//! Gmail query syntax, label-based filtering, and safe batch operations with built-in
|
||||
//! dry-run capabilities.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The messages command enables users to:
|
||||
//! - **Query messages**: Using Gmail's powerful search syntax
|
||||
//! - **Filter by labels**: Target specific message categories
|
||||
//! - **Batch operations**: Perform actions on multiple messages efficiently
|
||||
//! - **Safety controls**: Preview operations before execution
|
||||
//!
|
||||
//! ## Command Structure
|
||||
//!
|
||||
//! ```bash
|
||||
//! cull-gmail messages [OPTIONS] <ACTION>
|
||||
//! ```
|
||||
//!
|
||||
//! ### Available Actions
|
||||
//!
|
||||
//! - **`list`**: Display message information without modifications
|
||||
//! - **`trash`**: Move messages to Gmail's Trash folder (recoverable)
|
||||
//! - **`delete`**: Permanently delete messages (irreversible)
|
||||
//!
|
||||
//! ### Filtering Options
|
||||
//!
|
||||
//! - **`-l, --labels`**: Filter by Gmail labels (can be specified multiple times)
|
||||
//! - **`-Q, --query`**: Advanced Gmail query string using Gmail search syntax
|
||||
//! - **`-m, --max-results`**: Maximum results per page (default: 200)
|
||||
//! - **`-p, --pages`**: Maximum number of pages to process (0 = all pages)
|
||||
//!
|
||||
//! ## Gmail Query Syntax
|
||||
//!
|
||||
//! The module supports Gmail's full query syntax including:
|
||||
//!
|
||||
//! ### Date Queries
|
||||
//! - `older_than:1y` - Messages older than 1 year
|
||||
//! - `newer_than:30d` - Messages newer than 30 days
|
||||
//! - `after:2023/1/1` - Messages after specific date
|
||||
//!
|
||||
//! ### Label Queries
|
||||
//! - `label:promotions` - Messages with promotions label
|
||||
//! - `-label:important` - Messages WITHOUT important label
|
||||
//!
|
||||
//! ### Content Queries
|
||||
//! - `subject:newsletter` - Subject contains "newsletter"
|
||||
//! - `from:example.com` - Messages from domain
|
||||
//! - `has:attachment` - Messages with attachments
|
||||
//!
|
||||
//! ## Safety Features
|
||||
//!
|
||||
//! - **Preview mode**: List action shows what would be affected
|
||||
//! - **Pagination**: Controlled processing with page limits
|
||||
//! - **Error handling**: Graceful handling of API errors and network issues
|
||||
//! - **Logging**: Comprehensive operation logging for audit trails
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ### List Recent Messages
|
||||
//! ```bash
|
||||
//! cull-gmail messages -m 10 list
|
||||
//! ```
|
||||
//!
|
||||
//! ### Find Old Promotional Emails
|
||||
//! ```bash
|
||||
//! cull-gmail messages -Q "label:promotions older_than:1y" list
|
||||
//! ```
|
||||
//!
|
||||
//! ### Batch Trash Operation
|
||||
//! ```bash
|
||||
//! cull-gmail messages -Q "label:newsletters older_than:6m" trash
|
||||
//! ```
|
||||
//!
|
||||
//! ### Multi-Label Query
|
||||
//! ```bash
|
||||
//! cull-gmail messages -l "promotions" -l "newsletters" -Q "older_than:3m" list
|
||||
//! ```
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cull_gmail::{GmailClient, MessageList, Result, RuleProcessor};
|
||||
|
||||
/// Available actions for Gmail message operations.
|
||||
///
|
||||
/// This enum defines the three primary operations that can be performed on Gmail messages
|
||||
/// through the CLI, each with different levels of safety and reversibility.
|
||||
///
|
||||
/// # Action Safety Levels
|
||||
///
|
||||
/// - **List**: Safe inspection operation with no modifications
|
||||
/// - **Trash**: Recoverable operation (messages can be restored for ~30 days)
|
||||
/// - **Delete**: Permanent operation (irreversible)
|
||||
///
|
||||
/// # Usage Context
|
||||
///
|
||||
/// Actions are typically used in this progression:
|
||||
/// 1. **List** - Preview messages that match criteria
|
||||
/// 2. **Trash** - Move messages to recoverable trash
|
||||
/// 3. **Delete** - Permanently remove messages (use with extreme caution)
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum MessageAction {
|
||||
/// Display message information without making any changes.
|
||||
///
|
||||
/// This is the safest operation, showing message details including:
|
||||
/// - Message subject and sender
|
||||
/// - Date and size information
|
||||
/// - Labels and threading information
|
||||
/// - Internal Gmail message IDs
|
||||
List,
|
||||
|
||||
/// Move messages to Gmail's Trash folder.
|
||||
///
|
||||
/// This operation:
|
||||
/// - Moves messages to the Trash label
|
||||
/// - Allows recovery for approximately 30 days
|
||||
/// - Is reversible through Gmail's web interface
|
||||
/// - Provides a safety buffer before permanent deletion
|
||||
Trash,
|
||||
|
||||
/// Permanently delete messages from Gmail.
|
||||
///
|
||||
/// **WARNING**: This operation is irreversible!
|
||||
/// - Messages are permanently removed from Gmail
|
||||
/// - No recovery is possible after deletion
|
||||
/// - Use extreme caution and always test with list first
|
||||
/// - Consider using trash instead for safety
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Command-line interface for Gmail message querying and batch operations.
|
||||
///
|
||||
/// This structure encapsulates all configuration options for the messages subcommand,
|
||||
/// providing comprehensive filtering, pagination, and action capabilities for Gmail
|
||||
/// message management. It supports complex queries using Gmail's search syntax and
|
||||
/// multiple filtering mechanisms.
|
||||
///
|
||||
/// # Configuration Categories
|
||||
///
|
||||
/// - **Pagination**: Control result set size and page limits
|
||||
/// - **Filtering**: Label-based and query-based message selection
|
||||
/// - **Actions**: Operations to perform on selected messages
|
||||
///
|
||||
/// # Usage Patterns
|
||||
///
|
||||
/// ## Safe Exploration
|
||||
/// ```bash
|
||||
/// # Start with list to preview results
|
||||
/// cull-gmail messages -Q "older_than:1y" list
|
||||
///
|
||||
/// # Then perform actions on the same query
|
||||
/// cull-gmail messages -Q "older_than:1y" trash
|
||||
/// ```
|
||||
///
|
||||
/// ## Controlled Processing
|
||||
/// ```bash
|
||||
/// # Process in small batches
|
||||
/// cull-gmail messages -m 50 -p 5 -Q "label:newsletters" list
|
||||
/// ```
|
||||
///
|
||||
/// ## Multi-Criteria Filtering
|
||||
/// ```bash
|
||||
/// # Combine labels and query filters
|
||||
/// cull-gmail messages -l "promotions" -l "social" -Q "older_than:6m" trash
|
||||
/// ```
|
||||
///
|
||||
/// # Safety Considerations
|
||||
///
|
||||
/// - Always use `list` action first to preview results
|
||||
/// - Start with small page sizes for destructive operations
|
||||
/// - Use `trash` instead of `delete` when possible for recoverability
|
||||
/// - Test queries thoroughly before batch operations
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct MessagesCli {
|
||||
/// Maximum number of messages to retrieve per page.
|
||||
///
|
||||
/// Controls the batch size for Gmail API requests. Larger values are more
|
||||
/// efficient but may hit API rate limits. Smaller values provide more
|
||||
/// granular control and progress feedback.
|
||||
///
|
||||
/// **Range**: 1-500 (Gmail API limit)
|
||||
/// **Performance**: 100-200 is typically optimal
|
||||
#[arg(short, long,display_order = 1, help_heading = "Config", default_value = cull_gmail::DEFAULT_MAX_RESULTS)]
|
||||
max_results: u32,
|
||||
|
||||
/// Maximum number of pages to process.
|
||||
///
|
||||
/// Limits the total number of API requests and messages processed.
|
||||
/// Use 0 for unlimited pages (process all matching messages).
|
||||
///
|
||||
/// **Safety**: Start with 1-2 pages for testing destructive operations
|
||||
/// **Performance**: Higher values process more messages but take longer
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
display_order = 1,
|
||||
help_heading = "Config",
|
||||
default_value = "1"
|
||||
)]
|
||||
pages: u32,
|
||||
|
||||
/// Gmail labels to filter messages (can be specified multiple times).
|
||||
///
|
||||
/// Filters messages to only those containing ALL specified labels.
|
||||
/// Use `cull-gmail labels` to see available labels in your account.
|
||||
///
|
||||
/// **Examples**:
|
||||
/// - `-l "INBOX"` - Messages in inbox
|
||||
/// - `-l "promotions" -l "unread"` - Unread promotional messages
|
||||
#[arg(short, long, display_order = 1, help_heading = "Config")]
|
||||
labels: Vec<String>,
|
||||
|
||||
/// Gmail query string using Gmail's advanced search syntax.
|
||||
///
|
||||
/// Supports the same query syntax as Gmail's web interface search box.
|
||||
/// Can be combined with label filters for more precise targeting.
|
||||
///
|
||||
/// **Examples**:
|
||||
/// - `"older_than:1y"` - Messages older than 1 year
|
||||
/// - `"from:noreply@example.com older_than:30d"` - Old automated emails
|
||||
/// - `"has:attachment larger:10M"` - Large attachments
|
||||
#[arg(short = 'Q', long, display_order = 1, help_heading = "Config")]
|
||||
query: Option<String>,
|
||||
|
||||
/// Action to perform on the filtered messages.
|
||||
///
|
||||
/// Determines what operation to execute on messages matching the filter criteria.
|
||||
/// Actions range from safe inspection (list) to permanent deletion (delete).
|
||||
#[command(subcommand)]
|
||||
action: MessageAction,
|
||||
}
|
||||
|
||||
impl MessagesCli {
|
||||
/// Executes the messages command with the configured parameters and action.
|
||||
///
|
||||
/// This method orchestrates the complete message processing workflow:
|
||||
/// 1. **Parameter Configuration**: Apply filters, pagination, and query settings
|
||||
/// 2. **Message Retrieval**: Fetch messages from Gmail API based on criteria
|
||||
/// 3. **Action Execution**: Perform the specified operation on retrieved messages
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client for API operations and state management
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the complete operation.
|
||||
///
|
||||
/// # Processing Flow
|
||||
///
|
||||
/// ## Parameter Setup
|
||||
/// - Apply label filters to restrict message scope
|
||||
/// - Configure Gmail query string for advanced filtering
|
||||
/// - Set pagination parameters for controlled processing
|
||||
///
|
||||
/// ## Message Retrieval
|
||||
/// - Execute Gmail API requests to fetch matching messages
|
||||
/// - Handle pagination according to configured limits
|
||||
/// - Process results in manageable batches
|
||||
///
|
||||
/// ## Action Execution
|
||||
/// - **List**: Display message information with logging level awareness
|
||||
/// - **Trash**: Move messages to Gmail Trash (recoverable)
|
||||
/// - **Delete**: Permanently remove messages (irreversible)
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The method handles various error conditions:
|
||||
/// - **Parameter errors**: Invalid labels or malformed queries
|
||||
/// - **API errors**: Network issues, authentication failures, rate limits
|
||||
/// - **Action errors**: Failures during trash or delete operations
|
||||
///
|
||||
/// # Performance Considerations
|
||||
///
|
||||
/// - **Batch processing**: Messages are processed in configurable batches
|
||||
/// - **Rate limiting**: Respects Gmail API quotas and limits
|
||||
/// - **Memory management**: Efficient handling of large result sets
|
||||
///
|
||||
/// # Safety Features
|
||||
///
|
||||
/// - **Logging awareness**: List output adapts to logging verbosity
|
||||
/// - **Error isolation**: Individual message failures don't stop batch processing
|
||||
/// - **Progress tracking**: Detailed logging for operation monitoring
|
||||
pub(crate) async fn run(&self, client: &mut GmailClient) -> Result<()> {
|
||||
self.set_parameters(client)?;
|
||||
|
||||
client.get_messages(self.pages).await?;
|
||||
|
||||
match self.action {
|
||||
MessageAction::List => {
|
||||
if log::max_level() >= log::Level::Info {
|
||||
client.log_messages("", "").await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
MessageAction::Trash => client.batch_trash().await,
|
||||
MessageAction::Delete => client.batch_delete().await,
|
||||
}
|
||||
|
||||
// Ok(())
|
||||
}
|
||||
|
||||
/// Configures the Gmail client with filtering and pagination parameters.
|
||||
///
|
||||
/// This method applies all user-specified configuration to the Gmail client,
|
||||
/// preparing it for message retrieval operations. It handles label filters,
|
||||
/// query strings, and pagination settings with comprehensive error checking.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client to configure
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of parameter configuration.
|
||||
///
|
||||
/// # Configuration Steps
|
||||
///
|
||||
/// ## Label Filtering
|
||||
/// - Validates label names against available Gmail labels
|
||||
/// - Applies multiple label filters with AND logic
|
||||
/// - Skips label configuration if no labels specified
|
||||
///
|
||||
/// ## Query Configuration
|
||||
/// - Applies Gmail query string if provided
|
||||
/// - Combines with label filters for refined targeting
|
||||
/// - Uses Gmail's native query syntax parsing
|
||||
///
|
||||
/// ## Pagination Setup
|
||||
/// - Configures maximum results per page for API efficiency
|
||||
/// - Logs configuration values for debugging and verification
|
||||
/// - Ensures values are within Gmail API limits
|
||||
///
|
||||
/// # Error Conditions
|
||||
///
|
||||
/// The method can fail due to:
|
||||
/// - **Invalid labels**: Label names that don't exist in the Gmail account
|
||||
/// - **Malformed queries**: Query syntax that Gmail API cannot parse
|
||||
/// - **Parameter limits**: Values outside Gmail API acceptable ranges
|
||||
///
|
||||
/// # Logging
|
||||
///
|
||||
/// Configuration steps are logged at appropriate levels:
|
||||
/// - **Trace**: Detailed parameter values for debugging
|
||||
/// - **Debug**: Configuration confirmation and validation results
|
||||
fn set_parameters(&self, client: &mut GmailClient) -> Result<()> {
|
||||
if !self.labels().is_empty() {
|
||||
client.add_labels(self.labels())?;
|
||||
}
|
||||
|
||||
if let Some(query) = self.query().as_ref() {
|
||||
client.set_query(query)
|
||||
}
|
||||
|
||||
log::trace!("Max results: `{}`", self.max_results());
|
||||
client.set_max_results(self.max_results());
|
||||
log::debug!("List max results set to {}", client.max_results());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a reference to the configured Gmail labels for filtering.
|
||||
///
|
||||
/// This accessor provides access to the list of labels that will be used
|
||||
/// to filter messages. Labels are combined with AND logic, meaning messages
|
||||
/// must have ALL specified labels to be included in results.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a reference to the vector of label names as configured by the user.
|
||||
/// An empty vector indicates no label-based filtering will be applied.
|
||||
pub(crate) fn labels(&self) -> &Vec<String> {
|
||||
&self.labels
|
||||
}
|
||||
|
||||
/// Returns a reference to the configured Gmail query string.
|
||||
///
|
||||
/// This accessor provides access to the advanced query string that will be
|
||||
/// applied to message filtering. The query uses Gmail's native search syntax
|
||||
/// and can be combined with label filters for precise targeting.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a reference to the optional query string. `None` indicates
|
||||
/// no advanced query filtering will be applied.
|
||||
pub(crate) fn query(&self) -> &Option<String> {
|
||||
&self.query
|
||||
}
|
||||
|
||||
/// Returns the maximum number of messages to retrieve per page.
|
||||
///
|
||||
/// This accessor provides the configured batch size for Gmail API requests.
|
||||
/// The value determines how many messages are fetched in each API call,
|
||||
/// affecting both performance and memory usage.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the maximum results per page as configured by the user or default value.
|
||||
/// The value is guaranteed to be within Gmail API acceptable limits.
|
||||
pub(crate) fn max_results(&self) -> u32 {
|
||||
self.max_results
|
||||
}
|
||||
}
|
||||
377
crates/cull-gmail/src/cli/rules_cli.rs
Normal file
377
crates/cull-gmail/src/cli/rules_cli.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! # Gmail Rules CLI Module
|
||||
//!
|
||||
//! This module provides command-line interface functionality for configuring and executing
|
||||
//! automated Gmail message retention rules. It enables users to create sophisticated
|
||||
//! message lifecycle policies with configurable retention periods, label targeting,
|
||||
//! and automated actions (trash/delete).
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The rules command provides two main functionalities:
|
||||
//! - **Configuration**: Create, modify, and manage retention rules
|
||||
//! - **Execution**: Run configured rules to process Gmail messages automatically
|
||||
//!
|
||||
//! ## Rule-Based Message Management
|
||||
//!
|
||||
//! Rules enable automated message lifecycle management by:
|
||||
//! - **Time-based filtering**: Target messages based on age criteria
|
||||
//! - **Label-based targeting**: Apply rules to specific Gmail labels
|
||||
//! - **Automated actions**: Perform trash or delete operations
|
||||
//! - **Safety controls**: Built-in dry-run and logging capabilities
|
||||
//!
|
||||
//! ## Command Structure
|
||||
//!
|
||||
//! ```bash
|
||||
//! cull-gmail rules <SUBCOMMAND>
|
||||
//! ```
|
||||
//!
|
||||
//! ### Available Subcommands
|
||||
//!
|
||||
//! - **`config`**: Configure retention rules, labels, and actions
|
||||
//! - **`run`**: Execute configured rules with optional safety controls
|
||||
//!
|
||||
//! ## Rule Configuration
|
||||
//!
|
||||
//! Rules are stored in TOML format with the following structure:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [rules."1"]
|
||||
//! id = 1
|
||||
//! retention = { age = "y:2", generate_label = true }
|
||||
//! labels = ["old-emails"]
|
||||
//! action = "Trash"
|
||||
//!
|
||||
//! [rules."2"]
|
||||
//! id = 2
|
||||
//! retention = { age = "m:6", generate_label = true }
|
||||
//! labels = ["promotions", "newsletters"]
|
||||
//! action = "Delete"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Retention Periods
|
||||
//!
|
||||
//! Supported time formats:
|
||||
//! - **Years**: `y:1`, `y:2`, etc.
|
||||
//! - **Months**: `m:6`, `m:12`, etc.
|
||||
//! - **Days**: `d:30`, `d:90`, etc.
|
||||
//!
|
||||
//! ## Actions
|
||||
//!
|
||||
//! - **Trash**: Move messages to recoverable Trash folder (~30 day recovery)
|
||||
//! - **Delete**: Permanently remove messages (irreversible)
|
||||
//!
|
||||
//! ## Safety Features
|
||||
//!
|
||||
//! - **Dry-run mode**: Default execution mode prevents accidental data loss
|
||||
//! - **Rule validation**: Configuration validation before execution
|
||||
//! - **Comprehensive logging**: Detailed operation tracking
|
||||
//! - **Error isolation**: Individual rule failures don't stop processing
|
||||
//!
|
||||
//! ## Usage Examples
|
||||
//!
|
||||
//! ### Configure Rules
|
||||
//! ```bash
|
||||
//! # Add a new rule
|
||||
//! cull-gmail rules config rules add
|
||||
//!
|
||||
//! # Configure rule labels
|
||||
//! cull-gmail rules config label add 1 "old-emails"
|
||||
//!
|
||||
//! # Set rule action
|
||||
//! cull-gmail rules config action 1 trash
|
||||
//! ```
|
||||
//!
|
||||
//! ### Execute Rules
|
||||
//! ```bash
|
||||
//! # Dry-run (safe preview)
|
||||
//! cull-gmail rules run
|
||||
//!
|
||||
//! # Execute for real
|
||||
//! cull-gmail rules run --execute
|
||||
//!
|
||||
//! # Execute only specific action types
|
||||
//! cull-gmail rules run --execute --skip-delete
|
||||
//! ```
|
||||
//!
|
||||
//! ## Integration
|
||||
//!
|
||||
//! This module integrates with:
|
||||
//! - **Rules engine**: Core rule processing and validation
|
||||
//! - **GmailClient**: Message querying and batch operations
|
||||
//! - **Configuration system**: TOML-based rule persistence
|
||||
//! - **Logging system**: Comprehensive operation tracking
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod config_cli;
|
||||
mod run_cli;
|
||||
|
||||
use cull_gmail::{GmailClient, Result, Rules};
|
||||
|
||||
use config_cli::ConfigCli;
|
||||
use run_cli::RunCli;
|
||||
|
||||
/// Available subcommands for rules management and execution.
|
||||
///
|
||||
/// This enum defines the two main operational modes for the rules CLI:
|
||||
/// configuration management and rule execution. Each mode provides
|
||||
/// specialized functionality for different aspects of rule lifecycle management.
|
||||
///
|
||||
/// # Command Categories
|
||||
///
|
||||
/// - **Config**: Rule definition, modification, and management operations
|
||||
/// - **Run**: Rule execution with various safety and control options
|
||||
///
|
||||
/// # Workflow Integration
|
||||
///
|
||||
/// Typical usage follows this pattern:
|
||||
/// 1. Use `config` to set up rules, labels, and actions
|
||||
/// 2. Use `run` to execute rules with dry-run testing
|
||||
/// 3. Use `run --execute` for live rule execution
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SubCmds {
|
||||
/// Configure Gmail message retention rules, labels, and actions.
|
||||
///
|
||||
/// Provides comprehensive rule management functionality including:
|
||||
/// - **Rule creation**: Define new retention policies
|
||||
/// - **Label management**: Configure target labels for rules
|
||||
/// - **Action setting**: Specify trash or delete actions
|
||||
/// - **Rule modification**: Update existing rule parameters
|
||||
///
|
||||
/// The config subcommand enables fine-grained control over rule behaviour
|
||||
/// and provides validation to ensure rules are properly configured
|
||||
/// before execution.
|
||||
#[clap(name = "config")]
|
||||
Config(ConfigCli),
|
||||
|
||||
/// Execute configured retention rules with optional safety controls.
|
||||
///
|
||||
/// Provides rule execution functionality with comprehensive safety features:
|
||||
/// - **Dry-run mode**: Preview rule effects without making changes
|
||||
/// - **Selective execution**: Skip specific action types (trash/delete)
|
||||
/// - **Error handling**: Continue processing despite individual failures
|
||||
/// - **Progress tracking**: Detailed logging of rule execution
|
||||
///
|
||||
/// The run subcommand is the primary interface for automated message
|
||||
/// lifecycle management based on configured retention policies.
|
||||
#[clap(name = "run")]
|
||||
Run(RunCli),
|
||||
}
|
||||
|
||||
/// Command-line interface for Gmail message retention rule management.
|
||||
///
|
||||
/// This structure represents the rules subcommand, providing comprehensive
|
||||
/// functionality for both configuring and executing automated Gmail message
|
||||
/// retention policies. It serves as the main entry point for rule-based
|
||||
/// message lifecycle management.
|
||||
///
|
||||
/// # Core Functionality
|
||||
///
|
||||
/// - **Rule Configuration**: Create, modify, and manage retention rules
|
||||
/// - **Label Management**: Associate rules with specific Gmail labels
|
||||
/// - **Action Control**: Configure trash or delete actions for rules
|
||||
/// - **Rule Execution**: Run configured rules with safety controls
|
||||
///
|
||||
/// # Architecture
|
||||
///
|
||||
/// The RulesCli delegates to specialized subcommands:
|
||||
/// - **ConfigCli**: Handles all rule configuration operations
|
||||
/// - **RunCli**: Manages rule execution and safety controls
|
||||
///
|
||||
/// # Configuration Flow
|
||||
///
|
||||
/// 1. **Rule Creation**: Define retention periods and basic parameters
|
||||
/// 2. **Label Assignment**: Associate rules with target Gmail labels
|
||||
/// 3. **Action Configuration**: Set appropriate actions (trash/delete)
|
||||
/// 4. **Validation**: Ensure rules are properly configured
|
||||
/// 5. **Execution**: Run rules with appropriate safety controls
|
||||
///
|
||||
/// # Safety Integration
|
||||
///
|
||||
/// The RulesCli incorporates multiple safety layers:
|
||||
/// - **Configuration validation**: Rules are validated before execution
|
||||
/// - **Dry-run capabilities**: Preview rule effects before applying changes
|
||||
/// - **Error isolation**: Individual rule failures don't stop processing
|
||||
/// - **Comprehensive logging**: Detailed tracking of all operations
|
||||
///
|
||||
/// # Usage Context
|
||||
///
|
||||
/// This CLI is designed for:
|
||||
/// - **System administrators**: Managing organizational Gmail retention policies
|
||||
/// - **Power users**: Implementing personal email organization strategies
|
||||
/// - **Automation**: Scheduled execution of maintenance tasks
|
||||
/// - **Compliance**: Meeting data retention requirements
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct RulesCli {
|
||||
/// Subcommand selection for rules operations.
|
||||
///
|
||||
/// Determines whether to perform configuration management or rule execution.
|
||||
/// Each subcommand provides specialized functionality for its domain.
|
||||
#[command(subcommand)]
|
||||
sub_command: SubCmds,
|
||||
rules: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl RulesCli {
|
||||
/// Executes the rules command based on the selected subcommand.
|
||||
///
|
||||
/// This method coordinates the rules workflow by first loading the current
|
||||
/// rule configuration, then dispatching to the appropriate subcommand handler
|
||||
/// based on user selection (config or run).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client for API operations during rule execution
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the rules operation.
|
||||
///
|
||||
/// # Operation Flow
|
||||
///
|
||||
/// ## Rule Loading
|
||||
/// - Attempts to load existing rules from configuration file
|
||||
/// - Creates default configuration if no rules file exists
|
||||
/// - Validates rule structure and consistency
|
||||
///
|
||||
/// ## Subcommand Dispatch
|
||||
/// - **Config operations**: Delegate to ConfigCli for rule management
|
||||
/// - **Run operations**: Delegate to RunCli for rule execution
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The method handles several error conditions:
|
||||
/// - **Configuration errors**: Problems loading or parsing rules file
|
||||
/// - **Validation errors**: Invalid rule configurations or conflicts
|
||||
/// - **Execution errors**: Failures during rule processing or Gmail operations
|
||||
///
|
||||
/// # Side Effects
|
||||
///
|
||||
/// ## Configuration Mode
|
||||
/// - May modify the rules configuration file
|
||||
/// - Creates backup copies of configuration when making changes
|
||||
/// - Validates configuration consistency after modifications
|
||||
///
|
||||
/// ## Execution Mode
|
||||
/// - May modify Gmail messages according to rule actions
|
||||
/// - Produces detailed logging of operations performed
|
||||
/// - Updates rule execution tracking and statistics
|
||||
///
|
||||
/// # Safety Features
|
||||
///
|
||||
/// - **Configuration validation**: Rules are validated before use
|
||||
/// - **Error isolation**: Subcommand errors don't affect rule loading
|
||||
/// - **State preservation**: Configuration errors don't corrupt existing rules
|
||||
pub async fn run(&self, client: &mut GmailClient) -> Result<()> {
|
||||
self.run_with_rules_path(client, None).await
|
||||
}
|
||||
|
||||
/// Executes the rules command with an optional custom rules path.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client` - Mutable Gmail client for API operations
|
||||
/// * `rules_path` - Optional path to rules file
|
||||
pub async fn run_with_rules_path(
|
||||
&self,
|
||||
client: &mut GmailClient,
|
||||
mut rules_path: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
log::info!("Rules path: {rules_path:?}");
|
||||
if let Some(p) = &self.rules {
|
||||
rules_path = Some(p.as_path());
|
||||
}
|
||||
log::info!("Rules path: {rules_path:?}");
|
||||
|
||||
let rules = get_rules_from(rules_path)?;
|
||||
|
||||
match &self.sub_command {
|
||||
SubCmds::Config(config_cli) => config_cli.run(rules),
|
||||
SubCmds::Run(run_cli) => run_cli.run(client, rules).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads Gmail retention rules from configuration with automatic fallback.
|
||||
///
|
||||
/// This function provides robust rule loading with automatic configuration
|
||||
/// creation when no existing rules are found. It ensures that the rules
|
||||
/// subsystem always has a valid configuration to work with.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<Rules>` containing the loaded or newly created rules configuration.
|
||||
///
|
||||
/// # Loading Strategy
|
||||
///
|
||||
/// ## Primary Path
|
||||
/// - Attempts to load existing rules from the configured rules file
|
||||
/// - Validates rule structure and consistency
|
||||
/// - Returns loaded rules if successful
|
||||
///
|
||||
/// ## Fallback Path
|
||||
/// - Creates new default rules configuration if loading fails
|
||||
/// - Saves the default configuration to disk for future use
|
||||
/// - Returns the newly created default configuration
|
||||
///
|
||||
/// # Configuration Location
|
||||
///
|
||||
/// Rules are typically stored in:
|
||||
/// - **Default location**: `~/.cull-gmail/rules.toml`
|
||||
/// - **Format**: TOML configuration with structured rule definitions
|
||||
/// - **Permissions**: Should be readable/writable by user only
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// The function handles various error scenarios:
|
||||
/// - **Missing configuration**: Creates default configuration automatically
|
||||
/// - **Corrupted configuration**: Logs warnings and falls back to defaults
|
||||
/// - **File system errors**: Propagates errors for disk access issues
|
||||
///
|
||||
/// # Default Configuration
|
||||
///
|
||||
/// When creating a new configuration, the function:
|
||||
/// - Initializes an empty rules collection
|
||||
/// - Sets up proper TOML structure for future rule additions
|
||||
/// - Saves the configuration to disk for persistence
|
||||
///
|
||||
/// # Logging
|
||||
///
|
||||
/// The function provides appropriate logging:
|
||||
/// - **Info**: Successful rule loading
|
||||
/// - **Warn**: Fallback to default configuration
|
||||
/// - **Error**: Critical failures during configuration creation
|
||||
///
|
||||
/// # Usage Context
|
||||
///
|
||||
/// This function is called by:
|
||||
/// - **Rules CLI**: To load rules before configuration or execution
|
||||
/// - **Main CLI**: For default rule execution when no subcommand is specified
|
||||
/// - **Validation systems**: To verify rule configuration integrity
|
||||
///
|
||||
/// Loads rules from the default location.
|
||||
pub fn get_rules() -> Result<Rules> {
|
||||
get_rules_from(None)
|
||||
}
|
||||
|
||||
/// Loads rules from a specified path, or the default location if None.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Optional path to the rules file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the loaded rules, or creates and saves default rules if not found.
|
||||
pub fn get_rules_from(path: Option<&Path>) -> Result<Rules> {
|
||||
match Rules::load_from(path) {
|
||||
Ok(c) => Ok(c),
|
||||
Err(_) => {
|
||||
log::warn!("Configuration not found, creating default config.");
|
||||
let rules = Rules::new();
|
||||
rules.save_to(path)?;
|
||||
Ok(rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
crates/cull-gmail/src/cli/rules_cli/config_cli.rs
Normal file
73
crates/cull-gmail/src/cli/rules_cli/config_cli.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod action_rule_cli;
|
||||
mod add_label_cli;
|
||||
mod add_rule_cli;
|
||||
mod list_label_cli;
|
||||
mod remove_label_cli;
|
||||
mod rm_rule_cli;
|
||||
|
||||
use action_rule_cli::ActionRuleCli;
|
||||
use add_label_cli::AddLabelCli;
|
||||
use cull_gmail::{Result, Rules};
|
||||
use list_label_cli::ListLabelCli;
|
||||
use remove_label_cli::RemoveLabelCli;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SubCmds {
|
||||
/// List the rules configured and saved in the config file
|
||||
// #[clap(name = "list-rules", subcommand_help_heading = "Rules")]
|
||||
#[clap(name = "list-rules")]
|
||||
ListRules,
|
||||
/// Add a rules to the config file
|
||||
// #[clap(name = "add-rule", subcommand_help_heading = "Rules")]
|
||||
#[clap(name = "add-rule")]
|
||||
AddRule(add_rule_cli::AddRuleCli),
|
||||
/// Remove a rule from the config file
|
||||
// #[clap(
|
||||
// name = "remove-rule",
|
||||
// alias = "rm-rule",
|
||||
// subcommand_help_heading = "Rules"
|
||||
// )]
|
||||
#[clap(name = "remove-rule", alias = "rm-rule")]
|
||||
RemoveRule(rm_rule_cli::RmRuleCli),
|
||||
// #[clap(name = "set-action-on-rule", subcommand_help_heading = "Rules")]
|
||||
#[clap(name = "set-action-on-rule")]
|
||||
ActionRule(ActionRuleCli),
|
||||
/// List the labels associated with a rule
|
||||
// #[clap(name = "list-labels", subcommand_help_heading = "Label")]
|
||||
#[clap(name = "list-labels")]
|
||||
List(ListLabelCli),
|
||||
/// Add label to rule
|
||||
// #[clap(name = "add-label", subcommand_help_heading = "Label")]
|
||||
#[clap(name = "add-label")]
|
||||
Add(AddLabelCli),
|
||||
/// Remove a label from a
|
||||
// #[clap(
|
||||
// name = "remove-label",
|
||||
// alias = "rm-label",
|
||||
// subcommand_help_heading = "Label"
|
||||
// )]
|
||||
#[clap(name = "remove-label", alias = "rm-label")]
|
||||
Remove(RemoveLabelCli),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ConfigCli {
|
||||
#[command(subcommand)]
|
||||
sub_command: SubCmds,
|
||||
}
|
||||
|
||||
impl ConfigCli {
|
||||
pub fn run(&self, rules: Rules) -> Result<()> {
|
||||
match &self.sub_command {
|
||||
SubCmds::ActionRule(action_cli) => action_cli.run(rules),
|
||||
SubCmds::ListRules => rules.list_rules(),
|
||||
SubCmds::AddRule(add_cli) => add_cli.run(rules),
|
||||
SubCmds::RemoveRule(rm_cli) => rm_cli.run(rules),
|
||||
SubCmds::List(list_cli) => list_cli.run(rules),
|
||||
SubCmds::Add(add_cli) => add_cli.run(rules),
|
||||
SubCmds::Remove(rm_cli) => rm_cli.run(rules),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use clap::{Parser, ValueEnum};
|
||||
use cull_gmail::{EolAction, Error, Result, Rules};
|
||||
|
||||
#[derive(Debug, Clone, Parser, ValueEnum)]
|
||||
pub enum Action {
|
||||
/// Set the action to trash
|
||||
#[clap(name = "trash")]
|
||||
Trash,
|
||||
/// Set the action to
|
||||
#[clap(name = "add")]
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ActionRuleCli {
|
||||
/// Id of the rule on which action applies
|
||||
#[clap(short, long)]
|
||||
id: usize,
|
||||
/// Configuration commands
|
||||
#[command(subcommand)]
|
||||
action: Action,
|
||||
}
|
||||
|
||||
impl ActionRuleCli {
|
||||
pub fn run(&self, mut config: Rules) -> Result<()> {
|
||||
if config.get_rule(self.id).is_none() {
|
||||
return Err(Error::RuleNotFound(self.id));
|
||||
}
|
||||
|
||||
match self.action {
|
||||
Action::Trash => config.set_action_on_rule(self.id, &EolAction::Trash),
|
||||
Action::Delete => config.set_action_on_rule(self.id, &EolAction::Trash),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use clap::Parser;
|
||||
|
||||
use cull_gmail::{Error, Result, Rules};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AddLabelCli {
|
||||
/// Id of the rule on which action applies
|
||||
#[clap(short, long)]
|
||||
id: usize,
|
||||
/// Label to add to the rule
|
||||
#[clap(short, long)]
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl AddLabelCli {
|
||||
pub fn run(&self, mut config: Rules) -> Result<()> {
|
||||
if config.get_rule(self.id).is_none() {
|
||||
return Err(Error::RuleNotFound(self.id));
|
||||
}
|
||||
|
||||
config.add_label_to_rule(self.id, &self.label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::fmt;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use cull_gmail::{Error, MessageAge, Retention, Rules};
|
||||
|
||||
#[derive(Debug, Clone, Parser, ValueEnum)]
|
||||
pub enum Period {
|
||||
Days,
|
||||
Weeks,
|
||||
Months,
|
||||
Years,
|
||||
}
|
||||
|
||||
impl fmt::Display for Period {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Period::Days => write!(f, "days"),
|
||||
Period::Weeks => write!(f, "weeks"),
|
||||
Period::Months => write!(f, "months"),
|
||||
Period::Years => write!(f, "years"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AddRuleCli {
|
||||
/// Period for the rule
|
||||
#[arg(short, long)]
|
||||
period: Period,
|
||||
/// Count of the period
|
||||
#[arg(short, long, default_value = "1")]
|
||||
count: i64,
|
||||
/// Optional specific label; if not specified one will be generated
|
||||
#[arg(short, long)]
|
||||
label: Option<String>,
|
||||
/// Immediate delete instead of move to trash
|
||||
#[arg(long)]
|
||||
delete: bool,
|
||||
}
|
||||
|
||||
impl AddRuleCli {
|
||||
pub fn run(&self, mut config: Rules) -> Result<(), Error> {
|
||||
let generate = self.label.is_none();
|
||||
let message_age = MessageAge::new(self.period.to_string().as_str(), self.count)?;
|
||||
let retention = Retention::new(message_age, generate);
|
||||
|
||||
config.add_rule(retention, self.label.as_deref(), self.delete);
|
||||
config.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use clap::Parser;
|
||||
|
||||
use cull_gmail::{Error, Result, Rules};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ListLabelCli {
|
||||
/// Id of the rule on which action applies
|
||||
#[clap(short, long)]
|
||||
id: usize,
|
||||
}
|
||||
|
||||
impl ListLabelCli {
|
||||
pub fn run(&self, config: Rules) -> Result<()> {
|
||||
let Some(rule) = config.get_rule(self.id) else {
|
||||
return Err(Error::RuleNotFound(self.id));
|
||||
};
|
||||
|
||||
print!("Labels in rule: ");
|
||||
for (i, label) in rule.labels().iter().enumerate() {
|
||||
if i != 0 {
|
||||
print!(", {label}");
|
||||
} else {
|
||||
print!("`{label}");
|
||||
}
|
||||
}
|
||||
println!("`");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use clap::Parser;
|
||||
|
||||
use cull_gmail::{Error, Result, Rules};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct RemoveLabelCli {
|
||||
/// Id of the rule on which action applies
|
||||
#[clap(short, long)]
|
||||
id: usize,
|
||||
/// Label to remove from the rule
|
||||
#[clap(short, long)]
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl RemoveLabelCli {
|
||||
pub fn run(&self, mut config: Rules) -> Result<()> {
|
||||
if config.get_rule(self.id).is_none() {
|
||||
return Err(Error::RuleNotFound(self.id));
|
||||
}
|
||||
|
||||
config.remove_label_from_rule(self.id, &self.label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use clap::Parser;
|
||||
use cull_gmail::{Error, Rules};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct RmRuleCli {
|
||||
/// Id of the rule to remove
|
||||
#[clap(short, long)]
|
||||
id: Option<usize>,
|
||||
/// Label in the rule to remove (the rule will be removed)
|
||||
#[clap(short, long)]
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl RmRuleCli {
|
||||
pub fn run(&self, mut config: Rules) -> Result<(), Error> {
|
||||
if self.id.is_none() && self.label.is_none() {
|
||||
return Err(Error::NoRuleSelector);
|
||||
}
|
||||
|
||||
if let Some(id) = self.id {
|
||||
config.remove_rule_by_id(id)?;
|
||||
config.save()?;
|
||||
}
|
||||
|
||||
if let Some(label) = &self.label {
|
||||
config.remove_rule_by_label(label)?;
|
||||
config.save()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
23
crates/cull-gmail/src/cli/rules_cli/run_cli.rs
Normal file
23
crates/cull-gmail/src/cli/rules_cli/run_cli.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use clap::Parser;
|
||||
use cull_gmail::{GmailClient, Result, Rules};
|
||||
|
||||
use crate::run_rules;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct RunCli {
|
||||
/// Execute the action
|
||||
#[clap(short, long, display_order = 1, help_heading = "Action")]
|
||||
execute: bool,
|
||||
/// Skip any rules that apply the action `trash`
|
||||
#[clap(short = 't', long, display_order = 2, help_heading = "Skip Action")]
|
||||
skip_trash: bool,
|
||||
/// Skip any rules that apply the action `delete`
|
||||
#[clap(short = 'd', long, display_order = 3, help_heading = "Skip Action")]
|
||||
skip_delete: bool,
|
||||
}
|
||||
|
||||
impl RunCli {
|
||||
pub async fn run(&self, client: &mut GmailClient, rules: Rules) -> Result<()> {
|
||||
run_rules(client, rules, self.execute).await
|
||||
}
|
||||
}
|
||||
530
crates/cull-gmail/src/cli/token_cli.rs
Normal file
530
crates/cull-gmail/src/cli/token_cli.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
//! # Token Management CLI Module
|
||||
//!
|
||||
//! This module provides CLI functionality for exporting and importing OAuth2 tokens
|
||||
//! to support running the application in ephemeral environments like containers or CI/CD pipelines.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The token management system allows users to:
|
||||
//!
|
||||
//! - **Export tokens**: Extract current OAuth2 tokens to a compressed base64 string
|
||||
//! - **Import tokens**: Recreate token files from environment variables
|
||||
//! - **Ephemeral workflows**: Run in clean environments by restoring tokens from env vars
|
||||
//!
|
||||
//! ## Use Cases
|
||||
//!
|
||||
//! ### Container Deployments
|
||||
//! ```bash
|
||||
//! # Export tokens from development environment
|
||||
//! cull-gmail token export
|
||||
//!
|
||||
//! # Set environment variable in container
|
||||
//! docker run -e CULL_GMAIL_TOKEN_CACHE="<exported-string>" my-app
|
||||
//! ```
|
||||
//!
|
||||
//! ### CI/CD Pipelines
|
||||
//! ```bash
|
||||
//! # Store tokens as secret in CI system
|
||||
//! cull-gmail token export > token.secret
|
||||
//!
|
||||
//! # Use in pipeline
|
||||
//! export CULL_GMAIL_TOKEN_CACHE=$(cat token.secret)
|
||||
//! cull-gmail messages list --query "older_than:30d"
|
||||
//! ```
|
||||
//!
|
||||
//! ### Periodic Jobs
|
||||
//! ```bash
|
||||
//! # One-time setup: export tokens
|
||||
//! TOKENS=$(cull-gmail token export)
|
||||
//!
|
||||
//! # Recurring job: restore and use
|
||||
//! export CULL_GMAIL_TOKEN_CACHE="$TOKENS"
|
||||
//! cull-gmail rules run
|
||||
//! ```
|
||||
//!
|
||||
//! ## Security Considerations
|
||||
//!
|
||||
//! - **Token Sensitivity**: Exported tokens contain OAuth2 refresh tokens - treat as secrets
|
||||
//! - **Environment Variables**: Use secure secret management for token storage
|
||||
//! - **Expiration**: Tokens may expire and require re-authentication
|
||||
//! - **Scope Limitations**: Exported tokens maintain original OAuth2 scope restrictions
|
||||
//!
|
||||
//! ## Token Format
|
||||
//!
|
||||
//! Exported tokens are compressed JSON structures containing:
|
||||
//! - OAuth2 access tokens
|
||||
//! - Refresh tokens
|
||||
//! - Token metadata and expiration
|
||||
//! - Encoded as base64 for environment variable compatibility
|
||||
|
||||
use crate::{ClientConfig, Result};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as Base64Engine};
|
||||
use clap::Subcommand;
|
||||
use cull_gmail::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Token management operations for ephemeral environments.
|
||||
///
|
||||
/// This CLI subcommand provides functionality to export OAuth2 tokens to compressed
|
||||
/// strings suitable for environment variables, and import them in clean environments
|
||||
/// to avoid re-authentication flows.
|
||||
///
|
||||
/// ## Subcommands
|
||||
///
|
||||
/// - **export**: Export current tokens to stdout as base64-encoded string
|
||||
/// - **import**: Import tokens from environment variable (typically automatic)
|
||||
///
|
||||
/// ## Usage Examples
|
||||
///
|
||||
/// ### Export Tokens
|
||||
/// ```bash
|
||||
/// # Export to stdout
|
||||
/// cull-gmail token export
|
||||
///
|
||||
/// # Export to file
|
||||
/// cull-gmail token export > tokens.env
|
||||
///
|
||||
/// # Export to environment variable
|
||||
/// export MY_TOKENS=$(cull-gmail token export)
|
||||
/// ```
|
||||
///
|
||||
/// ### Import Usage
|
||||
/// ```bash
|
||||
/// # Set environment variable
|
||||
/// export CULL_GMAIL_TOKEN_CACHE="<base64-string>"
|
||||
///
|
||||
/// # Run normally - tokens will be restored automatically
|
||||
/// cull-gmail labels
|
||||
/// ```
|
||||
#[derive(clap::Parser, Debug)]
|
||||
pub struct TokenCli {
|
||||
#[command(subcommand)]
|
||||
command: TokenCommand,
|
||||
}
|
||||
|
||||
/// Available token management operations.
|
||||
///
|
||||
/// Each operation handles different aspects of token lifecycle management
|
||||
/// for ephemeral environment support.
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TokenCommand {
|
||||
/// Export current OAuth2 tokens to a compressed string.
|
||||
///
|
||||
/// This command reads the current token cache and outputs a base64-encoded,
|
||||
/// compressed representation suitable for storage in environment variables
|
||||
/// or CI/CD secret systems.
|
||||
///
|
||||
/// ## Output Format
|
||||
///
|
||||
/// The output is a single line containing a base64-encoded string that represents
|
||||
/// the compressed JSON structure of all OAuth2 tokens and metadata.
|
||||
///
|
||||
/// ## Examples
|
||||
///
|
||||
/// ```bash
|
||||
/// # Basic export
|
||||
/// cull-gmail token export
|
||||
///
|
||||
/// # Store in environment variable
|
||||
/// export TOKENS=$(cull-gmail token export)
|
||||
///
|
||||
/// # Save to file
|
||||
/// cull-gmail token export > token.secret
|
||||
/// ```
|
||||
Export,
|
||||
|
||||
/// Import OAuth2 tokens from environment variable.
|
||||
///
|
||||
/// This command is typically not called directly, as token import happens
|
||||
/// automatically during client initialization when the CULL_GMAIL_TOKEN_CACHE
|
||||
/// environment variable is present.
|
||||
///
|
||||
/// ## Manual Import
|
||||
///
|
||||
/// ```bash
|
||||
/// # Set the environment variable
|
||||
/// export CULL_GMAIL_TOKEN_CACHE="<base64-string>"
|
||||
///
|
||||
/// # Import explicitly (usually automatic)
|
||||
/// cull-gmail token import
|
||||
/// ```
|
||||
Import,
|
||||
}
|
||||
|
||||
impl TokenCli {
|
||||
/// Execute the token management command.
|
||||
///
|
||||
/// This method dispatches to the appropriate token operation based on the
|
||||
/// selected subcommand and handles the complete workflow for token export
|
||||
/// or import operations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_config` - Client configuration containing token storage paths
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating success or failure of the token operation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - File I/O errors when reading or writing token files
|
||||
/// - Serialization errors when processing token data
|
||||
/// - Environment variable errors during import operations
|
||||
pub async fn run(&self, client_config: &ClientConfig) -> Result<()> {
|
||||
match &self.command {
|
||||
TokenCommand::Export => export_tokens(client_config).await,
|
||||
TokenCommand::Import => import_tokens(client_config).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export OAuth2 tokens to a compressed base64 string.
|
||||
///
|
||||
/// This function reads the token cache directory, compresses all token files,
|
||||
/// and outputs a base64-encoded string suitable for environment variable storage.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Client configuration containing token persistence path
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` with the base64 string printed to stdout, or an error
|
||||
/// if token files cannot be read or processed.
|
||||
///
|
||||
/// # Process Flow
|
||||
///
|
||||
/// 1. **Read Token Directory**: Scan the OAuth2 token persistence directory
|
||||
/// 2. **Collect Token Files**: Read all token-related files and metadata
|
||||
/// 3. **Compress Data**: Use gzip compression on the JSON structure
|
||||
/// 4. **Encode**: Convert to base64 for environment variable compatibility
|
||||
/// 5. **Output**: Print the resulting string to stdout
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `Error::TokenNotFound` - No token cache directory or files found
|
||||
/// - I/O errors reading token files
|
||||
/// - Serialization errors processing token data
|
||||
async fn export_tokens(config: &ClientConfig) -> Result<()> {
|
||||
let token_path = Path::new(config.persist_path());
|
||||
let mut token_data = std::collections::HashMap::new();
|
||||
|
||||
if token_path.is_file() {
|
||||
// OAuth2 token is stored as a single file
|
||||
let filename = token_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::FileIo("Invalid token filename".to_string()))?;
|
||||
|
||||
let content = fs::read_to_string(token_path)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to read token file: {e}")))?;
|
||||
|
||||
token_data.insert(filename.to_string(), content);
|
||||
} else if token_path.is_dir() {
|
||||
// Token directory with multiple files (legacy support)
|
||||
for entry in fs::read_dir(token_path).map_err(|e| Error::FileIo(e.to_string()))? {
|
||||
let entry = entry.map_err(|e| Error::FileIo(e.to_string()))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::FileIo("Invalid filename in token cache".to_string()))?;
|
||||
|
||||
let content = fs::read_to_string(&path).map_err(|e| {
|
||||
Error::FileIo(format!("Failed to read token file {filename}: {e}"))
|
||||
})?;
|
||||
|
||||
token_data.insert(filename.to_string(), content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(Error::TokenNotFound(format!(
|
||||
"Token cache not found: {}",
|
||||
token_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if token_data.is_empty() {
|
||||
return Err(Error::TokenNotFound(
|
||||
"No token data found in cache".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Serialize to JSON
|
||||
let json_data = serde_json::to_string(&token_data)
|
||||
.map_err(|e| Error::SerializationError(format!("Failed to serialize token data: {e}")))?;
|
||||
|
||||
// Compress using flate2
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder
|
||||
.write_all(json_data.as_bytes())
|
||||
.map_err(|e| Error::SerializationError(format!("Failed to compress token data: {e}")))?;
|
||||
let compressed_data = encoder
|
||||
.finish()
|
||||
.map_err(|e| Error::SerializationError(format!("Failed to finalize compression: {e}")))?;
|
||||
|
||||
// Encode to base64
|
||||
let encoded = Base64Engine.encode(&compressed_data);
|
||||
|
||||
// Output to stdout
|
||||
println!("{encoded}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import OAuth2 tokens from environment variable.
|
||||
///
|
||||
/// This function reads the CULL_GMAIL_TOKEN_CACHE environment variable,
|
||||
/// decompresses the token data, and recreates the token cache files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Client configuration containing token persistence path
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating successful token restoration or an error
|
||||
/// if the environment variable is missing or token data cannot be processed.
|
||||
///
|
||||
/// # Process Flow
|
||||
///
|
||||
/// 1. **Read Environment**: Get CULL_GMAIL_TOKEN_CACHE environment variable
|
||||
/// 2. **Decode**: Base64 decode the token string
|
||||
/// 3. **Decompress**: Gunzip the token data
|
||||
/// 4. **Parse**: Deserialize JSON token structure
|
||||
/// 5. **Recreate Files**: Write token files to cache directory
|
||||
/// 6. **Set Permissions**: Ensure appropriate file permissions for security
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `Error::TokenNotFound` - Environment variable not set
|
||||
/// - Decoding/decompression errors for malformed token data
|
||||
/// - I/O errors creating token files
|
||||
pub async fn import_tokens(config: &ClientConfig) -> Result<()> {
|
||||
let token_env = std::env::var("CULL_GMAIL_TOKEN_CACHE").map_err(|_| {
|
||||
Error::TokenNotFound("CULL_GMAIL_TOKEN_CACHE environment variable not set".to_string())
|
||||
})?;
|
||||
|
||||
restore_tokens_from_string(&token_env, config.persist_path())?;
|
||||
|
||||
log::info!("Tokens successfully imported from environment variable");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore token files from a compressed base64 string.
|
||||
///
|
||||
/// This internal function handles the complete token restoration process,
|
||||
/// including decoding, decompression, and file recreation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `token_string` - Base64-encoded compressed token data
|
||||
/// * `persist_path` - Directory path where token files should be created
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` indicating successful restoration or processing errors.
|
||||
///
|
||||
/// # File Permissions
|
||||
///
|
||||
/// Created token files are set to 600 (owner read/write only) for security.
|
||||
pub fn restore_tokens_from_string(token_string: &str, persist_path: &str) -> Result<()> {
|
||||
// Decode from base64
|
||||
let compressed_data = Base64Engine.decode(token_string.trim()).map_err(|e| {
|
||||
Error::SerializationError(format!("Failed to decode base64 token data: {e}"))
|
||||
})?;
|
||||
|
||||
// Decompress
|
||||
use flate2::read::GzDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
let mut decoder = GzDecoder::new(compressed_data.as_slice());
|
||||
let mut json_data = String::new();
|
||||
decoder
|
||||
.read_to_string(&mut json_data)
|
||||
.map_err(|e| Error::SerializationError(format!("Failed to decompress token data: {e}")))?;
|
||||
|
||||
// Parse JSON
|
||||
let token_files: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&json_data)
|
||||
.map_err(|e| Error::SerializationError(format!("Failed to parse token JSON: {e}")))?;
|
||||
|
||||
let token_path = Path::new(persist_path);
|
||||
|
||||
// Count files for logging
|
||||
let file_count = token_files.len();
|
||||
|
||||
if file_count == 1
|
||||
&& token_files.keys().next().map(|k| k.as_str())
|
||||
== token_path.file_name().and_then(|n| n.to_str())
|
||||
{
|
||||
// Single file case - write directly to the persist path
|
||||
let content = token_files.into_values().next().unwrap();
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = token_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
Error::FileIo(format!(
|
||||
"Failed to create token directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
fs::write(token_path, &content)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to write token file: {e}")))?;
|
||||
|
||||
// Set secure permissions (600 - owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(token_path)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?
|
||||
.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(token_path, perms)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?;
|
||||
}
|
||||
} else {
|
||||
// Multiple files case - create directory structure
|
||||
fs::create_dir_all(token_path).map_err(|e| {
|
||||
Error::FileIo(format!(
|
||||
"Failed to create token directory {persist_path}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Write token files
|
||||
for (filename, content) in token_files {
|
||||
let file_path = token_path.join(&filename);
|
||||
fs::write(&file_path, &content).map_err(|e| {
|
||||
Error::FileIo(format!("Failed to write token file {filename}: {e}"))
|
||||
})?;
|
||||
|
||||
// Set secure permissions (600 - owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&file_path)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?
|
||||
.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&file_path, perms)
|
||||
.map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Restored {file_count} token files to {persist_path}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_token_export_import_cycle() {
|
||||
// Create a temporary directory structure
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let token_dir = temp_dir.path().join("gmail1");
|
||||
fs::create_dir_all(&token_dir).expect("Failed to create token dir");
|
||||
|
||||
// Create mock token files
|
||||
let mut test_files = HashMap::new();
|
||||
test_files.insert(
|
||||
"tokencache.json".to_string(),
|
||||
r#"{"access_token":"test_access","refresh_token":"test_refresh"}"#.to_string(),
|
||||
);
|
||||
test_files.insert(
|
||||
"metadata.json".to_string(),
|
||||
r#"{"created":"2023-01-01","expires":"2023-12-31"}"#.to_string(),
|
||||
);
|
||||
|
||||
for (filename, content) in &test_files {
|
||||
fs::write(token_dir.join(filename), content).expect("Failed to write test token file");
|
||||
}
|
||||
|
||||
// Test export
|
||||
let config = crate::ClientConfig::builder()
|
||||
.with_client_id("test")
|
||||
.with_config_path(temp_dir.path().to_str().unwrap())
|
||||
.build();
|
||||
|
||||
// Export tokens (this would normally print to stdout)
|
||||
// We'll test the internal function instead
|
||||
let result = tokio_test::block_on(export_tokens(&config));
|
||||
assert!(result.is_ok(), "Export should succeed");
|
||||
|
||||
// For full integration test, we would capture stdout and test import
|
||||
// but that requires more complex setup with process isolation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_tokens_from_string() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let persist_path = temp_dir.path().join("gmail1").to_string_lossy().to_string();
|
||||
|
||||
// Create test data
|
||||
let mut token_data = HashMap::new();
|
||||
token_data.insert("test.json".to_string(), r#"{"token":"value"}"#.to_string());
|
||||
|
||||
let json_str = serde_json::to_string(&token_data).unwrap();
|
||||
|
||||
// Compress
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(json_str.as_bytes()).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
// Encode
|
||||
let encoded = Base64Engine.encode(&compressed);
|
||||
|
||||
// Test restore
|
||||
let result = restore_tokens_from_string(&encoded, &persist_path);
|
||||
assert!(result.is_ok(), "Restore should succeed: {result:?}");
|
||||
|
||||
// Verify file was created
|
||||
let restored_path = Path::new(&persist_path).join("test.json");
|
||||
assert!(restored_path.exists(), "Token file should be restored");
|
||||
|
||||
let restored_content = fs::read_to_string(restored_path).unwrap();
|
||||
assert_eq!(restored_content, r#"{"token":"value"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_token_directory() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config = crate::ClientConfig::builder()
|
||||
.with_client_id("test")
|
||||
.with_config_path(temp_dir.path().join("nonexistent").to_str().unwrap())
|
||||
.build();
|
||||
|
||||
let result = tokio_test::block_on(export_tokens(&config));
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::TokenNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_base64_restore() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let persist_path = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let result = restore_tokens_from_string("invalid-base64!", &persist_path);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::SerializationError(_)));
|
||||
}
|
||||
}
|
||||
1046
crates/cull-gmail/src/client_config.rs
Normal file
1046
crates/cull-gmail/src/client_config.rs
Normal file
File diff suppressed because it is too large
Load Diff
165
crates/cull-gmail/src/client_config/config_root.rs
Normal file
165
crates/cull-gmail/src/client_config/config_root.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::{env, fmt::Display, path::PathBuf};
|
||||
|
||||
use lazy_regex::{Lazy, Regex, lazy_regex};
|
||||
|
||||
static ROOT_CONFIG: Lazy<Regex> = lazy_regex!(r"^(?P<class>[hrc]):(?P<path>.+)$");
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub enum RootBase {
|
||||
#[default]
|
||||
None,
|
||||
Crate,
|
||||
Home,
|
||||
Root,
|
||||
}
|
||||
|
||||
impl Display for RootBase {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RootBase::None => write!(f, ""),
|
||||
RootBase::Crate => write!(f, "c:"),
|
||||
RootBase::Home => write!(f, "h:"),
|
||||
RootBase::Root => write!(f, "r:"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RootBase {
|
||||
fn path(&self) -> PathBuf {
|
||||
match self {
|
||||
RootBase::None => PathBuf::new(),
|
||||
RootBase::Crate => PathBuf::new(),
|
||||
RootBase::Home => env::home_dir().unwrap_or_default(),
|
||||
RootBase::Root => PathBuf::from("/"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ConfigRoot {
|
||||
root: RootBase,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Display for ConfigRoot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}{}", self.root, self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRoot {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
log::debug!("parsing the string `{s}`");
|
||||
let Some(captures) = ROOT_CONFIG.captures(s) else {
|
||||
return ConfigRoot {
|
||||
root: RootBase::None,
|
||||
path: "".to_string(),
|
||||
};
|
||||
};
|
||||
log::debug!("found captures `{captures:?}`");
|
||||
|
||||
let path = String::from(if let Some(p) = captures.name("path") {
|
||||
p.as_str()
|
||||
} else {
|
||||
""
|
||||
});
|
||||
log::debug!("set the path to `{path}`");
|
||||
|
||||
let class = if let Some(c) = captures.name("class") {
|
||||
c.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
log::debug!("found the class `{class:?}`");
|
||||
|
||||
let root = match class {
|
||||
"c" => RootBase::Crate,
|
||||
"h" => RootBase::Home,
|
||||
"r" => RootBase::Root,
|
||||
"" => RootBase::None,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
ConfigRoot { root, path }
|
||||
}
|
||||
|
||||
pub fn set_root_base(&mut self, root: &RootBase) {
|
||||
self.root = root.to_owned();
|
||||
}
|
||||
|
||||
pub fn set_path(&mut self, path: &str) {
|
||||
self.path = path.to_string();
|
||||
}
|
||||
|
||||
pub fn full_path(&self) -> PathBuf {
|
||||
self.root.path().join(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::test_utils::get_test_logger;
|
||||
|
||||
#[test]
|
||||
fn test_parse_to_home() {
|
||||
get_test_logger();
|
||||
let input = "h:.cull-gmail".to_string();
|
||||
log::debug!("Input set to: `{input}`");
|
||||
let dir_part = input[2..].to_string();
|
||||
|
||||
let user_home = env::home_dir().unwrap();
|
||||
|
||||
let expected = user_home.join(dir_part).display().to_string();
|
||||
|
||||
let output = ConfigRoot::parse(&input);
|
||||
log::debug!("Output set to: `{output:?}`");
|
||||
|
||||
assert_eq!(expected, output.full_path().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_to_root() {
|
||||
get_test_logger();
|
||||
let input = "r:.cull-gmail".to_string();
|
||||
log::debug!("Input set to: `{input}`");
|
||||
let dir_part = input[2..].to_string();
|
||||
|
||||
let expected = format!("/{dir_part}");
|
||||
|
||||
let output = ConfigRoot::parse(&input);
|
||||
log::debug!("Output set to: `{output:?}`");
|
||||
|
||||
assert_eq!(expected, output.full_path().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_to_crate() {
|
||||
get_test_logger();
|
||||
let input = "c:.cull-gmail".to_string();
|
||||
log::debug!("Input set to: `{input}`");
|
||||
let expected = input[2..].to_string();
|
||||
|
||||
let output = ConfigRoot::parse(&input);
|
||||
log::debug!("Output set to: `{output:?}`");
|
||||
|
||||
assert_eq!(expected, output.full_path().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_to_none() {
|
||||
get_test_logger();
|
||||
let input = ".cull-gmail".to_string();
|
||||
log::debug!("Input set to: `{input}`");
|
||||
|
||||
let expected = "".to_string();
|
||||
|
||||
let output = ConfigRoot::parse(&input);
|
||||
log::debug!("Output set to: `{output:?}`");
|
||||
|
||||
assert_eq!(expected, output.full_path().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
524
crates/cull-gmail/src/eol_action.rs
Normal file
524
crates/cull-gmail/src/eol_action.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
//! # End-of-Life Action Module
|
||||
//!
|
||||
//! This module defines the actions that can be performed on Gmail messages
|
||||
//! when they reach their end-of-life criteria based on configured rules.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The `EolAction` enum specifies how messages should be handled when they
|
||||
//! meet the criteria for removal from a Gmail account. The module provides
|
||||
//! two primary actions:
|
||||
//!
|
||||
//! - **Trash**: Moves messages to the trash folder (reversible)
|
||||
//! - **Delete**: Permanently deletes messages (irreversible)
|
||||
//!
|
||||
//! ## Safety Considerations
|
||||
//!
|
||||
//! - **Trash** action allows message recovery from Gmail's trash folder
|
||||
//! - **Delete** action permanently removes messages and cannot be undone
|
||||
//! - Always test rules carefully before applying delete actions
|
||||
//!
|
||||
//! ## Usage Examples
|
||||
//!
|
||||
//! ### Basic Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use cull_gmail::EolAction;
|
||||
//!
|
||||
//! // Default action is Trash (safer option)
|
||||
//! let action = EolAction::default();
|
||||
//! assert_eq!(action, EolAction::Trash);
|
||||
//!
|
||||
//! // Parse from string
|
||||
//! let delete_action = EolAction::parse("delete").unwrap();
|
||||
//! assert_eq!(delete_action, EolAction::Delete);
|
||||
//!
|
||||
//! // Display as string
|
||||
//! println!("Action: {}", delete_action); // Prints: "Action: delete"
|
||||
//! ```
|
||||
//!
|
||||
//! ### Integration with Rules
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use cull_gmail::EolAction;
|
||||
//!
|
||||
//! fn configure_rule_action(action_str: &str) -> Option<EolAction> {
|
||||
//! match EolAction::parse(action_str) {
|
||||
//! Some(action) => {
|
||||
//! println!("Configured action: {}", action);
|
||||
//! Some(action)
|
||||
//! }
|
||||
//! None => {
|
||||
//! eprintln!("Invalid action: {}", action_str);
|
||||
//! None
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## String Representation
|
||||
//!
|
||||
//! The enum implements both parsing from strings and display formatting:
|
||||
//!
|
||||
//! | Variant | String | Description |
|
||||
//! |---------|--------|--------------|
|
||||
//! | `Trash` | "trash" | Move to trash (recoverable) |
|
||||
//! | `Delete` | "delete" | Permanent deletion |
|
||||
//!
|
||||
//! Parsing is case-insensitive, so "TRASH", "Trash", and "trash" are all valid.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Represents the action to take on Gmail messages that meet end-of-life criteria.
|
||||
///
|
||||
/// This enum defines the two possible actions for handling messages when they
|
||||
/// reach the end of their lifecycle based on configured retention rules.
|
||||
///
|
||||
/// # Variants
|
||||
///
|
||||
/// - [`Trash`](EolAction::Trash) - Move messages to Gmail's trash folder (default, reversible)
|
||||
/// - [`Delete`](EolAction::Delete) - Permanently delete messages (irreversible)
|
||||
///
|
||||
/// # Default Behavior
|
||||
///
|
||||
/// The default action is [`Trash`](EolAction::Trash), which provides a safety net
|
||||
/// by allowing message recovery from the trash folder.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// // Using the default (Trash)
|
||||
/// let safe_action = EolAction::default();
|
||||
/// assert_eq!(safe_action, EolAction::Trash);
|
||||
///
|
||||
/// // Comparing actions
|
||||
/// let delete = EolAction::Delete;
|
||||
/// let trash = EolAction::Trash;
|
||||
/// assert_ne!(delete, trash);
|
||||
///
|
||||
/// // Converting to string for logging/display
|
||||
/// println!("Action: {}", delete); // Prints: "delete"
|
||||
/// ```
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EolAction {
|
||||
/// Move the message to Gmail's trash folder.
|
||||
///
|
||||
/// This is the default and safer option as it allows message recovery.
|
||||
/// Messages in the trash are automatically deleted by Gmail after 30 days.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// This action is reversible - messages can be recovered from the trash folder
|
||||
/// until they are automatically purged or manually deleted from trash.
|
||||
#[default]
|
||||
Trash,
|
||||
|
||||
/// Permanently delete the message immediately.
|
||||
///
|
||||
/// This action bypasses the trash folder and permanently removes the message.
|
||||
///
|
||||
/// # Warning
|
||||
///
|
||||
/// This action is **irreversible**. Once deleted, messages cannot be recovered.
|
||||
/// Use with extreme caution and thorough testing of rule criteria.
|
||||
///
|
||||
/// # Use Cases
|
||||
///
|
||||
/// - Sensitive data that should not remain in trash
|
||||
/// - Storage optimization where trash recovery is not needed
|
||||
/// - Automated cleanup of known disposable messages
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl fmt::Display for EolAction {
|
||||
/// Formats the `EolAction` as a lowercase string.
|
||||
///
|
||||
/// This implementation provides a consistent string representation
|
||||
/// for logging, configuration, and user interfaces.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `"trash"` for [`EolAction::Trash`]
|
||||
/// - `"delete"` for [`EolAction::Delete`]
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// assert_eq!(EolAction::Trash.to_string(), "trash");
|
||||
/// assert_eq!(EolAction::Delete.to_string(), "delete");
|
||||
///
|
||||
/// // Useful for logging
|
||||
/// let action = EolAction::default();
|
||||
/// println!("Performing action: {}", action);
|
||||
/// ```
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
EolAction::Trash => write!(f, "trash"),
|
||||
EolAction::Delete => write!(f, "delete"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EolAction {
|
||||
/// Parses a string into an `EolAction` variant.
|
||||
///
|
||||
/// This method provides case-insensitive parsing from string representations
|
||||
/// to `EolAction` variants. It's useful for configuration file parsing,
|
||||
/// command-line arguments, and user input validation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` - A string slice to parse. Case is ignored.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Some(EolAction)` if the string matches a valid variant
|
||||
/// - `None` if the string is not recognized
|
||||
///
|
||||
/// # Valid Input Strings
|
||||
///
|
||||
/// - `"trash"`, `"Trash"`, `"TRASH"` → [`EolAction::Trash`]
|
||||
/// - `"delete"`, `"Delete"`, `"DELETE"` → [`EolAction::Delete`]
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// // Valid parsing (case-insensitive)
|
||||
/// assert_eq!(EolAction::parse("trash"), Some(EolAction::Trash));
|
||||
/// assert_eq!(EolAction::parse("TRASH"), Some(EolAction::Trash));
|
||||
/// assert_eq!(EolAction::parse("Delete"), Some(EolAction::Delete));
|
||||
///
|
||||
/// // Invalid input
|
||||
/// assert_eq!(EolAction::parse("invalid"), None);
|
||||
/// assert_eq!(EolAction::parse(""), None);
|
||||
/// ```
|
||||
///
|
||||
/// # Use Cases
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// fn parse_user_action(input: &str) -> Result<EolAction, String> {
|
||||
/// EolAction::parse(input)
|
||||
/// .ok_or_else(|| format!("Invalid action: '{}'. Use 'trash' or 'delete'.", input))
|
||||
/// }
|
||||
///
|
||||
/// assert!(parse_user_action("trash").is_ok());
|
||||
/// assert!(parse_user_action("invalid").is_err());
|
||||
/// ```
|
||||
pub fn parse(input: &str) -> Option<Self> {
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"trash" => Some(EolAction::Trash),
|
||||
"delete" => Some(EolAction::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the action is reversible (can be undone).
|
||||
///
|
||||
/// This method helps determine if an action allows for message recovery,
|
||||
/// which is useful for safety checks and user confirmations.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `true` for [`EolAction::Trash`] (messages can be recovered from trash)
|
||||
/// - `false` for [`EolAction::Delete`] (messages are permanently deleted)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// assert!(EolAction::Trash.is_reversible());
|
||||
/// assert!(!EolAction::Delete.is_reversible());
|
||||
///
|
||||
/// // Use in safety checks
|
||||
/// let action = EolAction::Delete;
|
||||
/// if !action.is_reversible() {
|
||||
/// println!("Warning: This action cannot be undone!");
|
||||
/// }
|
||||
/// ```
|
||||
pub fn is_reversible(&self) -> bool {
|
||||
match self {
|
||||
EolAction::Trash => true,
|
||||
EolAction::Delete => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all possible `EolAction` variants.
|
||||
///
|
||||
/// This method is useful for generating help text, validation lists,
|
||||
/// or iterating over all possible actions.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An array containing all `EolAction` variants in declaration order.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use cull_gmail::EolAction;
|
||||
///
|
||||
/// let all_actions = EolAction::variants();
|
||||
/// assert_eq!(all_actions.len(), 2);
|
||||
/// assert_eq!(all_actions[0], EolAction::Trash);
|
||||
/// assert_eq!(all_actions[1], EolAction::Delete);
|
||||
///
|
||||
/// // Generate help text
|
||||
/// println!("Available actions:");
|
||||
/// for action in EolAction::variants() {
|
||||
/// println!(" {} - {}", action,
|
||||
/// if action.is_reversible() { "reversible" } else { "irreversible" });
|
||||
/// }
|
||||
/// ```
|
||||
pub fn variants() -> &'static [EolAction] {
|
||||
&[EolAction::Trash, EolAction::Delete]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_action_is_trash() {
|
||||
let action = EolAction::default();
|
||||
assert_eq!(action, EolAction::Trash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_and_equality() {
|
||||
let trash1 = EolAction::Trash;
|
||||
let trash2 = trash1; // Copy semantics
|
||||
assert_eq!(trash1, trash2);
|
||||
|
||||
let delete1 = EolAction::Delete;
|
||||
let delete2 = delete1; // Copy semantics
|
||||
assert_eq!(delete1, delete2);
|
||||
|
||||
assert_ne!(trash1, delete1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_formatting() {
|
||||
assert_eq!(format!("{:?}", EolAction::Trash), "Trash");
|
||||
assert_eq!(format!("{:?}", EolAction::Delete), "Delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_formatting() {
|
||||
assert_eq!(EolAction::Trash.to_string(), "trash");
|
||||
assert_eq!(EolAction::Delete.to_string(), "delete");
|
||||
assert_eq!(format!("{}", EolAction::Trash), "trash");
|
||||
assert_eq!(format!("{}", EolAction::Delete), "delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_inputs() {
|
||||
// Test lowercase
|
||||
assert_eq!(EolAction::parse("trash"), Some(EolAction::Trash));
|
||||
assert_eq!(EolAction::parse("delete"), Some(EolAction::Delete));
|
||||
|
||||
// Test uppercase
|
||||
assert_eq!(EolAction::parse("TRASH"), Some(EolAction::Trash));
|
||||
assert_eq!(EolAction::parse("DELETE"), Some(EolAction::Delete));
|
||||
|
||||
// Test mixed case
|
||||
assert_eq!(EolAction::parse("Trash"), Some(EolAction::Trash));
|
||||
assert_eq!(EolAction::parse("Delete"), Some(EolAction::Delete));
|
||||
assert_eq!(EolAction::parse("TrAsH"), Some(EolAction::Trash));
|
||||
assert_eq!(EolAction::parse("dElEtE"), Some(EolAction::Delete));
|
||||
|
||||
// Test with whitespace
|
||||
assert_eq!(EolAction::parse(" trash "), Some(EolAction::Trash));
|
||||
assert_eq!(EolAction::parse("\tdelete\n"), Some(EolAction::Delete));
|
||||
assert_eq!(EolAction::parse(" TRASH "), Some(EolAction::Trash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_inputs() {
|
||||
// Invalid strings
|
||||
assert_eq!(EolAction::parse("invalid"), None);
|
||||
assert_eq!(EolAction::parse("remove"), None);
|
||||
assert_eq!(EolAction::parse("destroy"), None);
|
||||
assert_eq!(EolAction::parse("archive"), None);
|
||||
|
||||
// Empty and whitespace
|
||||
assert_eq!(EolAction::parse(""), None);
|
||||
assert_eq!(EolAction::parse(" "), None);
|
||||
assert_eq!(EolAction::parse("\t\n"), None);
|
||||
|
||||
// Partial matches
|
||||
assert_eq!(EolAction::parse("tras"), None);
|
||||
assert_eq!(EolAction::parse("delet"), None);
|
||||
assert_eq!(EolAction::parse("trashh"), None);
|
||||
assert_eq!(EolAction::parse("deletee"), None);
|
||||
|
||||
// Special characters
|
||||
assert_eq!(EolAction::parse("trash!"), None);
|
||||
assert_eq!(EolAction::parse("delete?"), None);
|
||||
assert_eq!(EolAction::parse("trash-delete"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_edge_cases() {
|
||||
// Unicode variations
|
||||
assert_eq!(EolAction::parse("trash"), Some(EolAction::Trash)); // Unicode 't'
|
||||
|
||||
// Numbers and symbols
|
||||
assert_eq!(EolAction::parse("trash123"), None);
|
||||
assert_eq!(EolAction::parse("123delete"), None);
|
||||
assert_eq!(EolAction::parse("t@rash"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_reversible() {
|
||||
assert!(EolAction::Trash.is_reversible());
|
||||
assert!(!EolAction::Delete.is_reversible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variants() {
|
||||
let variants = EolAction::variants();
|
||||
assert_eq!(variants.len(), 2);
|
||||
assert_eq!(variants[0], EolAction::Trash);
|
||||
assert_eq!(variants[1], EolAction::Delete);
|
||||
|
||||
// Ensure all enum variants are included
|
||||
assert!(variants.contains(&EolAction::Trash));
|
||||
assert!(variants.contains(&EolAction::Delete));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variants_completeness() {
|
||||
// Verify that variants() returns all possible enum values
|
||||
let variants = EolAction::variants();
|
||||
|
||||
// Test that we can parse back to all variants
|
||||
for variant in variants {
|
||||
let string_repr = variant.to_string();
|
||||
let parsed = EolAction::parse(&string_repr);
|
||||
assert_eq!(parsed, Some(*variant));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_trait() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(EolAction::Trash, "safe");
|
||||
map.insert(EolAction::Delete, "dangerous");
|
||||
|
||||
assert_eq!(map.get(&EolAction::Trash), Some(&"safe"));
|
||||
assert_eq!(map.get(&EolAction::Delete), Some(&"dangerous"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_conversion() {
|
||||
// Test that display -> parse -> display is consistent
|
||||
let actions = [EolAction::Trash, EolAction::Delete];
|
||||
|
||||
for action in actions {
|
||||
let string_repr = action.to_string();
|
||||
let parsed = EolAction::parse(&string_repr).expect("Should parse successfully");
|
||||
assert_eq!(action, parsed);
|
||||
assert_eq!(string_repr, parsed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safety_properties() {
|
||||
// Verify safety properties are as expected
|
||||
assert!(
|
||||
EolAction::Trash.is_reversible(),
|
||||
"Trash should be reversible for safety"
|
||||
);
|
||||
assert!(
|
||||
!EolAction::Delete.is_reversible(),
|
||||
"Delete should be irreversible"
|
||||
);
|
||||
assert_eq!(
|
||||
EolAction::default(),
|
||||
EolAction::Trash,
|
||||
"Default should be the safer option"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_case_insensitive_parsing() {
|
||||
let test_cases = [
|
||||
("trash", Some(EolAction::Trash)),
|
||||
("TRASH", Some(EolAction::Trash)),
|
||||
("Trash", Some(EolAction::Trash)),
|
||||
("TrAsH", Some(EolAction::Trash)),
|
||||
("delete", Some(EolAction::Delete)),
|
||||
("DELETE", Some(EolAction::Delete)),
|
||||
("Delete", Some(EolAction::Delete)),
|
||||
("DeLeTe", Some(EolAction::Delete)),
|
||||
("invalid", None),
|
||||
("INVALID", None),
|
||||
("", None),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
assert_eq!(
|
||||
EolAction::parse(input),
|
||||
expected,
|
||||
"Failed for input: '{input}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_practical_usage_scenarios() {
|
||||
// Test common usage patterns
|
||||
|
||||
// Configuration parsing scenario
|
||||
let config_value = "delete";
|
||||
let action = EolAction::parse(config_value).unwrap_or_default();
|
||||
assert_eq!(action, EolAction::Delete);
|
||||
|
||||
// Invalid config falls back to default (safe)
|
||||
let invalid_config = "invalid_action";
|
||||
let safe_action = EolAction::parse(invalid_config).unwrap_or_default();
|
||||
assert_eq!(safe_action, EolAction::Trash);
|
||||
|
||||
// Logging/display scenario
|
||||
let action = EolAction::Delete;
|
||||
let log_message = format!("Executing {action} action");
|
||||
assert_eq!(log_message, "Executing delete action");
|
||||
|
||||
// Safety check scenario
|
||||
let dangerous_action = EolAction::Delete;
|
||||
if !dangerous_action.is_reversible() {
|
||||
// This would prompt user confirmation in real usage
|
||||
// Test that we can detect dangerous actions
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling_patterns() {
|
||||
// Test error handling patterns that might be used with this enum
|
||||
|
||||
fn parse_with_error(input: &str) -> Result<EolAction, String> {
|
||||
EolAction::parse(input)
|
||||
.ok_or_else(|| format!("Invalid action: '{input}'. Valid options: trash, delete"))
|
||||
}
|
||||
|
||||
// Valid cases
|
||||
assert!(parse_with_error("trash").is_ok());
|
||||
assert!(parse_with_error("delete").is_ok());
|
||||
|
||||
// Error cases
|
||||
let error = parse_with_error("invalid").unwrap_err();
|
||||
assert!(error.contains("Invalid action: 'invalid'"));
|
||||
assert!(error.contains("trash, delete"));
|
||||
}
|
||||
}
|
||||
63
crates/cull-gmail/src/error.rs
Normal file
63
crates/cull-gmail/src/error.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error messages for cull-gmail
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// Invalid paging mode option
|
||||
#[error("Invalid paging mode option")]
|
||||
InvalidPagingMode,
|
||||
/// Configuration directory not set
|
||||
#[error("Configuration directory not set")]
|
||||
DirectoryUnset,
|
||||
/// Expansion of home directory in `{0}` failed
|
||||
#[error("Expansion of home directory in `{0}` failed")]
|
||||
HomeExpansionFailed(String),
|
||||
/// No labels found in mailbox
|
||||
#[error("No labels found in mailbox")]
|
||||
NoLabelsFound,
|
||||
/// No rule selector specified (i.e. --id or --label)
|
||||
#[error("No rule selector specified (i.e. --id or --label)")]
|
||||
NoRuleSelector,
|
||||
/// No rule for label
|
||||
#[error("No rule for label {0}")]
|
||||
NoRuleFoundForLabel(String),
|
||||
/// No rule for label
|
||||
#[error("No query string calculated for rule #{0}")]
|
||||
NoQueryStringCalculated(usize),
|
||||
/// No label found in the mailbox
|
||||
#[error("Label {0} not found in the mailbox")]
|
||||
LabelNotFoundInMailbox(String),
|
||||
/// Rule not found for ID
|
||||
#[error("No rule for id {0}")]
|
||||
RuleNotFound(usize),
|
||||
/// Label not found in the rule set
|
||||
#[error("Label `{0}` not found in the rule set")]
|
||||
LabelNotFoundInRules(String),
|
||||
/// Directory creation failed for `{0}`
|
||||
#[error("Directory creation failed for `{0:?}`")]
|
||||
DirectoryCreationFailed((String, Box<std::io::Error>)),
|
||||
/// Error from the google_gmail1 crate
|
||||
#[error(transparent)]
|
||||
GoogleGmail1(#[from] Box<google_gmail1::Error>),
|
||||
/// Error from std::io
|
||||
#[error(transparent)]
|
||||
StdIO(#[from] std::io::Error),
|
||||
/// Error from toml_de
|
||||
#[error(transparent)]
|
||||
TomlDe(#[from] toml::de::Error),
|
||||
/// Error from config
|
||||
#[error(transparent)]
|
||||
Config(#[from] config::ConfigError),
|
||||
/// Invalid message age specification
|
||||
#[error("Invalid message age: {0}")]
|
||||
InvalidMessageAge(String),
|
||||
/// Token not found or missing
|
||||
#[error("Token error: {0}")]
|
||||
TokenNotFound(String),
|
||||
/// File I/O error with context
|
||||
#[error("File I/O error: {0}")]
|
||||
FileIo(String),
|
||||
/// Serialization/deserialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
}
|
||||
420
crates/cull-gmail/src/gmail_client.rs
Normal file
420
crates/cull-gmail/src/gmail_client.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! # Gmail Client Module
|
||||
//!
|
||||
//! This module provides the core Gmail API client functionality for the cull-gmail application.
|
||||
//! The `GmailClient` struct manages Gmail API connections, authentication, and message operations.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The Gmail client provides:
|
||||
//!
|
||||
//! - Authenticated Gmail API access using OAuth2 flows
|
||||
//! - Label management and mapping functionality
|
||||
//! - Message list operations with filtering support
|
||||
//! - Configuration-based setup with credential management
|
||||
//! - Integration with Gmail's REST API via the `google-gmail1` crate
|
||||
//!
|
||||
//! ## Authentication
|
||||
//!
|
||||
//! The client uses OAuth2 authentication with the "installed application" flow,
|
||||
//! requiring client credentials (client ID and secret) to be configured. Tokens
|
||||
//! are automatically managed and persisted to disk for reuse.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! The client is configured using [`ClientConfig`] which specifies:
|
||||
//! - OAuth2 credentials (client ID, client secret)
|
||||
//! - Token persistence location
|
||||
//! - Configuration file paths
|
||||
//!
|
||||
//! ## Error Handling
|
||||
//!
|
||||
//! All operations return `Result<T, Error>` where [`Error`] encompasses:
|
||||
//! - Gmail API errors (network, authentication, quota)
|
||||
//! - Configuration and credential errors
|
||||
//! - I/O errors from file operations
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ### Basic Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use cull_gmail::{ClientConfig, GmailClient};
|
||||
//!
|
||||
//! # async fn example() -> cull_gmail::Result<()> {
|
||||
//! // Create configuration with OAuth2 credentials
|
||||
//! let config = ClientConfig::builder()
|
||||
//! .with_client_id("your-client-id.googleusercontent.com")
|
||||
//! .with_client_secret("your-client-secret")
|
||||
//! .build();
|
||||
//!
|
||||
//! // Initialize Gmail client with authentication
|
||||
//! let client = GmailClient::new_with_config(config).await?;
|
||||
//!
|
||||
//! // Display available labels
|
||||
//! client.show_label();
|
||||
//!
|
||||
//! // Get label ID for a specific label name
|
||||
//! if let Some(inbox_id) = client.get_label_id("INBOX") {
|
||||
//! println!("Inbox ID: {}", inbox_id);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Label Operations
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use cull_gmail::{ClientConfig, GmailClient};
|
||||
//!
|
||||
//! # async fn example() -> cull_gmail::Result<()> {
|
||||
//! # let config = ClientConfig::builder().build();
|
||||
//! let client = GmailClient::new_with_config(config).await?;
|
||||
//!
|
||||
//! // Check if a label exists
|
||||
//! match client.get_label_id("Important") {
|
||||
//! Some(id) => println!("Important label ID: {}", id),
|
||||
//! None => println!("Important label not found"),
|
||||
//! }
|
||||
//!
|
||||
//! // List all available labels (logged to console)
|
||||
//! client.show_label();
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Thread Safety
|
||||
//!
|
||||
//! The Gmail client contains async operations and internal state. While individual
|
||||
//! operations are thread-safe, the client itself should not be shared across
|
||||
//! threads without proper synchronization.
|
||||
//!
|
||||
//! ## Rate Limits
|
||||
//!
|
||||
//! The Gmail API has usage quotas and rate limits. The client does not implement
|
||||
//! automatic retry logic, so applications should handle rate limit errors appropriately.
|
||||
//!
|
||||
//! [`ClientConfig`]: crate::ClientConfig
|
||||
//! [`Error`]: crate::Error
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use google_gmail1::{
|
||||
Gmail,
|
||||
hyper_rustls::{HttpsConnector, HttpsConnectorBuilder},
|
||||
hyper_util::{
|
||||
client::legacy::{Client, connect::HttpConnector},
|
||||
rt::TokioExecutor,
|
||||
},
|
||||
yup_oauth2::{CustomHyperClientBuilder, InstalledFlowAuthenticator, InstalledFlowReturnMethod},
|
||||
};
|
||||
|
||||
mod message_summary;
|
||||
|
||||
pub(crate) use message_summary::MessageSummary;
|
||||
|
||||
use crate::{ClientConfig, Error, Result, rules::EolRule};
|
||||
|
||||
/// Default maximum number of results to return per page from Gmail API calls.
|
||||
///
|
||||
/// This constant defines the default page size for Gmail API list operations.
|
||||
/// The value "200" represents a balance between API efficiency and memory usage.
|
||||
///
|
||||
/// Gmail API supports up to 500 results per page, but 200 provides good performance
|
||||
/// while keeping response sizes manageable.
|
||||
pub const DEFAULT_MAX_RESULTS: &str = "200";
|
||||
|
||||
/// Gmail API client providing authenticated access to Gmail operations.
|
||||
///
|
||||
/// `GmailClient` manages the connection to Gmail's REST API, handles OAuth2 authentication,
|
||||
/// maintains label mappings, and provides methods for message list operations.
|
||||
///
|
||||
/// The client contains internal state for:
|
||||
/// - Authentication credentials and tokens
|
||||
/// - Label name-to-ID mappings
|
||||
/// - Query filters and pagination settings
|
||||
/// - Retrieved message summaries
|
||||
/// - Rule processing configuration
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use cull_gmail::{ClientConfig, GmailClient};
|
||||
///
|
||||
/// # async fn example() -> cull_gmail::Result<()> {
|
||||
/// let config = ClientConfig::builder()
|
||||
/// .with_client_id("client-id")
|
||||
/// .with_client_secret("client-secret")
|
||||
/// .build();
|
||||
///
|
||||
/// let mut client = GmailClient::new_with_config(config).await?;
|
||||
/// client.show_label();
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct GmailClient {
|
||||
hub: Gmail<HttpsConnector<HttpConnector>>,
|
||||
label_map: BTreeMap<String, String>,
|
||||
pub(crate) max_results: u32,
|
||||
pub(crate) label_ids: Vec<String>,
|
||||
pub(crate) query: String,
|
||||
pub(crate) messages: Vec<MessageSummary>,
|
||||
pub(crate) rule: Option<EolRule>,
|
||||
pub(crate) execute: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GmailClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GmailClient")
|
||||
.field("label_map", &self.label_map)
|
||||
.field("max_results", &self.max_results)
|
||||
.field("label_ids", &self.label_ids)
|
||||
.field("query", &self.query)
|
||||
.field("messages_count", &self.messages.len())
|
||||
.field("execute", &self.execute)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl GmailClient {
|
||||
// /// Create a new Gmail Api connection and fetch label map using credential file.
|
||||
// pub async fn new_from_credential_file(credential_file: &str) -> Result<Self> {
|
||||
// let (config_dir, secret) = {
|
||||
// let config_dir = crate::utils::assure_config_dir_exists("~/.cull-gmail")?;
|
||||
|
||||
// let home_dir = env::home_dir().unwrap();
|
||||
|
||||
// let path = home_dir.join(".cull-gmail").join(credential_file);
|
||||
// let json_str = fs::read_to_string(path).expect("could not read path");
|
||||
|
||||
// let console: ConsoleApplicationSecret =
|
||||
// serde_json::from_str(&json_str).expect("could not convert to struct");
|
||||
|
||||
// let secret: ApplicationSecret = console.installed.unwrap();
|
||||
// (config_dir, secret)
|
||||
// };
|
||||
|
||||
// GmailClient::new_from_secret(secret, &config_dir).await
|
||||
// }
|
||||
|
||||
/// Creates a new Gmail client with the provided configuration.
|
||||
///
|
||||
/// This method initializes a Gmail API client with OAuth2 authentication using the
|
||||
/// "installed application" flow. It sets up the HTTPS connector, authenticates
|
||||
/// using the provided credentials, and fetches the label mapping from Gmail.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Client configuration containing OAuth2 credentials and settings
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a configured `GmailClient` ready for API operations, or an error if:
|
||||
/// - Authentication fails (invalid credentials, network issues)
|
||||
/// - Gmail API is unreachable
|
||||
/// - Label fetching fails
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This method can fail with:
|
||||
/// - [`Error::GoogleGmail1`] - Gmail API errors during authentication or label fetch
|
||||
/// - Network connectivity issues during OAuth2 flow
|
||||
/// - [`Error::NoLabelsFound`] - If no labels exist in the mailbox (unusual)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use cull_gmail::{ClientConfig, GmailClient};
|
||||
///
|
||||
/// # async fn example() -> cull_gmail::Result<()> {
|
||||
/// let config = ClientConfig::builder()
|
||||
/// .with_client_id("123456789-abc.googleusercontent.com")
|
||||
/// .with_client_secret("your-client-secret")
|
||||
/// .build();
|
||||
///
|
||||
/// let client = GmailClient::new_with_config(config).await?;
|
||||
/// println!("Gmail client initialized successfully");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method contains `.unwrap()` calls for:
|
||||
/// - HTTPS connector building (should not fail with valid TLS setup)
|
||||
/// - Default max results parsing (hardcoded valid string)
|
||||
/// - OAuth2 authenticator building (should not fail with valid config)
|
||||
///
|
||||
/// [`Error::GoogleGmail1`]: crate::Error::GoogleGmail1
|
||||
/// [`Error::NoLabelsFound`]: crate::Error::NoLabelsFound
|
||||
pub async fn new_with_config(config: ClientConfig) -> Result<Self> {
|
||||
let executor = TokioExecutor::new();
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()
|
||||
.unwrap()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
|
||||
let client = Client::builder(executor.clone()).build(connector.clone());
|
||||
log::trace!("file to persist tokens to `{}`", config.persist_path());
|
||||
|
||||
let auth_client = Client::builder(executor).build(connector);
|
||||
let auth = InstalledFlowAuthenticator::with_client(
|
||||
config.secret().clone(),
|
||||
InstalledFlowReturnMethod::HTTPRedirect,
|
||||
CustomHyperClientBuilder::from(auth_client),
|
||||
)
|
||||
.persist_tokens_to_disk(config.persist_path())
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hub = Gmail::new(client, auth);
|
||||
let label_map = GmailClient::get_label_map(&hub).await?;
|
||||
|
||||
Ok(GmailClient {
|
||||
hub,
|
||||
label_map,
|
||||
max_results: DEFAULT_MAX_RESULTS.parse::<u32>().unwrap(),
|
||||
label_ids: Vec::new(),
|
||||
query: String::new(),
|
||||
messages: Vec::new(),
|
||||
rule: None,
|
||||
execute: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches the label mapping from Gmail API.
|
||||
///
|
||||
/// This method retrieves all labels from the user's Gmail account and creates
|
||||
/// a mapping from label names to their corresponding label IDs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `hub` - The Gmail API hub instance for making API calls
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `BTreeMap` containing label name to ID mappings, or an error if
|
||||
/// the API call fails or no labels are found.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::GoogleGmail1`] - Gmail API request failure
|
||||
/// - [`Error::NoLabelsFound`] - No labels exist in the mailbox
|
||||
///
|
||||
/// [`Error::GoogleGmail1`]: crate::Error::GoogleGmail1
|
||||
/// [`Error::NoLabelsFound`]: crate::Error::NoLabelsFound
|
||||
async fn get_label_map(
|
||||
hub: &Gmail<HttpsConnector<HttpConnector>>,
|
||||
) -> Result<BTreeMap<String, String>> {
|
||||
let call = hub.users().labels_list("me");
|
||||
let (_response, list) = call
|
||||
.add_scope("https://mail.google.com/")
|
||||
.doit()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
|
||||
let Some(label_list) = list.labels else {
|
||||
return Err(Error::NoLabelsFound);
|
||||
};
|
||||
|
||||
let mut label_map = BTreeMap::new();
|
||||
for label in &label_list {
|
||||
if label.id.is_some() && label.name.is_some() {
|
||||
let name = label.name.clone().unwrap();
|
||||
let id = label.id.clone().unwrap();
|
||||
label_map.insert(name, id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(label_map)
|
||||
}
|
||||
|
||||
/// Retrieves the Gmail label ID for a given label name.
|
||||
///
|
||||
/// This method looks up a label name in the internal label mapping and returns
|
||||
/// the corresponding Gmail label ID if found.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The label name to look up (case-sensitive)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(String)` containing the label ID if the label exists,
|
||||
/// or `None` if the label name is not found.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::{ClientConfig, GmailClient};
|
||||
/// # async fn example(client: &GmailClient) {
|
||||
/// // Look up standard Gmail labels
|
||||
/// if let Some(inbox_id) = client.get_label_id("INBOX") {
|
||||
/// println!("Inbox ID: {}", inbox_id);
|
||||
/// }
|
||||
///
|
||||
/// // Look up custom labels
|
||||
/// match client.get_label_id("Important") {
|
||||
/// Some(id) => println!("Found label ID: {}", id),
|
||||
/// None => println!("Label 'Important' not found"),
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn get_label_id(&self, name: &str) -> Option<String> {
|
||||
self.label_map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Displays all available labels and their IDs to the log.
|
||||
///
|
||||
/// This method iterates through the internal label mapping and outputs each
|
||||
/// label name and its corresponding ID using the `log::info!` macro.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::{ClientConfig, GmailClient};
|
||||
/// # async fn example() -> cull_gmail::Result<()> {
|
||||
/// # let config = ClientConfig::builder().build();
|
||||
/// let client = GmailClient::new_with_config(config).await?;
|
||||
///
|
||||
/// // Display all labels (output goes to log)
|
||||
/// client.show_label();
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// Output example:
|
||||
/// ```text
|
||||
/// INFO: INBOX: Label_1
|
||||
/// INFO: SENT: Label_2
|
||||
/// INFO: Important: Label_3
|
||||
/// ```
|
||||
pub fn show_label(&self) {
|
||||
for (name, id) in self.label_map.iter() {
|
||||
log::info!("{name}: {id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a clone of the Gmail API hub for direct API access.
|
||||
///
|
||||
/// This method provides access to the underlying Gmail API client hub,
|
||||
/// allowing for direct API operations not covered by the higher-level
|
||||
/// methods in this struct.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A cloned `Gmail` hub instance configured with the same authentication
|
||||
/// and connectors as this client.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
|
||||
self.hub.clone()
|
||||
}
|
||||
}
|
||||
327
crates/cull-gmail/src/gmail_client/message_summary.rs
Normal file
327
crates/cull-gmail/src/gmail_client/message_summary.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
//! # Message Summary Module
|
||||
//!
|
||||
//! This module provides the `MessageSummary` struct for representing Gmail message metadata
|
||||
//! in a simplified format suitable for display and processing.
|
||||
|
||||
use crate::utils::Elide;
|
||||
|
||||
/// A simplified representation of Gmail message metadata.
|
||||
///
|
||||
/// `MessageSummary` stores essential message information including ID, subject, and date.
|
||||
/// It provides methods for accessing this information with fallback text for missing data.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// // MessageSummary is pub(crate), so this example is for illustration only
|
||||
/// # struct MessageSummary { id: String, subject: Option<String>, date: Option<String> }
|
||||
/// # impl MessageSummary {
|
||||
/// # fn new(id: &str) -> Self { Self { id: id.to_string(), subject: None, date: None } }
|
||||
/// # fn set_subject(&mut self, subject: Option<String>) { self.subject = subject; }
|
||||
/// # fn set_date(&mut self, date: Option<String>) { self.date = date; }
|
||||
/// # fn subject(&self) -> &str { self.subject.as_deref().unwrap_or("*** No Subject ***") }
|
||||
/// # fn date(&self) -> &str { self.date.as_deref().unwrap_or("*** No Date ***") }
|
||||
/// # fn list_date_and_subject(&self) -> String { format!("Date: {}, Subject: {}", self.date(), self.subject()) }
|
||||
/// # }
|
||||
/// let mut summary = MessageSummary::new("message_123");
|
||||
/// summary.set_subject(Some("Hello World".to_string()));
|
||||
/// summary.set_date(Some("2023-01-15 10:30:00".to_string()));
|
||||
///
|
||||
/// println!("Subject: {}", summary.subject());
|
||||
/// println!("Date: {}", summary.date());
|
||||
/// println!("Summary: {}", summary.list_date_and_subject());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageSummary {
|
||||
id: String,
|
||||
date: Option<String>,
|
||||
subject: Option<String>,
|
||||
}
|
||||
|
||||
impl MessageSummary {
|
||||
/// Creates a new `MessageSummary` with the given message ID.
|
||||
///
|
||||
/// The subject and date fields are initialized as `None` and can be set later
|
||||
/// using the setter methods.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - The Gmail message ID
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # struct MessageSummary(String);
|
||||
/// # impl MessageSummary {
|
||||
/// # fn new(id: &str) -> Self { Self(id.to_string()) }
|
||||
/// # fn id(&self) -> &str { &self.0 }
|
||||
/// # }
|
||||
/// let summary = MessageSummary::new("1234567890abcdef");
|
||||
/// assert_eq!(summary.id(), "1234567890abcdef");
|
||||
/// ```
|
||||
pub(crate) fn new(id: &str) -> Self {
|
||||
MessageSummary {
|
||||
id: id.to_string(),
|
||||
date: None,
|
||||
subject: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Gmail message ID.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Sets the subject line of the message.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `subject` - Optional subject line text
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn set_subject(&mut self, subject: Option<String>) {
|
||||
self.subject = subject
|
||||
}
|
||||
|
||||
/// Returns the subject line or a fallback message if none is set.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The subject line if available, otherwise "*** No Subject for Message ***".
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn subject(&self) -> &str {
|
||||
if let Some(s) = &self.subject {
|
||||
s
|
||||
} else {
|
||||
"*** No Subject for Message ***"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the date of the message.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `date` - Optional date string (typically in RFC format)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn set_date(&mut self, date: Option<String>) {
|
||||
self.date = date
|
||||
}
|
||||
|
||||
/// Returns the message date or a fallback message if none is set.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The date string if available, otherwise "*** No Date for Message ***".
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn date(&self) -> &str {
|
||||
if let Some(d) = &self.date {
|
||||
d
|
||||
} else {
|
||||
"*** No Date for Message ***"
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a formatted string combining date and subject for list display.
|
||||
///
|
||||
/// This method extracts a portion of the date (characters 5-16) and combines it
|
||||
/// with an elided version of the subject line for compact display in message lists.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A formatted string with date and subject, or an error message if either
|
||||
/// field is missing.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # fn example() { }
|
||||
/// ```
|
||||
pub(crate) fn list_date_and_subject(&self) -> String {
|
||||
let Some(date) = self.date.as_ref() else {
|
||||
return "***invalid date or subject***".to_string();
|
||||
};
|
||||
|
||||
let Some(subject) = self.subject.as_ref() else {
|
||||
return "***invalid date or subject***".to_string();
|
||||
};
|
||||
let s = date[5..16].to_string();
|
||||
let s = format!("{s}: {}", subject.clone().elide(24));
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_new() {
|
||||
let summary = MessageSummary::new("test_message_id");
|
||||
assert_eq!(summary.id(), "test_message_id");
|
||||
assert_eq!(summary.subject(), "*** No Subject for Message ***");
|
||||
assert_eq!(summary.date(), "*** No Date for Message ***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_set_subject() {
|
||||
let mut summary = MessageSummary::new("test_id");
|
||||
|
||||
// Test setting a subject
|
||||
summary.set_subject(Some("Test Subject".to_string()));
|
||||
assert_eq!(summary.subject(), "Test Subject");
|
||||
|
||||
// Test setting subject to None
|
||||
summary.set_subject(None);
|
||||
assert_eq!(summary.subject(), "*** No Subject for Message ***");
|
||||
|
||||
// Test empty subject
|
||||
summary.set_subject(Some("".to_string()));
|
||||
assert_eq!(summary.subject(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_set_date() {
|
||||
let mut summary = MessageSummary::new("test_id");
|
||||
|
||||
// Test setting a date
|
||||
summary.set_date(Some("2023-12-25 10:30:00".to_string()));
|
||||
assert_eq!(summary.date(), "2023-12-25 10:30:00");
|
||||
|
||||
// Test setting date to None
|
||||
summary.set_date(None);
|
||||
assert_eq!(summary.date(), "*** No Date for Message ***");
|
||||
|
||||
// Test empty date
|
||||
summary.set_date(Some("".to_string()));
|
||||
assert_eq!(summary.date(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_list_date_and_subject_valid() {
|
||||
let mut summary = MessageSummary::new("test_id");
|
||||
|
||||
// Set up a realistic date and subject
|
||||
summary.set_date(Some("2023-12-25 10:30:00 GMT".to_string()));
|
||||
summary.set_subject(Some(
|
||||
"This is a very long subject that should be elided".to_string(),
|
||||
));
|
||||
|
||||
let display = summary.list_date_and_subject();
|
||||
|
||||
// The method extracts characters 5-16 from date and elides subject to 24 chars
|
||||
// "2023-12-25 10:30:00 GMT" -> chars 5-16 would be "2-25 10:30"
|
||||
assert!(display.contains("2-25 10:30"));
|
||||
assert!(display.contains(":"));
|
||||
assert!(display.len() <= 40); // Should be reasonably short for display
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_list_date_and_subject_missing_fields() {
|
||||
let mut summary = MessageSummary::new("test_id");
|
||||
|
||||
// Test with missing date
|
||||
summary.set_subject(Some("Test Subject".to_string()));
|
||||
let result = summary.list_date_and_subject();
|
||||
assert_eq!(result, "***invalid date or subject***");
|
||||
|
||||
// Test with missing subject
|
||||
let mut summary2 = MessageSummary::new("test_id");
|
||||
summary2.set_date(Some("2023-12-25 10:30:00".to_string()));
|
||||
let result2 = summary2.list_date_and_subject();
|
||||
assert_eq!(result2, "***invalid date or subject***");
|
||||
|
||||
// Test with both missing
|
||||
let summary3 = MessageSummary::new("test_id");
|
||||
let result3 = summary3.list_date_and_subject();
|
||||
assert_eq!(result3, "***invalid date or subject***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_clone() {
|
||||
let mut original = MessageSummary::new("original_id");
|
||||
original.set_subject(Some("Original Subject".to_string()));
|
||||
original.set_date(Some("2023-12-25 10:30:00".to_string()));
|
||||
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id(), cloned.id());
|
||||
assert_eq!(original.subject(), cloned.subject());
|
||||
assert_eq!(original.date(), cloned.date());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_debug() {
|
||||
let mut summary = MessageSummary::new("debug_test_id");
|
||||
summary.set_subject(Some("Debug Subject".to_string()));
|
||||
summary.set_date(Some("2023-12-25".to_string()));
|
||||
|
||||
let debug_str = format!("{summary:?}");
|
||||
|
||||
// Verify the debug output contains expected fields
|
||||
assert!(debug_str.contains("MessageSummary"));
|
||||
assert!(debug_str.contains("debug_test_id"));
|
||||
assert!(debug_str.contains("Debug Subject"));
|
||||
assert!(debug_str.contains("2023-12-25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_unicode_handling() {
|
||||
let mut summary = MessageSummary::new("unicode_test");
|
||||
|
||||
// Test with Unicode characters in subject and date
|
||||
summary.set_subject(Some("📧 Important émails with 中文字符".to_string()));
|
||||
summary.set_date(Some("2023-12-25 10:30:00 UTC+8 🕒".to_string()));
|
||||
|
||||
assert_eq!(summary.subject(), "📧 Important émails with 中文字符");
|
||||
assert_eq!(summary.date(), "2023-12-25 10:30:00 UTC+8 🕒");
|
||||
|
||||
// Ensure list formatting doesn't panic with Unicode
|
||||
let display = summary.list_date_and_subject();
|
||||
assert!(!display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_summary_edge_cases() {
|
||||
let test_cases = vec![
|
||||
("", "Empty ID"),
|
||||
("a", "Single char ID"),
|
||||
(
|
||||
"very_long_message_id_that_exceeds_normal_length_expectations_123456789",
|
||||
"Very long ID",
|
||||
),
|
||||
("msg-with-dashes", "ID with dashes"),
|
||||
("msg_with_underscores", "ID with underscores"),
|
||||
("123456789", "Numeric ID"),
|
||||
];
|
||||
|
||||
for (id, description) in test_cases {
|
||||
let summary = MessageSummary::new(id);
|
||||
assert_eq!(summary.id(), id, "Failed for case: {description}");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
crates/cull-gmail/src/lib.rs
Normal file
36
crates/cull-gmail/src/lib.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![warn(missing_docs)]
|
||||
#![cfg_attr(docsrs, feature(rustdoc_missing_doc_code_examples))]
|
||||
#![cfg_attr(docsrs, warn(rustdoc::invalid_codeblock_attributes))]
|
||||
#![doc = include_str!("../docs/lib/lib.md")]
|
||||
|
||||
mod client_config;
|
||||
mod eol_action;
|
||||
mod error;
|
||||
mod gmail_client;
|
||||
mod message_list;
|
||||
mod retention;
|
||||
mod rule_processor;
|
||||
mod rules;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_utils;
|
||||
|
||||
pub(crate) mod utils;
|
||||
|
||||
pub use gmail_client::DEFAULT_MAX_RESULTS;
|
||||
|
||||
pub use client_config::ClientConfig;
|
||||
pub use gmail_client::GmailClient;
|
||||
pub(crate) use gmail_client::MessageSummary;
|
||||
pub use retention::Retention;
|
||||
pub use rules::Rules;
|
||||
|
||||
pub use eol_action::EolAction;
|
||||
pub use error::Error;
|
||||
pub use retention::MessageAge;
|
||||
|
||||
pub use message_list::MessageList;
|
||||
pub use rule_processor::RuleProcessor;
|
||||
|
||||
/// Type alias for result with crate Error
|
||||
pub type Result<O> = std::result::Result<O, Error>;
|
||||
906
crates/cull-gmail/src/message_list.rs
Normal file
906
crates/cull-gmail/src/message_list.rs
Normal file
@@ -0,0 +1,906 @@
|
||||
//! # Message List Module
|
||||
//!
|
||||
//! This module provides the `MessageList` trait for interacting with Gmail message lists.
|
||||
//! The trait defines methods for retrieving, filtering, and managing collections of Gmail messages.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The `MessageList` trait provides:
|
||||
//!
|
||||
//! - Message list retrieval with pagination support
|
||||
//! - Label and query-based filtering
|
||||
//! - Message metadata fetching and logging
|
||||
//! - Configuration of result limits and query parameters
|
||||
//!
|
||||
//! ## Error Handling
|
||||
//!
|
||||
//! All asynchronous methods return `Result<T>` where errors may include:
|
||||
//! - Gmail API communication errors
|
||||
//! - Authentication failures
|
||||
//! - Network connectivity issues
|
||||
//! - Invalid query parameters
|
||||
//!
|
||||
//! ## Threading
|
||||
//!
|
||||
//! All async methods in this trait are `Send` compatible, allowing them to be used
|
||||
//! across thread boundaries in concurrent contexts.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use cull_gmail::{GmailClient, MessageList, ClientConfig};
|
||||
//!
|
||||
//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Create a client with proper configuration (credentials required)
|
||||
//! let config = ClientConfig::builder()
|
||||
//! .with_client_id("your-client-id")
|
||||
//! .with_client_secret("your-client-secret")
|
||||
//! .build();
|
||||
//! let mut client = GmailClient::new_with_config(config).await?;
|
||||
//!
|
||||
//! // Configure search parameters
|
||||
//! client.set_query("is:unread");
|
||||
//! client.set_max_results(50);
|
||||
//!
|
||||
//! // Retrieve messages from Gmail
|
||||
//! client.get_messages(1).await?;
|
||||
//!
|
||||
//! // Access retrieved message summaries
|
||||
//! let messages = client.messages();
|
||||
//! println!("Found {} messages", messages.len());
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
|
||||
|
||||
use crate::{GmailClient, MessageSummary, Result};
|
||||
|
||||
use google_gmail1::{
|
||||
Gmail,
|
||||
api::{ListMessagesResponse, Message as GmailMessage},
|
||||
hyper_rustls::HttpsConnector,
|
||||
hyper_util::client::legacy::connect::HttpConnector,
|
||||
};
|
||||
|
||||
/// A trait for interacting with Gmail message lists, providing methods for
|
||||
/// retrieving, filtering, and managing collections of Gmail messages.
|
||||
///
|
||||
/// This trait abstracts the core operations needed to work with Gmail message lists,
|
||||
/// including pagination, filtering by labels and queries, and configuring result limits.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use cull_gmail::{MessageList, GmailClient, ClientConfig};
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ClientConfig::builder().build();
|
||||
/// let mut client = GmailClient::new_with_config(config).await?;
|
||||
///
|
||||
/// // Set search parameters
|
||||
/// client.set_query("is:unread");
|
||||
/// client.set_max_results(100);
|
||||
///
|
||||
/// // Retrieve first page of messages
|
||||
/// client.get_messages(1).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub trait MessageList {
|
||||
/// Fetches detailed metadata for stored messages and logs their subjects and dates.
|
||||
///
|
||||
/// This method retrieves the subject line and date for each message currently
|
||||
/// stored in the message list and outputs them to the log.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `pre` text to display before the date/subject message identifier
|
||||
/// - `post` text to display after the date/subject message identifier
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` on success, or an error if the Gmail API request fails.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This method can fail if:
|
||||
/// - The Gmail API is unreachable
|
||||
/// - Authentication credentials are invalid or expired
|
||||
/// - Network connectivity issues occur
|
||||
/// - Individual message retrieval fails
|
||||
fn log_messages(
|
||||
&mut self,
|
||||
pre: &str,
|
||||
post: &str,
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Retrieves a list of messages from Gmail based on current filter settings.
|
||||
///
|
||||
/// This method calls the Gmail API to get a page of messages matching the
|
||||
/// configured query and label filters. Retrieved message IDs are stored
|
||||
/// internally for further operations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `next_page_token` - Optional token for pagination. Use `None` for the first page,
|
||||
/// or the token from a previous response to get subsequent pages.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the raw `ListMessagesResponse` from the Gmail API, which contains
|
||||
/// message metadata and pagination tokens.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This method can fail if:
|
||||
/// - The Gmail API request fails
|
||||
/// - Authentication is invalid
|
||||
/// - The query syntax is malformed
|
||||
/// - Network issues prevent the API call
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::{MessageList, GmailClient, ClientConfig};
|
||||
/// # async fn example(mut client: impl MessageList) -> cull_gmail::Result<()> {
|
||||
/// // Get the first page of results
|
||||
/// let response = client.list_messages(None).await?;
|
||||
///
|
||||
/// // Get the next page if available
|
||||
/// if let Some(token) = response.next_page_token {
|
||||
/// let next_page = client.list_messages(Some(token)).await?;
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
fn list_messages(
|
||||
&mut self,
|
||||
next_page_token: Option<String>,
|
||||
) -> impl std::future::Future<Output = Result<ListMessagesResponse>> + Send;
|
||||
|
||||
/// Retrieves multiple pages of messages based on the specified page limit.
|
||||
///
|
||||
/// This method handles pagination automatically, fetching the specified number
|
||||
/// of pages or all available pages if `pages` is 0.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pages` - Number of pages to retrieve:
|
||||
/// - `0`: Fetch all available pages
|
||||
/// - `1`: Fetch only the first page
|
||||
/// - `n > 1`: Fetch exactly `n` pages or until no more pages are available
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` on success. All retrieved messages are stored internally
|
||||
/// and can be accessed via `messages()`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This method can fail if any individual page request fails. See `list_messages`
|
||||
/// for specific error conditions.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::{MessageList, GmailClient, ClientConfig};
|
||||
/// # async fn example(mut client: impl MessageList) -> cull_gmail::Result<()> {
|
||||
/// // Get all available pages
|
||||
/// client.get_messages(0).await?;
|
||||
///
|
||||
/// // Get exactly 3 pages
|
||||
/// client.get_messages(3).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
fn get_messages(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Returns a reference to the Gmail API hub for direct API access.
|
||||
///
|
||||
/// This method provides access to the underlying Gmail API client for
|
||||
/// advanced operations not covered by this trait.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A cloned `Gmail` hub instance configured with the appropriate connectors.
|
||||
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>>;
|
||||
|
||||
/// Returns the list of label IDs currently configured for message filtering.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of Gmail label ID strings. These IDs are used to filter
|
||||
/// messages during API calls.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # fn example(client: impl MessageList) {
|
||||
/// let labels = client.label_ids();
|
||||
/// println!("Filtering by {} labels", labels.len());
|
||||
/// # }
|
||||
/// ```
|
||||
fn label_ids(&self) -> Vec<String>;
|
||||
|
||||
/// Returns a list of message IDs for all currently stored messages.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of Gmail message ID strings. These IDs can be used for
|
||||
/// further Gmail API operations on specific messages.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # fn example(client: impl MessageList) {
|
||||
/// let message_ids = client.message_ids();
|
||||
/// println!("Found {} messages", message_ids.len());
|
||||
/// # }
|
||||
/// ```
|
||||
fn message_ids(&self) -> Vec<String>;
|
||||
|
||||
/// Returns a reference to the collection of message summaries.
|
||||
///
|
||||
/// This method provides access to all message summaries currently stored,
|
||||
/// including any metadata that has been fetched.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A reference to a vector of `MessageSummary` objects containing
|
||||
/// message IDs and any retrieved metadata.
|
||||
fn messages(&self) -> &Vec<MessageSummary>;
|
||||
|
||||
/// Sets the search query string for filtering messages.
|
||||
///
|
||||
/// This method configures the Gmail search query that will be used in
|
||||
/// subsequent API calls. The query uses Gmail's search syntax.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - A Gmail search query string (e.g., "is:unread", "from:example@gmail.com")
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # fn example(mut client: impl MessageList) {
|
||||
/// client.set_query("is:unread older_than:30d");
|
||||
/// client.set_query("from:noreply@example.com");
|
||||
/// # }
|
||||
/// ```
|
||||
fn set_query(&mut self, query: &str);
|
||||
|
||||
/// Adds Gmail label IDs to the current filter list.
|
||||
///
|
||||
/// This method appends the provided label IDs to the existing list of
|
||||
/// labels used for filtering messages. Messages must match ALL specified labels.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `label_ids` - A slice of Gmail label ID strings to add to the filter
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # fn example(mut client: impl MessageList) {
|
||||
/// let label_ids = vec!["Label_1".to_string(), "Label_2".to_string()];
|
||||
/// client.add_labels_ids(&label_ids);
|
||||
/// # }
|
||||
/// ```
|
||||
fn add_labels_ids(&mut self, label_ids: &[String]);
|
||||
|
||||
/// Adds Gmail labels by name to the current filter list.
|
||||
///
|
||||
/// This method resolves label names to their corresponding IDs and adds them
|
||||
/// to the filter list. This is more convenient than using `add_labels_ids`
|
||||
/// when you know the label names but not their IDs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `labels` - A slice of Gmail label name strings (e.g., "INBOX", "SPAM")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<()>` on success, or an error if label name resolution fails.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This method can fail if label name to ID resolution is not available
|
||||
/// or if the underlying label ID mapping is not accessible.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # async fn example(mut client: impl MessageList) -> cull_gmail::Result<()> {
|
||||
/// let labels = vec!["INBOX".to_string(), "IMPORTANT".to_string()];
|
||||
/// client.add_labels(&labels)?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
fn add_labels(&mut self, labels: &[String]) -> Result<()>;
|
||||
|
||||
/// Returns the current maximum results limit per API request.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The maximum number of messages to retrieve in a single API call.
|
||||
/// Default is typically 200.
|
||||
fn max_results(&self) -> u32;
|
||||
|
||||
/// Sets the maximum number of results to return per API request.
|
||||
///
|
||||
/// This controls how many messages are retrieved in each page when calling
|
||||
/// the Gmail API. Larger values reduce the number of API calls needed but
|
||||
/// increase memory usage and response time.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - Maximum results per page (typically 1-500, Gmail API limits apply)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use cull_gmail::MessageList;
|
||||
/// # fn example(mut client: impl MessageList) {
|
||||
/// client.set_max_results(100); // Retrieve 100 messages per page
|
||||
/// client.set_max_results(500); // Retrieve 500 messages per page (maximum)
|
||||
/// # }
|
||||
/// ```
|
||||
fn set_max_results(&mut self, value: u32);
|
||||
}
|
||||
|
||||
/// Abstraction for Gmail API calls used by MessageList.
|
||||
pub(crate) trait GmailService {
|
||||
/// Fetch a page of messages using current filters.
|
||||
async fn list_messages_page(
|
||||
&self,
|
||||
label_ids: &[String],
|
||||
query: &str,
|
||||
max_results: u32,
|
||||
page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse>;
|
||||
|
||||
/// Fetch minimal metadata for a message (subject, date, etc.).
|
||||
async fn get_message_metadata(&self, message_id: &str) -> Result<GmailMessage>;
|
||||
}
|
||||
|
||||
impl GmailClient {
|
||||
/// Append any message IDs from a ListMessagesResponse into the provided messages vector.
|
||||
fn append_list_to_messages(out: &mut Vec<MessageSummary>, list: &ListMessagesResponse) {
|
||||
if let Some(msgs) = &list.messages {
|
||||
let mut list_ids: Vec<MessageSummary> = msgs
|
||||
.iter()
|
||||
.flat_map(|item| item.id.as_deref().map(MessageSummary::new))
|
||||
.collect();
|
||||
out.append(&mut list_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GmailService for GmailClient {
|
||||
async fn list_messages_page(
|
||||
&self,
|
||||
label_ids: &[String],
|
||||
query: &str,
|
||||
max_results: u32,
|
||||
page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse> {
|
||||
let hub = self.hub();
|
||||
let mut call = hub.users().messages_list("me").max_results(max_results);
|
||||
if !label_ids.is_empty() {
|
||||
for id in label_ids {
|
||||
call = call.add_label_ids(id);
|
||||
}
|
||||
}
|
||||
if !query.is_empty() {
|
||||
call = call.q(query);
|
||||
}
|
||||
if let Some(token) = page_token {
|
||||
call = call.page_token(&token);
|
||||
}
|
||||
let (_response, list) = call.doit().await.map_err(Box::new)?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn get_message_metadata(&self, message_id: &str) -> Result<GmailMessage> {
|
||||
let hub = self.hub();
|
||||
let (_res, m) = hub
|
||||
.users()
|
||||
.messages_get("me", message_id)
|
||||
.add_scope("https://mail.google.com/")
|
||||
.format("metadata")
|
||||
.add_metadata_headers("subject")
|
||||
.add_metadata_headers("date")
|
||||
.doit()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageList for GmailClient {
|
||||
/// Set the maximum results
|
||||
fn set_max_results(&mut self, value: u32) {
|
||||
self.max_results = value;
|
||||
}
|
||||
|
||||
/// Report the maximum results value
|
||||
fn max_results(&self) -> u32 {
|
||||
self.max_results
|
||||
}
|
||||
|
||||
/// Add label to the labels collection
|
||||
fn add_labels(&mut self, labels: &[String]) -> Result<()> {
|
||||
log::debug!("labels from command line: {labels:?}");
|
||||
let mut label_ids = Vec::new();
|
||||
for label in labels {
|
||||
if let Some(id) = self.get_label_id(label) {
|
||||
label_ids.push(id)
|
||||
}
|
||||
}
|
||||
self.add_labels_ids(label_ids.as_slice());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add label to the labels collection
|
||||
fn add_labels_ids(&mut self, label_ids: &[String]) {
|
||||
if !label_ids.is_empty() {
|
||||
self.label_ids.extend(label_ids.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the query string
|
||||
fn set_query(&mut self, query: &str) {
|
||||
self.query = query.to_string()
|
||||
}
|
||||
|
||||
/// Get the summary of the messages
|
||||
fn messages(&self) -> &Vec<MessageSummary> {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// Get a reference to the message_ids
|
||||
fn message_ids(&self) -> Vec<String> {
|
||||
self.messages.iter().map(|m| m.id().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Get a reference to the message_ids
|
||||
fn label_ids(&self) -> Vec<String> {
|
||||
self.label_ids.clone()
|
||||
}
|
||||
|
||||
/// Get the hub
|
||||
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
|
||||
self.hub().clone()
|
||||
}
|
||||
|
||||
/// Run the Gmail api as configured
|
||||
async fn get_messages(&mut self, pages: u32) -> Result<()> {
|
||||
let list = self.list_messages(None).await?;
|
||||
match pages {
|
||||
1 => {}
|
||||
0 => {
|
||||
let mut list = list;
|
||||
let mut page = 1;
|
||||
loop {
|
||||
page += 1;
|
||||
log::debug!("Processing page #{page}");
|
||||
if list.next_page_token.is_none() {
|
||||
break;
|
||||
}
|
||||
list = self.list_messages(list.next_page_token).await?;
|
||||
// self.log_message_subjects(&list).await?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut list = list;
|
||||
for page in 2..=pages {
|
||||
log::debug!("Processing page #{page}");
|
||||
if list.next_page_token.is_none() {
|
||||
break;
|
||||
}
|
||||
list = self.list_messages(list.next_page_token).await?;
|
||||
// self.log_message_subjects(&list).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_messages(
|
||||
&mut self,
|
||||
next_page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse> {
|
||||
if !self.label_ids.is_empty() {
|
||||
log::debug!("Setting labels for list: {:#?}", self.label_ids);
|
||||
}
|
||||
if !self.query.is_empty() {
|
||||
log::debug!("Setting query string `{}`", self.query);
|
||||
}
|
||||
if next_page_token.is_some() {
|
||||
log::debug!("Setting token for next page.");
|
||||
}
|
||||
|
||||
let list = self
|
||||
.list_messages_page(
|
||||
&self.label_ids,
|
||||
&self.query,
|
||||
self.max_results,
|
||||
next_page_token,
|
||||
)
|
||||
.await?;
|
||||
log::trace!(
|
||||
"Estimated {} messages.",
|
||||
list.result_size_estimate.unwrap_or(0)
|
||||
);
|
||||
|
||||
if list.result_size_estimate.unwrap_or(0) == 0 {
|
||||
log::warn!("Search returned no messages.");
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
Self::append_list_to_messages(&mut self.messages, &list);
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
async fn log_messages(&mut self, pre: &str, post: &str) -> Result<()> {
|
||||
for i in 0..self.messages.len() {
|
||||
let id = self.messages[i].id().to_string();
|
||||
log::trace!("{id}");
|
||||
let m = self.get_message_metadata(&id).await?;
|
||||
let message = &mut self.messages[i];
|
||||
log::trace!("Got the message: {m:?}");
|
||||
let Some(payload) = m.payload else { continue };
|
||||
let Some(headers) = payload.headers else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for header in headers {
|
||||
if let Some(name) = header.name {
|
||||
match name.to_lowercase().as_str() {
|
||||
"subject" => message.set_subject(header.value),
|
||||
"date" => message.set_date(header.value),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::info!("{pre}{}{post}", message.list_date_and_subject());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockList {
|
||||
label_ids: Vec<String>,
|
||||
query: String,
|
||||
max_results: u32,
|
||||
messages: Vec<MessageSummary>,
|
||||
}
|
||||
|
||||
impl MockList {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
label_ids: vec![],
|
||||
query: String::new(),
|
||||
max_results: 200,
|
||||
messages: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn push_msg(&mut self, id: &str) {
|
||||
self.messages.push(MessageSummary::new(id));
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageList for MockList {
|
||||
async fn log_messages(&mut self, _pre: &str, _post: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list_messages(
|
||||
&mut self,
|
||||
_next_page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse> {
|
||||
Ok(ListMessagesResponse::default())
|
||||
}
|
||||
async fn get_messages(&mut self, _pages: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
|
||||
panic!("not used in tests")
|
||||
}
|
||||
fn label_ids(&self) -> Vec<String> {
|
||||
self.label_ids.clone()
|
||||
}
|
||||
fn message_ids(&self) -> Vec<String> {
|
||||
self.messages.iter().map(|m| m.id().to_string()).collect()
|
||||
}
|
||||
fn messages(&self) -> &Vec<MessageSummary> {
|
||||
&self.messages
|
||||
}
|
||||
fn set_query(&mut self, query: &str) {
|
||||
self.query = query.to_string();
|
||||
}
|
||||
fn add_labels_ids(&mut self, label_ids: &[String]) {
|
||||
self.label_ids.extend_from_slice(label_ids);
|
||||
}
|
||||
fn add_labels(&mut self, _labels: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn max_results(&self) -> u32 {
|
||||
self.max_results
|
||||
}
|
||||
fn set_max_results(&mut self, value: u32) {
|
||||
self.max_results = value;
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct TestClient {
|
||||
label_ids: Vec<String>,
|
||||
query: String,
|
||||
max_results: u32,
|
||||
messages: Vec<MessageSummary>,
|
||||
pages: Mutex<HashMap<Option<String>, ListMessagesResponse>>,
|
||||
}
|
||||
|
||||
impl TestClient {
|
||||
fn with_pages(map: HashMap<Option<String>, ListMessagesResponse>) -> Self {
|
||||
Self {
|
||||
label_ids: vec![],
|
||||
query: String::new(),
|
||||
max_results: 200,
|
||||
messages: vec![],
|
||||
pages: Mutex::new(map),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::GmailService for TestClient {
|
||||
async fn list_messages_page(
|
||||
&self,
|
||||
_label_ids: &[String],
|
||||
_query: &str,
|
||||
_max_results: u32,
|
||||
page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse> {
|
||||
let map = self.pages.lock().unwrap();
|
||||
Ok(map
|
||||
.get(&page_token)
|
||||
.cloned()
|
||||
.unwrap_or_else(ListMessagesResponse::default))
|
||||
}
|
||||
|
||||
async fn get_message_metadata(&self, _message_id: &str) -> Result<GmailMessage> {
|
||||
Ok(GmailMessage::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageList for TestClient {
|
||||
fn set_max_results(&mut self, value: u32) {
|
||||
self.max_results = value;
|
||||
}
|
||||
fn max_results(&self) -> u32 {
|
||||
self.max_results
|
||||
}
|
||||
fn add_labels(&mut self, _labels: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn add_labels_ids(&mut self, label_ids: &[String]) {
|
||||
self.label_ids.extend_from_slice(label_ids);
|
||||
}
|
||||
fn set_query(&mut self, query: &str) {
|
||||
self.query = query.to_string();
|
||||
}
|
||||
fn messages(&self) -> &Vec<MessageSummary> {
|
||||
&self.messages
|
||||
}
|
||||
fn message_ids(&self) -> Vec<String> {
|
||||
self.messages.iter().map(|m| m.id().to_string()).collect()
|
||||
}
|
||||
fn label_ids(&self) -> Vec<String> {
|
||||
self.label_ids.clone()
|
||||
}
|
||||
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
|
||||
unimplemented!("not used in tests")
|
||||
}
|
||||
async fn get_messages(&mut self, pages: u32) -> Result<()> {
|
||||
let mut list = self.list_messages(None).await?;
|
||||
match pages {
|
||||
1 => {}
|
||||
0 => loop {
|
||||
if list.next_page_token.is_none() {
|
||||
break;
|
||||
}
|
||||
list = self.list_messages(list.next_page_token).await?;
|
||||
},
|
||||
_ => {
|
||||
for _page in 2..=pages {
|
||||
if list.next_page_token.is_none() {
|
||||
break;
|
||||
}
|
||||
list = self.list_messages(list.next_page_token).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn list_messages(
|
||||
&mut self,
|
||||
next_page_token: Option<String>,
|
||||
) -> Result<ListMessagesResponse> {
|
||||
let list = self
|
||||
.list_messages_page(
|
||||
&self.label_ids,
|
||||
&self.query,
|
||||
self.max_results,
|
||||
next_page_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if list.result_size_estimate.unwrap_or(0) == 0 {
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
if let Some(msgs) = &list.messages {
|
||||
let mut list_ids: Vec<MessageSummary> = msgs
|
||||
.iter()
|
||||
.flat_map(|item| item.id.as_deref().map(MessageSummary::new))
|
||||
.collect();
|
||||
self.messages.append(&mut list_ids);
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
async fn log_messages(&mut self, _pre: &str, _post: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_query_updates_state() {
|
||||
let mut ml = MockList::new();
|
||||
ml.set_query("from:noreply@example.com");
|
||||
// not directly accessible; rely on behaviour by calling again
|
||||
ml.set_query("is:unread");
|
||||
assert_eq!(ml.query, "is:unread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_label_ids_accumulates() {
|
||||
let mut ml = MockList::new();
|
||||
ml.add_labels_ids(&["Label_1".into()]);
|
||||
ml.add_labels_ids(&["Label_2".into(), "Label_3".into()]);
|
||||
assert_eq!(ml.label_ids, vec!["Label_1", "Label_2", "Label_3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_results_get_set() {
|
||||
let mut ml = MockList::new();
|
||||
assert_eq!(ml.max_results(), 200);
|
||||
ml.set_max_results(123);
|
||||
assert_eq!(ml.max_results(), 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_ids_maps_from_messages() {
|
||||
let mut ml = MockList::new();
|
||||
ml.push_msg("abc");
|
||||
ml.push_msg("def");
|
||||
assert_eq!(ml.message_ids(), vec!["abc", "def"]);
|
||||
assert_eq!(ml.messages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_list_to_messages_extracts_ids() {
|
||||
use google_gmail1::api::Message;
|
||||
let mut out = Vec::<MessageSummary>::new();
|
||||
let list = ListMessagesResponse {
|
||||
messages: Some(vec![
|
||||
Message {
|
||||
id: Some("m1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
id: Some("m2".into()),
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
GmailClient::append_list_to_messages(&mut out, &list);
|
||||
let ids: Vec<_> = out.iter().map(|m| m.id().to_string()).collect();
|
||||
assert_eq!(ids, vec!["m1", "m2"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_across_pages_collects_ids() {
|
||||
use google_gmail1::api::Message;
|
||||
let page1 = ListMessagesResponse {
|
||||
messages: Some(vec![
|
||||
Message {
|
||||
id: Some("a".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
id: Some("b".into()),
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
next_page_token: Some("t2".into()),
|
||||
result_size_estimate: Some(2),
|
||||
};
|
||||
let page2 = ListMessagesResponse {
|
||||
messages: Some(vec![Message {
|
||||
id: Some("c".into()),
|
||||
..Default::default()
|
||||
}]),
|
||||
next_page_token: None,
|
||||
result_size_estimate: Some(1),
|
||||
};
|
||||
let mut map = HashMap::new();
|
||||
map.insert(None, page1);
|
||||
map.insert(Some("t2".into()), page2);
|
||||
|
||||
let mut client = TestClient::with_pages(map);
|
||||
client.set_max_results(2);
|
||||
client.set_query("in:inbox");
|
||||
|
||||
client.get_messages(0).await.unwrap();
|
||||
assert_eq!(client.message_ids(), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_first_page_returns_early() {
|
||||
let page = ListMessagesResponse {
|
||||
messages: None,
|
||||
next_page_token: None,
|
||||
result_size_estimate: Some(0),
|
||||
};
|
||||
let mut map = HashMap::new();
|
||||
map.insert(None, page);
|
||||
let mut client = TestClient::with_pages(map);
|
||||
client.get_messages(0).await.unwrap();
|
||||
assert!(client.message_ids().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_param_gt1_but_no_next_token_stops() {
|
||||
use google_gmail1::api::Message;
|
||||
let first = ListMessagesResponse {
|
||||
messages: Some(vec![Message {
|
||||
id: Some("x".into()),
|
||||
..Default::default()
|
||||
}]),
|
||||
next_page_token: None,
|
||||
result_size_estimate: Some(1),
|
||||
};
|
||||
let mut map = HashMap::new();
|
||||
map.insert(None, first);
|
||||
let mut client = TestClient::with_pages(map);
|
||||
client.get_messages(5).await.unwrap();
|
||||
assert_eq!(client.message_ids(), vec!["x"]);
|
||||
}
|
||||
}
|
||||
213
crates/cull-gmail/src/retention.rs
Normal file
213
crates/cull-gmail/src/retention.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
mod message_age;
|
||||
|
||||
pub use message_age::MessageAge;
|
||||
|
||||
/// Retention policy configuration for email messages.
|
||||
///
|
||||
/// A retention policy defines how old messages should be before they are subject
|
||||
/// to retention actions (trash/delete), and whether a label should be automatically
|
||||
/// generated to categorize messages matching this policy.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::{Retention, MessageAge};
|
||||
///
|
||||
/// // Create a retention policy for messages older than 6 months
|
||||
/// let policy = Retention::new(MessageAge::Months(6), true);
|
||||
///
|
||||
/// // Create a retention policy without auto-generated labels
|
||||
/// let policy = Retention::new(MessageAge::Years(1), false);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Retention {
|
||||
age: MessageAge,
|
||||
generate_label: bool,
|
||||
}
|
||||
|
||||
impl Default for Retention {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
age: MessageAge::Years(5),
|
||||
generate_label: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Retention {
|
||||
/// Create a new retention policy.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `age` - The message age threshold for this retention policy
|
||||
/// * `generate_label` - Whether to automatically generate a label for messages matching this policy
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::{Retention, MessageAge};
|
||||
///
|
||||
/// // Policy for messages older than 30 days with auto-generated label
|
||||
/// let policy = Retention::new(MessageAge::Days(30), true);
|
||||
///
|
||||
/// // Policy for messages older than 1 year without label generation
|
||||
/// let policy = Retention::new(MessageAge::Years(1), false);
|
||||
/// ```
|
||||
pub fn new(age: MessageAge, generate_label: bool) -> Self {
|
||||
Retention {
|
||||
age,
|
||||
generate_label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the message age threshold for this retention policy.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::{Retention, MessageAge};
|
||||
///
|
||||
/// let policy = Retention::new(MessageAge::Days(30), true);
|
||||
/// assert_eq!(policy.age(), &MessageAge::Days(30));
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn age(&self) -> &MessageAge {
|
||||
&self.age
|
||||
}
|
||||
|
||||
/// Check if this retention policy should generate automatic labels.
|
||||
///
|
||||
/// When `true`, messages matching this retention policy will be automatically
|
||||
/// tagged with a generated label based on the age specification.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::{Retention, MessageAge};
|
||||
///
|
||||
/// let policy = Retention::new(MessageAge::Days(30), true);
|
||||
/// assert_eq!(policy.generate_label(), true);
|
||||
///
|
||||
/// let policy = Retention::new(MessageAge::Days(30), false);
|
||||
/// assert_eq!(policy.generate_label(), false);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn generate_label(&self) -> bool {
|
||||
self.generate_label
|
||||
}
|
||||
|
||||
/// Set whether this retention policy should generate automatic labels.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::{Retention, MessageAge};
|
||||
///
|
||||
/// let mut policy = Retention::new(MessageAge::Days(30), false);
|
||||
/// policy.set_generate_label(true);
|
||||
/// assert_eq!(policy.generate_label(), true);
|
||||
/// ```
|
||||
pub fn set_generate_label(&mut self, generate_label: bool) {
|
||||
self.generate_label = generate_label;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_retention_new() {
|
||||
let age = MessageAge::Days(30);
|
||||
let retention = Retention::new(age.clone(), true);
|
||||
|
||||
assert_eq!(retention.age(), &age);
|
||||
assert!(retention.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_new_no_label() {
|
||||
let age = MessageAge::Years(1);
|
||||
let retention = Retention::new(age.clone(), false);
|
||||
|
||||
assert_eq!(retention.age(), &age);
|
||||
assert!(!retention.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_set_generate_label() {
|
||||
let age = MessageAge::Months(6);
|
||||
let mut retention = Retention::new(age.clone(), false);
|
||||
|
||||
assert!(!retention.generate_label());
|
||||
|
||||
retention.set_generate_label(true);
|
||||
assert!(retention.generate_label());
|
||||
|
||||
retention.set_generate_label(false);
|
||||
assert!(!retention.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_clone() {
|
||||
let age = MessageAge::Weeks(2);
|
||||
let original = Retention::new(age.clone(), true);
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original, cloned);
|
||||
assert_eq!(original.age(), cloned.age());
|
||||
assert_eq!(original.generate_label(), cloned.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_equality() {
|
||||
let age1 = MessageAge::Days(30);
|
||||
let age2 = MessageAge::Days(30);
|
||||
let age3 = MessageAge::Days(31);
|
||||
|
||||
let retention1 = Retention::new(age1, true);
|
||||
let retention2 = Retention::new(age2, true);
|
||||
let retention3 = Retention::new(age3, true);
|
||||
let retention4 = Retention::new(MessageAge::Days(30), false);
|
||||
|
||||
assert_eq!(retention1, retention2);
|
||||
assert_ne!(retention1, retention3); // different age
|
||||
assert_ne!(retention1, retention4); // different generate_label
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_default() {
|
||||
let default = Retention::default();
|
||||
|
||||
assert_eq!(default.age(), &MessageAge::Years(5));
|
||||
assert!(default.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_with_different_age_types() {
|
||||
let retention_days = Retention::new(MessageAge::Days(90), true);
|
||||
let retention_weeks = Retention::new(MessageAge::Weeks(12), false);
|
||||
let retention_months = Retention::new(MessageAge::Months(3), true);
|
||||
let retention_years = Retention::new(MessageAge::Years(1), false);
|
||||
|
||||
assert_eq!(retention_days.age().period_type(), "days");
|
||||
assert_eq!(retention_weeks.age().period_type(), "weeks");
|
||||
assert_eq!(retention_months.age().period_type(), "months");
|
||||
assert_eq!(retention_years.age().period_type(), "years");
|
||||
|
||||
assert!(retention_days.generate_label());
|
||||
assert!(!retention_weeks.generate_label());
|
||||
assert!(retention_months.generate_label());
|
||||
assert!(!retention_years.generate_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_debug() {
|
||||
let retention = Retention::new(MessageAge::Days(30), true);
|
||||
let debug_str = format!("{retention:?}");
|
||||
|
||||
assert!(debug_str.contains("Retention"));
|
||||
assert!(debug_str.contains("Days(30)"));
|
||||
assert!(debug_str.contains("true"));
|
||||
}
|
||||
}
|
||||
407
crates/cull-gmail/src/retention/message_age.rs
Normal file
407
crates/cull-gmail/src/retention/message_age.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Message age specification for retention policies.
|
||||
///
|
||||
/// Defines different time periods that can be used to specify how old messages
|
||||
/// should be before they are subject to retention actions (trash/delete).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// // Create different message age specifications
|
||||
/// let days = MessageAge::Days(30);
|
||||
/// let weeks = MessageAge::Weeks(4);
|
||||
/// let months = MessageAge::Months(6);
|
||||
/// let years = MessageAge::Years(2);
|
||||
///
|
||||
/// // Use with retention policy
|
||||
/// println!("Messages older than {} will be processed", months);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum MessageAge {
|
||||
/// Number of days to retain the message
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
/// let age = MessageAge::Days(30);
|
||||
/// assert_eq!(age.to_string(), "d:30");
|
||||
/// ```
|
||||
Days(i64),
|
||||
/// Number of weeks to retain the message
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
/// let age = MessageAge::Weeks(4);
|
||||
/// assert_eq!(age.to_string(), "w:4");
|
||||
/// ```
|
||||
Weeks(i64),
|
||||
/// Number of months to retain the message
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
/// let age = MessageAge::Months(6);
|
||||
/// assert_eq!(age.to_string(), "m:6");
|
||||
/// ```
|
||||
Months(i64),
|
||||
/// Number of years to retain the message
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
/// let age = MessageAge::Years(2);
|
||||
/// assert_eq!(age.to_string(), "y:2");
|
||||
/// ```
|
||||
Years(i64),
|
||||
}
|
||||
|
||||
impl Display for MessageAge {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MessageAge::Days(v) => write!(f, "d:{v}"),
|
||||
MessageAge::Weeks(v) => write!(f, "w:{v}"),
|
||||
MessageAge::Months(v) => write!(f, "m:{v}"),
|
||||
MessageAge::Years(v) => write!(f, "y:{v}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageAge {
|
||||
/// Create a new `MessageAge` from a period string and count.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `period` - The time period ("days", "weeks", "months", "years")
|
||||
/// * `count` - The number of time periods (must be positive)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// let age = MessageAge::new("days", 30).unwrap();
|
||||
/// assert_eq!(age, MessageAge::Days(30));
|
||||
///
|
||||
/// let age = MessageAge::new("months", 6).unwrap();
|
||||
/// assert_eq!(age, MessageAge::Months(6));
|
||||
///
|
||||
/// // Invalid period returns an error
|
||||
/// assert!(MessageAge::new("invalid", 1).is_err());
|
||||
///
|
||||
/// // Negative count returns an error
|
||||
/// assert!(MessageAge::new("days", -1).is_err());
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The period string is not recognized
|
||||
/// - The count is negative or zero
|
||||
pub fn new(period: &str, count: i64) -> Result<Self> {
|
||||
if count <= 0 {
|
||||
return Err(Error::InvalidMessageAge(format!(
|
||||
"Count must be positive, got: {count}"
|
||||
)));
|
||||
}
|
||||
|
||||
match period.to_lowercase().as_str() {
|
||||
"days" => Ok(MessageAge::Days(count)),
|
||||
"weeks" => Ok(MessageAge::Weeks(count)),
|
||||
"months" => Ok(MessageAge::Months(count)),
|
||||
"years" => Ok(MessageAge::Years(count)),
|
||||
_ => Err(Error::InvalidMessageAge(format!(
|
||||
"Unknown period '{period}', expected one of: days, weeks, months, years"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `MessageAge` from a string representation (e.g., "d:30", "m:6").
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - String in format "`period:count`" where period is d/w/m/y
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// let age = MessageAge::parse("d:30").unwrap();
|
||||
/// assert_eq!(age, MessageAge::Days(30));
|
||||
///
|
||||
/// let age = MessageAge::parse("y:2").unwrap();
|
||||
/// assert_eq!(age, MessageAge::Years(2));
|
||||
///
|
||||
/// // Invalid format returns None
|
||||
/// assert!(MessageAge::parse("invalid").is_none());
|
||||
/// assert!(MessageAge::parse("d").is_none());
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn parse(s: &str) -> Option<MessageAge> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 3 || bytes[1] != b':' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let period = bytes[0];
|
||||
let count_str = &s[2..];
|
||||
let count = count_str.parse::<i64>().ok()?;
|
||||
|
||||
if count <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
match period {
|
||||
b'd' => Some(MessageAge::Days(count)),
|
||||
b'w' => Some(MessageAge::Weeks(count)),
|
||||
b'm' => Some(MessageAge::Months(count)),
|
||||
b'y' => Some(MessageAge::Years(count)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a label string for this message age.
|
||||
///
|
||||
/// This creates a standardized label that can be used to categorize
|
||||
/// messages based on their retention period.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// let age = MessageAge::Days(30);
|
||||
/// assert_eq!(age.label(), "retention/30-days");
|
||||
///
|
||||
/// let age = MessageAge::Years(1);
|
||||
/// assert_eq!(age.label(), "retention/1-years");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
MessageAge::Days(v) => format!("retention/{v}-days"),
|
||||
MessageAge::Weeks(v) => format!("retention/{v}-weeks"),
|
||||
MessageAge::Months(v) => format!("retention/{v}-months"),
|
||||
MessageAge::Years(v) => format!("retention/{v}-years"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the numeric value of this message age.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// let age = MessageAge::Days(30);
|
||||
/// assert_eq!(age.value(), 30);
|
||||
///
|
||||
/// let age = MessageAge::Years(2);
|
||||
/// assert_eq!(age.value(), 2);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn value(&self) -> i64 {
|
||||
match self {
|
||||
MessageAge::Days(v)
|
||||
| MessageAge::Weeks(v)
|
||||
| MessageAge::Months(v)
|
||||
| MessageAge::Years(v) => *v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the period type as a string.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
///
|
||||
/// let age = MessageAge::Days(30);
|
||||
/// assert_eq!(age.period_type(), "days");
|
||||
///
|
||||
/// let age = MessageAge::Years(2);
|
||||
/// assert_eq!(age.period_type(), "years");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn period_type(&self) -> &'static str {
|
||||
match self {
|
||||
MessageAge::Days(_) => "days",
|
||||
MessageAge::Weeks(_) => "weeks",
|
||||
MessageAge::Months(_) => "months",
|
||||
MessageAge::Years(_) => "years",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for MessageAge {
|
||||
type Error = Error;
|
||||
|
||||
/// Try to create a `MessageAge` from a string using the parse format.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cull_gmail::MessageAge;
|
||||
/// use std::convert::TryFrom;
|
||||
///
|
||||
/// let age = MessageAge::try_from("d:30").unwrap();
|
||||
/// assert_eq!(age, MessageAge::Days(30));
|
||||
///
|
||||
/// let age = MessageAge::try_from("invalid");
|
||||
/// assert!(age.is_err());
|
||||
/// ```
|
||||
fn try_from(value: &str) -> Result<Self> {
|
||||
Self::parse(value).ok_or_else(|| {
|
||||
Error::InvalidMessageAge(format!("Failed to parse MessageAge from '{value}'"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_message_age_new_valid() {
|
||||
// Test valid periods
|
||||
assert_eq!(MessageAge::new("days", 30).unwrap(), MessageAge::Days(30));
|
||||
assert_eq!(MessageAge::new("weeks", 4).unwrap(), MessageAge::Weeks(4));
|
||||
assert_eq!(MessageAge::new("months", 6).unwrap(), MessageAge::Months(6));
|
||||
assert_eq!(MessageAge::new("years", 2).unwrap(), MessageAge::Years(2));
|
||||
|
||||
// Test case insensitive
|
||||
assert_eq!(MessageAge::new("DAYS", 1).unwrap(), MessageAge::Days(1));
|
||||
assert_eq!(MessageAge::new("Days", 1).unwrap(), MessageAge::Days(1));
|
||||
assert_eq!(MessageAge::new("dAyS", 1).unwrap(), MessageAge::Days(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_new_invalid_period() {
|
||||
assert!(MessageAge::new("invalid", 1).is_err());
|
||||
assert!(MessageAge::new("day", 1).is_err()); // singular form
|
||||
assert!(MessageAge::new("", 1).is_err());
|
||||
|
||||
// Check error messages
|
||||
let err = MessageAge::new("invalid", 1).unwrap_err();
|
||||
assert!(err.to_string().contains("Unknown period 'invalid'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_new_invalid_count() {
|
||||
assert!(MessageAge::new("days", 0).is_err());
|
||||
assert!(MessageAge::new("days", -1).is_err());
|
||||
assert!(MessageAge::new("days", -100).is_err());
|
||||
|
||||
// Check error messages
|
||||
let err = MessageAge::new("days", -1).unwrap_err();
|
||||
assert!(err.to_string().contains("Count must be positive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_parse_valid() {
|
||||
assert_eq!(MessageAge::parse("d:30").unwrap(), MessageAge::Days(30));
|
||||
assert_eq!(MessageAge::parse("w:4").unwrap(), MessageAge::Weeks(4));
|
||||
assert_eq!(MessageAge::parse("m:6").unwrap(), MessageAge::Months(6));
|
||||
assert_eq!(MessageAge::parse("y:2").unwrap(), MessageAge::Years(2));
|
||||
|
||||
// Test large numbers
|
||||
assert_eq!(MessageAge::parse("d:999").unwrap(), MessageAge::Days(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_parse_invalid() {
|
||||
// Invalid format
|
||||
assert!(MessageAge::parse("invalid").is_none());
|
||||
assert!(MessageAge::parse("d").is_none());
|
||||
assert!(MessageAge::parse("d:").is_none());
|
||||
assert!(MessageAge::parse(":30").is_none());
|
||||
assert!(MessageAge::parse("x:30").is_none());
|
||||
|
||||
// Invalid count
|
||||
assert!(MessageAge::parse("d:0").is_none());
|
||||
assert!(MessageAge::parse("d:-1").is_none());
|
||||
assert!(MessageAge::parse("d:abc").is_none());
|
||||
|
||||
// Wrong separator
|
||||
assert!(MessageAge::parse("d-30").is_none());
|
||||
assert!(MessageAge::parse("d 30").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_display() {
|
||||
assert_eq!(MessageAge::Days(30).to_string(), "d:30");
|
||||
assert_eq!(MessageAge::Weeks(4).to_string(), "w:4");
|
||||
assert_eq!(MessageAge::Months(6).to_string(), "m:6");
|
||||
assert_eq!(MessageAge::Years(2).to_string(), "y:2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_label() {
|
||||
assert_eq!(MessageAge::Days(30).label(), "retention/30-days");
|
||||
assert_eq!(MessageAge::Weeks(4).label(), "retention/4-weeks");
|
||||
assert_eq!(MessageAge::Months(6).label(), "retention/6-months");
|
||||
assert_eq!(MessageAge::Years(2).label(), "retention/2-years");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_value() {
|
||||
assert_eq!(MessageAge::Days(30).value(), 30);
|
||||
assert_eq!(MessageAge::Weeks(4).value(), 4);
|
||||
assert_eq!(MessageAge::Months(6).value(), 6);
|
||||
assert_eq!(MessageAge::Years(2).value(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_period_type() {
|
||||
assert_eq!(MessageAge::Days(30).period_type(), "days");
|
||||
assert_eq!(MessageAge::Weeks(4).period_type(), "weeks");
|
||||
assert_eq!(MessageAge::Months(6).period_type(), "months");
|
||||
assert_eq!(MessageAge::Years(2).period_type(), "years");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_clone() {
|
||||
let original = MessageAge::Days(30);
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_age_eq() {
|
||||
assert_eq!(MessageAge::Days(30), MessageAge::Days(30));
|
||||
assert_ne!(MessageAge::Days(30), MessageAge::Days(31));
|
||||
assert_ne!(MessageAge::Days(30), MessageAge::Weeks(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_roundtrip() {
|
||||
let original = MessageAge::Days(30);
|
||||
let serialized = original.to_string();
|
||||
let parsed = MessageAge::parse(&serialized).unwrap();
|
||||
assert_eq!(original, parsed);
|
||||
|
||||
let original = MessageAge::Years(5);
|
||||
let serialized = original.to_string();
|
||||
let parsed = MessageAge::parse(&serialized).unwrap();
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from() {
|
||||
use std::convert::TryFrom;
|
||||
|
||||
assert_eq!(MessageAge::try_from("d:30").unwrap(), MessageAge::Days(30));
|
||||
assert_eq!(MessageAge::try_from("w:4").unwrap(), MessageAge::Weeks(4));
|
||||
assert_eq!(MessageAge::try_from("m:6").unwrap(), MessageAge::Months(6));
|
||||
assert_eq!(MessageAge::try_from("y:2").unwrap(), MessageAge::Years(2));
|
||||
|
||||
assert!(MessageAge::try_from("invalid").is_err());
|
||||
assert!(MessageAge::try_from("d:-1").is_err());
|
||||
}
|
||||
}
|
||||
931
crates/cull-gmail/src/rule_processor.rs
Normal file
931
crates/cull-gmail/src/rule_processor.rs
Normal file
@@ -0,0 +1,931 @@
|
||||
//! Rule Processing Module
|
||||
//!
|
||||
//! This module provides the [`RuleProcessor`] trait and its implementation for processing
|
||||
//! Gmail messages according to configured end-of-life (EOL) rules. It handles the complete
|
||||
//! workflow of finding messages, applying filters based on rules, and executing actions
|
||||
//! such as moving messages to trash or permanently deleting them.
|
||||
//!
|
||||
//! ## Safety Considerations
|
||||
//!
|
||||
//! - **Destructive Operations**: The [`RuleProcessor::batch_delete`] method permanently
|
||||
//! removes messages from Gmail and cannot be undone.
|
||||
//! - **Recoverable Operations**: The [`RuleProcessor::batch_trash`] method moves messages
|
||||
//! to the Gmail trash folder, from which they can be recovered within 30 days.
|
||||
//! - **Execute Flag**: All destructive operations are gated by an execute flag that must
|
||||
//! be explicitly set to `true`. When `false`, operations run in "dry-run" mode.
|
||||
//!
|
||||
//! ## Workflow
|
||||
//!
|
||||
//! 1. Set a rule using [`RuleProcessor::set_rule`]
|
||||
//! 2. Configure the execute flag with [`RuleProcessor::set_execute`]
|
||||
//! 3. Process messages for a label with [`RuleProcessor::find_rule_and_messages_for_label`]
|
||||
//! 4. The processor will automatically:
|
||||
//! - Find messages matching the rule's query
|
||||
//! - Prepare the message list via [`RuleProcessor::prepare`]
|
||||
//! - Execute the rule's action (trash) if execute flag is true
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```text
|
||||
//! use cull_gmail::{GmailClient, RuleProcessor, ClientConfig};
|
||||
//!
|
||||
//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Configure Gmail client with credentials
|
||||
//! let config = ClientConfig::builder()
|
||||
//! .with_client_id("your-client-id")
|
||||
//! .with_client_secret("your-client-secret")
|
||||
//! .build();
|
||||
//! let mut client = GmailClient::new_with_config(config).await?;
|
||||
//!
|
||||
//! // Rules would typically be loaded from configuration
|
||||
//! // let rule = load_rule_from_config("old-emails");
|
||||
//! // client.set_rule(rule);
|
||||
//!
|
||||
//! client.set_execute(true); // Set to false for dry-run
|
||||
//!
|
||||
//! // Process all messages with the "old-emails" label according to the rule
|
||||
//! client.find_rule_and_messages_for_label("old-emails").await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use google_gmail1::api::{BatchDeleteMessagesRequest, BatchModifyMessagesRequest};
|
||||
|
||||
use crate::{EolAction, Error, GmailClient, Result, message_list::MessageList, rules::EolRule};
|
||||
|
||||
/// Gmail label name for the trash folder.
|
||||
///
|
||||
/// This constant ensures consistent usage of the TRASH label throughout the module.
|
||||
const TRASH_LABEL: &str = "TRASH";
|
||||
|
||||
/// Gmail label name for the inbox folder.
|
||||
///
|
||||
/// This constant ensures consistent usage of the INBOX label throughout the module.
|
||||
const INBOX_LABEL: &str = "INBOX";
|
||||
|
||||
/// Gmail API scope for modifying messages (recommended scope for most operations).
|
||||
///
|
||||
/// This scope allows adding/removing labels, moving messages to trash, and other
|
||||
/// modification operations. Preferred over broader scopes for security.
|
||||
const GMAIL_MODIFY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
|
||||
|
||||
/// Gmail API scope for deleting messages.
|
||||
///
|
||||
/// This scope allows all operations and is required to authorise the batch
|
||||
/// delete operation. It is only used for batch delete. For all other
|
||||
/// operations `GMAIL_MODIFY_SCOPE` is preferred.
|
||||
const GMAIL_DELETE_SCOPE: &str = "https://mail.google.com/";
|
||||
|
||||
/// Internal trait defining the minimal operations needed for rule processing.
|
||||
///
|
||||
/// This trait is used internally to enable unit testing of orchestration logic
|
||||
/// without requiring network calls or real Gmail API access. It abstracts the
|
||||
/// core operations that the rule processor needs from the Gmail client.
|
||||
#[doc(hidden)]
|
||||
pub(crate) trait MailOperations {
|
||||
/// Add labels to the client for filtering
|
||||
fn add_labels(&mut self, labels: &[String]) -> Result<()>;
|
||||
|
||||
/// Get the current label IDs
|
||||
fn label_ids(&self) -> Vec<String>;
|
||||
|
||||
/// Set the query string for message filtering
|
||||
fn set_query(&mut self, query: &str);
|
||||
|
||||
/// Prepare messages by fetching from Gmail API
|
||||
fn prepare(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Execute trash operation on prepared messages
|
||||
fn batch_trash(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
/// Internal orchestration function for rule processing that can be unit tested.
|
||||
///
|
||||
/// This function contains the core rule processing logic extracted from the trait
|
||||
/// implementation to enable testing without network dependencies.
|
||||
async fn process_label_with_rule<T: MailOperations>(
|
||||
client: &mut T,
|
||||
rule: &EolRule,
|
||||
label: &str,
|
||||
pages: u32,
|
||||
execute: bool,
|
||||
) -> Result<()> {
|
||||
// Add the label for filtering
|
||||
client.add_labels(&[label.to_owned()])?;
|
||||
|
||||
// Validate label exists in mailbox
|
||||
if client.label_ids().is_empty() {
|
||||
return Err(Error::LabelNotFoundInMailbox(label.to_owned()));
|
||||
}
|
||||
|
||||
// Get query from rule
|
||||
let Some(query) = rule.eol_query() else {
|
||||
return Err(Error::NoQueryStringCalculated(rule.id()));
|
||||
};
|
||||
|
||||
// Set the query and prepare messages
|
||||
client.set_query(&query);
|
||||
log::info!("Ready to process messages for label: {label}");
|
||||
client.prepare(pages).await?;
|
||||
|
||||
// Execute or dry-run based on execute flag
|
||||
if execute {
|
||||
log::info!("Execute mode: applying rule action to messages");
|
||||
client.batch_trash().await
|
||||
} else {
|
||||
log::info!("Dry-run mode: no changes made to messages");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement the internal mail operations trait for GmailClient.
|
||||
impl MailOperations for GmailClient {
|
||||
fn add_labels(&mut self, labels: &[String]) -> Result<()> {
|
||||
MessageList::add_labels(self, labels)
|
||||
}
|
||||
|
||||
fn label_ids(&self) -> Vec<String> {
|
||||
MessageList::label_ids(self)
|
||||
}
|
||||
|
||||
fn set_query(&mut self, query: &str) {
|
||||
MessageList::set_query(self, query);
|
||||
}
|
||||
|
||||
async fn prepare(&mut self, pages: u32) -> Result<()> {
|
||||
self.get_messages(pages).await
|
||||
}
|
||||
|
||||
async fn batch_trash(&mut self) -> Result<()> {
|
||||
RuleProcessor::batch_trash(self).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for processing Gmail messages according to configured end-of-life rules.
|
||||
///
|
||||
/// This trait defines the interface for finding, filtering, and acting upon Gmail messages
|
||||
/// based on retention rules. Implementations should handle the complete workflow from
|
||||
/// rule application to message processing.
|
||||
pub trait RuleProcessor {
|
||||
/// Processes all messages for a specific Gmail label according to the configured rule.
|
||||
///
|
||||
/// This is the main entry point for rule processing. It coordinates the entire workflow:
|
||||
/// 1. Validates that the label exists in the mailbox
|
||||
/// 2. Applies the rule's query to find matching messages
|
||||
/// 3. Prepares the message list for processing
|
||||
/// 4. Executes the rule's action (if execute flag is true) or runs in dry-run mode
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `label` - The Gmail label name to process (e.g., "INBOX", "old-emails")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Processing completed successfully
|
||||
/// * `Err(Error::LabelNotFoundInMailbox)` - The specified label doesn't exist
|
||||
/// * `Err(Error::RuleNotFound)` - No rule has been set via [`set_rule`](Self::set_rule)
|
||||
/// * `Err(Error::NoQueryStringCalculated)` - The rule doesn't provide a valid query
|
||||
///
|
||||
/// # Side Effects
|
||||
///
|
||||
/// When execute flag is true, messages may be moved to trash or permanently deleted.
|
||||
/// When execute flag is false, runs in dry-run mode with no destructive actions.
|
||||
fn find_rule_and_messages_for_label(
|
||||
&mut self,
|
||||
label: &str,
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Sets the execution mode for destructive operations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - `true` to enable destructive operations, `false` for dry-run mode
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// When set to `true`, subsequent calls to processing methods will perform actual
|
||||
/// destructive operations on Gmail messages. Always verify your rules and queries
|
||||
/// in dry-run mode (`false`) before enabling execution.
|
||||
fn set_execute(&mut self, value: bool);
|
||||
|
||||
/// Initialises the message and label lists to prepare for application of rule.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * none
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```text
|
||||
/// use cull_gmail::{GmailClient, RuleProcessor, ClientConfig};
|
||||
///
|
||||
/// async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ClientConfig::builder()
|
||||
/// .with_client_id("your-client-id")
|
||||
/// .with_client_secret("your-client-secret")
|
||||
/// .build();
|
||||
/// let mut client = GmailClient::new_with_config(config).await?;
|
||||
///
|
||||
/// // Rules would typically be loaded from configuration
|
||||
/// // let rule = load_rule_from_config();
|
||||
/// // client.initialise_message_list();
|
||||
/// // client.set_rule(rule);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
fn initialise_lists(&mut self);
|
||||
|
||||
/// Configures the end-of-life rule to apply during processing.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `rule` - The `EolRule` containing query criteria and action to perform
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```text
|
||||
/// use cull_gmail::{GmailClient, RuleProcessor, ClientConfig};
|
||||
///
|
||||
/// async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ClientConfig::builder()
|
||||
/// .with_client_id("your-client-id")
|
||||
/// .with_client_secret("your-client-secret")
|
||||
/// .build();
|
||||
/// let mut client = GmailClient::new_with_config(config).await?;
|
||||
///
|
||||
/// // Rules would typically be loaded from configuration
|
||||
/// // let rule = load_rule_from_config();
|
||||
/// // client.set_rule(rule);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
fn set_rule(&mut self, rule: EolRule);
|
||||
|
||||
/// Returns the action that will be performed by the currently configured rule.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(EolAction)` - The action (e.g., `EolAction::Trash`) if a rule is set
|
||||
/// * `None` - If no rule has been configured via [`set_rule`](Self::set_rule)
|
||||
fn action(&self) -> Option<EolAction>;
|
||||
|
||||
/// Prepares the list of messages for processing by fetching them from Gmail.
|
||||
///
|
||||
/// This method queries the Gmail API to retrieve messages matching the current
|
||||
/// query and label filters, up to the specified number of pages.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pages` - Maximum number of result pages to fetch (0 = all pages)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Messages successfully retrieved and prepared
|
||||
/// * `Err(_)` - Gmail API error or network failure
|
||||
///
|
||||
/// # Side Effects
|
||||
///
|
||||
/// Makes API calls to Gmail to retrieve message metadata. No messages are
|
||||
/// modified by this operation.
|
||||
fn prepare(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Permanently deletes all prepared messages from Gmail.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All messages successfully deleted
|
||||
/// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// ⚠️ **DESTRUCTIVE OPERATION** - This permanently removes messages from Gmail.
|
||||
/// Deleted messages cannot be recovered. Use [`batch_trash`](Self::batch_trash)
|
||||
/// for recoverable deletion.
|
||||
///
|
||||
/// # Gmail API Requirements
|
||||
///
|
||||
/// Requires the `https://mail.google.com/` scope.
|
||||
fn batch_delete(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Calls the Gmail API to move a slice of the prepared messages to the Gmail
|
||||
/// trash folder.
|
||||
///
|
||||
/// Messages moved to trash can be recovered within 30 days through the Gmail
|
||||
/// web interface or API calls.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All messages successfully moved to trash
|
||||
/// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
|
||||
///
|
||||
/// # Recovery
|
||||
///
|
||||
/// Messages can be recovered from trash within 30 days. After 30 days,
|
||||
/// Gmail automatically purges trashed messages.
|
||||
///
|
||||
/// # Gmail API Requirements
|
||||
///
|
||||
/// Requires the `https://www.googleapis.com/auth/gmail.modify` scope.
|
||||
fn batch_trash(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Chunk the message lists to respect API limits and call required action.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All messages successfully deleted
|
||||
/// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
|
||||
///
|
||||
fn process_in_chunks(
|
||||
&self,
|
||||
message_ids: Vec<String>,
|
||||
action: EolAction,
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Calls the Gmail API to permanently deletes a slice from the list of messages.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All messages successfully deleted
|
||||
/// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// ⚠️ **DESTRUCTIVE OPERATION** - This permanently removes messages from Gmail.
|
||||
/// Deleted messages cannot be recovered. Use [`batch_trash`](Self::batch_trash)
|
||||
/// for recoverable deletion.
|
||||
///
|
||||
/// # Gmail API Requirements
|
||||
///
|
||||
/// Requires the `https://mail.google.com/` scope.
|
||||
fn call_batch_delete(
|
||||
&self,
|
||||
ids: &[String],
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
||||
/// Moves all prepared messages to the Gmail trash folder.
|
||||
///
|
||||
/// Messages moved to trash can be recovered within 30 days through the Gmail
|
||||
/// web interface or API calls.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All messages successfully moved to trash
|
||||
/// * `Err(_)` - Gmail API error, network failure, or insufficient permissions
|
||||
///
|
||||
/// # Recovery
|
||||
///
|
||||
/// Messages can be recovered from trash within 30 days. After 30 days,
|
||||
/// Gmail automatically purges trashed messages.
|
||||
///
|
||||
/// # Gmail API Requirements
|
||||
///
|
||||
/// Requires the `https://www.googleapis.com/auth/gmail.modify` scope.
|
||||
fn call_batch_trash(
|
||||
&self,
|
||||
ids: &[String],
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl RuleProcessor for GmailClient {
|
||||
/// Initialise the message list.
|
||||
///
|
||||
/// The message list is initialised to ensure that the rule is only processed
|
||||
/// on the in-scope messages.
|
||||
///
|
||||
/// This must be called before processing any labels.
|
||||
fn initialise_lists(&mut self) {
|
||||
self.messages = Vec::new();
|
||||
self.label_ids = Vec::new();
|
||||
}
|
||||
|
||||
/// Configures the end-of-life rule for this Gmail client.
|
||||
///
|
||||
/// The rule defines which messages to target and what action to perform on them.
|
||||
/// This must be called before processing any labels.
|
||||
fn set_rule(&mut self, value: EolRule) {
|
||||
self.rule = Some(value);
|
||||
}
|
||||
|
||||
/// Controls whether destructive operations are actually executed.
|
||||
///
|
||||
/// When `false` (dry-run mode), all operations are simulated but no actual
|
||||
/// changes are made to Gmail messages. When `true`, destructive operations
|
||||
/// like moving to trash or deleting will be performed.
|
||||
///
|
||||
/// **Default is `false` for safety.**
|
||||
fn set_execute(&mut self, value: bool) {
|
||||
self.execute = value;
|
||||
}
|
||||
|
||||
/// Returns the action that will be performed by the current rule.
|
||||
///
|
||||
/// This is useful for logging and verification before executing destructive operations.
|
||||
fn action(&self) -> Option<EolAction> {
|
||||
if let Some(rule) = &self.rule {
|
||||
return rule.action();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Orchestrates the complete rule processing workflow for a Gmail label.
|
||||
///
|
||||
/// This method implements the main processing logic by delegating to the internal
|
||||
/// orchestration function, which enables better testability while maintaining
|
||||
/// the same external behaviour.
|
||||
///
|
||||
/// The method respects the execute flag - when `false`, it runs in dry-run mode
|
||||
/// and only logs what would be done without making any changes.
|
||||
async fn find_rule_and_messages_for_label(&mut self, label: &str) -> Result<()> {
|
||||
// Ensure we have a rule configured and clone it to avoid borrow conflicts
|
||||
let Some(rule) = self.rule.clone() else {
|
||||
return Err(Error::RuleNotFound(0));
|
||||
};
|
||||
|
||||
let execute = self.execute;
|
||||
|
||||
// Delegate to internal orchestration function
|
||||
process_label_with_rule(self, &rule, label, 0, execute).await
|
||||
}
|
||||
|
||||
/// Fetches messages from Gmail API based on current query and label filters.
|
||||
///
|
||||
/// This is a read-only operation that retrieves message metadata from Gmail
|
||||
/// without modifying any messages. The results are cached internally for
|
||||
/// subsequent batch operations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pages` - Number of result pages to fetch (0 = all available pages)
|
||||
async fn prepare(&mut self, pages: u32) -> Result<()> {
|
||||
self.get_messages(pages).await
|
||||
}
|
||||
|
||||
/// Permanently deletes all prepared messages using Gmail's batch delete API.
|
||||
///
|
||||
/// ⚠️ **DESTRUCTIVE OPERATION** - This action cannot be undone!
|
||||
///
|
||||
/// This method uses the Gmail API's batch delete functionality to permanently
|
||||
/// remove messages from the user's mailbox. Once deleted, messages cannot be
|
||||
/// recovered through any means.
|
||||
///
|
||||
/// # API Scope Requirements
|
||||
///
|
||||
/// Uses `https://mail.google.com/` scope as it is required to immediately and
|
||||
/// permanently delete threads and messages, bypassing Trash.
|
||||
async fn batch_delete(&mut self) -> Result<()> {
|
||||
let message_ids = MessageList::message_ids(self);
|
||||
|
||||
// Early return if no messages to delete, avoiding unnecessary API calls
|
||||
if message_ids.is_empty() {
|
||||
log::info!("No messages to delete - skipping batch delete operation");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.log_messages("Message with subject `", "` permanently deleted")
|
||||
.await?;
|
||||
|
||||
self.process_in_chunks(message_ids, EolAction::Delete)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// Moves all prepared messages to Gmail's trash folder using batch modify API.
|
||||
///
|
||||
/// This is a recoverable operation - messages can be restored from trash within
|
||||
/// 30 days via the Gmail web interface or API calls. After 30 days, Gmail
|
||||
/// automatically purges trashed messages permanently.
|
||||
///
|
||||
/// The operation adds the TRASH label and removes any existing labels that were
|
||||
/// used to filter the messages, effectively moving them out of their current
|
||||
/// folders into the trash.
|
||||
///
|
||||
/// # API Scope Requirements
|
||||
///
|
||||
/// Uses `https://www.googleapis.com/auth/gmail.modify` scope for secure,
|
||||
/// minimal privilege access to Gmail message modification operations.
|
||||
async fn batch_trash(&mut self) -> Result<()> {
|
||||
let message_ids = MessageList::message_ids(self);
|
||||
|
||||
// Early return if no messages to trash, avoiding unnecessary API calls
|
||||
if message_ids.is_empty() {
|
||||
log::info!("No messages to trash - skipping batch trash operation");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.log_messages("Message with subject `", "` moved to trash")
|
||||
.await?;
|
||||
|
||||
self.process_in_chunks(message_ids, EolAction::Trash)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_in_chunks(&self, message_ids: Vec<String>, action: EolAction) -> Result<()> {
|
||||
let (chunks, remainder) = message_ids.as_chunks::<1000>();
|
||||
log::info!(
|
||||
"Message list chopped into {} chunks with {} ids in the remainder",
|
||||
chunks.len(),
|
||||
remainder.len()
|
||||
);
|
||||
|
||||
let act = async |action, list| match action {
|
||||
EolAction::Trash => self.call_batch_trash(list).await,
|
||||
EolAction::Delete => self.call_batch_delete(list).await,
|
||||
};
|
||||
|
||||
if !chunks.is_empty() {
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
log::info!("Processing chunk {i}");
|
||||
act(action, chunk).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if !remainder.is_empty() {
|
||||
log::info!("Processing remainder.");
|
||||
act(action, remainder).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn call_batch_delete(&self, ids: &[String]) -> Result<()> {
|
||||
let ids = Some(Vec::from(ids));
|
||||
let batch_request = BatchDeleteMessagesRequest { ids };
|
||||
log::trace!("{batch_request:#?}");
|
||||
|
||||
let res = self
|
||||
.hub()
|
||||
.users()
|
||||
.messages_batch_delete(batch_request, "me")
|
||||
.add_scope(GMAIL_DELETE_SCOPE)
|
||||
.doit()
|
||||
.await
|
||||
.map_err(Box::new);
|
||||
|
||||
log::trace!("Batch delete response {res:?}");
|
||||
|
||||
res?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn call_batch_trash(&self, ids: &[String]) -> Result<()> {
|
||||
let ids = Some(Vec::from(ids));
|
||||
let add_label_ids = Some(vec![TRASH_LABEL.to_string()]);
|
||||
let remove_label_ids = Some(vec![INBOX_LABEL.to_string()]);
|
||||
|
||||
let batch_request = BatchModifyMessagesRequest {
|
||||
add_label_ids,
|
||||
ids,
|
||||
remove_label_ids,
|
||||
};
|
||||
|
||||
log::trace!("{batch_request:#?}");
|
||||
|
||||
let _res = self
|
||||
.hub()
|
||||
.users()
|
||||
.messages_batch_modify(batch_request, "me")
|
||||
.add_scope(GMAIL_MODIFY_SCOPE)
|
||||
.doit()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EolAction, Error, MessageSummary, rules::EolRule};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Test helper to create a simple EolRule with or without a query
|
||||
fn create_test_rule(id: usize, has_query: bool) -> EolRule {
|
||||
use crate::{MessageAge, Retention};
|
||||
|
||||
let mut rule = EolRule::new(id);
|
||||
|
||||
if has_query {
|
||||
// Create a rule that will generate a query (using retention days)
|
||||
let retention = Retention::new(MessageAge::Days(30), false);
|
||||
rule.set_retention(retention);
|
||||
rule.add_label("test-label");
|
||||
}
|
||||
// For rules without query, we just return the basic rule with no retention set
|
||||
|
||||
rule
|
||||
}
|
||||
|
||||
/// Fake client implementation for testing the orchestration logic
|
||||
struct FakeClient {
|
||||
labels: Vec<String>,
|
||||
label_ids: Vec<String>,
|
||||
query: String,
|
||||
messages_prepared: bool,
|
||||
prepare_call_count: u32,
|
||||
batch_trash_call_count: Arc<Mutex<u32>>, // Use Arc<Mutex> for thread safety
|
||||
should_fail_add_labels: bool,
|
||||
should_fail_prepare: bool,
|
||||
should_fail_batch_trash: bool,
|
||||
simulate_missing_labels: bool, // Flag to simulate labels not being found
|
||||
}
|
||||
|
||||
impl Default for FakeClient {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
labels: Vec::new(),
|
||||
label_ids: Vec::new(),
|
||||
query: String::new(),
|
||||
messages_prepared: false,
|
||||
prepare_call_count: 0,
|
||||
batch_trash_call_count: Arc::new(Mutex::new(0)),
|
||||
should_fail_add_labels: false,
|
||||
should_fail_prepare: false,
|
||||
should_fail_batch_trash: false,
|
||||
simulate_missing_labels: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeClient {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a client that simulates missing labels (add_labels succeeds but no label_ids)
|
||||
fn with_missing_labels() -> Self {
|
||||
Self {
|
||||
simulate_missing_labels: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_labels(label_ids: Vec<String>) -> Self {
|
||||
Self {
|
||||
label_ids,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_failure(failure_type: &str) -> Self {
|
||||
match failure_type {
|
||||
"add_labels" => Self {
|
||||
should_fail_add_labels: true,
|
||||
..Default::default()
|
||||
},
|
||||
"prepare" => Self {
|
||||
should_fail_prepare: true,
|
||||
..Default::default()
|
||||
},
|
||||
"batch_trash" => Self {
|
||||
should_fail_batch_trash: true,
|
||||
..Default::default()
|
||||
},
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_batch_trash_call_count(&self) -> u32 {
|
||||
*self.batch_trash_call_count.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl MailOperations for FakeClient {
|
||||
fn add_labels(&mut self, labels: &[String]) -> Result<()> {
|
||||
if self.should_fail_add_labels {
|
||||
return Err(Error::DirectoryUnset); // Use a valid error variant
|
||||
}
|
||||
self.labels.extend(labels.iter().cloned());
|
||||
// Only populate label_ids if we're not simulating missing labels
|
||||
if !self.simulate_missing_labels && !labels.is_empty() {
|
||||
self.label_ids = labels.to_vec();
|
||||
}
|
||||
// When simulate_missing_labels is true, label_ids stays empty
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn label_ids(&self) -> Vec<String> {
|
||||
self.label_ids.clone()
|
||||
}
|
||||
|
||||
fn set_query(&mut self, query: &str) {
|
||||
self.query = query.to_owned();
|
||||
}
|
||||
|
||||
async fn prepare(&mut self, _pages: u32) -> Result<()> {
|
||||
// Always increment the counter to track that prepare was called
|
||||
self.prepare_call_count += 1;
|
||||
|
||||
if self.should_fail_prepare {
|
||||
return Err(Error::NoLabelsFound); // Use a valid error variant
|
||||
}
|
||||
self.messages_prepared = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn batch_trash(&mut self) -> Result<()> {
|
||||
// Always increment the counter to track that batch_trash was called
|
||||
*self.batch_trash_call_count.lock().unwrap() += 1;
|
||||
|
||||
if self.should_fail_batch_trash {
|
||||
return Err(Error::InvalidPagingMode); // Use a valid error variant
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_errors_when_label_missing() {
|
||||
let mut client = FakeClient::with_missing_labels(); // Simulate labels not being found
|
||||
let rule = create_test_rule(1, true);
|
||||
let label = "missing-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
|
||||
|
||||
assert!(matches!(result, Err(Error::LabelNotFoundInMailbox(_))));
|
||||
assert_eq!(client.prepare_call_count, 0);
|
||||
assert_eq!(client.get_batch_trash_call_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_errors_when_rule_has_no_query() {
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
let rule = create_test_rule(2, false); // Rule without query
|
||||
let label = "test-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
|
||||
|
||||
assert!(matches!(result, Err(Error::NoQueryStringCalculated(2))));
|
||||
assert_eq!(client.prepare_call_count, 0);
|
||||
assert_eq!(client.get_batch_trash_call_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dry_run_does_not_trash() {
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
let rule = create_test_rule(3, true);
|
||||
let label = "test-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, false).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(client.prepare_call_count, 1);
|
||||
assert_eq!(client.get_batch_trash_call_count(), 0); // Should not trash in dry-run mode
|
||||
assert!(client.messages_prepared);
|
||||
assert!(!client.query.is_empty()); // Query should be set
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_trashes_messages_once() {
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
let rule = create_test_rule(4, true);
|
||||
let label = "test-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(client.prepare_call_count, 1);
|
||||
assert_eq!(client.get_batch_trash_call_count(), 1); // Should trash when execute=true
|
||||
assert!(client.messages_prepared);
|
||||
assert!(!client.query.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_propagates_prepare_error() {
|
||||
// Create a client that will fail on prepare but has valid labels
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
client.should_fail_prepare = true; // Set the failure flag directly
|
||||
|
||||
let rule = create_test_rule(5, true);
|
||||
let label = "test-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(client.prepare_call_count, 1); // prepare should be called once
|
||||
assert_eq!(client.get_batch_trash_call_count(), 0); // Should not reach trash due to error
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_propagates_batch_trash_error() {
|
||||
// Create a client that will fail on batch_trash but has valid labels
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
client.should_fail_batch_trash = true; // Set the failure flag directly
|
||||
|
||||
let rule = create_test_rule(6, true);
|
||||
let label = "test-label";
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, 0, true).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(client.prepare_call_count, 1);
|
||||
assert_eq!(client.get_batch_trash_call_count(), 1); // Should attempt trash but fail
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pages_parameter_passed_correctly() {
|
||||
let mut client = FakeClient::with_labels(vec!["test-label".to_string()]);
|
||||
let rule = create_test_rule(7, true);
|
||||
let label = "test-label";
|
||||
let pages = 5;
|
||||
|
||||
let result = process_label_with_rule(&mut client, &rule, label, pages, false).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(client.prepare_call_count, 1);
|
||||
// Note: In a more sophisticated test, we'd verify pages parameter is passed to prepare
|
||||
// but our simple FakeClient doesn't track this. In practice, you might want to enhance it.
|
||||
}
|
||||
|
||||
/// Test the rule processor trait setters and getters
|
||||
#[test]
|
||||
fn test_rule_processor_setters_and_getters() {
|
||||
// Note: This test would need a mock GmailClient implementation
|
||||
// For now, we'll create a simple struct that implements RuleProcessor
|
||||
|
||||
struct MockProcessor {
|
||||
messages: Vec<MessageSummary>,
|
||||
rule: Option<EolRule>,
|
||||
execute: bool,
|
||||
labels: Vec<String>,
|
||||
}
|
||||
|
||||
impl RuleProcessor for MockProcessor {
|
||||
fn initialise_lists(&mut self) {
|
||||
self.messages = Vec::new();
|
||||
self.labels = Vec::new();
|
||||
}
|
||||
|
||||
fn set_rule(&mut self, rule: EolRule) {
|
||||
self.rule = Some(rule);
|
||||
}
|
||||
|
||||
fn set_execute(&mut self, value: bool) {
|
||||
self.execute = value;
|
||||
}
|
||||
|
||||
fn action(&self) -> Option<EolAction> {
|
||||
self.rule.as_ref().and_then(|r| r.action())
|
||||
}
|
||||
|
||||
async fn find_rule_and_messages_for_label(&mut self, _label: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare(&mut self, _pages: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn batch_delete(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn call_batch_delete(&self, _ids: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn batch_trash(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn call_batch_trash(&self, _ids: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_in_chunks(
|
||||
&self,
|
||||
_message_ids: Vec<String>,
|
||||
_action: EolAction,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut processor = MockProcessor {
|
||||
rule: None,
|
||||
execute: false,
|
||||
messages: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
};
|
||||
|
||||
// Test initial state
|
||||
assert!(processor.action().is_none());
|
||||
assert!(!processor.execute);
|
||||
|
||||
// Test rule setting
|
||||
let rule = create_test_rule(8, true);
|
||||
processor.set_rule(rule);
|
||||
assert!(processor.action().is_some());
|
||||
assert_eq!(processor.action(), Some(EolAction::Trash));
|
||||
|
||||
// Test execute flag setting
|
||||
processor.set_execute(true);
|
||||
assert!(processor.execute);
|
||||
|
||||
processor.set_execute(false);
|
||||
assert!(!processor.execute);
|
||||
}
|
||||
}
|
||||
1019
crates/cull-gmail/src/rules.rs
Normal file
1019
crates/cull-gmail/src/rules.rs
Normal file
File diff suppressed because it is too large
Load Diff
555
crates/cull-gmail/src/rules/eol_rule.rs
Normal file
555
crates/cull-gmail/src/rules/eol_rule.rs
Normal file
@@ -0,0 +1,555 @@
|
||||
//! End-of-life (EOL) rule implementation.
|
||||
//!
|
||||
//! This module provides the [`EolRule`] struct which defines rules for automatically
|
||||
//! processing Gmail messages based on their age. Rules can be configured to either
|
||||
//! move messages to trash or permanently delete them after a specified retention period.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use cull_gmail::{Retention, MessageAge, EolAction};
|
||||
//! use cull_gmail::rules::eol_rule::EolRule;
|
||||
//!
|
||||
//! // Create a new rule to delete messages older than 1 year
|
||||
//! let mut rule = EolRule::new(1);
|
||||
//! let retention = Retention::new(MessageAge::Years(1), true);
|
||||
//! rule.set_retention(retention);
|
||||
//! rule.set_action(&EolAction::Delete);
|
||||
//!
|
||||
//! // Add labels that this rule applies to
|
||||
//! rule.add_label("old-emails");
|
||||
//!
|
||||
//! println!("Rule description: {}", rule.describe());
|
||||
//! ```
|
||||
|
||||
use std::{collections::BTreeSet, fmt};
|
||||
|
||||
use chrono::{DateTime, Datelike, Local, TimeDelta, TimeZone};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{MessageAge, Retention, eol_action::EolAction};
|
||||
|
||||
/// A rule that defines end-of-life processing for Gmail messages.
|
||||
///
|
||||
/// An `EolRule` specifies conditions under which messages should be processed
|
||||
/// (moved to trash or deleted) based on their age and optional label filters.
|
||||
/// Each rule has a unique identifier and can be configured with retention periods,
|
||||
/// target labels, and actions to perform.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Creating a basic rule:
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let rule = EolRule::new(42);
|
||||
/// assert_eq!(rule.id(), 42);
|
||||
/// assert!(rule.labels().is_empty());
|
||||
/// ```
|
||||
///
|
||||
/// # Serialization
|
||||
///
|
||||
/// Rules can be serialized to and from TOML/JSON using serde.
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct EolRule {
|
||||
id: usize,
|
||||
retention: String,
|
||||
labels: BTreeSet<String>,
|
||||
query: Option<String>,
|
||||
action: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for EolRule {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if !self.retention.is_empty() {
|
||||
let (action, count, period) = self.get_action_period_count_strings();
|
||||
|
||||
write!(
|
||||
f,
|
||||
"Rule #{} is active on `{}` to {action} if it is more than {count} {period} old.",
|
||||
self.id,
|
||||
self.labels
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
)
|
||||
} else {
|
||||
write!(f, "Complete retention rule not set.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EolRule {
|
||||
/// Creates a new end-of-life rule with the specified unique identifier.
|
||||
///
|
||||
/// The rule is created with default settings:
|
||||
/// - Action: Move to trash (not delete)
|
||||
/// - No retention period set
|
||||
/// - No labels specified
|
||||
/// - No custom query
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - A unique identifier for this rule
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let rule = EolRule::new(1);
|
||||
/// assert_eq!(rule.id(), 1);
|
||||
/// assert!(rule.labels().is_empty());
|
||||
/// ```
|
||||
pub(crate) fn new(id: usize) -> Self {
|
||||
EolRule {
|
||||
id,
|
||||
action: EolAction::Trash.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the retention period for this rule.
|
||||
///
|
||||
/// The retention period determines how old messages must be before this rule
|
||||
/// applies to them. If the retention is configured to generate labels, the
|
||||
/// appropriate label will be automatically added to this rule.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The retention configuration specifying age and label generation
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge};
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// let retention = Retention::new(MessageAge::Months(6), true);
|
||||
/// rule.set_retention(retention);
|
||||
///
|
||||
/// assert_eq!(rule.retention(), "m:6");
|
||||
/// assert!(rule.labels().contains(&"retention/6-months".to_string()));
|
||||
/// ```
|
||||
pub(crate) fn set_retention(&mut self, value: Retention) -> &mut Self {
|
||||
self.retention = value.age().to_string();
|
||||
if value.generate_label() {
|
||||
self.add_label(&value.age().label());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the retention period string for this rule.
|
||||
///
|
||||
/// The retention string follows the format used by [`MessageAge`],
|
||||
/// such as "d:30" for 30 days or "y:1" for 1 year.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge};
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// let retention = Retention::new(MessageAge::Days(90), false);
|
||||
/// rule.set_retention(retention);
|
||||
///
|
||||
/// assert_eq!(rule.retention(), "d:90");
|
||||
/// ```
|
||||
pub(crate) fn retention(&self) -> &str {
|
||||
&self.retention
|
||||
}
|
||||
|
||||
/// Adds a label that this rule should apply to.
|
||||
///
|
||||
/// Labels are used to filter which messages this rule processes. Messages
|
||||
/// must have one of the rule's labels to be affected by the rule.
|
||||
/// Duplicate labels are ignored.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The label name to add
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// rule.add_label("newsletter")
|
||||
/// .add_label("promotions");
|
||||
///
|
||||
/// let labels = rule.labels();
|
||||
/// assert!(labels.contains(&"newsletter".to_string()));
|
||||
/// assert!(labels.contains(&"promotions".to_string()));
|
||||
/// assert_eq!(labels.len(), 2);
|
||||
/// ```
|
||||
pub(crate) fn add_label(&mut self, value: &str) -> &mut Self {
|
||||
self.labels.insert(value.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Removes a label from this rule.
|
||||
///
|
||||
/// If the label is not present, this operation does nothing.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The label name to remove
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// rule.add_label("temp-label");
|
||||
/// assert!(rule.labels().contains(&"temp-label".to_string()));
|
||||
///
|
||||
/// rule.remove_label("temp-label");
|
||||
/// assert!(!rule.labels().contains(&"temp-label".to_string()));
|
||||
/// ```
|
||||
pub(crate) fn remove_label(&mut self, value: &str) {
|
||||
self.labels.remove(value);
|
||||
}
|
||||
|
||||
/// Returns the unique identifier for this rule.
|
||||
///
|
||||
/// Each rule has a unique ID that distinguishes it from other rules
|
||||
/// in the same rule set.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let rule = EolRule::new(42);
|
||||
/// assert_eq!(rule.id(), 42);
|
||||
/// ```
|
||||
pub fn id(&self) -> usize {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Returns a list of all labels that this rule applies to.
|
||||
///
|
||||
/// Labels determine which messages this rule will process. Only messages
|
||||
/// with one of these labels will be affected by the rule.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::rules::eol_rule::EolRule;
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// rule.add_label("spam").add_label("newsletter");
|
||||
///
|
||||
/// let labels = rule.labels();
|
||||
/// assert_eq!(labels.len(), 2);
|
||||
/// assert!(labels.contains(&"spam".to_string()));
|
||||
/// assert!(labels.contains(&"newsletter".to_string()));
|
||||
/// ```
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
self.labels.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Sets the action to perform when this rule matches messages.
|
||||
///
|
||||
/// The action determines what happens to messages that match this rule's
|
||||
/// criteria (age and labels).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The action to perform (Trash or Delete)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, EolAction};
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// rule.set_action(&EolAction::Delete);
|
||||
///
|
||||
/// assert_eq!(rule.action(), Some(EolAction::Delete));
|
||||
/// ```
|
||||
pub(crate) fn set_action(&mut self, value: &EolAction) -> &mut Self {
|
||||
self.action = value.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the action that will be performed by this rule.
|
||||
///
|
||||
/// The action determines what happens to messages that match this rule:
|
||||
/// - `Trash`: Move messages to the trash folder
|
||||
/// - `Delete`: Permanently delete messages
|
||||
///
|
||||
/// Returns `None` if the action string cannot be parsed (should not happen
|
||||
/// with properly constructed rules).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, EolAction};
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// rule.set_action(&EolAction::Trash);
|
||||
///
|
||||
/// assert_eq!(rule.action(), Some(EolAction::Trash));
|
||||
/// ```
|
||||
pub fn action(&self) -> Option<EolAction> {
|
||||
EolAction::parse(&self.action)
|
||||
}
|
||||
|
||||
/// Returns a human-readable description of what this rule does.
|
||||
///
|
||||
/// The description includes the rule ID, the action that will be performed,
|
||||
/// and the age threshold for messages.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge, EolAction};
|
||||
/// let mut rule = EolRule::new(5);
|
||||
/// let retention = Retention::new(MessageAge::Months(3), false);
|
||||
/// rule.set_retention(retention);
|
||||
/// rule.set_action(&EolAction::Delete);
|
||||
///
|
||||
/// let description = rule.describe();
|
||||
/// assert!(description.contains("Rule #5"));
|
||||
/// assert!(description.contains("delete"));
|
||||
/// assert!(description.contains("3 months"));
|
||||
/// ```
|
||||
pub fn describe(&self) -> String {
|
||||
let (action, count, period) = self.get_action_period_count_strings();
|
||||
format!(
|
||||
"Rule #{}, to {action} if it is more than {count} {period} old.",
|
||||
self.id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Describe the action that will be performed by the rule and its conditions
|
||||
fn get_action_period_count_strings(&self) -> (String, usize, String) {
|
||||
let count = &self.retention[2..];
|
||||
let count = count.parse::<usize>().unwrap_or(0); // Default to 0 if parsing fails
|
||||
let mut period = match self.retention.chars().nth(0) {
|
||||
Some('d') => "day",
|
||||
Some('w') => "week",
|
||||
Some('m') => "month",
|
||||
Some('y') => "year",
|
||||
Some(_) => unreachable!(),
|
||||
None => unreachable!(),
|
||||
}
|
||||
.to_string();
|
||||
if count > 1 {
|
||||
period.push('s');
|
||||
}
|
||||
|
||||
let action = match self.action.to_lowercase().as_str() {
|
||||
"trash" => "move the message to trash",
|
||||
"delete" => "delete the message",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
(action.to_string(), count, period)
|
||||
}
|
||||
|
||||
/// Generates a Gmail search query for messages that match this rule's age criteria.
|
||||
///
|
||||
/// This method calculates the cut-off date based on the rule's retention period
|
||||
/// and returns a Gmail search query string that can be used to find messages
|
||||
/// older than the specified threshold.
|
||||
///
|
||||
/// Returns `None` if the retention period is not set or cannot be parsed.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use cull_gmail::{rules::eol_rule::EolRule, Retention, MessageAge};
|
||||
/// let mut rule = EolRule::new(1);
|
||||
/// let retention = Retention::new(MessageAge::Days(30), false);
|
||||
/// rule.set_retention(retention);
|
||||
///
|
||||
/// if let Some(query) = rule.eol_query() {
|
||||
/// println!("Gmail query: {}", query);
|
||||
/// // Output will be something like "before: 2024-08-15"
|
||||
/// }
|
||||
/// ```
|
||||
pub(crate) fn eol_query(&self) -> Option<String> {
|
||||
let today = chrono::Local::now();
|
||||
self.calculate_for_date(today)
|
||||
}
|
||||
|
||||
fn calculate_for_date(&self, today: DateTime<Local>) -> Option<String> {
|
||||
let message_age = MessageAge::parse(&self.retention)?;
|
||||
log::debug!("testing for {message_age}");
|
||||
|
||||
let deadline = match message_age {
|
||||
MessageAge::Days(c) => {
|
||||
let delta = TimeDelta::days(c);
|
||||
log::debug!("delta for change: {delta}");
|
||||
let deadline = today.checked_sub_signed(delta)?;
|
||||
log::debug!("calculated deadline: {deadline}");
|
||||
deadline
|
||||
}
|
||||
MessageAge::Weeks(c) => {
|
||||
let delta = TimeDelta::weeks(c);
|
||||
today.checked_sub_signed(delta)?
|
||||
}
|
||||
MessageAge::Months(c) => {
|
||||
let day = today.day();
|
||||
let month = today.month();
|
||||
let year = today.year();
|
||||
let mut years = c as i32 / 12;
|
||||
let months = c % 12;
|
||||
let mut new_month = month - months as u32;
|
||||
|
||||
if new_month < 1 {
|
||||
years += 1;
|
||||
new_month += 12;
|
||||
}
|
||||
|
||||
let new_year = year - years;
|
||||
|
||||
Local
|
||||
.with_ymd_and_hms(new_year, new_month, day, 0, 0, 0)
|
||||
.single()?
|
||||
}
|
||||
MessageAge::Years(c) => {
|
||||
let day = today.day();
|
||||
let month = today.month();
|
||||
let year = today.year();
|
||||
let new_year = year - c as i32;
|
||||
|
||||
Local
|
||||
.with_ymd_and_hms(new_year, month, day, 0, 0, 0)
|
||||
.single()?
|
||||
}
|
||||
};
|
||||
|
||||
Some(format!("before: {}", deadline.format("%Y-%m-%d")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use chrono::{Local, TimeZone};
|
||||
|
||||
use crate::{MessageAge, Retention, rules::eol_rule::EolRule, test_utils::get_test_logger};
|
||||
|
||||
fn build_test_rule(age: MessageAge) -> EolRule {
|
||||
let retention = Retention::new(age, true);
|
||||
let mut rule = EolRule::new(1);
|
||||
rule.set_retention(retention);
|
||||
rule
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_for_eol_rule_5_years() {
|
||||
let rule = build_test_rule(crate::MessageAge::Years(5));
|
||||
|
||||
assert_eq!(
|
||||
"Rule #1 is active on `retention/5-years` to move the message to trash if it is more than 5 years old."
|
||||
.to_string(),
|
||||
rule.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_for_eol_rule_1_month() {
|
||||
let rule = build_test_rule(crate::MessageAge::Months(1));
|
||||
|
||||
assert_eq!(
|
||||
"Rule #1 is active on `retention/1-months` to move the message to trash if it is more than 1 month old."
|
||||
.to_string(),
|
||||
rule.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_for_eol_rule_13_weeks() {
|
||||
let rule = build_test_rule(crate::MessageAge::Weeks(13));
|
||||
|
||||
assert_eq!(
|
||||
"Rule #1 is active on `retention/13-weeks` to move the message to trash if it is more than 13 weeks old."
|
||||
.to_string(),
|
||||
rule.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_for_eol_rule_365_days() {
|
||||
let rule = build_test_rule(crate::MessageAge::Days(365));
|
||||
|
||||
assert_eq!(
|
||||
"Rule #1 is active on `retention/365-days` to move the message to trash if it is more than 365 days old."
|
||||
.to_string(),
|
||||
rule.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eol_query_for_eol_rule_5_years() {
|
||||
let rule = build_test_rule(crate::MessageAge::Years(5));
|
||||
|
||||
let test_today = Local
|
||||
.with_ymd_and_hms(2025, 9, 15, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let query = rule
|
||||
.calculate_for_date(test_today)
|
||||
.expect("Failed to calculate query");
|
||||
|
||||
assert_eq!("before: 2020-09-15", query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eol_query_for_eol_rule_1_month() {
|
||||
let rule = build_test_rule(crate::MessageAge::Months(1));
|
||||
|
||||
let test_today = Local
|
||||
.with_ymd_and_hms(2025, 9, 15, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let query = rule
|
||||
.calculate_for_date(test_today)
|
||||
.expect("Failed to calculate query");
|
||||
|
||||
assert_eq!("before: 2025-08-15", query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eol_query_for_eol_rule_13_weeks() {
|
||||
let rule = build_test_rule(crate::MessageAge::Weeks(13));
|
||||
|
||||
let test_today = Local
|
||||
.with_ymd_and_hms(2025, 9, 15, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let query = rule
|
||||
.calculate_for_date(test_today)
|
||||
.expect("Failed to calculate query");
|
||||
|
||||
assert_eq!("before: 2025-06-16", query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eol_query_for_eol_rule_365_days() {
|
||||
let rule = build_test_rule(crate::MessageAge::Days(365));
|
||||
|
||||
let test_today = Local
|
||||
.with_ymd_and_hms(2025, 9, 15, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let query = rule
|
||||
.calculate_for_date(test_today)
|
||||
.expect("Failed to calculate query");
|
||||
|
||||
assert_eq!("before: 2024-09-15", query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eol_query_for_eol_rule_3038_days() {
|
||||
get_test_logger();
|
||||
let rule = build_test_rule(crate::MessageAge::Days(6580));
|
||||
|
||||
let test_today = Local
|
||||
.with_ymd_and_hms(2025, 9, 15, 0, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let query = rule
|
||||
.calculate_for_date(test_today)
|
||||
.expect("Failed to calculate query");
|
||||
|
||||
assert_eq!("before: 2007-09-10", query);
|
||||
}
|
||||
}
|
||||
8
crates/cull-gmail/src/test_utils.rs
Normal file
8
crates/cull-gmail/src/test_utils.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use log::LevelFilter;
|
||||
|
||||
pub(crate) fn get_test_logger() {
|
||||
let mut builder = env_logger::Builder::new();
|
||||
builder.filter(None, LevelFilter::Debug);
|
||||
builder.format_timestamp_secs().format_module_path(false);
|
||||
let _ = builder.try_init();
|
||||
}
|
||||
57
crates/cull-gmail/src/utils.rs
Normal file
57
crates/cull-gmail/src/utils.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::{env, fs, io};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) fn assure_config_dir_exists(dir: &str) -> Result<String> {
|
||||
let trdir = dir.trim();
|
||||
if trdir.is_empty() {
|
||||
return Err(Error::DirectoryUnset);
|
||||
}
|
||||
|
||||
let expanded_config_dir = if trdir.as_bytes()[0] == b'~' {
|
||||
match env::var("HOME")
|
||||
.ok()
|
||||
.or_else(|| env::var("UserProfile").ok())
|
||||
{
|
||||
None => {
|
||||
return Err(Error::HomeExpansionFailed(trdir.to_string()));
|
||||
}
|
||||
Some(mut user) => {
|
||||
user.push_str(&trdir[1..]);
|
||||
user
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trdir.to_string()
|
||||
};
|
||||
|
||||
if let Err(err) = fs::create_dir(&expanded_config_dir)
|
||||
&& err.kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(Error::DirectoryCreationFailed((
|
||||
expanded_config_dir,
|
||||
Box::new(err),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(expanded_config_dir)
|
||||
}
|
||||
|
||||
pub(crate) trait Elide {
|
||||
fn elide(&mut self, to: usize) -> &mut Self;
|
||||
}
|
||||
|
||||
impl Elide for String {
|
||||
fn elide(&mut self, to: usize) -> &mut Self {
|
||||
if self.len() <= to {
|
||||
self
|
||||
} else {
|
||||
let mut range = to - 4;
|
||||
while !self.is_char_boundary(range) {
|
||||
range -= 1;
|
||||
}
|
||||
self.replace_range(range.., " ...");
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
909
crates/cull-gmail/tests/cli_integration_tests.rs
Normal file
909
crates/cull-gmail/tests/cli_integration_tests.rs
Normal file
@@ -0,0 +1,909 @@
|
||||
//! CLI Integration Tests for cull-gmail
|
||||
//!
|
||||
//! This module provides comprehensive integration testing for the CLI interface,
|
||||
//! validating argument parsing, subcommand execution, configuration handling,
|
||||
//! and error scenarios without requiring actual Gmail API connectivity.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
use tokio::process::Command as AsyncCommand;
|
||||
|
||||
/// Test utilities and common setup for CLI integration tests
|
||||
mod test_utils {
|
||||
use super::*;
|
||||
|
||||
/// Test fixture containing temporary directories and mock configurations
|
||||
pub struct CliTestFixture {
|
||||
pub temp_dir: TempDir,
|
||||
pub config_dir: PathBuf,
|
||||
pub binary_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CliTestFixture {
|
||||
/// Create a new test fixture with temporary directory structure
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let config_dir = temp_dir.path().join(".config").join("cull-gmail");
|
||||
fs::create_dir_all(&config_dir)?;
|
||||
|
||||
// Get the path to the compiled binary - try multiple locations
|
||||
let binary_path = if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
// Running under cargo test - binary is in workspace root target/
|
||||
// CARGO_MANIFEST_DIR = crates/cull-gmail, so go up two levels
|
||||
let workspace_root = PathBuf::from(&manifest_dir).join("../..").canonicalize()?;
|
||||
let release_binary = workspace_root
|
||||
.join("target")
|
||||
.join("release")
|
||||
.join("cull-gmail");
|
||||
if release_binary.exists() {
|
||||
release_binary
|
||||
} else {
|
||||
workspace_root
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("cull-gmail")
|
||||
}
|
||||
} else if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
|
||||
// CI environments may set CARGO_TARGET_DIR
|
||||
let release_binary = PathBuf::from(&target_dir)
|
||||
.join("release")
|
||||
.join("cull-gmail");
|
||||
if release_binary.exists() {
|
||||
release_binary
|
||||
} else {
|
||||
PathBuf::from(&target_dir).join("debug").join("cull-gmail")
|
||||
}
|
||||
} else {
|
||||
// Fallback for other scenarios
|
||||
std::env::current_exe()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("cull-gmail")
|
||||
};
|
||||
|
||||
// Validate that the binary exists
|
||||
if !binary_path.exists() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("CLI binary not found at path: {binary_path:?}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
temp_dir,
|
||||
config_dir,
|
||||
binary_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a mock configuration file
|
||||
pub fn create_config_file(&self, content: &str) -> std::io::Result<PathBuf> {
|
||||
let config_file = self.config_dir.join("config.toml");
|
||||
fs::write(&config_file, content)?;
|
||||
Ok(config_file)
|
||||
}
|
||||
|
||||
/// Create a mock client credentials file
|
||||
pub fn create_credentials_file(&self, content: &str) -> std::io::Result<PathBuf> {
|
||||
let creds_file = self.config_dir.join("client_secret.json");
|
||||
fs::write(&creds_file, content)?;
|
||||
Ok(creds_file)
|
||||
}
|
||||
|
||||
/// Execute CLI command with arguments and environment variables
|
||||
pub fn execute_cli(
|
||||
&self,
|
||||
args: &[&str],
|
||||
env_vars: Option<HashMap<&str, &str>>,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
let mut cmd = Command::new(&self.binary_path);
|
||||
cmd.args(args);
|
||||
cmd.env("HOME", self.temp_dir.path());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
if let Some(env) = env_vars {
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.output()
|
||||
}
|
||||
|
||||
/// Execute async CLI command for testing interactive scenarios
|
||||
pub async fn execute_cli_async(
|
||||
&self,
|
||||
args: &[&str],
|
||||
env_vars: Option<HashMap<&str, &str>>,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
let mut cmd = AsyncCommand::new(&self.binary_path);
|
||||
cmd.args(args);
|
||||
cmd.env("HOME", self.temp_dir.path());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
if let Some(env) = env_vars {
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.output().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock Gmail API responses for testing
|
||||
pub fn mock_credentials_json() -> &'static str {
|
||||
r#"{
|
||||
"installed": {
|
||||
"client_id": "test-client-id.googleusercontent.com",
|
||||
"project_id": "test-project",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": "test-client-secret",
|
||||
"redirect_uris": ["http://localhost"]
|
||||
}
|
||||
}"#
|
||||
}
|
||||
|
||||
/// Mock configuration TOML content
|
||||
pub fn mock_config_toml() -> &'static str {
|
||||
r#"
|
||||
[client]
|
||||
client_id = "test-client-id"
|
||||
client_secret = "test-client-secret"
|
||||
max_results = "100"
|
||||
|
||||
[[rules]]
|
||||
name = "old_promotions"
|
||||
query = "category:promotions older_than:30d"
|
||||
action = "delete"
|
||||
enabled = true
|
||||
|
||||
[[rules]]
|
||||
name = "old_social"
|
||||
query = "category:social older_than:60d"
|
||||
action = "trash"
|
||||
enabled = false
|
||||
"#
|
||||
}
|
||||
}
|
||||
|
||||
/// Test CLI argument parsing and help output
|
||||
mod argument_parsing_tests {
|
||||
use super::test_utils::*;
|
||||
|
||||
#[test]
|
||||
fn test_cli_help_output() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["--help"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Verify help output contains key elements
|
||||
assert!(stdout.contains("cull-gmail"));
|
||||
assert!(stdout.contains("USAGE:") || stdout.contains("Usage:"));
|
||||
assert!(stdout.contains("labels"));
|
||||
assert!(stdout.contains("messages"));
|
||||
assert!(stdout.contains("rules"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_version_output() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["--version"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Should contain version information
|
||||
assert!(stdout.contains("cull-gmail"));
|
||||
assert!(stdout.contains("0.0.10") || stdout.split_whitespace().count() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbosity_flags() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Test different verbosity levels
|
||||
let verbosity_tests = [
|
||||
(vec!["-v", "labels"], "WARN"),
|
||||
(vec!["-vv", "labels"], "INFO"),
|
||||
(vec!["-vvv", "labels"], "DEBUG"),
|
||||
(vec!["-vvvv", "labels"], "TRACE"),
|
||||
];
|
||||
|
||||
for (args, _expected_level) in verbosity_tests {
|
||||
let output = fixture
|
||||
.execute_cli(&args, None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Command should parse successfully (may succeed with valid auth or fail gracefully)
|
||||
// The important thing is that verbosity flags are accepted (not argument parsing error)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_subcommand() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["invalid-command"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should show error message about invalid subcommand
|
||||
assert!(stderr.contains("error:") || stderr.contains("unrecognized"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Test labels subcommand functionality
|
||||
mod labels_tests {
|
||||
use super::test_utils::*;
|
||||
|
||||
#[test]
|
||||
fn test_labels_help() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels", "--help"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(stdout.contains("labels") || stdout.contains("List Gmail labels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_labels_without_credentials() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should fail with configuration or authentication error (unless valid credentials exist)
|
||||
if !output.status.success() {
|
||||
assert!(
|
||||
stderr.contains("config")
|
||||
|| stderr.contains("credentials")
|
||||
|| stderr.contains("authentication")
|
||||
|| stderr.contains("client_secret")
|
||||
|| stderr.contains("OAuth")
|
||||
|| stderr.contains("token")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_labels_with_mock_config() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create mock configuration files
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config file");
|
||||
fixture
|
||||
.create_credentials_file(mock_credentials_json())
|
||||
.expect("Failed to create credentials file");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// In CI/isolated environments, the test should succeed or fail gracefully
|
||||
// We mainly test that config files are being found and processed
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Test passes if any of these conditions are met:
|
||||
// 1. Command succeeds with real credentials
|
||||
// 2. Command fails but found the config files (not "config file not found")
|
||||
// 3. Command fails at OAuth/authentication step (normal for mock data)
|
||||
let config_found =
|
||||
!stderr.contains("config file not found") && !stderr.contains("No such file");
|
||||
let auth_related_failure = stderr.contains("OAuth")
|
||||
|| stderr.contains("authentication")
|
||||
|| stderr.contains("token")
|
||||
|| stderr.contains("credentials")
|
||||
|| stderr.contains("client");
|
||||
|
||||
assert!(
|
||||
output.status.success() || config_found || auth_related_failure,
|
||||
"Command failed unexpectedly. Exit code: {:?}, stderr: {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test messages subcommand functionality
|
||||
mod messages_tests {
|
||||
use super::test_utils::*;
|
||||
|
||||
#[test]
|
||||
fn test_messages_help() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "--help"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(stdout.contains("messages"));
|
||||
assert!(stdout.contains("query") || stdout.contains("QUERY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_list_action() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "--query", "in:inbox", "list"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should parse arguments correctly (may succeed or fail gracefully, but not with parse error)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_trash_action() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "--query", "in:spam", "trash"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Trash command should be accepted (not argument parsing error)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_pagination_options() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(
|
||||
&[
|
||||
"messages",
|
||||
"--query",
|
||||
"in:inbox",
|
||||
"--max-results",
|
||||
"50",
|
||||
"--pages",
|
||||
"2",
|
||||
"list",
|
||||
],
|
||||
None,
|
||||
)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Pagination arguments should be accepted (not argument parsing error)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_invalid_action() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "--query", "test", "invalid-action"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("error:") || stderr.contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_without_query() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "list"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Messages list should work with or without explicit query (may use defaults)
|
||||
// The test validates that the command is well-formed, not the query requirement
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test rules subcommand functionality
|
||||
mod rules_tests {
|
||||
use super::test_utils::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_rules_help() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "--help"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(stdout.contains("rules"));
|
||||
assert!(stdout.contains("config") || stdout.contains("run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_config_subcommand() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "config"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should attempt to create/display config
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should either succeed or show meaningful output about config
|
||||
assert!(
|
||||
output.status.success()
|
||||
|| stdout.contains("config")
|
||||
|| stderr.contains("config")
|
||||
|| stdout.contains("toml")
|
||||
|| stderr.contains("toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_run_without_config() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "run"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should fail gracefully when no config is found
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("config") || stderr.contains("file") || stderr.contains("not found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "This test requires OAuth and may hang in CI environments"]
|
||||
fn test_rules_run_with_config() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create config files and credentials in both supported locations
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config file");
|
||||
fixture
|
||||
.create_credentials_file(mock_credentials_json())
|
||||
.expect("Failed to create credentials file");
|
||||
|
||||
// Also create legacy config path
|
||||
let legacy_dir = fixture.temp_dir.path().join(".cull-gmail");
|
||||
fs::create_dir_all(&legacy_dir).expect("Failed to create legacy config directory");
|
||||
let legacy_config_path = legacy_dir.join("cull-gmail.toml");
|
||||
fs::write(&legacy_config_path, mock_config_toml()).expect("Failed to write legacy config");
|
||||
let legacy_creds_path = legacy_dir.join("credential.json");
|
||||
fs::write(&legacy_creds_path, mock_credentials_json())
|
||||
.expect("Failed to write legacy credentials");
|
||||
|
||||
// Add environment variables to prevent long hangs during OAuth attempts
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("HTTP_TIMEOUT", "5");
|
||||
env_vars.insert("CONNECT_TIMEOUT", "3");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "run"], Some(env_vars))
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should succeed or fail gracefully - mainly tests that config is found and processed
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
|
||||
// Test passes if:
|
||||
// 1. Command succeeds entirely, or
|
||||
// 2. Fails with auth/credentials error (normal for mock data), or
|
||||
// 3. Fails but config was found (not "config file not found")
|
||||
let config_processed =
|
||||
!stderr.contains("config file not found") && !stderr.contains("No such file");
|
||||
let auth_failure = stderr.contains("credentials")
|
||||
|| stderr.contains("authentication")
|
||||
|| stderr.contains("OAuth")
|
||||
|| stderr.contains("token");
|
||||
let credential_issue = stderr.contains("could not read path");
|
||||
|
||||
// The main goal is to test that the rules subcommand works and config is processed
|
||||
// In CI environments, OAuth will fail with mock data, which is expected
|
||||
assert!(
|
||||
output.status.success() || auth_failure || config_processed || credential_issue,
|
||||
"Rules command failed unexpectedly. Exit code: {exit_code}, stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_run_execution() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config file");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "run"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Rules run command should be accepted (not argument parsing error)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 2,
|
||||
"Exit code 2 indicates argument parsing error, got: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_config_validation() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create config files in both supported locations
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config file");
|
||||
|
||||
// Test that rules config subcommand works (doesn't require OAuth)
|
||||
let output = fixture
|
||||
.execute_cli(&["rules", "config"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Rules config should work without authentication
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
|
||||
// Should not crash and should handle config processing
|
||||
assert!(
|
||||
exit_code != 139, // No segfault
|
||||
"Rules config command crashed. Exit code: {exit_code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test configuration and environment handling
|
||||
mod configuration_tests {
|
||||
use super::test_utils::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_config_file_hierarchy() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create config in expected location
|
||||
let config_content = r#"
|
||||
[client]
|
||||
client_id = "test-from-config"
|
||||
client_secret = "secret-from-config"
|
||||
"#;
|
||||
fixture
|
||||
.create_config_file(config_content)
|
||||
.expect("Failed to create config");
|
||||
|
||||
// Any command should now find the config
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should not complain about missing config anymore
|
||||
assert!(!stderr.contains("config file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_variable_precedence() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("CULL_GMAIL_CLIENT_ID", "env-client-id");
|
||||
env_vars.insert("CULL_GMAIL_CLIENT_SECRET", "env-secret");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], Some(env_vars))
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Environment variables should be recognized
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!stderr.contains("client_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_config_format() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create malformed config
|
||||
fixture
|
||||
.create_config_file("invalid toml content [[[")
|
||||
.expect("Failed to create config");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("config") || stderr.contains("parse") || stderr.contains("toml"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Test error handling and edge cases
|
||||
mod error_handling_tests {
|
||||
use super::test_utils::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_graceful_keyboard_interrupt() {
|
||||
// This test would require more complex setup with signal handling
|
||||
// For now, we ensure the CLI handles missing dependencies gracefully
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["messages", "--query", "test", "list"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should not crash (no segfault)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 139,
|
||||
"Segmentation fault detected, got exit code: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_query_syntax() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config");
|
||||
fixture
|
||||
.create_credentials_file(mock_credentials_json())
|
||||
.expect("Failed to create credentials");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(
|
||||
&["messages", "--query", "invalid:query:syntax:::", "list"],
|
||||
None,
|
||||
)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// Should handle invalid queries gracefully (no segfault)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 139,
|
||||
"Segmentation fault detected, got exit code: {exit_code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_timeout_simulation() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Set very short timeout to trigger timeout behavior
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("HTTP_TIMEOUT", "1");
|
||||
env_vars.insert("CONNECT_TIMEOUT", "1");
|
||||
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config");
|
||||
fixture
|
||||
.create_credentials_file(mock_credentials_json())
|
||||
.expect("Failed to create credentials");
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], Some(env_vars))
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
// In CI environments, this test mainly validates the CLI doesn't crash
|
||||
// Timeout behavior may vary depending on network configuration
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
|
||||
// Test passes if:
|
||||
// 1. Command succeeds (maybe with valid credentials)
|
||||
// 2. Command fails with timeout/network errors
|
||||
// 3. Command fails with auth errors (normal for mock data)
|
||||
// 4. Command doesn't crash (no segfault)
|
||||
assert!(
|
||||
exit_code != 139, // No segfault
|
||||
"Command crashed with segfault. Exit code: {exit_code}, stderr: {stderr}"
|
||||
);
|
||||
|
||||
// Optional: check for expected error types (but don't require them)
|
||||
let has_expected_errors = output.status.success()
|
||||
|| stderr.contains("timeout")
|
||||
|| stderr.contains("network")
|
||||
|| stderr.contains("connection")
|
||||
|| stderr.contains("authentication")
|
||||
|| stderr.contains("OAuth")
|
||||
|| stderr.contains("credentials");
|
||||
|
||||
// Log additional info for debugging if needed
|
||||
if !has_expected_errors {
|
||||
eprintln!("Warning: Unexpected error type. Exit code: {exit_code}, stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_denied_scenarios() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
// Create config files in both supported locations
|
||||
let config_path = fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config");
|
||||
|
||||
// Also create legacy config path: ~/.cull-gmail/cull-gmail.toml
|
||||
let legacy_dir = fixture.temp_dir.path().join(".cull-gmail");
|
||||
fs::create_dir_all(&legacy_dir).expect("Failed to create legacy config directory");
|
||||
let legacy_config_path = legacy_dir.join("cull-gmail.toml");
|
||||
fs::write(&legacy_config_path, mock_config_toml()).expect("Failed to write legacy config");
|
||||
|
||||
// Try to remove read permissions from both config files (this might not work on all systems/CI)
|
||||
let permission_change_worked = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let restrict_both = [
|
||||
fs::metadata(&config_path).ok().and_then(|metadata| {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o000);
|
||||
fs::set_permissions(&config_path, perms).ok()
|
||||
}),
|
||||
fs::metadata(&legacy_config_path).ok().and_then(|metadata| {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o000);
|
||||
fs::set_permissions(&legacy_config_path, perms).ok()
|
||||
}),
|
||||
];
|
||||
restrict_both.iter().any(|result| result.is_some())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false // Permission manipulation not supported on non-Unix
|
||||
}
|
||||
};
|
||||
|
||||
let output = fixture
|
||||
.execute_cli(&["labels"], None)
|
||||
.expect("Failed to execute CLI");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
|
||||
// Test behavior depends on whether permission change worked
|
||||
if permission_change_worked {
|
||||
// If permissions were successfully restricted, expect permission-related errors
|
||||
assert!(
|
||||
!output.status.success()
|
||||
&& (stderr.contains("permission")
|
||||
|| stderr.contains("access")
|
||||
|| stderr.contains("denied")
|
||||
|| stderr.contains("Permission denied")),
|
||||
"Expected permission error when config file is unreadable. Exit code: {exit_code}, stderr: {stderr}"
|
||||
);
|
||||
} else {
|
||||
// If permission change didn't work (CI/containerized environments),
|
||||
// just ensure the command doesn't crash
|
||||
assert!(
|
||||
exit_code != 139, // No segfault
|
||||
"Command should not crash even if permission test cannot run. Exit code: {exit_code}, stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async integration tests for concurrent operations
|
||||
mod async_integration_tests {
|
||||
use super::test_utils::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_cli_executions() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config");
|
||||
|
||||
// Execute multiple CLI commands concurrently
|
||||
let tasks = vec![
|
||||
fixture.execute_cli_async(&["labels", "--help"], None),
|
||||
fixture.execute_cli_async(&["messages", "--help"], None),
|
||||
fixture.execute_cli_async(&["rules", "--help"], None),
|
||||
];
|
||||
|
||||
let results = futures::future::join_all(tasks).await;
|
||||
|
||||
// All help commands should succeed
|
||||
for result in results {
|
||||
let output = result.expect("Failed to execute CLI");
|
||||
assert!(output.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_async_command_timeout() {
|
||||
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
|
||||
|
||||
fixture
|
||||
.create_config_file(mock_config_toml())
|
||||
.expect("Failed to create config");
|
||||
fixture
|
||||
.create_credentials_file(mock_credentials_json())
|
||||
.expect("Failed to create credentials");
|
||||
|
||||
// Test with timeout
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture.execute_cli_async(&["labels"], None),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
let output = output.expect("Failed to execute CLI");
|
||||
// Command completed within timeout (no segfault)
|
||||
let exit_code = output.status.code().unwrap_or(0);
|
||||
assert!(
|
||||
exit_code != 139,
|
||||
"Segmentation fault detected, got exit code: {exit_code}"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout occurred - this is acceptable for integration tests
|
||||
// as we may not have real credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
crates/cull-gmail/tests/gmail_client_unit_tests.rs
Normal file
51
crates/cull-gmail/tests/gmail_client_unit_tests.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Unit tests for the Gmail client module.
|
||||
//!
|
||||
//! These tests focus on testing the individual components and methods of the Gmail client
|
||||
//! that can be tested without requiring actual Gmail API calls.
|
||||
|
||||
/// Test module for Gmail client functionality
|
||||
mod gmail_client_tests {
|
||||
use cull_gmail::ClientConfig;
|
||||
|
||||
/// Test the default max results constant
|
||||
#[test]
|
||||
fn test_default_max_results() {
|
||||
let default_max = cull_gmail::DEFAULT_MAX_RESULTS;
|
||||
assert_eq!(default_max, "200");
|
||||
|
||||
// Verify it can be parsed as u32
|
||||
let parsed: u32 = default_max
|
||||
.parse()
|
||||
.expect("DEFAULT_MAX_RESULTS should be a valid u32");
|
||||
assert_eq!(parsed, 200);
|
||||
}
|
||||
|
||||
/// Test that DEFAULT_MAX_RESULTS is a reasonable value for Gmail API
|
||||
#[test]
|
||||
fn test_default_max_results_range() {
|
||||
let default_max: u32 = cull_gmail::DEFAULT_MAX_RESULTS
|
||||
.parse()
|
||||
.expect("DEFAULT_MAX_RESULTS should be a valid u32");
|
||||
|
||||
// Gmail API supports up to 500 results per page
|
||||
assert!(default_max > 0, "Max results should be positive");
|
||||
assert!(
|
||||
default_max <= 500,
|
||||
"Max results should not exceed Gmail API limit"
|
||||
);
|
||||
assert!(
|
||||
default_max >= 10,
|
||||
"Max results should be reasonable for performance"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that ClientConfig builder compiles and creates a config
|
||||
#[test]
|
||||
fn test_client_config_builder_works() {
|
||||
let _config = ClientConfig::builder()
|
||||
.with_client_id("test-id")
|
||||
.with_client_secret("test-secret")
|
||||
.build();
|
||||
// Test passes if we reach here without panicking
|
||||
}
|
||||
}
|
||||
36
crates/cull-gmail/tests/gmail_message_list_integration.rs
Normal file
36
crates/cull-gmail/tests/gmail_message_list_integration.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Optional integration test for Gmail API interactions.
|
||||
//
|
||||
// This test is ignored by default to avoid network use in CI.
|
||||
// To run locally, ensure you have valid OAuth client credentials and set up
|
||||
// the configuration as required by `ClientConfig`.
|
||||
//
|
||||
// Example to run:
|
||||
// cargo test --test gmail_message_list_integration -- --ignored
|
||||
|
||||
use cull_gmail::{ClientConfig, GmailClient, MessageList, Result};
|
||||
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn list_first_page_of_messages_smoke_test() -> Result<()> {
|
||||
// Configure with your own credentials before running locally.
|
||||
let config = ClientConfig::builder()
|
||||
// .with_config_base(&cull_gmail::client_config::config_root::RootBase::Home)
|
||||
// .with_config_path(".cull-gmail")
|
||||
// .with_credential_file("client_secret.json")
|
||||
// Alternatively specify client_id/client_secret and related fields:
|
||||
// .with_client_id("<your-client-id>")
|
||||
// .with_client_secret("<your-client-secret>")
|
||||
.build();
|
||||
|
||||
let mut client = GmailClient::new_with_config(config).await?;
|
||||
|
||||
// Configure a conservative query to avoid heavy traffic
|
||||
client.set_query("in:inbox newer_than:30d");
|
||||
client.set_max_results(10);
|
||||
|
||||
// Should complete without error; results may be empty depending on mailbox
|
||||
client.get_messages(1).await?;
|
||||
let _ids = client.message_ids();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
552
crates/cull-gmail/tests/init_integration_tests.rs
Normal file
552
crates/cull-gmail/tests/init_integration_tests.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
//! Integration tests for the init CLI command.
|
||||
|
||||
use assert_cmd::cargo_bin;
|
||||
use assert_cmd::prelude::*;
|
||||
use assert_fs::fixture::ChildPath;
|
||||
use assert_fs::prelude::*;
|
||||
use predicates::prelude::*;
|
||||
use std::process::Command;
|
||||
|
||||
/// Creates a mock OAuth2 credential file with test data.
|
||||
///
|
||||
/// This helper function creates a valid OAuth2 credential JSON file
|
||||
/// suitable for testing credential file handling without using real credentials.
|
||||
fn create_mock_credential_file(credential_file: &ChildPath) {
|
||||
credential_file
|
||||
.write_str(
|
||||
r#"{
|
||||
"installed": {
|
||||
"client_id": "test-client-id.googleusercontent.com",
|
||||
"client_secret": "test-client-secret",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": ["http://localhost"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper to run init command with config and rules directories.
|
||||
///
|
||||
/// This helper reduces duplication when testing init with separate directories.
|
||||
fn run_init_with_dirs(
|
||||
config_dir: &std::path::Path,
|
||||
rules_dir: &std::path::Path,
|
||||
dry_run: bool,
|
||||
) -> assert_cmd::assert::Assert {
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
let config_arg = format!("c:{}", config_dir.to_string_lossy());
|
||||
let rules_arg = format!("c:{}", rules_dir.to_string_lossy());
|
||||
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&config_arg,
|
||||
"--rules-dir",
|
||||
&rules_arg,
|
||||
]);
|
||||
|
||||
if dry_run {
|
||||
cmd.arg("--dry-run");
|
||||
}
|
||||
|
||||
cmd.assert()
|
||||
}
|
||||
|
||||
/// Helper to run init command with credential file.
|
||||
///
|
||||
/// This helper reduces duplication when testing init with credential files.
|
||||
fn run_init_with_credential(
|
||||
config_dir: &std::path::Path,
|
||||
credential_path: &std::path::Path,
|
||||
dry_run: bool,
|
||||
) -> assert_cmd::assert::Assert {
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
let config_arg = format!("c:{}", config_dir.to_string_lossy());
|
||||
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&config_arg,
|
||||
"--credential-file",
|
||||
credential_path.to_str().unwrap(),
|
||||
]);
|
||||
|
||||
if dry_run {
|
||||
cmd.arg("--dry-run");
|
||||
}
|
||||
|
||||
cmd.assert()
|
||||
}
|
||||
|
||||
/// Helper to verify standard init file creation.
|
||||
///
|
||||
/// Checks that config directory, cull-gmail.toml, and gmail1 directory were created.
|
||||
fn verify_standard_init_files(config_dir: &std::path::Path) {
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("cull-gmail.toml").exists());
|
||||
assert!(config_dir.join("gmail1").exists());
|
||||
}
|
||||
|
||||
/// Helper to verify standard config file content.
|
||||
///
|
||||
/// Checks for common configuration entries.
|
||||
fn verify_standard_config_content(config_dir: &std::path::Path) {
|
||||
let config_content = std::fs::read_to_string(config_dir.join("cull-gmail.toml")).unwrap();
|
||||
assert!(config_content.contains("credential_file = \"credential.json\""));
|
||||
assert!(config_content.contains("execute = false"));
|
||||
}
|
||||
|
||||
/// Helper to run init command with basic options.
|
||||
///
|
||||
/// This helper reduces duplication when testing basic init command execution.
|
||||
fn run_init_basic(config_dir: &std::path::Path, skip_rules: bool) -> assert_cmd::assert::Assert {
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
let config_arg = format!("c:{}", config_dir.to_string_lossy());
|
||||
|
||||
cmd.arg("init");
|
||||
cmd.arg("--config-dir");
|
||||
cmd.arg(config_arg);
|
||||
|
||||
if skip_rules {
|
||||
cmd.arg("--skip-rules");
|
||||
}
|
||||
|
||||
cmd.assert()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_help() {
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args(["init", "--help"]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"Initialize cull-gmail configuration",
|
||||
))
|
||||
.stdout(predicate::str::contains("--config-dir"))
|
||||
.stdout(predicate::str::contains("--credential-file"))
|
||||
.stdout(predicate::str::contains("--dry-run"))
|
||||
.stdout(predicate::str::contains("--interactive"))
|
||||
.stdout(predicate::str::contains("--force"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_dry_run_new_setup() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("DRY RUN: No changes will be made"))
|
||||
.stdout(predicate::str::contains("Planned operations:"))
|
||||
.stdout(predicate::str::contains("Create directory:"))
|
||||
.stdout(predicate::str::contains("Write file:"))
|
||||
.stdout(predicate::str::contains("cull-gmail.toml"))
|
||||
.stdout(predicate::str::contains("rules.toml"))
|
||||
.stdout(predicate::str::contains("Ensure token directory:"))
|
||||
.stdout(predicate::str::contains("gmail1"))
|
||||
.stdout(predicate::str::contains("OAuth2 authentication skipped"))
|
||||
.stdout(predicate::str::contains(
|
||||
"To apply these changes, run without --dry-run",
|
||||
));
|
||||
|
||||
// Verify no files were actually created
|
||||
assert!(!config_dir.exists());
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_with_separate_rules_directory() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("config");
|
||||
let rules_dir = temp_dir.path().join("rules");
|
||||
|
||||
run_init_with_dirs(&config_dir, &rules_dir, false)
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"Initialization completed successfully!",
|
||||
));
|
||||
|
||||
// Verify config directory was created
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("cull-gmail.toml").exists());
|
||||
assert!(config_dir.join("gmail1").exists());
|
||||
|
||||
// Verify rules directory was created separately
|
||||
assert!(rules_dir.exists());
|
||||
assert!(rules_dir.join("rules.toml").exists());
|
||||
|
||||
// Verify rules.toml is NOT in config directory
|
||||
assert!(!config_dir.join("rules.toml").exists());
|
||||
|
||||
// Verify config file references the correct rules path
|
||||
let config_content = std::fs::read_to_string(config_dir.join("cull-gmail.toml")).unwrap();
|
||||
let rules_path = rules_dir.join("rules.toml");
|
||||
assert!(config_content.contains(&rules_path.to_string_lossy().to_string()));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_rules_dir_dry_run() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("config");
|
||||
let rules_dir = temp_dir.path().join("rules");
|
||||
|
||||
run_init_with_dirs(&config_dir, &rules_dir, true)
|
||||
.success()
|
||||
.stdout(predicate::str::contains("DRY RUN: No changes will be made"))
|
||||
.stdout(predicate::str::contains("Create directory:"))
|
||||
.stdout(predicate::str::contains("rules.toml"));
|
||||
|
||||
// Verify no directories were created in dry-run
|
||||
assert!(!config_dir.exists());
|
||||
assert!(!rules_dir.exists());
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_dry_run_with_credential_file() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
let credential_file = temp_dir.child("credential.json");
|
||||
|
||||
// Create a mock credential file
|
||||
create_mock_credential_file(&credential_file);
|
||||
|
||||
run_init_with_credential(&config_dir, credential_file.path(), true)
|
||||
.success()
|
||||
.stdout(predicate::str::contains("DRY RUN: No changes will be made"))
|
||||
.stdout(predicate::str::contains("Planned operations:"))
|
||||
.stdout(predicate::str::contains("Copy file:"))
|
||||
.stdout(predicate::str::contains("credential.json"))
|
||||
.stdout(predicate::str::contains("OAuth2 authentication would open"));
|
||||
|
||||
// Verify no files were actually created
|
||||
assert!(!config_dir.exists());
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_actual_execution() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
run_init_basic(&config_dir, false)
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"Initialization completed successfully!",
|
||||
))
|
||||
.stdout(predicate::str::contains("Configuration directory:"))
|
||||
.stdout(predicate::str::contains("Files created:"))
|
||||
.stdout(predicate::str::contains("cull-gmail.toml"))
|
||||
.stdout(predicate::str::contains("rules.toml"))
|
||||
.stdout(predicate::str::contains("Next steps:"));
|
||||
|
||||
// Verify standard files were created
|
||||
verify_standard_init_files(&config_dir);
|
||||
assert!(config_dir.join("rules.toml").exists());
|
||||
|
||||
// Verify file contents
|
||||
verify_standard_config_content(&config_dir);
|
||||
|
||||
let rules_content = std::fs::read_to_string(config_dir.join("rules.toml")).unwrap();
|
||||
assert!(rules_content.contains("# Example rules for cull-gmail"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_force_overwrite() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
// Create config directory and file first
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("cull-gmail.toml"), "old config").unwrap();
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--force",
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("DRY RUN: No changes will be made"))
|
||||
.stdout(predicate::str::contains("(with backup)"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_existing_config_no_force_fails() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
// Create config directory and file first
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("cull-gmail.toml"), "existing config").unwrap();
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("already exists"))
|
||||
.stderr(predicate::str::contains("--force"))
|
||||
.stderr(predicate::str::contains("--interactive"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_with_credential_file_copy() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
let credential_file = temp_dir.child("source_credential.json");
|
||||
|
||||
// Create a mock credential file
|
||||
create_mock_credential_file(&credential_file);
|
||||
|
||||
// This will fail at OAuth step, but we can check that files were created correctly
|
||||
let _output = run_init_with_credential(&config_dir, credential_file.path(), false)
|
||||
.get_output()
|
||||
.clone();
|
||||
|
||||
// Verify files were created up to the OAuth step
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("cull-gmail.toml").exists());
|
||||
assert!(config_dir.join("rules.toml").exists());
|
||||
assert!(config_dir.join("credential.json").exists());
|
||||
assert!(config_dir.join("gmail1").exists());
|
||||
|
||||
// Verify credential file was copied
|
||||
let copied_credential_content =
|
||||
std::fs::read_to_string(config_dir.join("credential.json")).unwrap();
|
||||
assert!(copied_credential_content.contains("test-client-id.googleusercontent.com"));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Verify credential file has secure permissions
|
||||
let metadata = std::fs::metadata(config_dir.join("credential.json")).unwrap();
|
||||
let permissions = metadata.permissions();
|
||||
assert_eq!(permissions.mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_invalid_credential_file() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
let credential_file = temp_dir.child("invalid_credential.json");
|
||||
|
||||
// Create an invalid credential file
|
||||
credential_file.write_str("invalid json content").unwrap();
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--credential-file",
|
||||
credential_file.path().to_str().unwrap(),
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Invalid credential file format"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_nonexistent_credential_file() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
let nonexistent_file = temp_dir.path().join("nonexistent.json");
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--credential-file",
|
||||
nonexistent_file.to_str().unwrap(),
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("not found"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
// This test would require real Gmail credentials and should be ignored by default
|
||||
#[test]
|
||||
#[ignore = "requires real Gmail OAuth2 credentials"]
|
||||
fn test_init_oauth_integration() {
|
||||
// This test should only run when CULL_GMAIL_TEST_CREDENTIAL_FILE is set
|
||||
let credential_file = std::env::var("CULL_GMAIL_TEST_CREDENTIAL_FILE")
|
||||
.expect("CULL_GMAIL_TEST_CREDENTIAL_FILE must be set for OAuth integration test");
|
||||
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--credential-file",
|
||||
&credential_file,
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"OAuth2 authentication successful!",
|
||||
))
|
||||
.stdout(predicate::str::contains("gmail1/ (OAuth2 token cache)"));
|
||||
|
||||
// Verify token files were created
|
||||
assert!(config_dir.join("gmail1").exists());
|
||||
|
||||
// Check if there are token-related files in the gmail1 directory
|
||||
let gmail_dir_contents = std::fs::read_dir(config_dir.join("gmail1")).unwrap();
|
||||
let has_token_files = gmail_dir_contents.count() > 0;
|
||||
assert!(
|
||||
has_token_files,
|
||||
"Expected token files to be created in gmail1 directory"
|
||||
);
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_with_skip_rules_dry_run_shows_skipped() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--skip-rules",
|
||||
"--dry-run",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("DRY RUN: No changes will be made"))
|
||||
.stdout(predicate::str::contains("Planned operations:"))
|
||||
.stdout(predicate::str::contains("cull-gmail.toml"))
|
||||
.stdout(predicate::str::contains(
|
||||
"rules.toml: skipped (per --skip-rules flag)",
|
||||
))
|
||||
.stdout(predicate::str::contains(
|
||||
"The rules file path is configured in cull-gmail.toml",
|
||||
))
|
||||
.stdout(predicate::str::contains(
|
||||
"Expected to be provided externally",
|
||||
));
|
||||
|
||||
// Verify no files were actually created
|
||||
assert!(!config_dir.exists());
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_with_skip_rules_creates_config_but_not_rules() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("test-config");
|
||||
|
||||
run_init_basic(&config_dir, true)
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"Initialization completed successfully!",
|
||||
))
|
||||
.stdout(predicate::str::contains(
|
||||
"rules.toml (SKIPPED - expected to be provided externally)",
|
||||
));
|
||||
|
||||
// Verify standard files were created
|
||||
verify_standard_init_files(&config_dir);
|
||||
|
||||
// Verify rules.toml was NOT created
|
||||
assert!(!config_dir.join("rules.toml").exists());
|
||||
|
||||
// Verify config file contains skip-rules comment
|
||||
let config_content = std::fs::read_to_string(config_dir.join("cull-gmail.toml")).unwrap();
|
||||
assert!(config_content.contains("NOTE: rules.toml creation was skipped via --skip-rules flag"));
|
||||
assert!(config_content.contains("expected to be provided externally"));
|
||||
assert!(config_content.contains("rules = \"rules.toml\""));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_with_skip_rules_and_rules_dir_creates_dir_only() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("config");
|
||||
let rules_dir = temp_dir.path().join("rules");
|
||||
|
||||
let mut cmd = Command::new(cargo_bin!("cull-gmail"));
|
||||
cmd.args([
|
||||
"init",
|
||||
"--skip-rules",
|
||||
"--config-dir",
|
||||
&format!("c:{}", config_dir.to_string_lossy()),
|
||||
"--rules-dir",
|
||||
&format!("c:{}", rules_dir.to_string_lossy()),
|
||||
]);
|
||||
|
||||
cmd.assert().success().stdout(predicate::str::contains(
|
||||
"Initialization completed successfully!",
|
||||
));
|
||||
|
||||
// Verify config directory was created
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("cull-gmail.toml").exists());
|
||||
|
||||
// Verify rules directory was created
|
||||
assert!(rules_dir.exists());
|
||||
|
||||
// Verify rules.toml was NOT created in either directory
|
||||
assert!(!config_dir.join("rules.toml").exists());
|
||||
assert!(!rules_dir.join("rules.toml").exists());
|
||||
|
||||
// Verify config file references the correct rules path
|
||||
let config_content = std::fs::read_to_string(config_dir.join("cull-gmail.toml")).unwrap();
|
||||
let expected_rules_path = rules_dir.join("rules.toml");
|
||||
assert!(config_content.contains(&expected_rules_path.to_string_lossy().to_string()));
|
||||
assert!(config_content.contains("NOTE: rules.toml creation was skipped via --skip-rules flag"));
|
||||
|
||||
temp_dir.close().unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user