use super::model::SettingsFile;
use std::{
io,
path::{Path, PathBuf},
};
use tracing::{info, warn};
const APP_DIR_NAME: &str = "indicatrix-cut";
const LEGACY_APP_DIR_NAME: &str = "indicatrix-cut";
const SETTINGS_FILE_NAME: &str = "settings.toml";
fn settings_path_under(app_dir_name: &str) -> PathBuf {
platform_config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(app_dir_name)
.join(SETTINGS_FILE_NAME)
}
#[must_use]
pub fn default_settings_path() -> PathBuf {
settings_path_under(APP_DIR_NAME)
}
fn legacy_settings_path() -> PathBuf {
settings_path_under(LEGACY_APP_DIR_NAME)
}
pub fn migrate_legacy_settings_if_needed(new_path: &Path) {
copy_legacy_settings_if_absent(&legacy_settings_path(), new_path);
}
fn copy_legacy_settings_if_absent(old_path: &Path, new_path: &Path) {
if new_path.exists() {
return;
}
if !old_path.exists() {
return;
}
if let Some(parent) = new_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!(
"Could not create {} to migrate legacy indicatrix-cut settings into; \
starting with defaults instead: {e}",
parent.display()
);
return;
}
match std::fs::copy(old_path, new_path) {
Ok(_) => info!(
"Migrated settings from the legacy config at {} to {}.",
old_path.display(),
new_path.display()
),
Err(e) => warn!(
"Could not copy legacy settings from {} to {}; starting with defaults \
instead: {e}",
old_path.display(),
new_path.display()
),
}
}
#[cfg(target_os = "windows")]
fn platform_config_dir() -> Option<PathBuf> {
std::env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn platform_config_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| {
PathBuf::from(home)
.join("Library")
.join("Application Support")
})
}
#[cfg(all(unix, not(target_os = "macos")))]
fn platform_config_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
}
#[must_use]
pub fn load_or_default(path: &Path) -> SettingsFile {
let mut file = match std::fs::read_to_string(path) {
Ok(contents) => match toml::from_str::<SettingsFile>(&contents) {
Ok(file) => file,
Err(e) => {
warn!(
"Settings file at {} is corrupt ({e}); falling back to defaults.",
path.display()
);
SettingsFile::default()
}
},
Err(e) if e.kind() == io::ErrorKind::NotFound => {
info!(
"No settings file at {} yet; using defaults.",
path.display()
);
SettingsFile::default()
}
Err(e) => {
warn!(
"Could not read settings file at {} ({e}); falling back to defaults.",
path.display()
);
SettingsFile::default()
}
};
file.ensure_built_in_presets();
file
}
pub fn save(path: &Path, settings: &SettingsFile) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let toml_str = toml::to_string_pretty(settings)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, toml_str)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::model::{AppSettings, LightingPreset};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"indicatrix-cut-settings-test-{tag}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn missing_file_falls_back_to_defaults_without_panicking() {
let dir = TempDir::new("missing");
let path = dir.path().join("does-not-exist.toml");
let loaded = load_or_default(&path);
assert_eq!(loaded, SettingsFile::default());
}
#[test]
fn corrupt_file_falls_back_to_defaults_without_panicking() {
let dir = TempDir::new("corrupt");
let path = dir.path().join("settings.toml");
std::fs::write(&path, "this is { not [ valid toml at all").unwrap();
let loaded = load_or_default(&path);
assert_eq!(loaded, SettingsFile::default());
}
#[test]
fn unreadable_directory_in_place_of_file_falls_back_to_defaults() {
let dir = TempDir::new("isdir");
let path = dir.path().join("settings.toml");
std::fs::create_dir_all(&path).unwrap();
let loaded = load_or_default(&path);
assert_eq!(loaded, SettingsFile::default());
}
#[test]
fn partially_corrupt_file_keeps_valid_fields_and_defaults_missing_ones() {
let dir = TempDir::new("partial");
let path = dir.path().join("settings.toml");
std::fs::write(
&path,
"[settings]\nexposure = 1.75\nsomething_unknown = true\n",
)
.unwrap();
let loaded = load_or_default(&path);
assert_eq!(loaded.settings.exposure, 1.75);
assert_eq!(
loaded.settings.target_samples,
AppSettings::default().target_samples
);
assert_eq!(
loaded.presets.len(),
super::super::model::built_in_presets().len()
);
}
#[test]
fn library_panel_collapsed_state_round_trips_through_save_and_load() {
let dir = TempDir::new("library-panel-collapsed");
let path = dir.path().join("settings.toml");
let mut collapsed = SettingsFile::default();
collapsed.settings.library_panel_collapsed = true;
save(&path, &collapsed).unwrap();
assert!(load_or_default(&path).settings.library_panel_collapsed);
let mut expanded = SettingsFile::default();
expanded.settings.library_panel_collapsed = false;
save(&path, &expanded).unwrap();
assert!(!load_or_default(&path).settings.library_panel_collapsed);
}
#[test]
fn missing_library_panel_collapsed_key_defaults_to_expanded() {
let dir = TempDir::new("library-panel-collapsed-missing-key");
let path = dir.path().join("settings.toml");
std::fs::write(&path, "[settings]\nexposure = 1.0\n").unwrap();
assert!(!load_or_default(&path).settings.library_panel_collapsed);
}
#[test]
fn solid_view_mode_round_trips_through_save_and_load() {
let dir = TempDir::new("solid-view-mode");
let path = dir.path().join("settings.toml");
let mut both = SettingsFile::default();
both.settings.solid_view_mode = 2;
save(&path, &both).unwrap();
assert_eq!(load_or_default(&path).settings.solid_view_mode, 2);
let mut solid = SettingsFile::default();
solid.settings.solid_view_mode = 0;
save(&path, &solid).unwrap();
assert_eq!(load_or_default(&path).settings.solid_view_mode, 0);
}
#[test]
fn missing_solid_view_mode_key_defaults_to_solid() {
let dir = TempDir::new("solid-view-mode-missing-key");
let path = dir.path().join("settings.toml");
std::fs::write(&path, "[settings]\nexposure = 1.0\n").unwrap();
assert_eq!(load_or_default(&path).settings.solid_view_mode, 0);
}
#[test]
fn save_then_load_round_trips_settings_and_presets() {
let dir = TempDir::new("roundtrip");
let path = dir.path().join("nested").join("settings.toml");
let mut original = SettingsFile::default();
original.settings.exposure = 1.65;
original.settings.max_bounces = 20;
original.settings.selected_material = "Ruby".to_string();
original
.upsert_user_preset(LightingPreset {
name: "Golden Hour".to_string(),
built_in: false,
light_yaw_deg: 88.0,
light_pitch_deg: 33.0,
exposure: 1.15,
lighting_rig: "D65 Daylight (5500K)".to_string(),
camera_distance: 2.9,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
})
.unwrap();
save(&path, &original).expect("save should succeed, creating parent dirs");
assert!(path.exists());
let loaded = load_or_default(&path);
assert_eq!(loaded, original);
}
#[test]
fn save_overwrites_existing_file_atomically() {
let dir = TempDir::new("overwrite");
let path = dir.path().join("settings.toml");
let first = SettingsFile::default();
save(&path, &first).unwrap();
let mut second = SettingsFile::default();
second.settings.exposure = 2.0;
save(&path, &second).unwrap();
let loaded = load_or_default(&path);
assert_eq!(loaded.settings.exposure, 2.0);
assert!(!path.with_extension("toml.tmp").exists());
}
#[test]
fn default_settings_path_is_non_empty_and_ends_with_settings_file_name() {
let path = default_settings_path();
assert!(path.to_string_lossy().ends_with("settings.toml"));
assert!(path.components().any(|c| c.as_os_str() == APP_DIR_NAME));
}
mod migration {
use super::*;
#[test]
fn migrates_when_editor_settings_are_absent() {
let dir = TempDir::new("migrate-absent");
let old_path = dir.path().join("old-settings.toml");
let new_path = dir.path().join("new-settings.toml");
std::fs::write(&old_path, "[settings]\nexposure = 2.5\n").unwrap();
copy_legacy_settings_if_absent(&old_path, &new_path);
assert!(new_path.exists());
assert_eq!(
std::fs::read_to_string(&new_path).unwrap(),
std::fs::read_to_string(&old_path).unwrap()
);
assert!(old_path.exists());
}
#[test]
fn does_not_fire_when_editor_settings_already_exist() {
let dir = TempDir::new("migrate-existing");
let old_path = dir.path().join("old-settings.toml");
let new_path = dir.path().join("new-settings.toml");
std::fs::write(&old_path, "[settings]\nexposure = 2.5\n").unwrap();
std::fs::write(&new_path, "[settings]\nexposure = 9.9\n").unwrap();
copy_legacy_settings_if_absent(&old_path, &new_path);
assert_eq!(
std::fs::read_to_string(&new_path).unwrap(),
"[settings]\nexposure = 9.9\n"
);
}
#[test]
fn survives_a_missing_legacy_file() {
let dir = TempDir::new("migrate-no-legacy");
let old_path = dir.path().join("does-not-exist.toml");
let new_path = dir.path().join("new-settings.toml");
copy_legacy_settings_if_absent(&old_path, &new_path);
assert!(
!new_path.exists(),
"nothing to migrate from -> nothing written"
);
}
#[test]
fn survives_an_unreadable_legacy_file() {
let dir = TempDir::new("migrate-unreadable-legacy");
let old_path = dir.path().join("old-settings.toml");
std::fs::create_dir_all(&old_path).unwrap();
let new_path = dir.path().join("new-settings.toml");
copy_legacy_settings_if_absent(&old_path, &new_path);
assert!(
!new_path.exists(),
"a failed copy must not leave a partial/empty destination file"
);
}
#[test]
fn creates_the_destination_directory_when_missing() {
let dir = TempDir::new("migrate-nested-dest");
let old_path = dir.path().join("old-settings.toml");
std::fs::write(&old_path, "[settings]\nexposure = 3.0\n").unwrap();
let new_path = dir.path().join("nested").join("new-settings.toml");
copy_legacy_settings_if_absent(&old_path, &new_path);
assert!(new_path.exists());
}
}
}