cull_gmail/client_config.rs
1//! # Gmail Client Configuration Module
2//!
3//! This module provides configuration management for Gmail API authentication and client setup.
4//! It handles OAuth2 credential loading, configuration parsing, and client initialization
5//! with flexible configuration sources including files, environment variables, and direct parameters.
6//!
7//! ## Overview
8//!
9//! The configuration system supports multiple authentication methods:
10//!
11//! - **File-based OAuth2 credentials**: Load Google Cloud Platform OAuth2 credentials from JSON files
12//! - **Direct configuration**: Set OAuth2 parameters programmatically via builder pattern
13//! - **Mixed configuration**: Combine file-based and programmatic configuration as needed
14//!
15//! ## Configuration Sources
16//!
17//! The module supports hierarchical configuration loading:
18//!
19//! 1. **Direct OAuth2 parameters** (highest priority)
20//! 2. **Credential file** specified via `credential_file` parameter
21//! 3. **Environment variables** via the `config` crate integration
22//!
23//! ## Security Considerations
24//!
25//! - **Credential Storage**: OAuth2 secrets are handled securely and never logged
26//! - **File Permissions**: Credential files should have restricted permissions (600 or similar)
27//! - **Error Handling**: File I/O and parsing errors are propagated with context
28//! - **Token Persistence**: OAuth2 tokens are stored in configurable directories with appropriate permissions
29//!
30//! ## Configuration Directory Structure
31//!
32//! The module supports flexible directory structures:
33//!
34//! ```text
35//! ~/.cull-gmail/ # Default configuration root
36//! ├── client_secret.json # OAuth2 credentials
37//! ├── gmail1/ # OAuth2 token cache
38//! │ ├── tokencache.json # Cached access/refresh tokens
39//! │ └── ... # Other OAuth2 artifacts
40//! └── config.toml # Application configuration
41//! ```
42//!
43//! ## Path Resolution
44//!
45//! The module supports multiple path resolution schemes:
46//!
47//! - `h:path` - Resolve relative to user's home directory
48//! - `r:path` - Resolve relative to system root directory
49//! - `c:path` - Resolve relative to current working directory
50//! - `path` - Use path as-is (no prefix resolution)
51//!
52//! ## Usage Examples
53//!
54//! ### Builder Pattern with Credential File
55//!
56//! ```rust,no_run
57//! use cull_gmail::ClientConfig;
58//!
59//! let config = ClientConfig::builder()
60//! .with_credential_file("client_secret.json")
61//! .with_config_path("~/.cull-gmail")
62//! .build();
63//! ```
64//!
65//! ### Builder Pattern with Direct OAuth2 Parameters
66//!
67//! ```rust
68//! use cull_gmail::ClientConfig;
69//!
70//! let config = ClientConfig::builder()
71//! .with_client_id("your-client-id.googleusercontent.com")
72//! .with_client_secret("your-client-secret")
73//! .with_auth_uri("https://accounts.google.com/o/oauth2/auth")
74//! .with_token_uri("https://oauth2.googleapis.com/token")
75//! .add_redirect_uri("http://localhost:8080")
76//! .build();
77//! ```
78//!
79//! ### Configuration from Config File
80//!
81//! ```rust,no_run
82//! use cull_gmail::ClientConfig;
83//! use config::Config;
84//!
85//! let app_config = Config::builder()
86//! .set_default("credential_file", "client_secret.json")?
87//! .set_default("config_root", "h:.cull-gmail")?
88//! .add_source(config::File::with_name("config.toml"))
89//! .build()?;
90//!
91//! let client_config = ClientConfig::new_from_configuration(app_config)?;
92//! # Ok::<(), Box<dyn std::error::Error>>(())
93//! ```
94//!
95//! ## Integration with Gmail Client
96//!
97//! The configuration integrates seamlessly with the Gmail client:
98//!
99//! ```rust,no_run
100//! use cull_gmail::{ClientConfig, GmailClient};
101//!
102//! # async fn example() -> cull_gmail::Result<()> {
103//! let config = ClientConfig::builder()
104//! .with_credential_file("client_secret.json")
105//! .build();
106//!
107//! let client = GmailClient::new_with_config(config).await?;
108//! # Ok(())
109//! # }
110//! ```
111//!
112//! ## Error Handling
113//!
114//! The module uses the crate's unified error type for consistent error handling:
115//!
116//! ```rust,no_run
117//! use cull_gmail::{ClientConfig, Result};
118//! use config::Config;
119//!
120//! fn load_config(app_config: Config) -> Result<ClientConfig> {
121//! match ClientConfig::new_from_configuration(app_config) {
122//! Ok(config) => Ok(config),
123//! Err(e) => {
124//! eprintln!("Configuration error: {}", e);
125//! Err(e)
126//! }
127//! }
128//! }
129//! ```
130//!
131//! ## Thread Safety
132//!
133//! All configuration types are safe to clone and use across threads. However,
134//! file I/O operations are synchronous and should be performed during application
135//! initialization rather than in performance-critical paths.
136
137use std::{fs, path::PathBuf};
138
139use config::Config;
140use google_gmail1::yup_oauth2::{ApplicationSecret, ConsoleApplicationSecret};
141
142use crate::Result;
143
144mod config_root;
145
146use config_root::ConfigRoot;
147
148/// Gmail client configuration containing OAuth2 credentials and persistence settings.
149///
150/// This struct holds all necessary configuration for Gmail API authentication and client setup,
151/// including OAuth2 application secrets, configuration directory paths, and token persistence settings.
152///
153/// # Fields
154///
155/// The struct contains private fields that are accessed through getter methods to ensure
156/// proper encapsulation and prevent accidental mutation of sensitive configuration data.
157///
158/// # Security
159///
160/// The `secret` field contains sensitive OAuth2 credentials including client secrets.
161/// These values are never logged or exposed in debug output beyond their type information.
162///
163/// # Thread Safety
164///
165/// `ClientConfig` is safe to clone and use across threads. All contained data is either
166/// immutable or safely clonable.
167///
168/// # Examples
169///
170/// ```rust
171/// use cull_gmail::ClientConfig;
172///
173/// // Create configuration with builder pattern
174/// let config = ClientConfig::builder()
175/// .with_client_id("test-client-id")
176/// .with_client_secret("test-secret")
177/// .build();
178///
179/// // Access configuration values
180/// assert_eq!(config.secret().client_id, "test-client-id");
181/// assert!(config.persist_path().contains("gmail1"));
182/// ```
183#[derive(Debug)]
184pub struct ClientConfig {
185 /// OAuth2 application secret containing client credentials and endpoints.
186 /// This field contains sensitive information and should be handled carefully.
187 secret: ApplicationSecret,
188
189 /// Configuration root path resolver for determining base directories.
190 /// Supports multiple path resolution schemes (home, root, current directory).
191 config_root: ConfigRoot,
192
193 /// Full path where OAuth2 tokens should be persisted.
194 /// Typically resolves to something like `~/.cull-gmail/gmail1`.
195 persist_path: String,
196}
197
198impl ClientConfig {
199 /// Creates a new configuration builder for constructing `ClientConfig` instances.
200 ///
201 /// The builder pattern allows for flexible configuration construction with method chaining.
202 /// This is the preferred way to create new configurations as it provides compile-time
203 /// guarantees about required fields and allows for incremental configuration building.
204 ///
205 /// # Returns
206 ///
207 /// A new `ConfigBuilder` instance initialized with sensible defaults.
208 ///
209 /// # Examples
210 ///
211 /// ```rust
212 /// use cull_gmail::ClientConfig;
213 ///
214 /// let config = ClientConfig::builder()
215 /// .with_client_id("your-client-id")
216 /// .with_client_secret("your-secret")
217 /// .build();
218 /// ```
219 pub fn builder() -> ConfigBuilder {
220 ConfigBuilder::default()
221 }
222
223 /// Creates a new `ClientConfig` from an external configuration source.
224 ///
225 /// This method supports hierarchical configuration loading with the following priority:
226 /// 1. Direct OAuth2 parameters (`client_id`, `client_secret`, `token_uri`, `auth_uri`)
227 /// 2. Credential file specified via `credential_file` parameter
228 ///
229 /// # Configuration Parameters
230 ///
231 /// ## Required Parameters (one of these sets):
232 ///
233 /// **Direct OAuth2 Configuration:**
234 /// - `client_id`: OAuth2 client identifier
235 /// - `client_secret`: OAuth2 client secret
236 /// - `token_uri`: Token exchange endpoint URL
237 /// - `auth_uri`: Authorization endpoint URL
238 ///
239 /// **OR**
240 ///
241 /// **File-based Configuration:**
242 /// - `credential_file`: Path to JSON credential file (relative to `config_root`)
243 ///
244 /// ## Always Required:
245 /// - `config_root`: Base directory for configuration files (supports path prefixes)
246 ///
247 /// # Arguments
248 ///
249 /// * `configs` - Configuration object containing OAuth2 and path settings
250 ///
251 /// # Returns
252 ///
253 /// Returns `Ok(ClientConfig)` on successful configuration loading, or an error if:
254 /// - Required configuration parameters are missing
255 /// - Credential file cannot be read or parsed
256 /// - OAuth2 credential structure is invalid
257 ///
258 /// # Errors
259 ///
260 /// This method can return errors for:
261 /// - Missing required configuration keys
262 /// - File I/O errors when reading credential files
263 /// - JSON parsing errors for malformed credential files
264 /// - Invalid OAuth2 credential structure
265 ///
266 /// # Examples
267 ///
268 /// ```rust,no_run
269 /// use cull_gmail::ClientConfig;
270 /// use config::Config;
271 ///
272 /// // Configuration with credential file
273 /// let app_config = Config::builder()
274 /// .set_default("credential_file", "client_secret.json")?
275 /// .set_default("config_root", "h:.cull-gmail")?
276 /// .build()?;
277 ///
278 /// let client_config = ClientConfig::new_from_configuration(app_config)?;
279 /// # Ok::<(), Box<dyn std::error::Error>>(())
280 /// ```
281 ///
282 /// ```rust,no_run
283 /// use cull_gmail::ClientConfig;
284 /// use config::Config;
285 ///
286 /// // Configuration with direct OAuth2 parameters
287 /// let app_config = Config::builder()
288 /// .set_default("client_id", "your-client-id")?
289 /// .set_default("client_secret", "your-secret")?
290 /// .set_default("token_uri", "https://oauth2.googleapis.com/token")?
291 /// .set_default("auth_uri", "https://accounts.google.com/o/oauth2/auth")?
292 /// .set_default("config_root", "h:.cull-gmail")?
293 /// .build()?;
294 ///
295 /// let client_config = ClientConfig::new_from_configuration(app_config)?;
296 /// # Ok::<(), Box<dyn std::error::Error>>(())
297 /// ```
298 pub fn new_from_configuration(configs: Config) -> Result<Self> {
299 log::debug!("Configurations: {configs:#?}");
300 let root = configs.get_string("config_root")?;
301 let config_root = ConfigRoot::parse(&root);
302
303 log::trace!("Configs are: {configs:#?}");
304
305 let secret = if let Ok(client_id) = configs.get_string("client_id")
306 && let Ok(client_secret) = configs.get_string("client_secret")
307 && let Ok(token_uri) = configs.get_string("token_uri")
308 && let Ok(auth_uri) = configs.get_string("auth_uri")
309 {
310 log::info!("Generating the application secret from the environment!");
311 ApplicationSecret {
312 client_id,
313 client_secret,
314 token_uri,
315 auth_uri,
316 project_id: None,
317 redirect_uris: Vec::new(),
318 client_email: None,
319 auth_provider_x509_cert_url: None,
320 client_x509_cert_url: None,
321 }
322 } else {
323 log::info!("Generating the application secret from the credential file!");
324 let credential_file = configs.get_string("credential_file")?;
325 log::info!("root: {config_root}");
326 let path = config_root.full_path().join(credential_file);
327 log::info!("path: {}", path.display());
328 let json_str = fs::read_to_string(path).expect("could not read path");
329
330 let console: ConsoleApplicationSecret =
331 serde_json::from_str(&json_str).expect("could not convert to struct");
332
333 console.installed.unwrap()
334 };
335
336 let persist_path = format!("{}/gmail1", config_root.full_path().display());
337
338 Ok(ClientConfig {
339 config_root,
340 secret,
341 persist_path,
342 })
343 }
344
345 /// Returns a reference to the OAuth2 application secret.
346 ///
347 /// This provides access to the OAuth2 credentials including client ID, client secret,
348 /// and endpoint URLs required for Gmail API authentication.
349 ///
350 /// # Security Note
351 ///
352 /// The returned `ApplicationSecret` contains sensitive information including the
353 /// OAuth2 client secret. Handle this data carefully and avoid logging or exposing it.
354 ///
355 /// # Examples
356 ///
357 /// ```rust
358 /// use cull_gmail::ClientConfig;
359 ///
360 /// let config = ClientConfig::builder()
361 /// .with_client_id("test-client-id")
362 /// .build();
363 ///
364 /// let secret = config.secret();
365 /// assert_eq!(secret.client_id, "test-client-id");
366 /// ```
367 pub fn secret(&self) -> &ApplicationSecret {
368 &self.secret
369 }
370
371 /// Returns the full path where OAuth2 tokens should be persisted.
372 ///
373 /// This path is used by the OAuth2 library to store and retrieve cached tokens,
374 /// enabling automatic token refresh without requiring user re-authentication.
375 ///
376 /// # Path Format
377 ///
378 /// The path typically follows the pattern: `{config_root}/gmail1`
379 ///
380 /// For example:
381 /// - `~/.cull-gmail/gmail1` (when config_root is `h:.cull-gmail`)
382 /// - `/etc/cull-gmail/gmail1` (when config_root is `r:etc/cull-gmail`)
383 ///
384 /// # Examples
385 ///
386 /// ```rust
387 /// use cull_gmail::ClientConfig;
388 ///
389 /// let config = ClientConfig::builder().build();
390 /// let persist_path = config.persist_path();
391 /// assert!(persist_path.contains("gmail1"));
392 /// ```
393 pub fn persist_path(&self) -> &str {
394 &self.persist_path
395 }
396
397 /// Returns a reference to the configuration root path resolver.
398 ///
399 /// The `ConfigRoot` handles path resolution with support for different base directories
400 /// including home directory, system root, and current working directory.
401 ///
402 /// # Examples
403 ///
404 /// ```rust
405 /// use cull_gmail::ClientConfig;
406 ///
407 /// let config = ClientConfig::builder()
408 /// .with_config_path(".cull-gmail")
409 /// .build();
410 ///
411 /// let config_root = config.config_root();
412 /// // config_root can be used to resolve additional paths
413 /// ```
414 pub fn config_root(&self) -> &ConfigRoot {
415 &self.config_root
416 }
417
418 /// Returns the fully resolved configuration directory path as a string.
419 ///
420 /// This method resolves the configuration root path to an absolute path string,
421 /// applying any path prefix resolution (home directory, system root, etc.).
422 ///
423 /// # Examples
424 ///
425 /// ```rust
426 /// use cull_gmail::ClientConfig;
427 ///
428 /// let config = ClientConfig::builder()
429 /// .with_config_path(".cull-gmail")
430 /// .build();
431 ///
432 /// let full_path = config.full_path();
433 /// // Returns the absolute path to the configuration directory
434 /// ```
435 pub fn full_path(&self) -> String {
436 self.config_root.full_path().display().to_string()
437 }
438}
439
440/// Builder for constructing `ClientConfig` instances with flexible configuration options.
441///
442/// The `ConfigBuilder` provides a fluent interface for constructing Gmail client configurations
443/// with support for both file-based and programmatic OAuth2 credential setup. It implements
444/// the builder pattern to ensure required configuration is provided while allowing optional
445/// parameters to be set incrementally.
446///
447/// # Configuration Methods
448///
449/// The builder supports two primary configuration approaches:
450///
451/// 1. **File-based configuration**: Load OAuth2 credentials from JSON files
452/// 2. **Direct configuration**: Set OAuth2 parameters programmatically
453///
454/// # Thread Safety
455///
456/// The builder is not thread-safe and should be used to construct configurations
457/// in a single-threaded context. The resulting `ClientConfig` instances are thread-safe.
458///
459/// # Examples
460///
461/// ## File-based Configuration
462///
463/// ```rust,no_run
464/// use cull_gmail::ClientConfig;
465///
466/// let config = ClientConfig::builder()
467/// .with_credential_file("client_secret.json")
468/// .with_config_path(".cull-gmail")
469/// .build();
470/// ```
471///
472/// ## Direct OAuth2 Configuration
473///
474/// ```rust
475/// use cull_gmail::ClientConfig;
476///
477/// let config = ClientConfig::builder()
478/// .with_client_id("your-client-id.googleusercontent.com")
479/// .with_client_secret("your-client-secret")
480/// .with_auth_uri("https://accounts.google.com/o/oauth2/auth")
481/// .with_token_uri("https://oauth2.googleapis.com/token")
482/// .add_redirect_uri("http://localhost:8080")
483/// .with_project_id("your-project-id")
484/// .build();
485/// ```
486///
487/// ## Mixed Configuration
488///
489/// ```rust,no_run
490/// use cull_gmail::ClientConfig;
491///
492/// let config = ClientConfig::builder()
493/// .with_credential_file("base_credentials.json")
494/// .add_redirect_uri("http://localhost:3000") // Additional redirect URI
495/// .with_project_id("override-project-id") // Override from file
496/// .build();
497/// ```
498#[derive(Debug)]
499pub struct ConfigBuilder {
500 /// OAuth2 application secret being constructed.
501 /// Contains client credentials, endpoints, and additional parameters.
502 secret: ApplicationSecret,
503
504 /// Configuration root path resolver for determining base directories.
505 /// Used to resolve relative paths in credential files and token storage.
506 config_root: ConfigRoot,
507}
508
509impl Default for ConfigBuilder {
510 /// Creates a new `ConfigBuilder` with sensible OAuth2 defaults.
511 ///
512 /// The default configuration includes:
513 /// - Standard Google OAuth2 endpoints (auth_uri, token_uri)
514 /// - Empty client credentials (must be set before use)
515 /// - Default configuration root (no path prefix)
516 ///
517 /// # Note
518 ///
519 /// The default instance requires additional configuration before it can be used
520 /// to create a functional `ClientConfig`. At minimum, you must set either:
521 /// - Client credentials via `with_client_id()` and `with_client_secret()`, or
522 /// - A credential file via `with_credential_file()`
523 fn default() -> Self {
524 let secret = ApplicationSecret {
525 auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
526 token_uri: "https://oauth2.googleapis.com/token".to_string(),
527 ..Default::default()
528 };
529
530 Self {
531 secret,
532 config_root: Default::default(),
533 }
534 }
535}
536
537impl ConfigBuilder {
538 pub fn with_config_base(&mut self, value: &config_root::RootBase) -> &mut Self {
539 self.config_root.set_root_base(value);
540 self
541 }
542
543 pub fn with_config_path(&mut self, value: &str) -> &mut Self {
544 self.config_root.set_path(value);
545 self
546 }
547
548 pub fn with_credential_file(&mut self, credential_file: &str) -> &mut Self {
549 let path = PathBuf::from(self.config_root.to_string()).join(credential_file);
550 log::info!("path: {}", path.display());
551 let json_str = fs::read_to_string(path).expect("could not read path");
552
553 let console: ConsoleApplicationSecret =
554 serde_json::from_str(&json_str).expect("could not convert to struct");
555
556 self.secret = console.installed.unwrap();
557 self
558 }
559
560 pub fn with_client_id(&mut self, value: &str) -> &mut Self {
561 self.secret.client_id = value.to_string();
562 self
563 }
564
565 pub fn with_client_secret(&mut self, value: &str) -> &mut Self {
566 self.secret.client_secret = value.to_string();
567 self
568 }
569
570 pub fn with_token_uri(&mut self, value: &str) -> &mut Self {
571 self.secret.token_uri = value.to_string();
572 self
573 }
574
575 pub fn with_auth_uri(&mut self, value: &str) -> &mut Self {
576 self.secret.auth_uri = value.to_string();
577 self
578 }
579
580 pub fn add_redirect_uri(&mut self, value: &str) -> &mut Self {
581 self.secret.redirect_uris.push(value.to_string());
582 self
583 }
584
585 pub fn with_project_id(&mut self, value: &str) -> &mut Self {
586 self.secret.project_id = Some(value.to_string());
587 self
588 }
589
590 pub fn with_client_email(&mut self, value: &str) -> &mut Self {
591 self.secret.client_email = Some(value.to_string());
592 self
593 }
594 pub fn with_auth_provider_x509_cert_url(&mut self, value: &str) -> &mut Self {
595 self.secret.auth_provider_x509_cert_url = Some(value.to_string());
596 self
597 }
598 pub fn with_client_x509_cert_url(&mut self, value: &str) -> &mut Self {
599 self.secret.client_x509_cert_url = Some(value.to_string());
600 self
601 }
602
603 fn full_path(&self) -> String {
604 self.config_root.full_path().display().to_string()
605 }
606
607 pub fn build(&self) -> ClientConfig {
608 let persist_path = format!("{}/gmail1", self.full_path());
609
610 ClientConfig {
611 secret: self.secret.clone(),
612 config_root: self.config_root.clone(),
613 persist_path,
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::test_utils::get_test_logger;
622 use config::Config;
623 use std::env;
624 use std::fs;
625 use tempfile::TempDir;
626
627 /// Helper function to create a temporary credential file for testing
628 fn create_test_credential_file(temp_dir: &TempDir, filename: &str, content: &str) -> String {
629 let file_path = temp_dir.path().join(filename);
630 fs::write(&file_path, content).expect("Failed to write test file");
631 file_path.to_string_lossy().to_string()
632 }
633
634 /// Sample valid OAuth2 credential JSON for testing
635 fn sample_valid_credential() -> &'static str {
636 r#"{
637 "installed": {
638 "client_id": "123456789-test.googleusercontent.com",
639 "project_id": "test-project",
640 "auth_uri": "https://accounts.google.com/o/oauth2/auth",
641 "token_uri": "https://oauth2.googleapis.com/token",
642 "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
643 "client_secret": "test-client-secret",
644 "redirect_uris": ["http://localhost"]
645 }
646}"#
647 }
648
649 #[test]
650 fn test_config_builder_defaults() {
651 let builder = ConfigBuilder::default();
652
653 assert_eq!(
654 builder.secret.auth_uri,
655 "https://accounts.google.com/o/oauth2/auth"
656 );
657 assert_eq!(
658 builder.secret.token_uri,
659 "https://oauth2.googleapis.com/token"
660 );
661 assert!(builder.secret.client_id.is_empty());
662 assert!(builder.secret.client_secret.is_empty());
663 }
664
665 #[test]
666 fn test_builder_pattern_direct_oauth2() {
667 let config = ClientConfig::builder()
668 .with_client_id("test-client-id")
669 .with_client_secret("test-client-secret")
670 .with_auth_uri("https://auth.example.com")
671 .with_token_uri("https://token.example.com")
672 .add_redirect_uri("http://localhost:8080")
673 .add_redirect_uri("http://localhost:3000")
674 .with_project_id("test-project")
675 .with_client_email("test@example.com")
676 .with_auth_provider_x509_cert_url("https://certs.example.com")
677 .with_client_x509_cert_url("https://client-cert.example.com")
678 .build();
679
680 assert_eq!(config.secret().client_id, "test-client-id");
681 assert_eq!(config.secret().client_secret, "test-client-secret");
682 assert_eq!(config.secret().auth_uri, "https://auth.example.com");
683 assert_eq!(config.secret().token_uri, "https://token.example.com");
684 assert_eq!(
685 config.secret().redirect_uris,
686 vec!["http://localhost:8080", "http://localhost:3000"]
687 );
688 assert_eq!(config.secret().project_id, Some("test-project".to_string()));
689 assert_eq!(
690 config.secret().client_email,
691 Some("test@example.com".to_string())
692 );
693 assert_eq!(
694 config.secret().auth_provider_x509_cert_url,
695 Some("https://certs.example.com".to_string())
696 );
697 assert_eq!(
698 config.secret().client_x509_cert_url,
699 Some("https://client-cert.example.com".to_string())
700 );
701 assert!(config.persist_path().contains("gmail1"));
702 }
703
704 #[test]
705 fn test_builder_with_config_path() {
706 let config = ClientConfig::builder()
707 .with_client_id("test-id")
708 .with_config_path(".test-config")
709 .build();
710
711 let full_path = config.full_path();
712 assert_eq!(full_path, ".test-config");
713 assert!(config.persist_path().contains(".test-config/gmail1"));
714 }
715
716 #[test]
717 fn test_builder_with_config_base_home() {
718 let config = ClientConfig::builder()
719 .with_client_id("test-id")
720 .with_config_base(&config_root::RootBase::Home)
721 .with_config_path(".test-config")
722 .build();
723
724 let expected_path = env::home_dir()
725 .unwrap_or_default()
726 .join(".test-config")
727 .display()
728 .to_string();
729
730 assert_eq!(config.full_path(), expected_path);
731 }
732
733 #[test]
734 fn test_builder_with_config_base_root() {
735 let config = ClientConfig::builder()
736 .with_client_id("test-id")
737 .with_config_base(&config_root::RootBase::Root)
738 .with_config_path("etc/test-config")
739 .build();
740
741 assert_eq!(config.full_path(), "/etc/test-config");
742 }
743
744 #[test]
745 fn test_config_from_direct_oauth2_params() {
746 let app_config = Config::builder()
747 .set_default("client_id", "direct-client-id")
748 .unwrap()
749 .set_default("client_secret", "direct-client-secret")
750 .unwrap()
751 .set_default("token_uri", "https://token.direct.com")
752 .unwrap()
753 .set_default("auth_uri", "https://auth.direct.com")
754 .unwrap()
755 .set_default("config_root", "h:.test-direct")
756 .unwrap()
757 .build()
758 .unwrap();
759
760 let config = ClientConfig::new_from_configuration(app_config).unwrap();
761
762 assert_eq!(config.secret().client_id, "direct-client-id");
763 assert_eq!(config.secret().client_secret, "direct-client-secret");
764 assert_eq!(config.secret().token_uri, "https://token.direct.com");
765 assert_eq!(config.secret().auth_uri, "https://auth.direct.com");
766 assert_eq!(config.secret().project_id, None);
767 assert!(config.secret().redirect_uris.is_empty());
768 }
769
770 #[test]
771 fn test_config_from_credential_file() {
772 get_test_logger();
773 let temp_dir = TempDir::new().expect("Failed to create temp dir");
774 let _cred_file =
775 create_test_credential_file(&temp_dir, "test_creds.json", sample_valid_credential());
776
777 let config_root = format!("c:{}", temp_dir.path().display());
778 let app_config = Config::builder()
779 .set_default("credential_file", "test_creds.json")
780 .unwrap()
781 .set_default("config_root", config_root.as_str())
782 .unwrap()
783 .build()
784 .unwrap();
785
786 let config = ClientConfig::new_from_configuration(app_config).unwrap();
787
788 assert_eq!(
789 config.secret().client_id,
790 "123456789-test.googleusercontent.com"
791 );
792 assert_eq!(config.secret().client_secret, "test-client-secret");
793 assert_eq!(config.secret().project_id, Some("test-project".to_string()));
794 assert_eq!(config.secret().redirect_uris, vec!["http://localhost"]);
795 }
796
797 #[test]
798 fn test_config_missing_required_params() {
799 // Test with missing config_root
800 let app_config = Config::builder()
801 .set_default("client_id", "test-id")
802 .unwrap()
803 .build()
804 .unwrap();
805
806 let result = ClientConfig::new_from_configuration(app_config);
807 assert!(result.is_err());
808 }
809
810 #[test]
811 fn test_config_incomplete_oauth2_params() {
812 // Test with some but not all OAuth2 parameters
813 let app_config = Config::builder()
814 .set_default("client_id", "test-id")
815 .unwrap()
816 .set_default("client_secret", "test-secret")
817 .unwrap()
818 // Missing token_uri and auth_uri
819 .set_default("config_root", "h:.test")
820 .unwrap()
821 .build()
822 .unwrap();
823
824 // Should fall back to credential_file approach, which should fail
825 let result = ClientConfig::new_from_configuration(app_config);
826 assert!(result.is_err());
827 }
828
829 #[test]
830 #[should_panic(expected = "could not read path")]
831 fn test_config_invalid_credential_file() {
832 let app_config = Config::builder()
833 .set_default("credential_file", "nonexistent.json")
834 .unwrap()
835 .set_default("config_root", "h:.test")
836 .unwrap()
837 .build()
838 .unwrap();
839
840 // This should panic with "could not read path" since the code uses .expect()
841 let _result = ClientConfig::new_from_configuration(app_config);
842 }
843
844 #[test]
845 #[should_panic(expected = "could not convert to struct")]
846 fn test_config_malformed_credential_file() {
847 get_test_logger();
848 let temp_dir = TempDir::new().expect("Failed to create temp dir");
849 let _cred_file = create_test_credential_file(&temp_dir, "malformed.json", "{ invalid json");
850
851 let config_root = format!("c:{}", temp_dir.path().display());
852 let app_config = Config::builder()
853 .set_default("credential_file", "malformed.json")
854 .unwrap()
855 .set_default("config_root", config_root.as_str())
856 .unwrap()
857 .build()
858 .unwrap();
859
860 // This should panic with "could not convert to struct" since the code uses .expect()
861 let _result = ClientConfig::new_from_configuration(app_config);
862 }
863
864 #[test]
865 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
866 fn test_config_credential_file_wrong_structure() {
867 get_test_logger();
868 let temp_dir = TempDir::new().expect("Failed to create temp dir");
869 let wrong_structure = r#"{"wrong": "structure"}"#;
870 let _cred_file = create_test_credential_file(&temp_dir, "wrong.json", wrong_structure);
871
872 let config_root = format!("c:{}", temp_dir.path().display());
873 let app_config = Config::builder()
874 .set_default("credential_file", "wrong.json")
875 .unwrap()
876 .set_default("config_root", config_root.as_str())
877 .unwrap()
878 .build()
879 .unwrap();
880
881 // This should panic with unwrap on None since console.installed is None
882 let _result = ClientConfig::new_from_configuration(app_config);
883 }
884
885 #[test]
886 fn test_persist_path_generation() {
887 let config = ClientConfig::builder()
888 .with_client_id("test")
889 .with_config_path("/custom/path")
890 .build();
891
892 assert_eq!(config.persist_path(), "/custom/path/gmail1");
893 }
894
895 #[test]
896 fn test_config_accessor_methods() {
897 let config = ClientConfig::builder()
898 .with_client_id("accessor-test-id")
899 .with_client_secret("accessor-test-secret")
900 .with_config_path("/test/path")
901 .build();
902
903 // Test secret() accessor
904 let secret = config.secret();
905 assert_eq!(secret.client_id, "accessor-test-id");
906 assert_eq!(secret.client_secret, "accessor-test-secret");
907
908 // Test persist_path() accessor
909 assert_eq!(config.persist_path(), "/test/path/gmail1");
910
911 // Test full_path() accessor
912 assert_eq!(config.full_path(), "/test/path");
913
914 // Test config_root() accessor
915 let config_root = config.config_root();
916 assert_eq!(config_root.full_path().display().to_string(), "/test/path");
917 }
918
919 #[test]
920 fn test_builder_method_chaining() {
921 // Test that all builder methods return &mut Self for chaining
922 let config = ClientConfig::builder()
923 .with_client_id("chain-test")
924 .with_client_secret("chain-secret")
925 .with_auth_uri("https://auth.chain.com")
926 .with_token_uri("https://token.chain.com")
927 .add_redirect_uri("http://redirect1.com")
928 .add_redirect_uri("http://redirect2.com")
929 .with_project_id("chain-project")
930 .with_client_email("chain@test.com")
931 .with_auth_provider_x509_cert_url("https://cert1.com")
932 .with_client_x509_cert_url("https://cert2.com")
933 .with_config_base(&config_root::RootBase::Home)
934 .with_config_path(".chain-test")
935 .build();
936
937 assert_eq!(config.secret().client_id, "chain-test");
938 assert_eq!(config.secret().redirect_uris.len(), 2);
939 }
940
941 #[test]
942 fn test_configuration_priority() {
943 // Test that direct OAuth2 params take priority over credential file
944 get_test_logger();
945 let temp_dir = TempDir::new().expect("Failed to create temp dir");
946 let _cred_file =
947 create_test_credential_file(&temp_dir, "priority.json", sample_valid_credential());
948
949 let config_root = format!("c:{}", temp_dir.path().display());
950 let app_config = Config::builder()
951 // Direct OAuth2 params (should take priority)
952 .set_default("client_id", "priority-client-id")
953 .unwrap()
954 .set_default("client_secret", "priority-client-secret")
955 .unwrap()
956 .set_default("token_uri", "https://priority.token.com")
957 .unwrap()
958 .set_default("auth_uri", "https://priority.auth.com")
959 .unwrap()
960 // Credential file (should be ignored)
961 .set_default("credential_file", "priority.json")
962 .unwrap()
963 .set_default("config_root", config_root.as_str())
964 .unwrap()
965 .build()
966 .unwrap();
967
968 let config = ClientConfig::new_from_configuration(app_config).unwrap();
969
970 // Should use direct params, not file contents
971 assert_eq!(config.secret().client_id, "priority-client-id");
972 assert_eq!(config.secret().client_secret, "priority-client-secret");
973 assert_eq!(config.secret().token_uri, "https://priority.token.com");
974 assert_ne!(
975 config.secret().client_id,
976 "123456789-test.googleusercontent.com"
977 ); // From file
978 }
979
980 #[test]
981 fn test_empty_redirect_uris() {
982 let config = ClientConfig::builder().with_client_id("test-id").build();
983
984 assert!(config.secret().redirect_uris.is_empty());
985 }
986
987 #[test]
988 fn test_multiple_redirect_uris() {
989 let config = ClientConfig::builder()
990 .with_client_id("test-id")
991 .add_redirect_uri("http://localhost:8080")
992 .add_redirect_uri("http://localhost:3000")
993 .add_redirect_uri("https://example.com/callback")
994 .build();
995
996 assert_eq!(config.secret().redirect_uris.len(), 3);
997 assert!(
998 config
999 .secret()
1000 .redirect_uris
1001 .contains(&"http://localhost:8080".to_string())
1002 );
1003 assert!(
1004 config
1005 .secret()
1006 .redirect_uris
1007 .contains(&"http://localhost:3000".to_string())
1008 );
1009 assert!(
1010 config
1011 .secret()
1012 .redirect_uris
1013 .contains(&"https://example.com/callback".to_string())
1014 );
1015 }
1016
1017 #[test]
1018 fn test_optional_fields() {
1019 let config = ClientConfig::builder()
1020 .with_client_id("optional-test")
1021 .build();
1022
1023 assert_eq!(config.secret().project_id, None);
1024 assert_eq!(config.secret().client_email, None);
1025 assert_eq!(config.secret().auth_provider_x509_cert_url, None);
1026 assert_eq!(config.secret().client_x509_cert_url, None);
1027 }
1028
1029 #[test]
1030 fn test_unicode_in_configuration() {
1031 let config = ClientConfig::builder()
1032 .with_client_id("unicode-客戶端-🔐-test")
1033 .with_client_secret("secret-with-unicode-密碼")
1034 .with_project_id("project-項目-id")
1035 .with_config_path(".unicode-配置")
1036 .build();
1037
1038 assert_eq!(config.secret().client_id, "unicode-客戶端-🔐-test");
1039 assert_eq!(config.secret().client_secret, "secret-with-unicode-密碼");
1040 assert_eq!(
1041 config.secret().project_id,
1042 Some("project-項目-id".to_string())
1043 );
1044 assert!(config.full_path().contains(".unicode-配置"));
1045 }
1046}