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    #[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    /// Folder that `upload` puts monthly albums in when no `--album` is
33    /// given. Created private if it doesn't exist.
34    #[serde(default = "default_upload_folder")]
35    pub default_folder: String,
36    /// What to do with RAW files (see `RawMode`).
37    #[serde(default)]
38    pub raw_mode: RawMode,
39    /// Files read (hashed, dated, RAWs rendered) at once. Uploads don't
40    /// wait on reads, so a couple are enough to keep them busy without
41    /// thrashing spinning disks.
42    #[serde(default = "default_read_threads")]
43    pub read_threads: usize,
44    /// What to do with the video half of a Live Photo (see
45    /// `LivePhotoVideos`).
46    #[serde(default)]
47    pub live_photo_videos: LivePhotoVideos,
48}
49
50/// The video half of a Live Photo: a short video with the same name as a
51/// photo next to it (`IMG_1234.HEIC` + `IMG_1234.MOV`). SmugMug shows it as
52/// a separate video.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
54#[serde(rename_all = "lowercase")]
55pub enum LivePhotoVideos {
56    /// Upload it, into the same day album as its photo
57    #[default]
58    Upload,
59    /// Leave it out
60    Skip,
61}
62
63fn default_read_threads() -> usize {
64    2
65}
66
67/// `smugmug-cli backup`: what to back up, where to, and how often.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct BackupConfig {
71    /// Directories to back up.
72    pub sources: Vec<PathBuf>,
73    /// SmugMug folder the year/month/day albums go in. Created private.
74    pub folder: String,
75    /// Paths to leave out, in .gitignore syntax, relative to each source
76    /// (e.g. "old_backup/", "**/Thumbs.db").
77    pub exclude: Vec<String>,
78    /// Time between the end of one run and the start of the next (e.g.
79    /// "6h", "30m", "1d"). Without it, `backup` runs once.
80    pub interval: Option<String>,
81    /// Overrides `upload.threads` for backups.
82    pub upload_threads: Option<usize>,
83    /// Overrides `upload.read_threads` for backups.
84    pub read_threads: Option<usize>,
85    /// Overrides `upload.live_photo_videos` for backups.
86    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/// How `upload` treats RAW files. Uploading RAW originals needs a SmugMug
104/// Source subscription; without one, a JPEG rendered from the RAW (its
105/// embedded camera preview) can be uploaded instead.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
107#[serde(rename_all = "lowercase")]
108pub enum RawMode {
109    /// Upload the RAW original with SmugMug Source, otherwise a rendered JPEG
110    #[default]
111    Auto,
112    /// Always upload a rendered JPEG instead of the RAW original
113    Render,
114    /// Upload the RAW original (skipped without SmugMug Source)
115    Original,
116    /// Don't upload RAW files
117    Skip,
118}
119
120/// What actually happens to RAW files in an upload, once `RawMode` is
121/// weighed against the account's subscription.
122#[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    // Try to load defaults from .env file (if it exists)
200    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    // Test authentication and detect SmugMug Source
251    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            // Try to fetch features to detect SmugMug Source
270            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                        // Check PremiumStorage field to detect SmugMug Source
274                        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                            // Allow user to override detection
315                            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                            // Couldn't detect, ask user
328                            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                        // Couldn't fetch features, ask user
341                        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
389/// Interactive OAuth sign-in: prints the authorize link, waits for the
390/// verifier code SmugMug shows after approval, and returns the access token
391/// pair.
392async 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
430/// `smugmug-cli auth`: obtain a new access token through the browser and
431/// store it. Uses the API key/secret from the existing config, else from
432/// the environment/.env, else prompts. Creates the config if missing.
433pub async fn auth_command() -> Result<()> {
434    let _ = dotenvy::dotenv();
435    // The file alone: it's saved back below, and credentials that only came
436    // from the environment mustn't end up in it.
437    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        // Same detection init does, without the interactive override.
485        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
504/// The credential variables, in `AuthConfig` field order.
505const CREDENTIAL_VARS: [&str; 4] = [
506    "SMUGMUG_API_KEY",
507    "SMUGMUG_API_SECRET",
508    "SMUGMUG_ACCESS_TOKEN",
509    "SMUGMUG_ACCESS_TOKEN_SECRET",
510];
511
512/// The config to run with: the config file with any credentials from the
513/// environment (as a container gets them) put over its own. Without a file,
514/// all four credentials must be in the environment. Never save this back:
515/// use `load_config_file` for that, so environment credentials stay out of
516/// the file.
517pub 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
528/// `config` (the file's, if there is one) with credentials from `env` put
529/// over its own.
530fn 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
577/// The config file exactly as written (no environment overrides), for
578/// changing and saving back.
579pub 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    // Try to load config
608    let config = match load_config() {
609        Ok(cfg) => cfg,
610        Err(_) => return false,
611    };
612
613    // Check if all auth fields are filled out
614    !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    // Helper function to create a test config
625    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        // Configs written before default_folder existed must still load.
730        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        // Auth fields should be empty strings
757        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        // Upload defaults
763        assert_eq!(config.upload.threads, 4);
764        assert_eq!(config.upload.retry_attempts, 3);
765        assert_eq!(config.upload.timeout_seconds, 300);
766
767        // Deduplication defaults
768        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        // Verify the TOML contains expected keys
786        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        // Verify the values are correct
797        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        // Verify auth fields
827        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        // Verify upload fields
836        assert_eq!(config.upload.threads, 16);
837        assert_eq!(config.upload.retry_attempts, 10);
838        assert_eq!(config.upload.timeout_seconds, 1200);
839
840        // Verify deduplication fields
841        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        // Serialize to TOML
853        let toml_string =
854            toml::to_string_pretty(&original_config).expect("Failed to serialize config");
855
856        // Deserialize back to Config
857        let deserialized_config: Config =
858            toml::from_str(&toml_string).expect("Failed to deserialize config");
859
860        // Verify all fields match
861        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        // Test various path formats
1067        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        // Test absolute path
1092        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        // Verify that empty strings are detected as not configured
1215        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        // Verify that filled strings are properly populated
1244        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}