use std::cell::OnceCell;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(serde::Deserialize, Default, Debug)]
pub struct Config {
pub window_width: Option<u32>,
pub window_height: Option<u32>,
pub default_template: Option<String>,
#[serde(default)]
pub extension: HashMap<String, String>,
#[serde(default)]
pub templates: HashMap<String, TemplateEntry>,
}
#[derive(serde::Deserialize, Default, Debug)]
pub struct TemplateEntry {
#[serde(default)]
pub params: HashMap<String, String>,
}
pub fn config_root() -> Option<PathBuf> {
let xdg = std::env::var_os("XDG_CONFIG_HOME")
.filter(|v| !v.is_empty())
.map(|v| PathBuf::from(v).join("tinyview"));
let home_config = std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config/tinyview"));
let legacy = std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".tinyview"));
let candidates = [xdg, home_config, legacy.clone()];
for cand in candidates.into_iter().flatten() {
if cand.is_dir() {
return Some(cand);
}
}
legacy
}
fn config_path() -> Option<PathBuf> {
config_root().map(|r| r.join("config.toml"))
}
pub fn load_if_needed(cache: &OnceCell<Option<Config>>) -> Option<&Config> {
cache
.get_or_init(|| {
let path = config_path()?;
let bytes = std::fs::read_to_string(&path).ok()?;
match toml::from_str::<Config>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("tinyview: warn: invalid config.toml: {e}");
None
}
}
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::OnceCell;
use std::io::Write;
static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_home<F: FnOnce(&std::path::Path)>(write_config: Option<&str>, body: F) {
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let dot = tmp.path().join(".tinyview");
std::fs::create_dir_all(&dot).expect("mkdir .tinyview");
if let Some(s) = write_config {
let mut f = std::fs::File::create(dot.join("config.toml")).expect("create config");
f.write_all(s.as_bytes()).expect("write config");
}
let prev = std::env::var_os("HOME");
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("HOME", tmp.path());
std::env::remove_var("XDG_CONFIG_HOME");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(tmp.path())));
match prev {
Some(p) => std::env::set_var("HOME", p),
None => std::env::remove_var("HOME"),
}
match prev_xdg {
Some(p) => std::env::set_var("XDG_CONFIG_HOME", p),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[test]
fn returns_none_when_config_absent() {
with_home(None, |_| {
let cache: OnceCell<Option<Config>> = OnceCell::new();
assert!(load_if_needed(&cache).is_none());
assert!(load_if_needed(&cache).is_none());
});
}
#[test]
fn returns_none_on_invalid_toml() {
with_home(Some("this is = not = valid = toml ==="), |_| {
let cache: OnceCell<Option<Config>> = OnceCell::new();
assert!(load_if_needed(&cache).is_none());
});
}
#[test]
fn parses_valid_config() {
let toml = r#"
window_width = 1200
window_height = 800
default_template = "raw"
[extension]
md = "markdown"
rs = "code"
[templates.markdown.params]
theme = "github"
[templates.code.params]
theme = "github-dark"
line_numbers = "true"
"#;
with_home(Some(toml), |_| {
let cache: OnceCell<Option<Config>> = OnceCell::new();
let cfg = load_if_needed(&cache).expect("config should load");
assert_eq!(cfg.window_width, Some(1200));
assert_eq!(cfg.window_height, Some(800));
assert_eq!(cfg.default_template.as_deref(), Some("raw"));
assert_eq!(
cfg.extension.get("md").map(String::as_str),
Some("markdown")
);
assert_eq!(cfg.extension.get("rs").map(String::as_str), Some("code"));
assert_eq!(
cfg.templates
.get("markdown")
.and_then(|t| t.params.get("theme"))
.map(String::as_str),
Some("github")
);
assert_eq!(
cfg.templates
.get("code")
.and_then(|t| t.params.get("line_numbers"))
.map(String::as_str),
Some("true")
);
});
}
fn with_env<F: FnOnce(&std::path::Path)>(home_subdirs: &[&str], xdg: Option<&str>, body: F) {
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
for d in home_subdirs {
std::fs::create_dir_all(tmp.path().join(d)).expect("mkdir subdir");
}
let prev_home = std::env::var_os("HOME");
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("HOME", tmp.path());
match xdg {
Some("") => std::env::set_var("XDG_CONFIG_HOME", ""),
Some(rel) => std::env::set_var("XDG_CONFIG_HOME", tmp.path().join(rel)),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(tmp.path())));
match prev_home {
Some(p) => std::env::set_var("HOME", p),
None => std::env::remove_var("HOME"),
}
match prev_xdg {
Some(p) => std::env::set_var("XDG_CONFIG_HOME", p),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[test]
fn config_root_prefers_xdg_when_set_and_present() {
with_env(
&[".config/tinyview", ".tinyview", "xdg/tinyview"],
Some("xdg"),
|home| {
assert_eq!(config_root(), Some(home.join("xdg/tinyview")));
},
);
}
#[test]
fn config_root_falls_back_to_dot_config() {
with_env(&[".config/tinyview", ".tinyview"], None, |home| {
assert_eq!(config_root(), Some(home.join(".config/tinyview")));
});
}
#[test]
fn config_root_falls_back_to_legacy_dot_tinyview() {
with_env(&[".tinyview"], None, |home| {
assert_eq!(config_root(), Some(home.join(".tinyview")));
});
}
#[test]
fn config_root_skips_nonexistent_xdg_dir() {
with_env(&[".tinyview"], Some("xdg"), |home| {
assert_eq!(config_root(), Some(home.join(".tinyview")));
});
}
#[test]
fn config_root_defaults_to_legacy_when_none_exist() {
with_env(&[], None, |home| {
assert_eq!(config_root(), Some(home.join(".tinyview")));
});
}
#[test]
fn config_root_ignores_empty_xdg() {
with_env(&[".config/tinyview"], Some(""), |home| {
assert_eq!(config_root(), Some(home.join(".config/tinyview")));
});
}
}