Skip to main content

smugmug_cli/
config.rs

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    /// Folder that `upload` puts monthly albums in when no `--album` is
31    /// given. Created private if it doesn't exist.
32    #[serde(default = "default_upload_folder")]
33    pub default_folder: String,
34    /// What to do with RAW files (see `RawMode`).
35    #[serde(default)]
36    pub raw_mode: RawMode,
37}
38
39/// How `upload` treats RAW files. Uploading RAW originals needs a SmugMug
40/// Source subscription; without one, a JPEG rendered from the RAW (its
41/// embedded camera preview) can be uploaded instead.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "lowercase")]
44pub enum RawMode {
45    /// Upload the RAW original with SmugMug Source, otherwise a rendered JPEG
46    #[default]
47    Auto,
48    /// Always upload a rendered JPEG instead of the RAW original
49    Render,
50    /// Upload the RAW original (skipped without SmugMug Source)
51    Original,
52    /// Don't upload RAW files
53    Skip,
54}
55
56/// What actually happens to RAW files in an upload, once `RawMode` is
57/// weighed against the account's subscription.
58#[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    // Try to load defaults from .env file (if it exists)
133    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    // Test authentication and detect SmugMug Source
184    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            // Try to fetch features to detect SmugMug Source
203            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                        // Check PremiumStorage field to detect SmugMug Source
207                        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                            // Allow user to override detection
248                            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                            // Couldn't detect, ask user
261                            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                        // Couldn't fetch features, ask user
274                        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
322/// Interactive OAuth sign-in: prints the authorize link, waits for the
323/// verifier code SmugMug shows after approval, and returns the access token
324/// pair.
325async 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
363/// `smugmug-cli auth`: obtain a new access token through the browser and
364/// store it. Uses the API key/secret from the existing config, else from
365/// the environment/.env, else prompts. Creates the config if missing.
366pub 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        // Same detection init does, without the interactive override.
416        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    // Try to load config
466    let config = match load_config() {
467        Ok(cfg) => cfg,
468        Err(_) => return false,
469    };
470
471    // Check if all auth fields are filled out
472    !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    // Helper function to create a test config
483    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        // Configs written before default_folder existed must still load.
525        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        // Auth fields should be empty strings
552        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        // Upload defaults
558        assert_eq!(config.upload.threads, 4);
559        assert_eq!(config.upload.retry_attempts, 3);
560        assert_eq!(config.upload.timeout_seconds, 300);
561
562        // Deduplication defaults
563        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        // Verify the TOML contains expected keys
581        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        // Verify the values are correct
592        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        // Verify auth fields
622        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        // Verify upload fields
631        assert_eq!(config.upload.threads, 16);
632        assert_eq!(config.upload.retry_attempts, 10);
633        assert_eq!(config.upload.timeout_seconds, 1200);
634
635        // Verify deduplication fields
636        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        // Serialize to TOML
648        let toml_string =
649            toml::to_string_pretty(&original_config).expect("Failed to serialize config");
650
651        // Deserialize back to Config
652        let deserialized_config: Config =
653            toml::from_str(&toml_string).expect("Failed to deserialize config");
654
655        // Verify all fields match
656        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        // Test various path formats
862        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        // Test absolute path
887        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        // Verify that empty strings are detected as not configured
1005        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        // Verify that filled strings are properly populated
1034        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}