use std::path::PathBuf;
use std::{fs, io};
use serde::{Deserialize, Serialize};
use crate::{platform, theme};
const HEADER: &str = "# vjuu settings: change them with `vjuu config`, or the gear in the title bar.\n";
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub tray: bool,
pub focus: bool,
pub hooks: Hooks,
pub theme: Theme,
}
impl Default for Config {
fn default() -> Self {
Self { tray: true, focus: true, hooks: Hooks::default(), theme: Theme::default() }
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Theme {
pub name: String,
pub light: String,
pub dark: String,
}
impl Default for Theme {
fn default() -> Self {
Self { name: theme::AUTO.into(), light: "light".into(), dark: "graphite".into() }
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Hooks {
pub plan: bool,
pub ask: bool,
pub turn: bool,
pub permission: bool,
}
impl Default for Hooks {
fn default() -> Self {
Self { plan: true, ask: true, turn: true, permission: true }
}
}
impl Config {
pub fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
let on = || match value {
"on" | "true" => Ok(true),
"off" | "false" => Ok(false),
_ => Err(format!("Unknown value `{value}`: use on or off.")),
};
let theme = |auto_ok: bool| {
let themes: Vec<String> = theme::list().into_iter().map(|t| t.name).collect();
if (auto_ok && value == theme::AUTO) || themes.iter().any(|t| t == value) {
Ok(value.to_string())
} else {
Err(format!("Unknown theme `{value}`: use {}.", themes.join(", ")))
}
};
match key {
"plan" => self.hooks.plan = on()?,
"ask" => self.hooks.ask = on()?,
"turn" => self.hooks.turn = on()?,
"permission" => self.hooks.permission = on()?,
"tray" => self.tray = on()?,
"focus" => self.focus = on()?,
"theme" => self.theme.name = theme(true)?,
"theme.light" => self.theme.light = theme(false)?,
"theme.dark" => self.theme.dark = theme(false)?,
_ => {
return Err(format!(
"Unknown setting `{key}`: use plan, ask, turn, permission, tray, focus, theme, theme.light or theme.dark."
));
}
}
Ok(())
}
pub fn to_toml(&self) -> String {
HEADER.to_string() + &toml::to_string(self).expect("Config serialises")
}
}
pub fn path() -> PathBuf {
platform::config_dir().join("config.toml")
}
pub fn load() -> Config {
let Ok(text) = fs::read_to_string(path()) else { return Config::default() };
toml::from_str(&text).unwrap_or_else(|e| {
eprintln!("Ignoring {}: {e}", path().display());
Config::default()
})
}
pub fn save(config: &Config) -> io::Result<()> {
let path = path();
fs::create_dir_all(path.parent().expect("config path has a folder"))?;
fs::write(path, config.to_toml())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_keys_stay_on() {
let config: Config = toml::from_str("[hooks]\nturn = false\n").unwrap();
assert_eq!(
config,
Config {
tray: true,
focus: true,
hooks: Hooks { plan: true, ask: true, turn: false, permission: true },
theme: Theme::default()
}
);
assert_eq!(toml::from_str::<Config>("").unwrap(), Config::default());
}
#[test]
fn round_trips_with_a_header() {
let mut config = Config::default();
config.set("ask", "off").unwrap();
config.set("tray", "off").unwrap();
let text = config.to_toml();
assert_eq!(
text,
format!(
"{HEADER}tray = false\nfocus = true\n\n[hooks]\nplan = true\nask = false\nturn = true\npermission = true\n\n\
[theme]\nname = \"auto\"\nlight = \"light\"\ndark = \"graphite\"\n"
)
);
assert_eq!(toml::from_str::<Config>(&text).unwrap(), config);
}
#[test]
fn rejects_unknown_settings_and_values() {
let mut config = Config::default();
for (key, value) in
[("theme", "on"), ("theme.dark", "auto"), ("theme.light", "../x"), ("plan", "yes"), ("", "")]
{
assert!(config.set(key, value).is_err(), "{key} {value}");
}
assert_eq!(config, Config::default());
}
}