1use anyhow::{Context, Result};
2use colored::*;
3use dialoguer::{Confirm, Input};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug, Serialize, Deserialize)]
9pub struct Config {
10 pub auth: AuthConfig,
11 pub upload: UploadConfig,
12 pub deduplication: DeduplicationConfig,
13}
14
15#[derive(Debug, Serialize, Deserialize)]
16pub struct AuthConfig {
17 pub api_key: String,
18 pub api_secret: String,
19 pub access_token: String,
20 pub access_token_secret: String,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct UploadConfig {
25 pub threads: usize,
26 pub retry_attempts: u32,
27 pub timeout_seconds: u64,
28 #[serde(default)]
29 pub has_smugmug_source: bool,
30 #[serde(default = "default_upload_folder")]
33 pub default_folder: String,
34 #[serde(default)]
36 pub raw_mode: RawMode,
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "lowercase")]
44pub enum RawMode {
45 #[default]
47 Auto,
48 Render,
50 Original,
52 Skip,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum RawHandling {
60 Upload,
61 Render,
62 Skip,
63}
64
65impl RawMode {
66 pub fn handling(self, has_smugmug_source: bool) -> RawHandling {
67 match (self, has_smugmug_source) {
68 (RawMode::Auto, true) | (RawMode::Original, true) => RawHandling::Upload,
69 (RawMode::Auto, false) | (RawMode::Render, _) => RawHandling::Render,
70 (RawMode::Original, false) | (RawMode::Skip, _) => RawHandling::Skip,
71 }
72 }
73}
74
75fn default_upload_folder() -> String {
76 "Uploads".to_string()
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80pub struct DeduplicationConfig {
81 pub enabled: bool,
82 pub cache_path: PathBuf,
83}
84
85impl Default for Config {
86 fn default() -> Self {
87 let cache_dir = directories::ProjectDirs::from("", "", "smugmug-cli")
88 .map(|dirs| dirs.cache_dir().to_path_buf())
89 .unwrap_or_else(|| PathBuf::from(".cache"));
90
91 Config {
92 auth: AuthConfig {
93 api_key: String::new(),
94 api_secret: String::new(),
95 access_token: String::new(),
96 access_token_secret: String::new(),
97 },
98 upload: UploadConfig {
99 threads: 4,
100 retry_attempts: 3,
101 timeout_seconds: 300,
102 has_smugmug_source: false,
103 default_folder: default_upload_folder(),
104 raw_mode: RawMode::default(),
105 },
106 deduplication: DeduplicationConfig {
107 enabled: true,
108 cache_path: cache_dir.join("hashes.db"),
109 },
110 }
111 }
112}
113
114fn get_config_path() -> Result<PathBuf> {
115 let proj_dirs = directories::ProjectDirs::from("", "", "smugmug-cli")
116 .context("Failed to determine config directory")?;
117
118 let config_dir = proj_dirs.config_dir();
119 fs::create_dir_all(config_dir).context("Failed to create config directory")?;
120
121 Ok(config_dir.join("config.toml"))
122}
123
124pub async fn init_config() -> Result<()> {
125 println!("SmugMug CLI Configuration\n");
126 println!("To get your API credentials:");
127 println!("1. Go to https://api.smugmug.com/api/developer/apply");
128 println!("2. Create an application to get your API Key and Secret");
129 println!("3. Sign in through your browser when prompted to get an Access Token and Secret");
130 println!(" (or run 'smugmug-cli auth' later to refresh them)\n");
131
132 let _ = dotenvy::dotenv();
134 let env_api_key = std::env::var("SMUGMUG_API_KEY").ok();
135 let env_api_secret = std::env::var("SMUGMUG_API_SECRET").ok();
136 let env_access_token = std::env::var("SMUGMUG_ACCESS_TOKEN").ok();
137 let env_access_token_secret = std::env::var("SMUGMUG_ACCESS_TOKEN_SECRET").ok();
138
139 let api_key: String = {
140 let mut input = Input::new().with_prompt("API Key");
141 if let Some(default) = env_api_key {
142 input = input.with_initial_text(default);
143 }
144 input.interact_text()?
145 };
146
147 let api_secret: String = {
148 let mut input = Input::new().with_prompt("API Secret");
149 if let Some(default) = env_api_secret {
150 input = input.with_initial_text(default);
151 }
152 input.interact_text()?
153 };
154
155 let use_browser = env_access_token.is_none()
156 && Confirm::new()
157 .with_prompt("Sign in to SmugMug in your browser to get an access token?")
158 .default(true)
159 .interact()?;
160
161 let (access_token, access_token_secret) = if use_browser {
162 let pair = browser_sign_in(&api_key, &api_secret).await?;
163 (pair.token, pair.secret)
164 } else {
165 let access_token: String = {
166 let mut input = Input::new().with_prompt("Access Token");
167 if let Some(default) = env_access_token {
168 input = input.with_initial_text(default);
169 }
170 input.interact_text()?
171 };
172
173 let access_token_secret: String = {
174 let mut input = Input::new().with_prompt("Access Token Secret");
175 if let Some(default) = env_access_token_secret {
176 input = input.with_initial_text(default);
177 }
178 input.interact_text()?
179 };
180 (access_token, access_token_secret)
181 };
182
183 println!(
185 "\n{}",
186 "Testing authentication and detecting features...".cyan()
187 );
188
189 let test_client = crate::api::SmugMugClient::new(
190 api_key.clone(),
191 api_secret.clone(),
192 access_token.clone(),
193 access_token_secret.clone(),
194 );
195
196 let mut has_smugmug_source = false;
197
198 match test_client.get_auth_user().await {
199 Ok(user_info) => {
200 println!("{}", "✓ Authentication successful!".green().bold());
201
202 if let Some(user_uri) = user_info["Response"]["User"]["Uri"].as_str() {
204 match test_client.get_user_features(user_uri).await {
205 Ok(features) => {
206 if let Some(premium_storage) =
208 features["Response"]["Features"]["PremiumStorage"].as_bool()
209 {
210 has_smugmug_source = premium_storage;
211
212 if premium_storage {
213 println!(
214 "{} {}",
215 "✓".green().bold(),
216 "Detected SmugMug Source subscription (PremiumStorage enabled)"
217 .green()
218 );
219 println!(
220 " {} {}",
221 "→".bright_cyan(),
222 "RAW file uploads enabled".cyan()
223 );
224 println!(
225 " {} {}",
226 "→".bright_cyan(),
227 "Original file downloads enabled".cyan()
228 );
229 println!(
230 " {} {}\n",
231 "→".bright_cyan(),
232 "Cloud storage backup enabled".cyan()
233 );
234 } else {
235 println!(
236 "{} {}",
237 "ℹ".cyan().bold(),
238 "SmugMug Source not detected".cyan()
239 );
240 println!(
241 " {}\n",
242 "RAW files will be uploaded as JPEGs rendered from them"
243 .bright_black()
244 );
245 }
246
247 let override_detection = Confirm::new()
249 .with_prompt("Override auto-detection?")
250 .default(false)
251 .interact()?;
252
253 if override_detection {
254 has_smugmug_source = Confirm::new()
255 .with_prompt("Do you have a SmugMug Source subscription?")
256 .default(has_smugmug_source)
257 .interact()?;
258 }
259 } else {
260 println!(
262 "{} {}",
263 "⚠".yellow().bold(),
264 "Could not auto-detect SmugMug Source".yellow()
265 );
266 has_smugmug_source = Confirm::new()
267 .with_prompt("Do you have a SmugMug Source subscription?")
268 .default(false)
269 .interact()?;
270 }
271 }
272 Err(_) => {
273 println!(
275 "{} {}",
276 "⚠".yellow().bold(),
277 "Could not fetch account features".yellow()
278 );
279 has_smugmug_source = Confirm::new()
280 .with_prompt("Do you have a SmugMug Source subscription?")
281 .default(false)
282 .interact()?;
283 }
284 }
285 }
286 }
287 Err(e) => {
288 println!(
289 "{} {}",
290 "✗".red().bold(),
291 format!("Authentication test failed: {}", e).red()
292 );
293 println!("{}", "Please verify your credentials are correct.".yellow());
294 return Err(e);
295 }
296 }
297
298 let mut config = Config::default();
299 config.auth.api_key = api_key;
300 config.auth.api_secret = api_secret;
301 config.auth.access_token = access_token;
302 config.auth.access_token_secret = access_token_secret;
303 config.upload.has_smugmug_source = has_smugmug_source;
304
305 save_config(&config)?;
306
307 let config_path = get_config_path()?;
308 println!(
309 "\n{} {}",
310 "✓".green().bold(),
311 format!("Configuration saved to: {}", config_path.display()).green()
312 );
313 println!(
314 "\n{} {}",
315 "You can now upload photos using:".cyan(),
316 "smugmug-cli upload <path>".bright_white().bold()
317 );
318
319 Ok(())
320}
321
322async fn browser_sign_in(
326 api_key: &str,
327 api_secret: &str,
328) -> Result<crate::api::oauth_flow::TokenPair> {
329 use crate::api::oauth_flow::{
330 OAuthEndpoints, authorize_url, get_access_token, get_request_token,
331 };
332
333 let endpoints = OAuthEndpoints::default();
334 let request_token = get_request_token(&endpoints, api_key, api_secret).await?;
335
336 println!(
337 "\n{}",
338 "Open this link, sign in, and approve access:".cyan()
339 );
340 println!(
341 "\n {}\n",
342 authorize_url(&endpoints, &request_token)
343 .bright_white()
344 .bold()
345 );
346
347 let verifier: String = Input::new()
348 .with_prompt("Code shown by SmugMug after approving")
349 .interact_text()?;
350
351 let pair = get_access_token(
352 &endpoints,
353 api_key,
354 api_secret,
355 &request_token,
356 verifier.trim(),
357 )
358 .await?;
359 println!("{}", "✓ Access token received".green().bold());
360 Ok(pair)
361}
362
363pub async fn auth_command() -> Result<()> {
367 let _ = dotenvy::dotenv();
368 let existing = load_config().ok();
369
370 let (api_key, api_secret) = match &existing {
371 Some(cfg) if !cfg.auth.api_key.is_empty() && !cfg.auth.api_secret.is_empty() => {
372 (cfg.auth.api_key.clone(), cfg.auth.api_secret.clone())
373 }
374 _ => {
375 let key = match std::env::var("SMUGMUG_API_KEY") {
376 Ok(v) if !v.is_empty() => v,
377 _ => Input::new().with_prompt("API Key").interact_text()?,
378 };
379 let secret = match std::env::var("SMUGMUG_API_SECRET") {
380 Ok(v) if !v.is_empty() => v,
381 _ => Input::new().with_prompt("API Secret").interact_text()?,
382 };
383 (key, secret)
384 }
385 };
386
387 let pair = browser_sign_in(&api_key, &api_secret).await?;
388
389 let client = crate::api::SmugMugClient::new(
390 api_key.clone(),
391 api_secret.clone(),
392 pair.token.clone(),
393 pair.secret.clone(),
394 );
395 let user = client
396 .get_auth_user()
397 .await
398 .context("Signed in, but the new access token was rejected")?;
399 if let Some(nick) = user["Response"]["User"]["NickName"].as_str() {
400 println!(
401 "{} {}",
402 "✓ Authenticated as".green().bold(),
403 nick.bright_white().bold()
404 );
405 }
406
407 let is_new = existing.is_none();
408 let mut config = existing.unwrap_or_default();
409 config.auth.api_key = api_key;
410 config.auth.api_secret = api_secret;
411 config.auth.access_token = pair.token;
412 config.auth.access_token_secret = pair.secret;
413
414 if is_new {
415 if let Some(user_uri) = user["Response"]["User"]["Uri"].as_str() {
417 if let Ok(features) = client.get_user_features(user_uri).await {
418 config.upload.has_smugmug_source =
419 features["Response"]["Features"]["PremiumStorage"]
420 .as_bool()
421 .unwrap_or(false);
422 }
423 }
424 }
425
426 save_config(&config)?;
427 println!(
428 "{} {}",
429 "✓".green().bold(),
430 format!("Credentials saved to: {}", get_config_path()?.display()).green()
431 );
432 Ok(())
433}
434
435pub fn load_config() -> Result<Config> {
436 let config_path = get_config_path()?;
437
438 if !config_path.exists() {
439 anyhow::bail!(
440 "Config file not found at: {}\n\n\
441 Run 'smugmug-cli init' to create your configuration.\n\
442 (Tip: You can create a .env file with your credentials, and init will use them as defaults)",
443 config_path.display()
444 );
445 }
446
447 let contents = fs::read_to_string(&config_path).context("Failed to read config file")?;
448
449 let config: Config = toml::from_str(&contents).context("Failed to parse config file")?;
450
451 Ok(config)
452}
453
454pub fn save_config(config: &Config) -> Result<()> {
455 let config_path = get_config_path()?;
456
457 let toml_string = toml::to_string_pretty(config).context("Failed to serialize config")?;
458
459 fs::write(&config_path, toml_string).context("Failed to write config file")?;
460
461 Ok(())
462}
463
464pub fn is_auth_configured() -> bool {
465 let config = match load_config() {
467 Ok(cfg) => cfg,
468 Err(_) => return false,
469 };
470
471 !config.auth.api_key.is_empty()
473 && !config.auth.api_secret.is_empty()
474 && !config.auth.access_token.is_empty()
475 && !config.auth.access_token_secret.is_empty()
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn create_test_config() -> Config {
484 Config {
485 auth: AuthConfig {
486 api_key: "test_api_key".to_string(),
487 api_secret: "test_api_secret".to_string(),
488 access_token: "test_access_token".to_string(),
489 access_token_secret: "test_access_token_secret".to_string(),
490 },
491 upload: UploadConfig {
492 threads: 8,
493 retry_attempts: 5,
494 timeout_seconds: 600,
495 has_smugmug_source: false,
496 default_folder: default_upload_folder(),
497 raw_mode: RawMode::default(),
498 },
499 deduplication: DeduplicationConfig {
500 enabled: false,
501 cache_path: PathBuf::from("/tmp/test_cache.db"),
502 },
503 }
504 }
505
506 #[test]
507 fn test_raw_mode_handling() {
508 assert_eq!(RawMode::Auto.handling(true), RawHandling::Upload);
509 assert_eq!(RawMode::Auto.handling(false), RawHandling::Render);
510 assert_eq!(RawMode::Render.handling(true), RawHandling::Render);
511 assert_eq!(RawMode::Original.handling(true), RawHandling::Upload);
512 assert_eq!(RawMode::Original.handling(false), RawHandling::Skip);
513 assert_eq!(RawMode::Skip.handling(true), RawHandling::Skip);
514
515 let upload: UploadConfig = toml::from_str(
516 "threads = 4\nretry_attempts = 3\ntimeout_seconds = 300\nraw_mode = \"render\"",
517 )
518 .unwrap();
519 assert_eq!(upload.raw_mode, RawMode::Render);
520 }
521
522 #[test]
523 fn test_config_without_default_folder_uses_uploads() {
524 let toml_str = r#"
526[auth]
527api_key = "k"
528api_secret = "s"
529access_token = "t"
530access_token_secret = "ts"
531
532[upload]
533threads = 4
534retry_attempts = 3
535timeout_seconds = 300
536
537[deduplication]
538enabled = true
539cache_path = "/tmp/cache"
540"#;
541 let config: Config = toml::from_str(toml_str).unwrap();
542 assert_eq!(config.upload.default_folder, "Uploads");
543 assert_eq!(Config::default().upload.default_folder, "Uploads");
544 assert_eq!(config.upload.raw_mode, RawMode::Auto);
545 }
546
547 #[test]
548 fn test_config_default() {
549 let config = Config::default();
550
551 assert_eq!(config.auth.api_key, "");
553 assert_eq!(config.auth.api_secret, "");
554 assert_eq!(config.auth.access_token, "");
555 assert_eq!(config.auth.access_token_secret, "");
556
557 assert_eq!(config.upload.threads, 4);
559 assert_eq!(config.upload.retry_attempts, 3);
560 assert_eq!(config.upload.timeout_seconds, 300);
561
562 assert!(config.deduplication.enabled);
564 assert!(
565 config
566 .deduplication
567 .cache_path
568 .to_string_lossy()
569 .ends_with("hashes.db")
570 );
571 }
572
573 #[test]
574 fn test_config_serialization_to_toml() {
575 let config = create_test_config();
576
577 let toml_string =
578 toml::to_string_pretty(&config).expect("Failed to serialize config to TOML");
579
580 assert!(toml_string.contains("api_key"));
582 assert!(toml_string.contains("api_secret"));
583 assert!(toml_string.contains("access_token"));
584 assert!(toml_string.contains("access_token_secret"));
585 assert!(toml_string.contains("threads"));
586 assert!(toml_string.contains("retry_attempts"));
587 assert!(toml_string.contains("timeout_seconds"));
588 assert!(toml_string.contains("enabled"));
589 assert!(toml_string.contains("cache_path"));
590
591 assert!(toml_string.contains("test_api_key"));
593 assert!(toml_string.contains("test_api_secret"));
594 assert!(toml_string.contains("8"));
595 assert!(toml_string.contains("5"));
596 assert!(toml_string.contains("600"));
597 }
598
599 #[test]
600 fn test_config_deserialization_from_toml() {
601 let toml_string = r#"
602[auth]
603api_key = "deserialize_api_key"
604api_secret = "deserialize_api_secret"
605access_token = "deserialize_access_token"
606access_token_secret = "deserialize_access_token_secret"
607
608[upload]
609threads = 16
610retry_attempts = 10
611timeout_seconds = 1200
612
613[deduplication]
614enabled = true
615cache_path = "/custom/path/cache.db"
616"#;
617
618 let config: Config =
619 toml::from_str(toml_string).expect("Failed to deserialize config from TOML");
620
621 assert_eq!(config.auth.api_key, "deserialize_api_key");
623 assert_eq!(config.auth.api_secret, "deserialize_api_secret");
624 assert_eq!(config.auth.access_token, "deserialize_access_token");
625 assert_eq!(
626 config.auth.access_token_secret,
627 "deserialize_access_token_secret"
628 );
629
630 assert_eq!(config.upload.threads, 16);
632 assert_eq!(config.upload.retry_attempts, 10);
633 assert_eq!(config.upload.timeout_seconds, 1200);
634
635 assert!(config.deduplication.enabled);
637 assert_eq!(
638 config.deduplication.cache_path,
639 PathBuf::from("/custom/path/cache.db")
640 );
641 }
642
643 #[test]
644 fn test_config_round_trip_serialization() {
645 let original_config = create_test_config();
646
647 let toml_string =
649 toml::to_string_pretty(&original_config).expect("Failed to serialize config");
650
651 let deserialized_config: Config =
653 toml::from_str(&toml_string).expect("Failed to deserialize config");
654
655 assert_eq!(
657 original_config.auth.api_key,
658 deserialized_config.auth.api_key
659 );
660 assert_eq!(
661 original_config.auth.api_secret,
662 deserialized_config.auth.api_secret
663 );
664 assert_eq!(
665 original_config.auth.access_token,
666 deserialized_config.auth.access_token
667 );
668 assert_eq!(
669 original_config.auth.access_token_secret,
670 deserialized_config.auth.access_token_secret
671 );
672 assert_eq!(
673 original_config.upload.threads,
674 deserialized_config.upload.threads
675 );
676 assert_eq!(
677 original_config.upload.retry_attempts,
678 deserialized_config.upload.retry_attempts
679 );
680 assert_eq!(
681 original_config.upload.timeout_seconds,
682 deserialized_config.upload.timeout_seconds
683 );
684 assert_eq!(
685 original_config.deduplication.enabled,
686 deserialized_config.deduplication.enabled
687 );
688 assert_eq!(
689 original_config.deduplication.cache_path,
690 deserialized_config.deduplication.cache_path
691 );
692 }
693
694 #[test]
695 fn test_invalid_toml_deserialization() {
696 let invalid_toml = r#"
697[auth]
698api_key = "test"
699# Missing required fields
700"#;
701
702 let result: Result<Config, _> = toml::from_str(invalid_toml);
703 assert!(
704 result.is_err(),
705 "Should fail when required fields are missing"
706 );
707 }
708
709 #[test]
710 fn test_invalid_type_in_toml() {
711 let invalid_toml = r#"
712[auth]
713api_key = "test"
714api_secret = "test"
715access_token = "test"
716access_token_secret = "test"
717
718[upload]
719threads = "not_a_number"
720retry_attempts = 3
721timeout_seconds = 300
722
723[deduplication]
724enabled = true
725cache_path = "/tmp/cache.db"
726"#;
727
728 let result: Result<Config, _> = toml::from_str(invalid_toml);
729 assert!(result.is_err(), "Should fail when field has wrong type");
730 }
731
732 #[test]
733 fn test_config_with_zero_threads() {
734 let toml_string = r#"
735[auth]
736api_key = "test"
737api_secret = "test"
738access_token = "test"
739access_token_secret = "test"
740
741[upload]
742threads = 0
743retry_attempts = 3
744timeout_seconds = 300
745
746[deduplication]
747enabled = true
748cache_path = "/tmp/cache.db"
749"#;
750
751 let config: Config =
752 toml::from_str(toml_string).expect("Should parse config with 0 threads");
753 assert_eq!(config.upload.threads, 0);
754 }
755
756 #[test]
757 fn test_config_with_large_values() {
758 let toml_string = r#"
759[auth]
760api_key = "test"
761api_secret = "test"
762access_token = "test"
763access_token_secret = "test"
764
765[upload]
766threads = 999999
767retry_attempts = 4294967295
768timeout_seconds = 9999999999
769
770[deduplication]
771enabled = true
772cache_path = "/tmp/cache.db"
773"#;
774
775 let config: Config =
776 toml::from_str(toml_string).expect("Should parse config with large values");
777 assert_eq!(config.upload.threads, 999999);
778 assert_eq!(config.upload.retry_attempts, u32::MAX);
779 assert_eq!(config.upload.timeout_seconds, 9999999999);
780 }
781
782 #[test]
783 fn test_config_with_empty_strings() {
784 let toml_string = r#"
785[auth]
786api_key = ""
787api_secret = ""
788access_token = ""
789access_token_secret = ""
790
791[upload]
792threads = 4
793retry_attempts = 3
794timeout_seconds = 300
795
796[deduplication]
797enabled = true
798cache_path = ""
799"#;
800
801 let config: Config =
802 toml::from_str(toml_string).expect("Should parse config with empty strings");
803 assert_eq!(config.auth.api_key, "");
804 assert_eq!(config.auth.api_secret, "");
805 assert_eq!(config.auth.access_token, "");
806 assert_eq!(config.auth.access_token_secret, "");
807 assert_eq!(config.deduplication.cache_path, PathBuf::from(""));
808 }
809
810 #[test]
811 fn test_config_with_special_characters() {
812 let toml_string = r#"
813[auth]
814api_key = "test!@#$%^&*()_+-={}[]|:;<>?,."
815api_secret = "test\nwith\nnewlines"
816access_token = "test with spaces"
817access_token_secret = "test'with'quotes"
818
819[upload]
820threads = 4
821retry_attempts = 3
822timeout_seconds = 300
823
824[deduplication]
825enabled = true
826cache_path = "/tmp/cache with spaces.db"
827"#;
828
829 let config: Config =
830 toml::from_str(toml_string).expect("Should parse config with special characters");
831 assert!(config.auth.api_key.contains("!@#$"));
832 assert!(config.auth.access_token.contains(" "));
833 }
834
835 #[test]
836 fn test_deduplication_enabled_false() {
837 let toml_string = r#"
838[auth]
839api_key = "test"
840api_secret = "test"
841access_token = "test"
842access_token_secret = "test"
843
844[upload]
845threads = 4
846retry_attempts = 3
847timeout_seconds = 300
848
849[deduplication]
850enabled = false
851cache_path = "/tmp/cache.db"
852"#;
853
854 let config: Config =
855 toml::from_str(toml_string).expect("Should parse config with deduplication disabled");
856 assert!(!config.deduplication.enabled);
857 }
858
859 #[test]
860 fn test_config_path_variations() {
861 let toml_string = r#"
863[auth]
864api_key = "test"
865api_secret = "test"
866access_token = "test"
867access_token_secret = "test"
868
869[upload]
870threads = 4
871retry_attempts = 3
872timeout_seconds = 300
873
874[deduplication]
875enabled = true
876cache_path = "relative/path/cache.db"
877"#;
878
879 let config: Config =
880 toml::from_str(toml_string).expect("Should parse config with relative path");
881 assert_eq!(
882 config.deduplication.cache_path,
883 PathBuf::from("relative/path/cache.db")
884 );
885
886 let toml_string = r#"
888[auth]
889api_key = "test"
890api_secret = "test"
891access_token = "test"
892access_token_secret = "test"
893
894[upload]
895threads = 4
896retry_attempts = 3
897timeout_seconds = 300
898
899[deduplication]
900enabled = true
901cache_path = "/absolute/path/cache.db"
902"#;
903
904 let config: Config =
905 toml::from_str(toml_string).expect("Should parse config with absolute path");
906 assert_eq!(
907 config.deduplication.cache_path,
908 PathBuf::from("/absolute/path/cache.db")
909 );
910 }
911
912 #[test]
913 fn test_auth_config_debug() {
914 let auth = AuthConfig {
915 api_key: "key".to_string(),
916 api_secret: "secret".to_string(),
917 access_token: "token".to_string(),
918 access_token_secret: "token_secret".to_string(),
919 };
920
921 let debug_string = format!("{:?}", auth);
922 assert!(debug_string.contains("AuthConfig"));
923 assert!(debug_string.contains("api_key"));
924 }
925
926 #[test]
927 fn test_upload_config_debug() {
928 let upload = UploadConfig {
929 threads: 4,
930 retry_attempts: 3,
931 timeout_seconds: 300,
932 has_smugmug_source: false,
933 default_folder: default_upload_folder(),
934 raw_mode: RawMode::default(),
935 };
936
937 let debug_string = format!("{:?}", upload);
938 assert!(debug_string.contains("UploadConfig"));
939 assert!(debug_string.contains("threads"));
940 }
941
942 #[test]
943 fn test_deduplication_config_debug() {
944 let dedup = DeduplicationConfig {
945 enabled: true,
946 cache_path: PathBuf::from("/tmp/cache.db"),
947 };
948
949 let debug_string = format!("{:?}", dedup);
950 assert!(debug_string.contains("DeduplicationConfig"));
951 assert!(debug_string.contains("enabled"));
952 }
953
954 #[test]
955 fn test_config_struct_creation() {
956 let config = Config {
957 auth: AuthConfig {
958 api_key: "test_key".to_string(),
959 api_secret: "test_secret".to_string(),
960 access_token: "test_token".to_string(),
961 access_token_secret: "test_token_secret".to_string(),
962 },
963 upload: UploadConfig {
964 threads: 8,
965 retry_attempts: 5,
966 timeout_seconds: 600,
967 has_smugmug_source: false,
968 default_folder: default_upload_folder(),
969 raw_mode: RawMode::default(),
970 },
971 deduplication: DeduplicationConfig {
972 enabled: true,
973 cache_path: PathBuf::from("/custom/cache.db"),
974 },
975 };
976
977 assert_eq!(config.auth.api_key, "test_key");
978 assert_eq!(config.upload.threads, 8);
979 assert!(config.deduplication.enabled);
980 }
981
982 #[test]
983 fn test_is_auth_configured_with_empty_fields() {
984 let toml_string = r#"
985[auth]
986api_key = ""
987api_secret = ""
988access_token = ""
989access_token_secret = ""
990
991[upload]
992threads = 4
993retry_attempts = 3
994timeout_seconds = 300
995
996[deduplication]
997enabled = true
998cache_path = "/tmp/cache.db"
999"#;
1000
1001 let config: Config =
1002 toml::from_str(toml_string).expect("Should parse config with empty auth fields");
1003
1004 assert!(config.auth.api_key.is_empty());
1006 assert!(config.auth.api_secret.is_empty());
1007 assert!(config.auth.access_token.is_empty());
1008 assert!(config.auth.access_token_secret.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_is_auth_configured_with_filled_fields() {
1013 let toml_string = r#"
1014[auth]
1015api_key = "test_key"
1016api_secret = "test_secret"
1017access_token = "test_token"
1018access_token_secret = "test_token_secret"
1019
1020[upload]
1021threads = 4
1022retry_attempts = 3
1023timeout_seconds = 300
1024
1025[deduplication]
1026enabled = true
1027cache_path = "/tmp/cache.db"
1028"#;
1029
1030 let config: Config =
1031 toml::from_str(toml_string).expect("Should parse config with filled auth fields");
1032
1033 assert!(!config.auth.api_key.is_empty());
1035 assert!(!config.auth.api_secret.is_empty());
1036 assert!(!config.auth.access_token.is_empty());
1037 assert!(!config.auth.access_token_secret.is_empty());
1038 }
1039}