Skip to main content

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}