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 #[serde(default)]
14 pub backup: BackupConfig,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct AuthConfig {
19 pub api_key: String,
20 pub api_secret: String,
21 pub access_token: String,
22 pub access_token_secret: String,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct UploadConfig {
27 pub threads: usize,
28 pub retry_attempts: u32,
29 pub timeout_seconds: u64,
30 #[serde(default)]
31 pub has_smugmug_source: bool,
32 #[serde(default = "default_upload_folder")]
35 pub default_folder: String,
36 #[serde(default)]
38 pub raw_mode: RawMode,
39 #[serde(default = "default_read_threads")]
43 pub read_threads: usize,
44 #[serde(default)]
47 pub live_photo_videos: LivePhotoVideos,
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
54#[serde(rename_all = "lowercase")]
55pub enum LivePhotoVideos {
56 #[default]
58 Upload,
59 Skip,
61}
62
63fn default_read_threads() -> usize {
64 2
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct BackupConfig {
71 pub sources: Vec<PathBuf>,
73 pub folder: String,
75 pub exclude: Vec<String>,
78 pub interval: Option<String>,
81 pub upload_threads: Option<usize>,
83 pub read_threads: Option<usize>,
85 pub live_photo_videos: Option<LivePhotoVideos>,
87}
88
89impl Default for BackupConfig {
90 fn default() -> Self {
91 BackupConfig {
92 sources: Vec::new(),
93 folder: "Backup".to_string(),
94 exclude: Vec::new(),
95 interval: None,
96 upload_threads: None,
97 read_threads: None,
98 live_photo_videos: None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
107#[serde(rename_all = "lowercase")]
108pub enum RawMode {
109 #[default]
111 Auto,
112 Render,
114 Original,
116 Skip,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum RawHandling {
124 Upload,
125 Render,
126 Skip,
127}
128
129impl RawMode {
130 pub fn handling(self, has_smugmug_source: bool) -> RawHandling {
131 match (self, has_smugmug_source) {
132 (RawMode::Auto, true) | (RawMode::Original, true) => RawHandling::Upload,
133 (RawMode::Auto, false) | (RawMode::Render, _) => RawHandling::Render,
134 (RawMode::Original, false) | (RawMode::Skip, _) => RawHandling::Skip,
135 }
136 }
137}
138
139fn default_upload_folder() -> String {
140 "Uploads".to_string()
141}
142
143#[derive(Debug, Serialize, Deserialize)]
144pub struct DeduplicationConfig {
145 pub enabled: bool,
146 pub cache_path: PathBuf,
147}
148
149impl Default for Config {
150 fn default() -> Self {
151 let cache_dir = directories::ProjectDirs::from("", "", "smugmug-cli")
152 .map(|dirs| dirs.cache_dir().to_path_buf())
153 .unwrap_or_else(|| PathBuf::from(".cache"));
154
155 Config {
156 auth: AuthConfig {
157 api_key: String::new(),
158 api_secret: String::new(),
159 access_token: String::new(),
160 access_token_secret: String::new(),
161 },
162 upload: UploadConfig {
163 threads: 4,
164 retry_attempts: 3,
165 timeout_seconds: 300,
166 has_smugmug_source: false,
167 default_folder: default_upload_folder(),
168 raw_mode: RawMode::default(),
169 read_threads: default_read_threads(),
170 live_photo_videos: LivePhotoVideos::default(),
171 },
172 deduplication: DeduplicationConfig {
173 enabled: true,
174 cache_path: cache_dir.join("hashes.db"),
175 },
176 backup: BackupConfig::default(),
177 }
178 }
179}
180
181fn get_config_path() -> Result<PathBuf> {
182 let proj_dirs = directories::ProjectDirs::from("", "", "smugmug-cli")
183 .context("Failed to determine config directory")?;
184
185 let config_dir = proj_dirs.config_dir();
186 fs::create_dir_all(config_dir).context("Failed to create config directory")?;
187
188 Ok(config_dir.join("config.toml"))
189}
190
191pub async fn init_config() -> Result<()> {
192 println!("SmugMug CLI Configuration\n");
193 println!("To get your API credentials:");
194 println!("1. Go to https://api.smugmug.com/api/developer/apply");
195 println!("2. Create an application to get your API Key and Secret");
196 println!("3. Sign in through your browser when prompted to get an Access Token and Secret");
197 println!(" (or run 'smugmug-cli auth' later to refresh them)\n");
198
199 let _ = dotenvy::dotenv();
201 let env_api_key = std::env::var("SMUGMUG_API_KEY").ok();
202 let env_api_secret = std::env::var("SMUGMUG_API_SECRET").ok();
203 let env_access_token = std::env::var("SMUGMUG_ACCESS_TOKEN").ok();
204 let env_access_token_secret = std::env::var("SMUGMUG_ACCESS_TOKEN_SECRET").ok();
205
206 let api_key: String = {
207 let mut input = Input::new().with_prompt("API Key");
208 if let Some(default) = env_api_key {
209 input = input.with_initial_text(default);
210 }
211 input.interact_text()?
212 };
213
214 let api_secret: String = {
215 let mut input = Input::new().with_prompt("API Secret");
216 if let Some(default) = env_api_secret {
217 input = input.with_initial_text(default);
218 }
219 input.interact_text()?
220 };
221
222 let use_browser = env_access_token.is_none()
223 && Confirm::new()
224 .with_prompt("Sign in to SmugMug in your browser to get an access token?")
225 .default(true)
226 .interact()?;
227
228 let (access_token, access_token_secret) = if use_browser {
229 let pair = browser_sign_in(&api_key, &api_secret).await?;
230 (pair.token, pair.secret)
231 } else {
232 let access_token: String = {
233 let mut input = Input::new().with_prompt("Access Token");
234 if let Some(default) = env_access_token {
235 input = input.with_initial_text(default);
236 }
237 input.interact_text()?
238 };
239
240 let access_token_secret: String = {
241 let mut input = Input::new().with_prompt("Access Token Secret");
242 if let Some(default) = env_access_token_secret {
243 input = input.with_initial_text(default);
244 }
245 input.interact_text()?
246 };
247 (access_token, access_token_secret)
248 };
249
250 println!(
252 "\n{}",
253 "Testing authentication and detecting features...".cyan()
254 );
255
256 let test_client = crate::api::SmugMugClient::new(
257 api_key.clone(),
258 api_secret.clone(),
259 access_token.clone(),
260 access_token_secret.clone(),
261 );
262
263 let mut has_smugmug_source = false;
264
265 match test_client.get_auth_user().await {
266 Ok(user_info) => {
267 println!("{}", "✓ Authentication successful!".green().bold());
268
269 if let Some(user_uri) = user_info["Response"]["User"]["Uri"].as_str() {
271 match test_client.get_user_features(user_uri).await {
272 Ok(features) => {
273 if let Some(premium_storage) =
275 features["Response"]["Features"]["PremiumStorage"].as_bool()
276 {
277 has_smugmug_source = premium_storage;
278
279 if premium_storage {
280 println!(
281 "{} {}",
282 "✓".green().bold(),
283 "Detected SmugMug Source subscription (PremiumStorage enabled)"
284 .green()
285 );
286 println!(
287 " {} {}",
288 "→".bright_cyan(),
289 "RAW file uploads enabled".cyan()
290 );
291 println!(
292 " {} {}",
293 "→".bright_cyan(),
294 "Original file downloads enabled".cyan()
295 );
296 println!(
297 " {} {}\n",
298 "→".bright_cyan(),
299 "Cloud storage backup enabled".cyan()
300 );
301 } else {
302 println!(
303 "{} {}",
304 "ℹ".cyan().bold(),
305 "SmugMug Source not detected".cyan()
306 );
307 println!(
308 " {}\n",
309 "RAW files will be uploaded as JPEGs rendered from them"
310 .bright_black()
311 );
312 }
313
314 let override_detection = Confirm::new()
316 .with_prompt("Override auto-detection?")
317 .default(false)
318 .interact()?;
319
320 if override_detection {
321 has_smugmug_source = Confirm::new()
322 .with_prompt("Do you have a SmugMug Source subscription?")
323 .default(has_smugmug_source)
324 .interact()?;
325 }
326 } else {
327 println!(
329 "{} {}",
330 "⚠".yellow().bold(),
331 "Could not auto-detect SmugMug Source".yellow()
332 );
333 has_smugmug_source = Confirm::new()
334 .with_prompt("Do you have a SmugMug Source subscription?")
335 .default(false)
336 .interact()?;
337 }
338 }
339 Err(_) => {
340 println!(
342 "{} {}",
343 "⚠".yellow().bold(),
344 "Could not fetch account features".yellow()
345 );
346 has_smugmug_source = Confirm::new()
347 .with_prompt("Do you have a SmugMug Source subscription?")
348 .default(false)
349 .interact()?;
350 }
351 }
352 }
353 }
354 Err(e) => {
355 println!(
356 "{} {}",
357 "✗".red().bold(),
358 format!("Authentication test failed: {}", e).red()
359 );
360 println!("{}", "Please verify your credentials are correct.".yellow());
361 return Err(e);
362 }
363 }
364
365 let mut config = Config::default();
366 config.auth.api_key = api_key;
367 config.auth.api_secret = api_secret;
368 config.auth.access_token = access_token;
369 config.auth.access_token_secret = access_token_secret;
370 config.upload.has_smugmug_source = has_smugmug_source;
371
372 save_config(&config)?;
373
374 let config_path = get_config_path()?;
375 println!(
376 "\n{} {}",
377 "✓".green().bold(),
378 format!("Configuration saved to: {}", config_path.display()).green()
379 );
380 println!(
381 "\n{} {}",
382 "You can now upload photos using:".cyan(),
383 "smugmug-cli upload <path>".bright_white().bold()
384 );
385
386 Ok(())
387}
388
389async fn browser_sign_in(
393 api_key: &str,
394 api_secret: &str,
395) -> Result<crate::api::oauth_flow::TokenPair> {
396 use crate::api::oauth_flow::{
397 OAuthEndpoints, authorize_url, get_access_token, get_request_token,
398 };
399
400 let endpoints = OAuthEndpoints::default();
401 let request_token = get_request_token(&endpoints, api_key, api_secret).await?;
402
403 println!(
404 "\n{}",
405 "Open this link, sign in, and approve access:".cyan()
406 );
407 println!(
408 "\n {}\n",
409 authorize_url(&endpoints, &request_token)
410 .bright_white()
411 .bold()
412 );
413
414 let verifier: String = Input::new()
415 .with_prompt("Code shown by SmugMug after approving")
416 .interact_text()?;
417
418 let pair = get_access_token(
419 &endpoints,
420 api_key,
421 api_secret,
422 &request_token,
423 verifier.trim(),
424 )
425 .await?;
426 println!("{}", "✓ Access token received".green().bold());
427 Ok(pair)
428}
429
430pub async fn auth_command() -> Result<()> {
434 let _ = dotenvy::dotenv();
435 let existing = load_config_file().ok();
438
439 let (api_key, api_secret) = match &existing {
440 Some(cfg) if !cfg.auth.api_key.is_empty() && !cfg.auth.api_secret.is_empty() => {
441 (cfg.auth.api_key.clone(), cfg.auth.api_secret.clone())
442 }
443 _ => {
444 let key = match std::env::var("SMUGMUG_API_KEY") {
445 Ok(v) if !v.is_empty() => v,
446 _ => Input::new().with_prompt("API Key").interact_text()?,
447 };
448 let secret = match std::env::var("SMUGMUG_API_SECRET") {
449 Ok(v) if !v.is_empty() => v,
450 _ => Input::new().with_prompt("API Secret").interact_text()?,
451 };
452 (key, secret)
453 }
454 };
455
456 let pair = browser_sign_in(&api_key, &api_secret).await?;
457
458 let client = crate::api::SmugMugClient::new(
459 api_key.clone(),
460 api_secret.clone(),
461 pair.token.clone(),
462 pair.secret.clone(),
463 );
464 let user = client
465 .get_auth_user()
466 .await
467 .context("Signed in, but the new access token was rejected")?;
468 if let Some(nick) = user["Response"]["User"]["NickName"].as_str() {
469 println!(
470 "{} {}",
471 "✓ Authenticated as".green().bold(),
472 nick.bright_white().bold()
473 );
474 }
475
476 let is_new = existing.is_none();
477 let mut config = existing.unwrap_or_default();
478 config.auth.api_key = api_key;
479 config.auth.api_secret = api_secret;
480 config.auth.access_token = pair.token;
481 config.auth.access_token_secret = pair.secret;
482
483 if is_new {
484 if let Some(user_uri) = user["Response"]["User"]["Uri"].as_str() {
486 if let Ok(features) = client.get_user_features(user_uri).await {
487 config.upload.has_smugmug_source =
488 features["Response"]["Features"]["PremiumStorage"]
489 .as_bool()
490 .unwrap_or(false);
491 }
492 }
493 }
494
495 save_config(&config)?;
496 println!(
497 "{} {}",
498 "✓".green().bold(),
499 format!("Credentials saved to: {}", get_config_path()?.display()).green()
500 );
501 Ok(())
502}
503
504const CREDENTIAL_VARS: [&str; 4] = [
506 "SMUGMUG_API_KEY",
507 "SMUGMUG_API_SECRET",
508 "SMUGMUG_ACCESS_TOKEN",
509 "SMUGMUG_ACCESS_TOKEN_SECRET",
510];
511
512pub fn load_config() -> Result<Config> {
518 let file = match load_config_file() {
519 Ok(config) => Some(config),
520 Err(e) if get_config_path()?.exists() => return Err(e),
521 Err(_) => None,
522 };
523 with_env_credentials(file, |name| {
524 std::env::var(name).ok().filter(|v| !v.is_empty())
525 })
526}
527
528fn with_env_credentials(
531 config: Option<Config>,
532 env: impl Fn(&str) -> Option<String>,
533) -> Result<Config> {
534 let values: Vec<Option<String>> = CREDENTIAL_VARS.iter().map(|name| env(name)).collect();
535 let mut config = match config {
536 Some(config) => config,
537 None if values.iter().all(Option::is_none) => {
538 anyhow::bail!(
539 "Config file not found at: {}\n\n\
540 Run 'smugmug-cli init' to create your configuration, or set {}.\n\
541 (Tip: You can create a .env file with your credentials, and init will use them as defaults)",
542 get_config_path()?.display(),
543 CREDENTIAL_VARS.join(", ")
544 );
545 }
546 None => {
547 let missing: Vec<&str> = CREDENTIAL_VARS
548 .iter()
549 .zip(&values)
550 .filter(|(_, v)| v.is_none())
551 .map(|(name, _)| *name)
552 .collect();
553 if !missing.is_empty() {
554 anyhow::bail!(
555 "No config file, and these credentials aren't set: {}",
556 missing.join(", ")
557 );
558 }
559 Config::default()
560 }
561 };
562 let [key, secret, token, token_secret] = <[Option<String>; 4]>::try_from(values).unwrap();
563 let auth = &mut config.auth;
564 for (field, value) in [
565 (&mut auth.api_key, key),
566 (&mut auth.api_secret, secret),
567 (&mut auth.access_token, token),
568 (&mut auth.access_token_secret, token_secret),
569 ] {
570 if let Some(value) = value {
571 *field = value;
572 }
573 }
574 Ok(config)
575}
576
577pub fn load_config_file() -> Result<Config> {
580 let config_path = get_config_path()?;
581
582 if !config_path.exists() {
583 anyhow::bail!(
584 "Config file not found at: {}\n\n\
585 Run 'smugmug-cli init' to create your configuration.\n\
586 (Tip: You can create a .env file with your credentials, and init will use them as defaults)",
587 config_path.display()
588 );
589 }
590
591 let contents = fs::read_to_string(&config_path).context("Failed to read config file")?;
592
593 toml::from_str(&contents).context("Failed to parse config file")
594}
595
596pub fn save_config(config: &Config) -> Result<()> {
597 let config_path = get_config_path()?;
598
599 let toml_string = toml::to_string_pretty(config).context("Failed to serialize config")?;
600
601 fs::write(&config_path, toml_string).context("Failed to write config file")?;
602
603 Ok(())
604}
605
606pub fn is_auth_configured() -> bool {
607 let config = match load_config() {
609 Ok(cfg) => cfg,
610 Err(_) => return false,
611 };
612
613 !config.auth.api_key.is_empty()
615 && !config.auth.api_secret.is_empty()
616 && !config.auth.access_token.is_empty()
617 && !config.auth.access_token_secret.is_empty()
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 fn create_test_config() -> Config {
626 Config {
627 auth: AuthConfig {
628 api_key: "test_api_key".to_string(),
629 api_secret: "test_api_secret".to_string(),
630 access_token: "test_access_token".to_string(),
631 access_token_secret: "test_access_token_secret".to_string(),
632 },
633 upload: UploadConfig {
634 threads: 8,
635 retry_attempts: 5,
636 timeout_seconds: 600,
637 has_smugmug_source: false,
638 default_folder: default_upload_folder(),
639 raw_mode: RawMode::default(),
640 read_threads: default_read_threads(),
641 live_photo_videos: LivePhotoVideos::default(),
642 },
643 deduplication: DeduplicationConfig {
644 enabled: false,
645 cache_path: PathBuf::from("/tmp/test_cache.db"),
646 },
647 backup: BackupConfig::default(),
648 }
649 }
650
651 fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
652 let vars: Vec<(String, String)> = vars
653 .iter()
654 .map(|(k, v)| (k.to_string(), v.to_string()))
655 .collect();
656 move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
657 }
658
659 #[test]
660 fn environment_credentials_override_the_file() {
661 let config = with_env_credentials(
662 Some(create_test_config()),
663 env_of(&[("SMUGMUG_ACCESS_TOKEN", "env-token")]),
664 )
665 .unwrap();
666 assert_eq!(config.auth.access_token, "env-token");
667 assert_eq!(config.auth.api_key, create_test_config().auth.api_key);
668 }
669
670 #[test]
671 fn without_a_file_all_four_credentials_are_needed() {
672 let err = with_env_credentials(
673 None,
674 env_of(&[("SMUGMUG_API_KEY", "k"), ("SMUGMUG_ACCESS_TOKEN", "t")]),
675 )
676 .unwrap_err()
677 .to_string();
678 assert!(err.contains("SMUGMUG_API_SECRET"), "{}", err);
679 assert!(err.contains("SMUGMUG_ACCESS_TOKEN_SECRET"), "{}", err);
680 assert!(!err.contains("SMUGMUG_API_KEY,"), "{}", err);
681
682 let config = with_env_credentials(
683 None,
684 env_of(&[
685 ("SMUGMUG_API_KEY", "k"),
686 ("SMUGMUG_API_SECRET", "s"),
687 ("SMUGMUG_ACCESS_TOKEN", "t"),
688 ("SMUGMUG_ACCESS_TOKEN_SECRET", "ts"),
689 ]),
690 )
691 .unwrap();
692 assert_eq!(
693 (
694 config.auth.api_key.as_str(),
695 config.auth.api_secret.as_str(),
696 config.auth.access_token.as_str(),
697 config.auth.access_token_secret.as_str()
698 ),
699 ("k", "s", "t", "ts")
700 );
701 }
702
703 #[test]
704 fn without_a_file_or_credentials_it_says_how_to_set_up() {
705 let err = with_env_credentials(None, env_of(&[]))
706 .unwrap_err()
707 .to_string();
708 assert!(err.contains("smugmug-cli init"), "{}", err);
709 }
710
711 #[test]
712 fn test_raw_mode_handling() {
713 assert_eq!(RawMode::Auto.handling(true), RawHandling::Upload);
714 assert_eq!(RawMode::Auto.handling(false), RawHandling::Render);
715 assert_eq!(RawMode::Render.handling(true), RawHandling::Render);
716 assert_eq!(RawMode::Original.handling(true), RawHandling::Upload);
717 assert_eq!(RawMode::Original.handling(false), RawHandling::Skip);
718 assert_eq!(RawMode::Skip.handling(true), RawHandling::Skip);
719
720 let upload: UploadConfig = toml::from_str(
721 "threads = 4\nretry_attempts = 3\ntimeout_seconds = 300\nraw_mode = \"render\"",
722 )
723 .unwrap();
724 assert_eq!(upload.raw_mode, RawMode::Render);
725 }
726
727 #[test]
728 fn test_config_without_default_folder_uses_uploads() {
729 let toml_str = r#"
731[auth]
732api_key = "k"
733api_secret = "s"
734access_token = "t"
735access_token_secret = "ts"
736
737[upload]
738threads = 4
739retry_attempts = 3
740timeout_seconds = 300
741
742[deduplication]
743enabled = true
744cache_path = "/tmp/cache"
745"#;
746 let config: Config = toml::from_str(toml_str).unwrap();
747 assert_eq!(config.upload.default_folder, "Uploads");
748 assert_eq!(Config::default().upload.default_folder, "Uploads");
749 assert_eq!(config.upload.raw_mode, RawMode::Auto);
750 }
751
752 #[test]
753 fn test_config_default() {
754 let config = Config::default();
755
756 assert_eq!(config.auth.api_key, "");
758 assert_eq!(config.auth.api_secret, "");
759 assert_eq!(config.auth.access_token, "");
760 assert_eq!(config.auth.access_token_secret, "");
761
762 assert_eq!(config.upload.threads, 4);
764 assert_eq!(config.upload.retry_attempts, 3);
765 assert_eq!(config.upload.timeout_seconds, 300);
766
767 assert!(config.deduplication.enabled);
769 assert!(
770 config
771 .deduplication
772 .cache_path
773 .to_string_lossy()
774 .ends_with("hashes.db")
775 );
776 }
777
778 #[test]
779 fn test_config_serialization_to_toml() {
780 let config = create_test_config();
781
782 let toml_string =
783 toml::to_string_pretty(&config).expect("Failed to serialize config to TOML");
784
785 assert!(toml_string.contains("api_key"));
787 assert!(toml_string.contains("api_secret"));
788 assert!(toml_string.contains("access_token"));
789 assert!(toml_string.contains("access_token_secret"));
790 assert!(toml_string.contains("threads"));
791 assert!(toml_string.contains("retry_attempts"));
792 assert!(toml_string.contains("timeout_seconds"));
793 assert!(toml_string.contains("enabled"));
794 assert!(toml_string.contains("cache_path"));
795
796 assert!(toml_string.contains("test_api_key"));
798 assert!(toml_string.contains("test_api_secret"));
799 assert!(toml_string.contains("8"));
800 assert!(toml_string.contains("5"));
801 assert!(toml_string.contains("600"));
802 }
803
804 #[test]
805 fn test_config_deserialization_from_toml() {
806 let toml_string = r#"
807[auth]
808api_key = "deserialize_api_key"
809api_secret = "deserialize_api_secret"
810access_token = "deserialize_access_token"
811access_token_secret = "deserialize_access_token_secret"
812
813[upload]
814threads = 16
815retry_attempts = 10
816timeout_seconds = 1200
817
818[deduplication]
819enabled = true
820cache_path = "/custom/path/cache.db"
821"#;
822
823 let config: Config =
824 toml::from_str(toml_string).expect("Failed to deserialize config from TOML");
825
826 assert_eq!(config.auth.api_key, "deserialize_api_key");
828 assert_eq!(config.auth.api_secret, "deserialize_api_secret");
829 assert_eq!(config.auth.access_token, "deserialize_access_token");
830 assert_eq!(
831 config.auth.access_token_secret,
832 "deserialize_access_token_secret"
833 );
834
835 assert_eq!(config.upload.threads, 16);
837 assert_eq!(config.upload.retry_attempts, 10);
838 assert_eq!(config.upload.timeout_seconds, 1200);
839
840 assert!(config.deduplication.enabled);
842 assert_eq!(
843 config.deduplication.cache_path,
844 PathBuf::from("/custom/path/cache.db")
845 );
846 }
847
848 #[test]
849 fn test_config_round_trip_serialization() {
850 let original_config = create_test_config();
851
852 let toml_string =
854 toml::to_string_pretty(&original_config).expect("Failed to serialize config");
855
856 let deserialized_config: Config =
858 toml::from_str(&toml_string).expect("Failed to deserialize config");
859
860 assert_eq!(
862 original_config.auth.api_key,
863 deserialized_config.auth.api_key
864 );
865 assert_eq!(
866 original_config.auth.api_secret,
867 deserialized_config.auth.api_secret
868 );
869 assert_eq!(
870 original_config.auth.access_token,
871 deserialized_config.auth.access_token
872 );
873 assert_eq!(
874 original_config.auth.access_token_secret,
875 deserialized_config.auth.access_token_secret
876 );
877 assert_eq!(
878 original_config.upload.threads,
879 deserialized_config.upload.threads
880 );
881 assert_eq!(
882 original_config.upload.retry_attempts,
883 deserialized_config.upload.retry_attempts
884 );
885 assert_eq!(
886 original_config.upload.timeout_seconds,
887 deserialized_config.upload.timeout_seconds
888 );
889 assert_eq!(
890 original_config.deduplication.enabled,
891 deserialized_config.deduplication.enabled
892 );
893 assert_eq!(
894 original_config.deduplication.cache_path,
895 deserialized_config.deduplication.cache_path
896 );
897 }
898
899 #[test]
900 fn test_invalid_toml_deserialization() {
901 let invalid_toml = r#"
902[auth]
903api_key = "test"
904# Missing required fields
905"#;
906
907 let result: Result<Config, _> = toml::from_str(invalid_toml);
908 assert!(
909 result.is_err(),
910 "Should fail when required fields are missing"
911 );
912 }
913
914 #[test]
915 fn test_invalid_type_in_toml() {
916 let invalid_toml = r#"
917[auth]
918api_key = "test"
919api_secret = "test"
920access_token = "test"
921access_token_secret = "test"
922
923[upload]
924threads = "not_a_number"
925retry_attempts = 3
926timeout_seconds = 300
927
928[deduplication]
929enabled = true
930cache_path = "/tmp/cache.db"
931"#;
932
933 let result: Result<Config, _> = toml::from_str(invalid_toml);
934 assert!(result.is_err(), "Should fail when field has wrong type");
935 }
936
937 #[test]
938 fn test_config_with_zero_threads() {
939 let toml_string = r#"
940[auth]
941api_key = "test"
942api_secret = "test"
943access_token = "test"
944access_token_secret = "test"
945
946[upload]
947threads = 0
948retry_attempts = 3
949timeout_seconds = 300
950
951[deduplication]
952enabled = true
953cache_path = "/tmp/cache.db"
954"#;
955
956 let config: Config =
957 toml::from_str(toml_string).expect("Should parse config with 0 threads");
958 assert_eq!(config.upload.threads, 0);
959 }
960
961 #[test]
962 fn test_config_with_large_values() {
963 let toml_string = r#"
964[auth]
965api_key = "test"
966api_secret = "test"
967access_token = "test"
968access_token_secret = "test"
969
970[upload]
971threads = 999999
972retry_attempts = 4294967295
973timeout_seconds = 9999999999
974
975[deduplication]
976enabled = true
977cache_path = "/tmp/cache.db"
978"#;
979
980 let config: Config =
981 toml::from_str(toml_string).expect("Should parse config with large values");
982 assert_eq!(config.upload.threads, 999999);
983 assert_eq!(config.upload.retry_attempts, u32::MAX);
984 assert_eq!(config.upload.timeout_seconds, 9999999999);
985 }
986
987 #[test]
988 fn test_config_with_empty_strings() {
989 let toml_string = r#"
990[auth]
991api_key = ""
992api_secret = ""
993access_token = ""
994access_token_secret = ""
995
996[upload]
997threads = 4
998retry_attempts = 3
999timeout_seconds = 300
1000
1001[deduplication]
1002enabled = true
1003cache_path = ""
1004"#;
1005
1006 let config: Config =
1007 toml::from_str(toml_string).expect("Should parse config with empty strings");
1008 assert_eq!(config.auth.api_key, "");
1009 assert_eq!(config.auth.api_secret, "");
1010 assert_eq!(config.auth.access_token, "");
1011 assert_eq!(config.auth.access_token_secret, "");
1012 assert_eq!(config.deduplication.cache_path, PathBuf::from(""));
1013 }
1014
1015 #[test]
1016 fn test_config_with_special_characters() {
1017 let toml_string = r#"
1018[auth]
1019api_key = "test!@#$%^&*()_+-={}[]|:;<>?,."
1020api_secret = "test\nwith\nnewlines"
1021access_token = "test with spaces"
1022access_token_secret = "test'with'quotes"
1023
1024[upload]
1025threads = 4
1026retry_attempts = 3
1027timeout_seconds = 300
1028
1029[deduplication]
1030enabled = true
1031cache_path = "/tmp/cache with spaces.db"
1032"#;
1033
1034 let config: Config =
1035 toml::from_str(toml_string).expect("Should parse config with special characters");
1036 assert!(config.auth.api_key.contains("!@#$"));
1037 assert!(config.auth.access_token.contains(" "));
1038 }
1039
1040 #[test]
1041 fn test_deduplication_enabled_false() {
1042 let toml_string = r#"
1043[auth]
1044api_key = "test"
1045api_secret = "test"
1046access_token = "test"
1047access_token_secret = "test"
1048
1049[upload]
1050threads = 4
1051retry_attempts = 3
1052timeout_seconds = 300
1053
1054[deduplication]
1055enabled = false
1056cache_path = "/tmp/cache.db"
1057"#;
1058
1059 let config: Config =
1060 toml::from_str(toml_string).expect("Should parse config with deduplication disabled");
1061 assert!(!config.deduplication.enabled);
1062 }
1063
1064 #[test]
1065 fn test_config_path_variations() {
1066 let toml_string = r#"
1068[auth]
1069api_key = "test"
1070api_secret = "test"
1071access_token = "test"
1072access_token_secret = "test"
1073
1074[upload]
1075threads = 4
1076retry_attempts = 3
1077timeout_seconds = 300
1078
1079[deduplication]
1080enabled = true
1081cache_path = "relative/path/cache.db"
1082"#;
1083
1084 let config: Config =
1085 toml::from_str(toml_string).expect("Should parse config with relative path");
1086 assert_eq!(
1087 config.deduplication.cache_path,
1088 PathBuf::from("relative/path/cache.db")
1089 );
1090
1091 let toml_string = r#"
1093[auth]
1094api_key = "test"
1095api_secret = "test"
1096access_token = "test"
1097access_token_secret = "test"
1098
1099[upload]
1100threads = 4
1101retry_attempts = 3
1102timeout_seconds = 300
1103
1104[deduplication]
1105enabled = true
1106cache_path = "/absolute/path/cache.db"
1107"#;
1108
1109 let config: Config =
1110 toml::from_str(toml_string).expect("Should parse config with absolute path");
1111 assert_eq!(
1112 config.deduplication.cache_path,
1113 PathBuf::from("/absolute/path/cache.db")
1114 );
1115 }
1116
1117 #[test]
1118 fn test_auth_config_debug() {
1119 let auth = AuthConfig {
1120 api_key: "key".to_string(),
1121 api_secret: "secret".to_string(),
1122 access_token: "token".to_string(),
1123 access_token_secret: "token_secret".to_string(),
1124 };
1125
1126 let debug_string = format!("{:?}", auth);
1127 assert!(debug_string.contains("AuthConfig"));
1128 assert!(debug_string.contains("api_key"));
1129 }
1130
1131 #[test]
1132 fn test_upload_config_debug() {
1133 let upload = UploadConfig {
1134 threads: 4,
1135 retry_attempts: 3,
1136 timeout_seconds: 300,
1137 has_smugmug_source: false,
1138 default_folder: default_upload_folder(),
1139 raw_mode: RawMode::default(),
1140 read_threads: 2,
1141 live_photo_videos: LivePhotoVideos::default(),
1142 };
1143
1144 let debug_string = format!("{:?}", upload);
1145 assert!(debug_string.contains("UploadConfig"));
1146 assert!(debug_string.contains("threads"));
1147 }
1148
1149 #[test]
1150 fn test_deduplication_config_debug() {
1151 let dedup = DeduplicationConfig {
1152 enabled: true,
1153 cache_path: PathBuf::from("/tmp/cache.db"),
1154 };
1155
1156 let debug_string = format!("{:?}", dedup);
1157 assert!(debug_string.contains("DeduplicationConfig"));
1158 assert!(debug_string.contains("enabled"));
1159 }
1160
1161 #[test]
1162 fn test_config_struct_creation() {
1163 let config = Config {
1164 auth: AuthConfig {
1165 api_key: "test_key".to_string(),
1166 api_secret: "test_secret".to_string(),
1167 access_token: "test_token".to_string(),
1168 access_token_secret: "test_token_secret".to_string(),
1169 },
1170 upload: UploadConfig {
1171 threads: 8,
1172 retry_attempts: 5,
1173 timeout_seconds: 600,
1174 has_smugmug_source: false,
1175 default_folder: default_upload_folder(),
1176 raw_mode: RawMode::default(),
1177 read_threads: default_read_threads(),
1178 live_photo_videos: LivePhotoVideos::default(),
1179 },
1180 deduplication: DeduplicationConfig {
1181 enabled: true,
1182 cache_path: PathBuf::from("/custom/cache.db"),
1183 },
1184 backup: BackupConfig::default(),
1185 };
1186
1187 assert_eq!(config.auth.api_key, "test_key");
1188 assert_eq!(config.upload.threads, 8);
1189 assert!(config.deduplication.enabled);
1190 }
1191
1192 #[test]
1193 fn test_is_auth_configured_with_empty_fields() {
1194 let toml_string = r#"
1195[auth]
1196api_key = ""
1197api_secret = ""
1198access_token = ""
1199access_token_secret = ""
1200
1201[upload]
1202threads = 4
1203retry_attempts = 3
1204timeout_seconds = 300
1205
1206[deduplication]
1207enabled = true
1208cache_path = "/tmp/cache.db"
1209"#;
1210
1211 let config: Config =
1212 toml::from_str(toml_string).expect("Should parse config with empty auth fields");
1213
1214 assert!(config.auth.api_key.is_empty());
1216 assert!(config.auth.api_secret.is_empty());
1217 assert!(config.auth.access_token.is_empty());
1218 assert!(config.auth.access_token_secret.is_empty());
1219 }
1220
1221 #[test]
1222 fn test_is_auth_configured_with_filled_fields() {
1223 let toml_string = r#"
1224[auth]
1225api_key = "test_key"
1226api_secret = "test_secret"
1227access_token = "test_token"
1228access_token_secret = "test_token_secret"
1229
1230[upload]
1231threads = 4
1232retry_attempts = 3
1233timeout_seconds = 300
1234
1235[deduplication]
1236enabled = true
1237cache_path = "/tmp/cache.db"
1238"#;
1239
1240 let config: Config =
1241 toml::from_str(toml_string).expect("Should parse config with filled auth fields");
1242
1243 assert!(!config.auth.api_key.is_empty());
1245 assert!(!config.auth.api_secret.is_empty());
1246 assert!(!config.auth.access_token.is_empty());
1247 assert!(!config.auth.access_token_secret.is_empty());
1248 }
1249}