use std::path::{Path, PathBuf};
use anyhow::Result;
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PetEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct AppConfig {
pub theme: Option<String>,
#[serde(rename = "max-desks")]
pub max_desks: Option<usize>,
#[serde(rename = "pack-dir")]
pub pack_dir: Option<String>,
#[serde(
rename = "last-seen-version",
default,
skip_serializing_if = "Option::is_none"
)]
pub last_seen_version: Option<String>,
#[serde(rename = "pets", default, skip_serializing_if = "Option::is_none")]
pub pets: Option<Vec<PetEntry>>,
}
pub fn resolve_pack_dir(config: &AppConfig, cli_pack_dir: Option<PathBuf>) -> Option<PathBuf> {
cli_pack_dir.or_else(|| {
config
.pack_dir
.as_ref()
.map(|p| PathBuf::from(expand_tilde(p, std::env::var("HOME").ok().as_deref())))
})
}
fn expand_tilde(p: &str, home: Option<&str>) -> String {
match home {
Some(h) if p == "~" => h.to_string(),
Some(h) if p.starts_with("~/") => format!("{h}{}", &p[1..]),
_ => p.to_string(),
}
}
pub fn config_path() -> PathBuf {
if let Ok(base) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(base).join("pixtuoid").join("config.toml");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".config")
.join("pixtuoid")
.join("config.toml");
}
PathBuf::from(".config/pixtuoid/config.toml")
}
pub fn load(path: &Path) -> AppConfig {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AppConfig::default(),
Err(e) => {
tracing::warn!(path = %path.display(), %e, "cannot read config — using defaults");
return AppConfig::default();
}
};
match toml::from_str(&contents) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(path = %path.display(), %e, "malformed config — using defaults");
AppConfig::default()
}
}
}
fn update_config<F>(path: &Path, mutate: F) -> Result<()>
where
F: FnOnce(&mut AppConfig),
{
let real_path = crate::install::io::resolve_symlink(path);
if let Some(parent) = real_path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_path = real_path.with_extension("toml.lock");
let lock_file = std::fs::File::create(&lock_path)?;
fs2::FileExt::try_lock_exclusive(&lock_file)
.map_err(|e| anyhow::anyhow!("config lock held by another process: {e}"))?;
let mut cfg = if real_path.exists() {
load(&real_path)
} else {
AppConfig::default()
};
mutate(&mut cfg);
let contents = toml::to_string_pretty(&cfg)?;
let tmp = real_path.with_extension("toml.tmp");
std::fs::write(&tmp, &contents)?;
std::fs::rename(&tmp, &real_path)?;
fs2::FileExt::unlock(&lock_file).ok();
let _ = std::fs::remove_file(&lock_path);
Ok(())
}
pub fn save(path: &Path, theme_name: &str) -> Result<()> {
update_config(path, |cfg| cfg.theme = Some(theme_name.to_string()))
}
pub fn save_version(path: &Path, version: &str) -> Result<()> {
update_config(path, |cfg| {
cfg.last_seen_version = Some(version.to_string())
})
}
pub fn resolve_theme(config: &AppConfig, cli_theme: Option<String>) -> String {
let config_theme = config.theme.as_deref().and_then(|t| {
if crate::tui::theme::theme_by_name(t).is_some() {
Some(t.to_string())
} else {
tracing::warn!(theme = %t, "unknown theme in config — ignoring");
None
}
});
cli_theme
.or(config_theme)
.unwrap_or_else(|| "normal".to_string())
}
pub fn resolve_pets(config: &AppConfig) -> Vec<crate::tui::pet::Pet> {
use crate::tui::pet::{Pet, PetKind};
match &config.pets {
None => PetKind::ALL.iter().map(|&k| Pet::defaulted(k)).collect(),
Some(entries) => {
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
let Some(kind) = entry.kind.as_deref().and_then(PetKind::from_config_name) else {
tracing::warn!(
pet = ?entry.kind,
"missing or unknown pet `kind` in [[pets]] config — skipping"
);
continue;
};
let name = entry
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| kind.default_name().to_string());
out.push(Pet { kind, name });
}
if out.is_empty() && !entries.is_empty() {
tracing::warn!("all [[pets]] entries had unknown kinds — no pets will appear");
}
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_missing_returns_defaults() {
let cfg = load(Path::new("/nonexistent/path/config.toml"));
assert!(cfg.theme.is_none());
}
#[test]
fn config_path_xdg_home_and_relative_branches() {
let _env = crate::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let saved_xdg = std::env::var_os("XDG_CONFIG_HOME");
let saved_home = std::env::var_os("HOME");
std::env::set_var("XDG_CONFIG_HOME", "/xdg/base");
std::env::set_var("HOME", "/home/u");
assert_eq!(
config_path(),
PathBuf::from("/xdg/base/pixtuoid/config.toml")
);
std::env::remove_var("XDG_CONFIG_HOME");
assert_eq!(
config_path(),
PathBuf::from("/home/u/.config/pixtuoid/config.toml")
);
std::env::remove_var("HOME");
assert_eq!(config_path(), PathBuf::from(".config/pixtuoid/config.toml"));
match saved_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match saved_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
#[test]
fn load_unreadable_path_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg = load(dir.path());
assert!(cfg.theme.is_none());
}
#[test]
fn expand_tilde_only_expands_leading_current_user_home() {
let home = Some("/Users/x");
assert_eq!(expand_tilde("~", home), "/Users/x");
assert_eq!(expand_tilde("~/packs/robot", home), "/Users/x/packs/robot");
assert_eq!(expand_tilde("~user/p", home), "~user/p");
assert_eq!(expand_tilde("rel/~/x", home), "rel/~/x");
assert_eq!(expand_tilde("/abs/p", home), "/abs/p");
assert_eq!(expand_tilde("~/p", None), "~/p");
}
#[test]
fn load_malformed_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "not valid { toml }}}").unwrap();
let cfg = load(&path);
assert!(cfg.theme.is_none());
}
#[test]
fn load_partial_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"cyberpunk\"\n").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("cyberpunk"));
}
#[test]
fn load_ignores_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"normal\"\nfuture-key = 42\n").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("normal"));
}
#[test]
fn save_then_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
save(&path, "dracula").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("dracula"));
}
#[test]
fn resolve_cli_wins_over_config() {
let cfg = AppConfig {
theme: Some("normal".into()),
..AppConfig::default()
};
let theme = resolve_theme(&cfg, Some("dracula".into()));
assert_eq!(theme, "dracula");
}
#[test]
fn resolve_config_wins_over_default() {
let cfg = AppConfig {
theme: Some("gruvbox".into()),
..AppConfig::default()
};
let theme = resolve_theme(&cfg, None);
assert_eq!(theme, "gruvbox");
}
#[test]
fn resolve_all_none_uses_default() {
let cfg = AppConfig::default();
let theme = resolve_theme(&cfg, None);
assert_eq!(theme, "normal");
}
#[test]
fn resolve_invalid_config_theme_falls_back_to_default() {
let cfg = AppConfig {
theme: Some("does-not-exist".into()),
..AppConfig::default()
};
let theme = resolve_theme(&cfg, None);
assert_eq!(theme, "normal");
}
#[test]
fn full_config_flow_file_drives_theme() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"cyberpunk\"\n").unwrap();
let cfg = load(&path);
let theme = resolve_theme(&cfg, None);
assert_eq!(theme, "cyberpunk");
}
#[test]
fn full_config_flow_cli_overrides_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"cyberpunk\"\n").unwrap();
let cfg = load(&path);
let theme = resolve_theme(&cfg, Some("dracula".into()));
assert_eq!(theme, "dracula");
}
#[test]
fn max_desks_config_set_no_cli() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "max-desks = 8\n").unwrap();
let cfg = load(&path);
let cli_max_desks: Option<usize> = None;
let desk_cap = cli_max_desks.or(cfg.max_desks);
assert_eq!(desk_cap, Some(8));
}
#[test]
fn max_desks_cli_overrides_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "max-desks = 8\n").unwrap();
let cfg = load(&path);
let cli_max_desks: Option<usize> = Some(4);
let desk_cap = cli_max_desks.or(cfg.max_desks);
assert_eq!(desk_cap, Some(4));
}
#[test]
fn max_desks_neither_set() {
let cfg = AppConfig::default();
let cli_max_desks: Option<usize> = None;
let desk_cap = cli_max_desks.or(cfg.max_desks);
assert_eq!(desk_cap, None);
}
#[test]
fn max_desks_no_config_file() {
let cfg = load(Path::new("/nonexistent/path/config.toml"));
let cli_max_desks: Option<usize> = None;
let desk_cap = cli_max_desks.or(cfg.max_desks);
assert_eq!(desk_cap, None);
}
#[test]
fn save_preserves_max_desks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"normal\"\nmax-desks = 8\n").unwrap();
save(&path, "cyberpunk").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("cyberpunk"));
assert_eq!(cfg.max_desks, Some(8));
}
#[test]
fn pack_dir_cli_wins_over_config() {
let cfg = AppConfig {
pack_dir: Some("/config/pack".into()),
..AppConfig::default()
};
let result = resolve_pack_dir(&cfg, Some(PathBuf::from("/cli/pack")));
assert_eq!(result, Some(PathBuf::from("/cli/pack")));
}
#[test]
fn pack_dir_config_used_when_no_cli() {
let cfg = AppConfig {
pack_dir: Some("/config/pack".into()),
..AppConfig::default()
};
let result = resolve_pack_dir(&cfg, None);
assert_eq!(result, Some(PathBuf::from("/config/pack")));
}
#[test]
fn pack_dir_neither_returns_none() {
let cfg = AppConfig::default();
let result = resolve_pack_dir(&cfg, None);
assert_eq!(result, None);
}
#[test]
fn pack_dir_config_expands_tilde() {
let cfg = AppConfig {
pack_dir: Some("~/my-pack".into()),
..AppConfig::default()
};
let result = resolve_pack_dir(&cfg, None);
if let Ok(home) = std::env::var("HOME") {
assert_eq!(result, Some(PathBuf::from(format!("{home}/my-pack"))));
}
}
#[test]
fn pack_dir_loaded_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "pack-dir = \"/custom/sprites\"\n").unwrap();
let cfg = load(&path);
assert_eq!(cfg.pack_dir.as_deref(), Some("/custom/sprites"));
}
#[test]
fn pets_absent_returns_all_with_default_names() {
let cfg = AppConfig::default();
let pets = resolve_pets(&cfg);
assert_eq!(pets.len(), crate::tui::pet::PetKind::ALL.len());
for pet in &pets {
assert_eq!(pet.name, pet.kind.default_name());
}
}
#[test]
fn pets_empty_vec_returns_none() {
let cfg = AppConfig {
pets: Some(vec![]),
..AppConfig::default()
};
assert!(resolve_pets(&cfg).is_empty());
}
#[test]
fn pets_unknown_kind_warns_and_skips() {
let cfg = AppConfig {
pets: Some(vec![
PetEntry {
kind: Some("cat".into()),
name: None,
},
PetEntry {
kind: Some("hamster".into()),
name: None,
},
]),
..AppConfig::default()
};
let pets = resolve_pets(&cfg);
assert_eq!(pets.len(), 1);
assert_eq!(pets[0].kind, crate::tui::pet::PetKind::Cat);
assert_eq!(pets[0].name, "Office Cat");
}
#[test]
fn pets_all_unknown_returns_empty() {
let cfg = AppConfig {
pets: Some(vec![
PetEntry {
kind: Some("hamster".into()),
name: None,
},
PetEntry {
kind: Some("parrot".into()),
name: None,
},
]),
..AppConfig::default()
};
assert!(resolve_pets(&cfg).is_empty());
}
#[test]
fn pets_entry_custom_name_attached() {
let cfg = AppConfig {
pets: Some(vec![
PetEntry {
kind: Some("cat".into()),
name: Some("Whiskers".into()),
},
PetEntry {
kind: Some("dog".into()),
name: Some("Rex".into()),
},
]),
..AppConfig::default()
};
let pets = resolve_pets(&cfg);
let name = |k| pets.iter().find(|p| p.kind == k).map(|p| p.name.as_str());
assert_eq!(name(crate::tui::pet::PetKind::Cat), Some("Whiskers"));
assert_eq!(name(crate::tui::pet::PetKind::Dog), Some("Rex"));
}
#[test]
fn pets_entry_absent_name_falls_back_to_default() {
let cfg = AppConfig {
pets: Some(vec![PetEntry {
kind: Some("dog".into()),
name: None,
}]),
..AppConfig::default()
};
assert_eq!(resolve_pets(&cfg)[0].name, "Office Dog");
}
#[test]
fn pets_entry_name_trimmed_empty_falls_back() {
let cfg = AppConfig {
pets: Some(vec![
PetEntry {
kind: Some("cat".into()),
name: Some(" Mittens ".into()),
},
PetEntry {
kind: Some("dog".into()),
name: Some(" ".into()), },
]),
..AppConfig::default()
};
let pets = resolve_pets(&cfg);
let name = |k| pets.iter().find(|p| p.kind == k).map(|p| p.name.as_str());
assert_eq!(name(crate::tui::pet::PetKind::Cat), Some("Mittens"));
assert_eq!(name(crate::tui::pet::PetKind::Dog), Some("Office Dog"));
}
#[test]
fn pets_loaded_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[[pets]]\nkind = \"dog\"\n").unwrap();
let cfg = load(&path);
assert_eq!(
cfg.pets,
Some(vec![PetEntry {
kind: Some("dog".into()),
name: None
}])
);
}
#[test]
fn pets_full_toml_resolves_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[[pets]]\nkind = \"cat\"\nname = \"Luna\"\n\n[[pets]]\nkind = \"dog\"\n",
)
.unwrap();
let cfg = load(&path);
let pets = resolve_pets(&cfg);
assert_eq!(pets.len(), 2);
let name = |k| pets.iter().find(|p| p.kind == k).map(|p| p.name.as_str());
assert_eq!(name(crate::tui::pet::PetKind::Cat), Some("Luna"));
assert_eq!(name(crate::tui::pet::PetKind::Dog), Some("Office Dog"));
}
#[test]
fn save_preserves_pets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"theme = \"normal\"\n[[pets]]\nkind = \"cat\"\nname = \"Luna\"\n",
)
.unwrap();
save(&path, "cyberpunk").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("cyberpunk"));
assert_eq!(
cfg.pets,
Some(vec![PetEntry {
kind: Some("cat".into()),
name: Some("Luna".into())
}])
);
}
#[test]
fn pets_empty_vec_serializes_as_inline_empty_array() {
let cfg = AppConfig {
pets: Some(vec![]),
..AppConfig::default()
};
let s = toml::to_string_pretty(&cfg).unwrap();
assert!(s.contains("pets = []"), "expected 'pets = []' in:\n{s}");
let reloaded: AppConfig = toml::from_str(&s).unwrap();
assert_eq!(reloaded.pets, Some(vec![]));
}
#[test]
fn pets_section_is_last_in_serialized_toml() {
let cfg = AppConfig {
theme: Some("normal".into()),
pets: Some(vec![PetEntry {
kind: Some("cat".into()),
name: None,
}]),
..AppConfig::default()
};
let s = toml::to_string_pretty(&cfg).unwrap();
let theme_pos = s.find("theme").expect("theme not in output");
let pets_pos = s.find("[[pets]]").expect("[[pets]] not in output");
assert!(theme_pos < pets_pos, "theme must precede [[pets]]:\n{s}");
}
#[test]
fn pets_missing_kind_is_non_fatal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"theme = \"cyberpunk\"\n[[pets]]\nname = \"Ghost\"\n\n[[pets]]\nkind = \"cat\"\n",
)
.unwrap();
let cfg = load(&path);
assert_eq!(
cfg.theme.as_deref(),
Some("cyberpunk"),
"theme must survive a kindless [[pets]] stanza (config not reset)"
);
let pets = resolve_pets(&cfg);
assert_eq!(
pets.len(),
1,
"the kindless stanza is skipped, the cat kept"
);
assert_eq!(pets[0].kind, crate::tui::pet::PetKind::Cat);
}
#[test]
fn save_version_persists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
save_version(&path, "0.4.0").unwrap();
let cfg = load(&path);
assert_eq!(cfg.last_seen_version.as_deref(), Some("0.4.0"));
}
#[test]
fn save_version_preserves_theme() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"cyberpunk\"\n").unwrap();
save_version(&path, "0.4.0").unwrap();
let cfg = load(&path);
assert_eq!(cfg.theme.as_deref(), Some("cyberpunk"));
assert_eq!(cfg.last_seen_version.as_deref(), Some("0.4.0"));
}
}