use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Settings {
#[serde(default)]
pub theme_name: Option<String>,
#[serde(default)]
pub selected_cleaners: Vec<String>,
}
impl Settings {
pub fn theme_index(&self) -> usize {
self.theme_name
.as_deref()
.map(crate::theme::theme_index_by_name)
.unwrap_or(0)
}
pub fn selection_key(category_name: &str, item_name: &str) -> String {
format!("{category_name}: {item_name}")
}
pub fn is_selected(&self, category_name: &str, item_name: &str) -> bool {
self.selected_cleaners
.iter()
.any(|k| k == &Self::selection_key(category_name, item_name))
}
}
pub fn settings_dir() -> Result<PathBuf> {
let dirs = directories::BaseDirs::new().context("could not determine config directory")?;
Ok(dirs.config_dir().join("cleansys"))
}
pub fn settings_json_path() -> Result<PathBuf> {
Ok(settings_dir()?.join("settings.json"))
}
pub fn tui_settings_json_path() -> Result<PathBuf> {
Ok(settings_dir()?.join("tui-settings.json"))
}
fn load_from(path: &Path) -> Result<Settings> {
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
return match serde_json::from_str::<Settings>(&content) {
Ok(s) => Ok(s),
Err(e) => {
log::warn!("settings file {path:?} is malformed ({e}); using defaults");
Ok(Settings::default())
}
};
}
Ok(Settings::default())
}
fn save_to(path: &Path, settings: &Settings) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
let content = serde_json::to_string_pretty(settings).context("failed to serialise settings")?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.context("failed to create temporary settings file")?;
tmp.write_all(content.as_bytes())
.context("failed to write settings to temporary file")?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("failed to persist settings file: {e}"))?;
Ok(())
}
pub fn load_settings() -> Result<Settings> {
load_from(&settings_json_path()?)
}
pub fn save_settings(settings: &Settings) -> Result<()> {
save_to(&settings_json_path()?, settings)
}
pub fn load_tui_settings() -> Result<Settings> {
load_from(&tui_settings_json_path()?)
}
pub fn save_tui_settings(settings: &Settings) -> Result<()> {
save_to(&tui_settings_json_path()?, settings)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn default_settings_have_no_theme() {
let s = Settings::default();
assert!(s.theme_name.is_none());
assert_eq!(s.theme_index(), 0);
assert!(s.selected_cleaners.is_empty());
}
#[test]
fn theme_index_resolves_known_name() {
let s = Settings {
theme_name: Some("Dracula".to_string()),
..Default::default()
};
assert_eq!(
s.theme_index(),
crate::theme::theme_index_by_name("Dracula")
);
}
#[test]
fn theme_index_falls_back_for_unknown_name() {
let s = Settings {
theme_name: Some("Not A Real Theme".to_string()),
..Default::default()
};
assert_eq!(s.theme_index(), 0);
}
#[test]
fn selection_key_combines_category_and_item() {
assert_eq!(
Settings::selection_key("User Land Cleaners", "Browser Caches"),
"User Land Cleaners: Browser Caches"
);
}
#[test]
fn is_selected_checks_selected_cleaners() {
let s = Settings {
selected_cleaners: vec!["User Land Cleaners: Browser Caches".to_string()],
..Default::default()
};
assert!(s.is_selected("User Land Cleaners", "Browser Caches"));
assert!(!s.is_selected("User Land Cleaners", "Trash"));
}
#[test]
fn round_trip_save_and_load() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let settings = Settings {
theme_name: Some("Nord".to_string()),
selected_cleaners: vec!["User Land Cleaners: Trash".to_string()],
};
save_to(&path, &settings).unwrap();
let loaded = load_from(&path).unwrap();
assert_eq!(loaded.theme_name.as_deref(), Some("Nord"));
assert_eq!(
loaded.selected_cleaners,
vec!["User Land Cleaners: Trash".to_string()]
);
}
#[test]
fn load_from_missing_file_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does-not-exist.json");
let loaded = load_from(&path).unwrap();
assert!(loaded.theme_name.is_none());
}
#[test]
fn load_from_malformed_file_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "not valid json").unwrap();
let loaded = load_from(&path).unwrap();
assert!(loaded.theme_name.is_none());
}
#[test]
fn save_creates_parent_directories() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("settings.json");
save_to(&path, &Settings::default()).unwrap();
assert!(path.exists());
}
}