use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerConfig {
pub url: Option<String>,
pub api_key: Option<String>,
}
pub fn config_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("", "", "immich-dupes") {
proj_dirs.config_dir().join("config.toml")
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
.join(".config")
.join("immich-dupes")
.join("config.toml")
}
}
pub fn load() -> Config {
load_inner().unwrap_or_default()
}
fn load_inner() -> Result<Config> {
let path = config_path();
if !path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn save(config: &Config) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
let temp_path = path.with_extension("toml.tmp");
let mut file = fs::File::create(&temp_path)
.with_context(|| format!("Failed to create temp config file: {}", temp_path.display()))?;
file.write_all(content.as_bytes())
.with_context(|| format!("Failed to write config file: {}", temp_path.display()))?;
file.sync_all()
.with_context(|| format!("Failed to sync config file: {}", temp_path.display()))?;
fs::rename(&temp_path, &path).with_context(|| {
format!(
"Failed to rename temp config {} to {}",
temp_path.display(),
path.display()
)
})?;
Ok(())
}
pub fn prompt_credentials() -> Result<(String, String)> {
let theme = ColorfulTheme::default();
println!();
println!("Immich server credentials not found.");
println!("Please enter your server details:");
println!();
let url: String = Input::with_theme(&theme)
.with_prompt("Immich server URL")
.validate_with(|input: &String| {
if input.starts_with("http://") || input.starts_with("https://") {
Ok(())
} else {
Err("URL must start with http:// or https://")
}
})
.interact_text()
.context("Failed to read URL input")?;
let api_key: String = Password::with_theme(&theme)
.with_prompt("API key")
.interact()
.context("Failed to read API key input")?;
Ok((url, api_key))
}
pub fn prompt_save(config_path: &Path) -> bool {
println!();
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Save credentials to {}?", config_path.display()))
.default(true)
.interact()
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.server.url.is_none());
assert!(config.server.api_key.is_none());
}
#[test]
fn test_config_path_exists() {
let path = config_path();
assert!(path.ends_with("config.toml"));
assert!(path.to_string_lossy().contains("immich-dupes"));
}
#[test]
fn test_load_nonexistent_returns_default() {
let config = load();
assert!(config.server.url.is_none());
}
#[test]
fn test_toml_roundtrip() {
let config = Config {
server: ServerConfig {
url: Some("https://immich.example.com".to_string()),
api_key: Some("test-api-key".to_string()),
},
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(
parsed.server.url.as_deref(),
Some("https://immich.example.com")
);
assert_eq!(parsed.server.api_key.as_deref(), Some("test-api-key"));
}
}