use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, PartialEq)]
#[serde(default)]
pub struct AppConfig {
pub provider: String,
pub api_key: String,
pub model: String,
pub language: String,
pub quality: String,
pub ui_lang: String,
pub enrich: bool,
pub base_url: String,
pub use_suggested_filename: bool,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
provider: "anthropic".into(),
api_key: String::new(),
model: String::new(),
language: "ja".into(),
quality: "medium".into(),
ui_lang: "ja".into(),
enrich: false,
base_url: String::new(),
use_suggested_filename: false,
}
}
}
impl AppConfig {
fn new_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("sdsforge").join("config.toml"))
}
fn old_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("sdsconv").join("config.toml"))
}
pub fn config_path_pub() -> Option<PathBuf> {
Self::new_config_path()
}
pub fn load() -> Self {
let Some(new_path) = Self::new_config_path() else {
return Self::default();
};
Self::load_from(&new_path, Self::old_config_path().as_deref())
}
fn load_from(new_path: &Path, old_path: Option<&Path>) -> Self {
if let Some(new_cfg) = read_config(new_path) {
if let Some(old_cfg) = old_path.and_then(read_config) {
if old_cfg != new_cfg {
tracing::warn!("{}", differ_warning(old_path.unwrap(), new_path));
}
}
return new_cfg;
}
if new_path.exists() {
return Self::default();
}
let Some(old_cfg) = old_path.and_then(read_config) else {
return Self::default();
};
if let Err(e) = write_config_atomically(new_path, &old_cfg) {
tracing::warn!("{}", migration_failed_warning(new_path, &e));
}
old_cfg
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::new_config_path()
.ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?;
write_config_atomically(&path, self)
}
}
fn read_config(path: &Path) -> Option<AppConfig> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn write_config_atomically(path: &Path, cfg: &AppConfig) -> anyhow::Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("config path has no parent directory"))?;
std::fs::create_dir_all(parent)?;
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, toml::to_string_pretty(cfg)?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
fn differ_warning(old_path: &Path, new_path: &Path) -> String {
format!(
"Both the old ({}) and new ({}) config files exist with different settings; \
using the new file. Delete the old one once you've confirmed the new settings \
are correct.",
old_path.display(),
new_path.display(),
)
}
fn migration_failed_warning(new_path: &Path, e: &anyhow::Error) -> String {
format!("Could not migrate config to {}: {e}", new_path.display())
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(api_key: &str) -> AppConfig {
AppConfig {
api_key: api_key.into(),
..AppConfig::default()
}
}
fn write_raw(path: &Path, cfg: &AppConfig) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, toml::to_string_pretty(cfg).unwrap()).unwrap();
}
#[test]
fn new_only_uses_new_and_leaves_it_alone() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
write_raw(&new_path, &cfg("new-key"));
let loaded = AppConfig::load_from(&new_path, None);
assert_eq!(loaded.api_key, "new-key");
}
#[test]
fn old_only_migrates_to_new() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, "old-key");
assert!(
new_path.exists(),
"migration should have written the new config file"
);
let migrated = read_config(&new_path).unwrap();
assert_eq!(migrated.api_key, "old-key");
assert!(old_path.exists());
assert_eq!(read_config(&old_path).unwrap().api_key, "old-key");
}
#[test]
fn both_exist_same_content_uses_new_without_warning_path() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&new_path, &cfg("same-key"));
write_raw(&old_path, &cfg("same-key"));
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, "same-key");
assert_eq!(read_config(&old_path).unwrap().api_key, "same-key");
}
#[test]
fn both_exist_different_content_prefers_new() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&new_path, &cfg("new-key"));
write_raw(&old_path, &cfg("old-key"));
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, "new-key");
assert_eq!(read_config(&old_path).unwrap().api_key, "old-key");
}
#[test]
fn old_config_corrupted_falls_back_to_default_and_does_not_migrate() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
std::fs::create_dir_all(old_path.parent().unwrap()).unwrap();
std::fs::write(&old_path, "not valid toml {{{").unwrap();
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, AppConfig::default().api_key);
assert!(
!new_path.exists(),
"nothing valid to migrate, new path must not be created"
);
}
#[test]
fn new_config_corrupted_falls_back_to_default_without_reading_old() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
std::fs::write(&new_path, "not valid toml {{{").unwrap();
write_raw(&old_path, &cfg("old-key"));
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, AppConfig::default().api_key);
assert_ne!(loaded.api_key, "old-key");
}
#[cfg(unix)]
#[test]
fn migration_target_dir_uncreatable_still_returns_old_config() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
loaded.api_key, "old-key",
"migration failure must not block using old config"
);
assert!(!new_path.exists());
}
#[cfg(unix)]
#[test]
fn migration_target_unwritable_still_returns_old_config() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
let new_dir = dir.path().join("sdsforge");
std::fs::create_dir_all(&new_dir).unwrap();
std::fs::set_permissions(&new_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let new_path = new_dir.join("config.toml");
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
std::fs::set_permissions(&new_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
loaded.api_key, "old-key",
"migration failure must not block using old config"
);
assert!(!new_path.exists());
}
#[test]
fn running_migration_twice_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
let first = AppConfig::load_from(&new_path, Some(&old_path));
let migrated_mtime = std::fs::metadata(&new_path).unwrap().modified().unwrap();
let second = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(first.api_key, second.api_key);
assert_eq!(read_config(&new_path).unwrap().api_key, "old-key");
assert_eq!(
std::fs::metadata(&new_path).unwrap().modified().unwrap(),
migrated_mtime
);
}
#[test]
fn neither_config_exists_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
let loaded = AppConfig::load_from(&new_path, Some(&old_path));
assert_eq!(loaded.api_key, AppConfig::default().api_key);
assert!(!new_path.exists());
}
#[test]
fn warning_messages_never_reference_config_contents() {
let old_path = Path::new("/tmp/does-not-exist/sdsconv/config.toml");
let new_path = Path::new("/tmp/does-not-exist/sdsforge/config.toml");
let secret = "sk-super-secret-value";
let differ_msg = differ_warning(old_path, new_path);
assert!(!differ_msg.contains(secret));
assert!(differ_msg.contains("sdsconv/config.toml"));
assert!(differ_msg.contains("sdsforge/config.toml"));
let io_err = anyhow::anyhow!("permission denied");
let migrate_msg = migration_failed_warning(new_path, &io_err);
assert!(!migrate_msg.contains(secret));
assert!(migrate_msg.contains("sdsforge/config.toml"));
let _ = cfg(secret);
}
#[test]
fn old_config_file_is_never_modified_by_load() {
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
let before = std::fs::read_to_string(&old_path).unwrap();
AppConfig::load_from(&new_path, Some(&old_path));
AppConfig::load_from(&new_path, Some(&old_path));
let after = std::fs::read_to_string(&old_path).unwrap();
assert_eq!(before, after);
assert!(old_path.exists(), "old config must never be auto-deleted");
}
#[test]
fn migrated_file_has_owner_only_permissions() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let new_path = dir.path().join("sdsforge/config.toml");
let old_path = dir.path().join("sdsconv/config.toml");
write_raw(&old_path, &cfg("old-key"));
AppConfig::load_from(&new_path, Some(&old_path));
let mode = std::fs::metadata(&new_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}
}