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 let root = configs.get_string("config_root")?;
300 let config_root = ConfigRoot::parse(&root);
301
302 log::trace!("Configs are: {configs:#?}");
303
304 let secret = if let Ok(client_id) = configs.get_string("client_id")
305 && let Ok(client_secret) = configs.get_string("client_secret")
306 && let Ok(token_uri) = configs.get_string("token_uri")
307 && let Ok(auth_uri) = configs.get_string("auth_uri")
308 {
309 log::info!("Generating the application secret from the environment!");
310 ApplicationSecret {
311 client_id,
312 client_secret,
313 token_uri,
314 auth_uri,
315 project_id: None,
316 redirect_uris: Vec::new(),
317 client_email: None,
318 auth_provider_x509_cert_url: None,
319 client_x509_cert_url: None,
320 }
321 } else {
322 log::info!("Generating the application secret from the credential file!");
323 let credential_file = configs.get_string("credential_file")?;
324 log::info!("root: {config_root}");
325 let path = config_root.full_path().join(credential_file);
326 log::info!("path: {}", path.display());
327 let json_str = fs::read_to_string(path).expect("could not read path");
328
329 let console: ConsoleApplicationSecret =
330 serde_json::from_str(&json_str).expect("could not convert to struct");
331
332 console.installed.unwrap()
333 };
334
335 let persist_path = format!("{}/gmail1", config_root.full_path().display());
336
337 Ok(ClientConfig {
338 config_root,
339 secret,
340 persist_path,
341 })
342 }
343
344 /// Returns a reference to the OAuth2 application secret.
345 ///
346 /// This provides access to the OAuth2 credentials including client ID, client secret,
347 /// and endpoint URLs required for Gmail API authentication.
348 ///
349 /// # Security Note
350 ///
351 /// The returned `ApplicationSecret` contains sensitive information including the
352 /// OAuth2 client secret. Handle this data carefully and avoid logging or exposing it.
353 ///
354 /// # Examples
355 ///
356 /// ```rust
357 /// use cull_gmail::ClientConfig;
358 ///
359 /// let config = ClientConfig::builder()
360 /// .with_client_id("test-client-id")
361 /// .build();
362 ///
363 /// let secret = config.secret();
364 /// assert_eq!(secret.client_id, "test-client-id");
365 /// ```
366 pub fn secret(&self) -> &ApplicationSecret {
367 &self.secret
368 }
369
370 /// Returns the full path where OAuth2 tokens should be persisted.
371 ///
372 /// This path is used by the OAuth2 library to store and retrieve cached tokens,
373 /// enabling automatic token refresh without requiring user re-authentication.
374 ///
375 /// # Path Format
376 ///
377 /// The path typically follows the pattern: `{config_root}/gmail1`
378 ///
379 /// For example:
380 /// - `~/.cull-gmail/gmail1` (when config_root is `h:.cull-gmail`)
381 /// - `/etc/cull-gmail/gmail1` (when config_root is `r:etc/cull-gmail`)
382 ///
383 /// # Examples
384 ///
385 /// ```rust
386 /// use cull_gmail::ClientConfig;
387 ///
388 /// let config = ClientConfig::builder().build();
389 /// let persist_path = config.persist_path();
390 /// assert!(persist_path.contains("gmail1"));
391 /// ```
392 pub fn persist_path(&self) -> &str {
393 &self.persist_path
394 }
395
396 /// Returns a reference to the configuration root path resolver.
397 ///
398 /// The `ConfigRoot` handles path resolution with support for different base directories
399 /// including home directory, system root, and current working directory.
400 ///
401 /// # Examples
402 ///
403 /// ```rust
404 /// use cull_gmail::ClientConfig;
405 ///
406 /// let config = ClientConfig::builder()
407 /// .with_config_path(".cull-gmail")
408 /// .build();
409 ///
410 /// let config_root = config.config_root();
411 /// // config_root can be used to resolve additional paths
412 /// ```
413 pub fn config_root(&self) -> &ConfigRoot {
414 &self.config_root
415 }
416
417 /// Returns the fully resolved configuration directory path as a string.
418 ///
419 /// This method resolves the configuration root path to an absolute path string,
420 /// applying any path prefix resolution (home directory, system root, etc.).
421 ///
422 /// # Examples
423 ///
424 /// ```rust
425 /// use cull_gmail::ClientConfig;
426 ///
427 /// let config = ClientConfig::builder()
428 /// .with_config_path(".cull-gmail")
429 /// .build();
430 ///
431 /// let full_path = config.full_path();
432 /// // Returns the absolute path to the configuration directory
433 /// ```
434 pub fn full_path(&self) -> String {
435 self.config_root.full_path().display().to_string()
436 }
437}
438
439/// Builder for constructing `ClientConfig` instances with flexible configuration options.
440///
441/// The `ConfigBuilder` provides a fluent interface for constructing Gmail client configurations
442/// with support for both file-based and programmatic OAuth2 credential setup. It implements
443/// the builder pattern to ensure required configuration is provided while allowing optional
444/// parameters to be set incrementally.
445///
446/// # Configuration Methods
447///
448/// The builder supports two primary configuration approaches:
449///
450/// 1. **File-based configuration**: Load OAuth2 credentials from JSON files
451/// 2. **Direct configuration**: Set OAuth2 parameters programmatically
452///
453/// # Thread Safety
454///
455/// The builder is not thread-safe and should be used to construct configurations
456/// in a single-threaded context. The resulting `ClientConfig` instances are thread-safe.
457///
458/// # Examples
459///
460/// ## File-based Configuration
461///
462/// ```rust,no_run
463/// use cull_gmail::ClientConfig;
464///
465/// let config = ClientConfig::builder()
466/// .with_credential_file("client_secret.json")
467/// .with_config_path(".cull-gmail")
468/// .build();
469/// ```
470///
471/// ## Direct OAuth2 Configuration
472///
473/// ```rust
474/// use cull_gmail::ClientConfig;
475///
476/// let config = ClientConfig::builder()
477/// .with_client_id("your-client-id.googleusercontent.com")
478/// .with_client_secret("your-client-secret")
479/// .with_auth_uri("https://accounts.google.com/o/oauth2/auth")
480/// .with_token_uri("https://oauth2.googleapis.com/token")
481/// .add_redirect_uri("http://localhost:8080")
482/// .with_project_id("your-project-id")
483/// .build();
484/// ```
485///
486/// ## Mixed Configuration
487///
488/// ```rust,no_run
489/// use cull_gmail::ClientConfig;
490///
491/// let config = ClientConfig::builder()
492/// .with_credential_file("base_credentials.json")
493/// .add_redirect_uri("http://localhost:3000") // Additional redirect URI
494/// .with_project_id("override-project-id") // Override from file
495/// .build();
496/// ```
497#[derive(Debug)]
498pub struct ConfigBuilder {
499 /// OAuth2 application secret being constructed.
500 /// Contains client credentials, endpoints, and additional parameters.
501 secret: ApplicationSecret,
502
503 /// Configuration root path resolver for determining base directories.
504 /// Used to resolve relative paths in credential files and token storage.
505 config_root: ConfigRoot,
506}
507
508impl Default for ConfigBuilder {
509 /// Creates a new `ConfigBuilder` with sensible OAuth2 defaults.
510 ///
511 /// The default configuration includes:
512 /// - Standard Google OAuth2 endpoints (auth_uri, token_uri)
513 /// - Empty client credentials (must be set before use)
514 /// - Default configuration root (no path prefix)
515 ///
516 /// # Note
517 ///
518 /// The default instance requires additional configuration before it can be used
519 /// to create a functional `ClientConfig`. At minimum, you must set either:
520 /// - Client credentials via `with_client_id()` and `with_client_secret()`, or
521 /// - A credential file via `with_credential_file()`
522 fn default() -> Self {
523 let secret = ApplicationSecret {
524 auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
525 token_uri: "https://oauth2.googleapis.com/token".to_string(),
526 ..Default::default()
527 };
528
529 Self {
530 secret,
531 config_root: Default::default(),
532 }
533 }
534}
535
536impl ConfigBuilder {
537 pub fn with_config_base(&mut self, value: &config_root::RootBase) -> &mut Self {
538 self.config_root.set_root_base(value);
539 self
540 }
541
542 pub fn with_config_path(&mut self, value: &str) -> &mut Self {
543 self.config_root.set_path(value);
544 self
545 }
546
547 pub fn with_credential_file(&mut self, credential_file: &str) -> &mut Self {
548 let path = PathBuf::from(self.config_root.to_string()).join(credential_file);
549 log::info!("path: {}", path.display());
550 let json_str = fs::read_to_string(path).expect("could not read path");
551
552 let console: ConsoleApplicationSecret =
553 serde_json::from_str(&json_str).expect("could not convert to struct");
554
555 self.secret = console.installed.unwrap();
556 self
557 }
558
559 pub fn with_client_id(&mut self, value: &str) -> &mut Self {
560 self.secret.client_id = value.to_string();
561 self
562 }
563
564 pub fn with_client_secret(&mut self, value: &str) -> &mut Self {
565 self.secret.client_secret = value.to_string();
566 self
567 }
568
569 pub fn with_token_uri(&mut self, value: &str) -> &mut Self {
570 self.secret.token_uri = value.to_string();
571 self
572 }
573
574 pub fn with_auth_uri(&mut self, value: &str) -> &mut Self {
575 self.secret.auth_uri = value.to_string();
576 self
577 }
578
579 pub fn add_redirect_uri(&mut self, value: &str) -> &mut Self {
580 self.secret.redirect_uris.push(value.to_string());
581 self
582 }
583
584 pub fn with_project_id(&mut self, value: &str) -> &mut Self {
585 self.secret.project_id = Some(value.to_string());
586 self
587 }
588
589 pub fn with_client_email(&mut self, value: &str) -> &mut Self {
590 self.secret.client_email = Some(value.to_string());
591 self
592 }
593 pub fn with_auth_provider_x509_cert_url(&mut self, value: &str) -> &mut Self {
594 self.secret.auth_provider_x509_cert_url = Some(value.to_string());
595 self
596 }
597 pub fn with_client_x509_cert_url(&mut self, value: &str) -> &mut Self {
598 self.secret.client_x509_cert_url = Some(value.to_string());
599 self
600 }
601
602 fn full_path(&self) -> String {
603 self.config_root.full_path().display().to_string()
604 }
605
606 pub fn build(&self) -> ClientConfig {
607 let persist_path = format!("{}/gmail1", self.full_path());
608
609 ClientConfig {
610 secret: self.secret.clone(),
611 config_root: self.config_root.clone(),
612 persist_path,
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::test_utils::get_test_logger;
621 use config::Config;
622 use std::env;
623 use std::fs;
624 use tempfile::TempDir;
625
626 /// Helper function to create a temporary credential file for testing
627 fn create_test_credential_file(temp_dir: &TempDir, filename: &str, content: &str) -> String {
628 let file_path = temp_dir.path().join(filename);
629 fs::write(&file_path, content).expect("Failed to write test file");
630 file_path.to_string_lossy().to_string()
631 }
632
633 /// Sample valid OAuth2 credential JSON for testing
634 fn sample_valid_credential() -> &'static str {
635 r#"{
636 "installed": {
637 "client_id": "123456789-test.googleusercontent.com",
638 "project_id": "test-project",
639 "auth_uri": "https://accounts.google.com/o/oauth2/auth",
640 "token_uri": "https://oauth2.googleapis.com/token",
641 "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
642 "client_secret": "test-client-secret",
643 "redirect_uris": ["http://localhost"]
644 }
645}"#
646 }
647
648 #[test]
649 fn test_config_builder_defaults() {
650 let builder = ConfigBuilder::default();
651
652 assert_eq!(
653 builder.secret.auth_uri,
654 "https://accounts.google.com/o/oauth2/auth"
655 );
656 assert_eq!(
657 builder.secret.token_uri,
658 "https://oauth2.googleapis.com/token"
659 );
660 assert!(builder.secret.client_id.is_empty());
661 assert!(builder.secret.client_secret.is_empty());
662 }
663
664 #[test]
665 fn test_builder_pattern_direct_oauth2() {
666 let config = ClientConfig::builder()
667 .with_client_id("test-client-id")
668 .with_client_secret("test-client-secret")
669 .with_auth_uri("https://auth.example.com")
670 .with_token_uri("https://token.example.com")
671 .add_redirect_uri("http://localhost:8080")
672 .add_redirect_uri("http://localhost:3000")
673 .with_project_id("test-project")
674 .with_client_email("test@example.com")
675 .with_auth_provider_x509_cert_url("https://certs.example.com")
676 .with_client_x509_cert_url("https://client-cert.example.com")
677 .build();
678
679 assert_eq!(config.secret().client_id, "test-client-id");
680 assert_eq!(config.secret().client_secret, "test-client-secret");
681 assert_eq!(config.secret().auth_uri, "https://auth.example.com");
682 assert_eq!(config.secret().token_uri, "https://token.example.com");
683 assert_eq!(
684 config.secret().redirect_uris,
685 vec!["http://localhost:8080", "http://localhost:3000"]
686 );
687 assert_eq!(config.secret().project_id, Some("test-project".to_string()));
688 assert_eq!(
689 config.secret().client_email,
690 Some("test@example.com".to_string())
691 );
692 assert_eq!(
693 config.secret().auth_provider_x509_cert_url,
694 Some("https://certs.example.com".to_string())
695 );
696 assert_eq!(
697 config.secret().client_x509_cert_url,
698 Some("https://client-cert.example.com".to_string())
699 );
700 assert!(config.persist_path().contains("gmail1"));
701 }
702
703 #[test]
704 fn test_builder_with_config_path() {
705 let config = ClientConfig::builder()
706 .with_client_id("test-id")
707 .with_config_path(".test-config")
708 .build();
709
710 let full_path = config.full_path();
711 assert_eq!(full_path, ".test-config");
712 assert!(config.persist_path().contains(".test-config/gmail1"));
713 }
714
715 #[test]
716 fn test_builder_with_config_base_home() {
717 let config = ClientConfig::builder()
718 .with_client_id("test-id")
719 .with_config_base(&config_root::RootBase::Home)
720 .with_config_path(".test-config")
721 .build();
722
723 let expected_path = env::home_dir()
724 .unwrap_or_default()
725 .join(".test-config")
726 .display()
727 .to_string();
728
729 assert_eq!(config.full_path(), expected_path);
730 }
731
732 #[test]
733 fn test_builder_with_config_base_root() {
734 let config = ClientConfig::builder()
735 .with_client_id("test-id")
736 .with_config_base(&config_root::RootBase::Root)
737 .with_config_path("etc/test-config")
738 .build();
739
740 assert_eq!(config.full_path(), "/etc/test-config");
741 }
742
743 #[test]
744 fn test_config_from_direct_oauth2_params() {
745 let app_config = Config::builder()
746 .set_default("client_id", "direct-client-id")
747 .unwrap()
748 .set_default("client_secret", "direct-client-secret")
749 .unwrap()
750 .set_default("token_uri", "https://token.direct.com")
751 .unwrap()
752 .set_default("auth_uri", "https://auth.direct.com")
753 .unwrap()
754 .set_default("config_root", "h:.test-direct")
755 .unwrap()
756 .build()
757 .unwrap();
758
759 let config = ClientConfig::new_from_configuration(app_config).unwrap();
760
761 assert_eq!(config.secret().client_id, "direct-client-id");
762 assert_eq!(config.secret().client_secret, "direct-client-secret");
763 assert_eq!(config.secret().token_uri, "https://token.direct.com");
764 assert_eq!(config.secret().auth_uri, "https://auth.direct.com");
765 assert_eq!(config.secret().project_id, None);
766 assert!(config.secret().redirect_uris.is_empty());
767 }
768
769 #[test]
770 fn test_config_from_credential_file() {
771 get_test_logger();
772 let temp_dir = TempDir::new().expect("Failed to create temp dir");
773 let _cred_file =
774 create_test_credential_file(&temp_dir, "test_creds.json", sample_valid_credential());
775
776 let config_root = format!("c:{}", temp_dir.path().display());
777 let app_config = Config::builder()
778 .set_default("credential_file", "test_creds.json")
779 .unwrap()
780 .set_default("config_root", config_root.as_str())
781 .unwrap()
782 .build()
783 .unwrap();
784
785 let config = ClientConfig::new_from_configuration(app_config).unwrap();
786
787 assert_eq!(
788 config.secret().client_id,
789 "123456789-test.googleusercontent.com"
790 );
791 assert_eq!(config.secret().client_secret, "test-client-secret");
792 assert_eq!(config.secret().project_id, Some("test-project".to_string()));
793 assert_eq!(config.secret().redirect_uris, vec!["http://localhost"]);
794 }
795
796 #[test]
797 fn test_config_missing_required_params() {
798 // Test with missing config_root
799 let app_config = Config::builder()
800 .set_default("client_id", "test-id")
801 .unwrap()
802 .build()
803 .unwrap();
804
805 let result = ClientConfig::new_from_configuration(app_config);
806 assert!(result.is_err());
807 }
808
809 #[test]
810 fn test_config_incomplete_oauth2_params() {
811 // Test with some but not all OAuth2 parameters
812 let app_config = Config::builder()
813 .set_default("client_id", "test-id")
814 .unwrap()
815 .set_default("client_secret", "test-secret")
816 .unwrap()
817 // Missing token_uri and auth_uri
818 .set_default("config_root", "h:.test")
819 .unwrap()
820 .build()
821 .unwrap();
822
823 // Should fall back to credential_file approach, which should fail
824 let result = ClientConfig::new_from_configuration(app_config);
825 assert!(result.is_err());
826 }
827
828 #[test]
829 #[should_panic(expected = "could not read path")]
830 fn test_config_invalid_credential_file() {
831 let app_config = Config::builder()
832 .set_default("credential_file", "nonexistent.json")
833 .unwrap()
834 .set_default("config_root", "h:.test")
835 .unwrap()
836 .build()
837 .unwrap();
838
839 // This should panic with "could not read path" since the code uses .expect()
840 let _result = ClientConfig::new_from_configuration(app_config);
841 }
842
843 #[test]
844 #[should_panic(expected = "could not convert to struct")]
845 fn test_config_malformed_credential_file() {
846 get_test_logger();
847 let temp_dir = TempDir::new().expect("Failed to create temp dir");
848 let _cred_file = create_test_credential_file(&temp_dir, "malformed.json", "{ invalid json");
849
850 let config_root = format!("c:{}", temp_dir.path().display());
851 let app_config = Config::builder()
852 .set_default("credential_file", "malformed.json")
853 .unwrap()
854 .set_default("config_root", config_root.as_str())
855 .unwrap()
856 .build()
857 .unwrap();
858
859 // This should panic with "could not convert to struct" since the code uses .expect()
860 let _result = ClientConfig::new_from_configuration(app_config);
861 }
862
863 #[test]
864 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
865 fn test_config_credential_file_wrong_structure() {
866 get_test_logger();
867 let temp_dir = TempDir::new().expect("Failed to create temp dir");
868 let wrong_structure = r#"{"wrong": "structure"}"#;
869 let _cred_file = create_test_credential_file(&temp_dir, "wrong.json", wrong_structure);
870
871 let config_root = format!("c:{}", temp_dir.path().display());
872 let app_config = Config::builder()
873 .set_default("credential_file", "wrong.json")
874 .unwrap()
875 .set_default("config_root", config_root.as_str())
876 .unwrap()
877 .build()
878 .unwrap();
879
880 // This should panic with unwrap on None since console.installed is None
881 let _result = ClientConfig::new_from_configuration(app_config);
882 }
883
884 #[test]
885 fn test_persist_path_generation() {
886 let config = ClientConfig::builder()
887 .with_client_id("test")
888 .with_config_path("/custom/path")
889 .build();
890
891 assert_eq!(config.persist_path(), "/custom/path/gmail1");
892 }
893
894 #[test]
895 fn test_config_accessor_methods() {
896 let config = ClientConfig::builder()
897 .with_client_id("accessor-test-id")
898 .with_client_secret("accessor-test-secret")
899 .with_config_path("/test/path")
900 .build();
901
902 // Test secret() accessor
903 let secret = config.secret();
904 assert_eq!(secret.client_id, "accessor-test-id");
905 assert_eq!(secret.client_secret, "accessor-test-secret");
906
907 // Test persist_path() accessor
908 assert_eq!(config.persist_path(), "/test/path/gmail1");
909
910 // Test full_path() accessor
911 assert_eq!(config.full_path(), "/test/path");
912
913 // Test config_root() accessor
914 let config_root = config.config_root();
915 assert_eq!(config_root.full_path().display().to_string(), "/test/path");
916 }
917
918 #[test]
919 fn test_builder_method_chaining() {
920 // Test that all builder methods return &mut Self for chaining
921 let config = ClientConfig::builder()
922 .with_client_id("chain-test")
923 .with_client_secret("chain-secret")
924 .with_auth_uri("https://auth.chain.com")
925 .with_token_uri("https://token.chain.com")
926 .add_redirect_uri("http://redirect1.com")
927 .add_redirect_uri("http://redirect2.com")
928 .with_project_id("chain-project")
929 .with_client_email("chain@test.com")
930 .with_auth_provider_x509_cert_url("https://cert1.com")
931 .with_client_x509_cert_url("https://cert2.com")
932 .with_config_base(&config_root::RootBase::Home)
933 .with_config_path(".chain-test")
934 .build();
935
936 assert_eq!(config.secret().client_id, "chain-test");
937 assert_eq!(config.secret().redirect_uris.len(), 2);
938 }
939
940 #[test]
941 fn test_configuration_priority() {
942 // Test that direct OAuth2 params take priority over credential file
943 get_test_logger();
944 let temp_dir = TempDir::new().expect("Failed to create temp dir");
945 let _cred_file =
946 create_test_credential_file(&temp_dir, "priority.json", sample_valid_credential());
947
948 let config_root = format!("c:{}", temp_dir.path().display());
949 let app_config = Config::builder()
950 // Direct OAuth2 params (should take priority)
951 .set_default("client_id", "priority-client-id")
952 .unwrap()
953 .set_default("client_secret", "priority-client-secret")
954 .unwrap()
955 .set_default("token_uri", "https://priority.token.com")
956 .unwrap()
957 .set_default("auth_uri", "https://priority.auth.com")
958 .unwrap()
959 // Credential file (should be ignored)
960 .set_default("credential_file", "priority.json")
961 .unwrap()
962 .set_default("config_root", config_root.as_str())
963 .unwrap()
964 .build()
965 .unwrap();
966
967 let config = ClientConfig::new_from_configuration(app_config).unwrap();
968
969 // Should use direct params, not file contents
970 assert_eq!(config.secret().client_id, "priority-client-id");
971 assert_eq!(config.secret().client_secret, "priority-client-secret");
972 assert_eq!(config.secret().token_uri, "https://priority.token.com");
973 assert_ne!(
974 config.secret().client_id,
975 "123456789-test.googleusercontent.com"
976 ); // From file
977 }
978
979 #[test]
980 fn test_empty_redirect_uris() {
981 let config = ClientConfig::builder().with_client_id("test-id").build();
982
983 assert!(config.secret().redirect_uris.is_empty());
984 }
985
986 #[test]
987 fn test_multiple_redirect_uris() {
988 let config = ClientConfig::builder()
989 .with_client_id("test-id")
990 .add_redirect_uri("http://localhost:8080")
991 .add_redirect_uri("http://localhost:3000")
992 .add_redirect_uri("https://example.com/callback")
993 .build();
994
995 assert_eq!(config.secret().redirect_uris.len(), 3);
996 assert!(
997 config
998 .secret()
999 .redirect_uris
1000 .contains(&"http://localhost:8080".to_string())
1001 );
1002 assert!(
1003 config
1004 .secret()
1005 .redirect_uris
1006 .contains(&"http://localhost:3000".to_string())
1007 );
1008 assert!(
1009 config
1010 .secret()
1011 .redirect_uris
1012 .contains(&"https://example.com/callback".to_string())
1013 );
1014 }
1015
1016 #[test]
1017 fn test_optional_fields() {
1018 let config = ClientConfig::builder()
1019 .with_client_id("optional-test")
1020 .build();
1021
1022 assert_eq!(config.secret().project_id, None);
1023 assert_eq!(config.secret().client_email, None);
1024 assert_eq!(config.secret().auth_provider_x509_cert_url, None);
1025 assert_eq!(config.secret().client_x509_cert_url, None);
1026 }
1027
1028 #[test]
1029 fn test_unicode_in_configuration() {
1030 let config = ClientConfig::builder()
1031 .with_client_id("unicode-客戶端-🔐-test")
1032 .with_client_secret("secret-with-unicode-密碼")
1033 .with_project_id("project-項目-id")
1034 .with_config_path(".unicode-配置")
1035 .build();
1036
1037 assert_eq!(config.secret().client_id, "unicode-客戶端-🔐-test");
1038 assert_eq!(config.secret().client_secret, "secret-with-unicode-密碼");
1039 assert_eq!(
1040 config.secret().project_id,
1041 Some("project-項目-id".to_string())
1042 );
1043 assert!(config.full_path().contains(".unicode-配置"));
1044 }
1045}