use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::theme::Theme;
const APP_NAME: &str = "octopeek";
const CONFIG_FILE: &str = "config.toml";
thread_local! {
static CONFIG_DIR_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
#[allow(dead_code)] pub fn set_config_dir_override(dir: impl Into<PathBuf>) {
let dir: PathBuf = dir.into();
CONFIG_DIR_OVERRIDE.with(|c| *c.borrow_mut() = Some(dir));
}
#[allow(dead_code)] pub fn clear_config_dir_override() {
CONFIG_DIR_OVERRIDE.with(|c| *c.borrow_mut() = None);
}
#[allow(dead_code)] pub fn with_config_dir_override<R>(dir: impl AsRef<Path>, f: impl FnOnce() -> R) -> R {
struct Guard(Option<PathBuf>);
impl Drop for Guard {
fn drop(&mut self) {
CONFIG_DIR_OVERRIDE.with(|c| *c.borrow_mut() = self.0.take());
}
}
let previous = CONFIG_DIR_OVERRIDE.with(|c| c.borrow_mut().replace(dir.as_ref().to_path_buf()));
let _guard = Guard(previous);
f()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub theme: Theme,
#[serde(default)]
pub repos: Vec<String>,
#[serde(default)]
pub auto_refresh_seconds: Option<u32>,
#[serde(default)]
pub show_ascii_glyphs: bool,
#[serde(default)]
pub show_all_prs: bool,
}
impl Config {
pub fn load() -> Self {
let Some(path) = config_path() else {
return Self::default();
};
let Ok(text) = fs::read_to_string(&path) else {
return Self::default();
};
match toml::from_str(&text) {
Ok(cfg) => cfg,
Err(e) => {
warn!(
"failed to parse config at {}: {e}; falling back to defaults",
path.display()
);
Self::default()
}
}
}
#[allow(dead_code)] pub fn save(&self) {
let Some(path) = config_path() else {
warn!("cannot resolve config path; skipping save");
return;
};
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
warn!("failed to create config dir {}: {e}", parent.display());
return;
}
let text = match toml::to_string_pretty(self) {
Ok(t) => t,
Err(e) => {
warn!("failed to serialize config: {e}");
return;
}
};
if let Err(e) = fs::write(&path, text) {
warn!("failed to write config to {}: {e}", path.display());
}
}
}
fn config_path() -> Option<PathBuf> {
if let Some(mut p) = CONFIG_DIR_OVERRIDE.with(|c| c.borrow().clone()) {
p.push(CONFIG_FILE);
return Some(p);
}
let mut path = dirs::config_dir()?;
path.push(APP_NAME);
path.push(CONFIG_FILE);
Some(path)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_config_round_trips() {
let config = Config::default();
let serialized = toml::to_string_pretty(&config).expect("serialization failed");
let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
assert_eq!(deserialized.theme, config.theme);
assert_eq!(deserialized.repos, config.repos);
assert_eq!(deserialized.auto_refresh_seconds, config.auto_refresh_seconds);
assert_eq!(deserialized.show_ascii_glyphs, config.show_ascii_glyphs);
assert_eq!(deserialized.show_all_prs, config.show_all_prs);
}
#[test]
fn partial_config_fills_defaults() {
let toml_str = r#"theme = "dracula""#;
let config: Config = toml::from_str(toml_str).expect("deserialization failed");
assert_eq!(config.theme, Theme::Dracula);
assert!(config.repos.is_empty());
assert_eq!(config.auto_refresh_seconds, None);
assert!(!config.show_ascii_glyphs);
assert!(!config.show_all_prs);
}
#[test]
fn auto_refresh_some_parses() {
let toml_str = "auto_refresh_seconds = 30\n";
let config: Config = toml::from_str(toml_str).expect("deserialization failed");
assert_eq!(config.auto_refresh_seconds, Some(30));
}
}