use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rust_i18n::t;
use toml_edit::{DocumentMut, value};
use crate::i18n::Lang;
pub const DEFAULT_CONFIG: &str = r#"# shdo – settings for this machine (not synced)
# UI language of the board: "en" or "de" (the CLI is always English)
# language = "en"
# Data directory (git repository with the tasks), default ~/.local/share/shdo
# dir = "~/tasks"
# Editor for `E` / `shdo edit`; defaults to $VISUAL, then $EDITOR
# editor = "hx"
# Theme, also set with `t` in the board
# theme = "tokyonight"
# Shown buckets (empty = all, "-" = no bucket), also set with `f` in the board
# buckets = ["work"]
# Show the done column, also toggled with `v` in the board
# show_done = true
# Show the calendar sidebar, also toggled with `c` in the board
# show_calendar = true
# Public holidays in the calendar: country-state, e.g. "de-bw"; empty = none
# (states: bw, by, be, bb, hb, hh, he, mv, ni, nw, rp, sl, sn, st, sh, th)
# holidays = "de-bw"
"#;
#[derive(Debug, Default, serde::Deserialize)]
pub struct AppConfig {
pub language: Option<String>,
pub dir: Option<String>,
pub editor: Option<String>,
pub theme: Option<String>,
#[serde(default)]
pub buckets: Vec<String>,
pub show_done: Option<bool>,
pub show_calendar: Option<bool>,
pub holidays: Option<String>,
}
pub fn config_dir() -> PathBuf {
let var = |name| std::env::var_os(name).filter(|v| !v.is_empty());
if let Some(dir) = var("SHDO_CONFIG_DIR") {
return PathBuf::from(dir);
}
match var("XDG_CONFIG_HOME") {
Some(x) => PathBuf::from(x).join("shdo"),
None => home().join(".config").join("shdo"),
}
}
pub fn default_data_dir() -> PathBuf {
match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
Some(x) => PathBuf::from(x).join("shdo"),
None => home().join(".local").join("share").join("shdo"),
}
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn expand_tilde(p: &str) -> PathBuf {
match p.strip_prefix("~/") {
Some(rest) => home().join(rest),
None if p == "~" => home(),
None => PathBuf::from(p),
}
}
impl AppConfig {
pub fn load(path: &Path) -> Result<AppConfig> {
match fs::read_to_string(path) {
Ok(raw) => toml::from_str(&raw)
.with_context(|| t!("io.invalid", path = path.display()).into_owned()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AppConfig::default()),
Err(e) => Err(e).with_context(|| t!("io.read", path = path.display()).into_owned()),
}
}
pub fn data_dir(&self, cli: Option<&Path>) -> PathBuf {
match (cli, &self.dir) {
(Some(p), _) => p.to_path_buf(),
(None, Some(d)) => expand_tilde(d),
(None, None) => default_data_dir(),
}
}
pub fn lang(&self) -> Lang {
self.language
.as_deref()
.and_then(Lang::from_code)
.unwrap_or_else(Lang::from_env)
}
pub fn editor(&self) -> String {
self.editor
.clone()
.or_else(|| std::env::var("VISUAL").ok())
.or_else(|| std::env::var("EDITOR").ok())
.filter(|e| !e.trim().is_empty())
.unwrap_or_else(|| "vi".into())
}
}
pub fn set_value(path: &Path, key: &str, val: &str) -> Result<()> {
update(path, |doc| doc[key] = value(val))
}
pub fn set_bool(path: &Path, key: &str, val: bool) -> Result<()> {
update(path, |doc| doc[key] = value(val))
}
pub fn set_list(path: &Path, key: &str, vals: &[String]) -> Result<()> {
update(path, |doc| {
doc[key] = value(
vals.iter()
.map(String::as_str)
.collect::<toml_edit::Array>(),
)
})
}
pub struct Setup {
pub language: Lang,
pub dir: String,
pub holidays: Option<String>,
}
pub fn write_setup(path: &Path, setup: &Setup) -> Result<()> {
update(path, |doc| {
doc["language"] = value(setup.language.code());
doc["dir"] = value(setup.dir.as_str());
match &setup.holidays {
Some(h) => doc["holidays"] = value(h.as_str()),
None => {
doc.remove("holidays");
}
}
})
}
pub fn contract_tilde(p: &Path) -> String {
let home = home();
match p.strip_prefix(&home) {
Ok(rest) if rest.as_os_str().is_empty() => "~".into(),
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => p.display().to_string(),
}
}
fn update(path: &Path, f: impl FnOnce(&mut DocumentMut)) -> Result<()> {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DEFAULT_CONFIG.into(),
Err(e) => return Err(e).with_context(|| t!("io.read", path = path.display()).into_owned()),
};
let mut doc: DocumentMut = raw
.parse()
.with_context(|| t!("io.invalid", path = path.display()).into_owned())?;
f(&mut doc);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
fs::write(path, doc.to_string())
.with_context(|| t!("io.write", path = path.display()).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_is_default() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::load(&tmp.path().join("nope.toml")).unwrap();
assert!(cfg.dir.is_none());
assert_eq!(cfg.data_dir(Some(Path::new("/x"))), PathBuf::from("/x"));
}
#[test]
fn setup_is_written_and_read_back() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
let mut setup = Setup {
language: Lang::De,
dir: "~/notes/tasks".into(),
holidays: Some("de-by".into()),
};
write_setup(&path, &setup).unwrap();
let cfg = AppConfig::load(&path).unwrap();
assert_eq!(cfg.lang(), Lang::De);
assert_eq!(cfg.data_dir(None), home().join("notes/tasks"));
assert_eq!(cfg.holidays.as_deref(), Some("de-by"));
setup.holidays = None;
write_setup(&path, &setup).unwrap();
assert!(AppConfig::load(&path).unwrap().holidays.is_none());
assert!(
fs::read_to_string(&path)
.unwrap()
.contains("# Data directory")
);
assert_eq!(contract_tilde(&home().join("a/b")), "~/a/b");
}
#[test]
fn set_value_keeps_comments() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("sub/config.toml");
set_value(&path, "theme", "nord").unwrap();
fs::write(
&path,
fs::read_to_string(&path).unwrap() + "dir = \"~/t\" # mein Repo\n",
)
.unwrap();
set_value(&path, "theme", "gruvbox").unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("# Data directory"));
assert!(raw.contains("# mein Repo"));
let cfg = AppConfig::load(&path).unwrap();
assert_eq!(cfg.theme.as_deref(), Some("gruvbox"));
assert_eq!(cfg.data_dir(None), home().join("t"));
set_list(&path, "buckets", &["work".into(), "-".into()]).unwrap();
let cfg = AppConfig::load(&path).unwrap();
assert_eq!(cfg.buckets, ["work", "-"]);
assert_eq!(cfg.theme.as_deref(), Some("gruvbox"));
}
}