use crate::log::ConsoleLog;
use dirs::data_dir;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::{env, fs};
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub default_file: Option<String>,
pub print_title: Option<bool>,
pub use_cache: Option<bool>,
#[serde(default)]
pub fortune_files: Vec<String>,
}
static APP_DIR_OVERRIDE: OnceLock<Mutex<HashMap<u64, PathBuf>>> = OnceLock::new();
fn thread_id_u64() -> u64 {
let id = std::thread::current().id();
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
hasher.finish()
}
pub fn set_app_dir_for_tests(dir: PathBuf) {
let m = APP_DIR_OVERRIDE.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = m.lock().unwrap();
let tid = thread_id_u64();
guard.insert(tid, dir);
}
pub fn app_dir() -> PathBuf {
if let Some(m) = APP_DIR_OVERRIDE.get() {
let guard = m.lock().unwrap();
let tid = thread_id_u64();
if let Some(dir) = guard.get(&tid).cloned() {
return dir;
}
}
if let Some(mut base) = data_dir() {
base.push("rfortune");
return base;
}
if let Some(home) = dirs::home_dir() {
let mut base = home;
#[cfg(target_os = "macos")]
{
base.push("Library");
base.push("Application Support");
}
#[cfg(target_os = "linux")]
{
base.push(".local");
base.push("share");
}
base.push("rfortune");
return base;
}
let mut p = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
p.push(".rfortune");
p
}
pub fn get_config_path() -> PathBuf {
let mut p = app_dir();
p.push("rfortune.conf");
p
}
pub fn get_default_path() -> PathBuf {
let mut p = app_dir();
p.push("rfortune.dat");
p
}
pub fn init_config_file() -> std::io::Result<()> {
let dir = app_dir();
fs::create_dir_all(&dir)?;
if let Err(e) = migrate_old_config() {
ConsoleLog::warn(format!("Migration warning: {e}"));
}
let path = get_config_path();
if path.exists() {
ConsoleLog::info("Configuration file already exists, skipping.");
return Ok(());
}
if let Err(e) = init_default_file() {
ConsoleLog::ko(format!("Error initializing fortune file: {e}"));
}
let cfg = Config {
default_file: Some(get_default_path().to_string_lossy().to_string()),
print_title: Some(true),
use_cache: Some(true),
fortune_files: vec![],
};
let yaml = serde_yaml::to_string(&cfg).expect("Failed to serialize config");
fs::write(path, yaml)?;
ConsoleLog::ok("Configuration file successfully created.");
Ok(())
}
pub fn init_default_file() -> std::io::Result<()> {
let dir = app_dir();
fs::create_dir_all(&dir)?;
let path = get_default_path();
if path.exists() {
return Ok(()); }
let sample = r#"Fortune favors the bold.
— Publius Vergilius Maro
%
Premature optimization is the root of all evil.
— Donald Knuth
%
In Rust we trust.
"#;
fs::write(path, sample)
}
pub fn load_config() -> Option<Config> {
let path = get_config_path();
let content = fs::read_to_string(&path).ok()?;
let mut cfg: Config = serde_yaml::from_str(&content).ok()?;
if cfg.fortune_files.is_empty()
&& let Some(df) = &cfg.default_file
{
cfg.fortune_files = vec![df.clone()];
let _ = cfg.save();
}
Some(cfg)
}
pub fn migrate_old_config() -> std::io::Result<()> {
let dir = app_dir();
let old_path = dir.join("config.yaml");
let new_path = get_config_path();
if !old_path.exists() || new_path.exists() {
return Ok(());
}
ConsoleLog::info("Old configuration file detected — attempting migration…");
match fs::read_to_string(&old_path) {
Ok(content) => match serde_yaml::from_str::<Config>(&content) {
Ok(cfg) => {
let yaml =
serde_yaml::to_string(&cfg).expect("Failed to serialize migrated config");
fs::write(&new_path, yaml)?;
let backup = dir.join("config.yaml.bak");
fs::rename(&old_path, &backup)?;
ConsoleLog::ok(format!(
"Configuration migrated successfully → {:?}",
new_path
));
ConsoleLog::info(format!("Backup saved as {:?}", backup));
}
Err(e) => {
ConsoleLog::ko(format!("Failed to parse old config.yaml: {e}"));
}
},
Err(e) => {
ConsoleLog::ko(format!("Failed to read old config.yaml: {e}"));
}
}
Ok(())
}
pub fn run_config_edit(editor_arg: Option<String>) -> std::io::Result<()> {
let path: PathBuf = get_config_path();
if !path.exists() {
ConsoleLog::warn("Configuration file not found. Creating a new one...");
if let Err(e) = init_config_file() {
ConsoleLog::ko(format!("Failed to create configuration file: {e}"))
}
}
let editor = if let Some(e) = editor_arg {
e
} else {
if let Ok(visual) = env::var("VISUAL") {
visual
} else if let Ok(editor) = env::var("EDITOR") {
editor
} else {
if cfg!(target_os = "windows") {
"notepad".to_string()
} else {
"nano".to_string()
}
}
};
ConsoleLog::info(format!(
"Opening configuration file with editor: {}",
editor
));
let status = Command::new(&editor)
.arg(path.to_string_lossy().to_string())
.status();
match status {
Ok(s) if s.success() => ConsoleLog::ok("Configuration file closed successfully."),
Ok(_) => ConsoleLog::warn("Editor exited with a non-zero status code."),
Err(e) => ConsoleLog::ko(format!("Failed to launch editor '{}': {e}", editor)),
}
Ok(())
}
impl Config {
pub fn save(&self) -> Result<(), String> {
let path = get_config_path();
let parent = path.parent().unwrap();
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Could not create config directory: {e}"))?;
}
let yaml =
serde_yaml::to_string(&self).map_err(|e| format!("Could not serialize config: {e}"))?;
fs::write(&path, yaml).map_err(|e| format!("Could not write config file: {e}"))
}
}