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"
# Bucket for new tasks when neither @bucket nor the filter decides (key, e.g. "work")
# default_bucket = "work"
# Show done tasks of the last N days (0 = all); `shdo archive` archives older ones
# done_days = 14
# Sync with the git remote when the board opens and before it closes
# (never on every change); `s` syncs in between
# auto_sync = false
# 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"
# What the board remembers by itself (filters, shown columns, calendar) is not kept
# here but in ~/.local/state/shdo/state.toml.
"#;
#[derive(Debug, Default, serde::Deserialize)]
pub struct AppConfig {
pub language: Option<String>,
pub dir: Option<String>,
pub editor: Option<String>,
pub theme: Option<String>,
pub auto_sync: Option<bool>,
pub default_bucket: Option<String>,
pub done_days: Option<u32>,
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),
}
}
pub const DONE_DAYS_DEFAULT: u32 = 14;
impl AppConfig {
pub fn done_days(&self) -> Option<u32> {
match self.done_days.unwrap_or(DONE_DAYS_DEFAULT) {
0 => None,
n => Some(n),
}
}
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 ensure_file(path: &Path) -> Result<()> {
if path.exists() {
return Ok(());
}
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
fs::write(path, DEFAULT_CONFIG)
.with_context(|| t!("io.write", path = path.display()).into_owned())
}
pub fn set_value(path: &Path, key: &str, val: &str) -> Result<()> {
update(path, |doc| doc[key] = value(val))
}
pub fn set_int(path: &Path, key: &str, val: i64) -> Result<()> {
update(path, |doc| doc[key] = value(val))
}
pub fn remove_key(path: &Path, key: &str) -> Result<()> {
update(path, |doc| {
doc.remove(key);
})
}
pub fn set_bool(path: &Path, key: &str, val: bool) -> Result<()> {
update(path, |doc| doc[key] = value(val))
}
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<()> {
update_file(path, DEFAULT_CONFIG, f)
}
pub(crate) fn update_file(
path: &Path,
template: &str,
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 => template.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())?;
let before: Vec<String> = doc.iter().map(|(k, _)| k.to_string()).collect();
f(&mut doc);
let out = place_new_keys(doc, &before);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
fs::write(path, out).with_context(|| t!("io.write", path = path.display()).into_owned())
}
fn place_new_keys(mut doc: DocumentMut, before: &[String]) -> String {
let new_keys: Vec<String> = doc
.iter()
.map(|(k, _)| k.to_string())
.filter(|k| !before.contains(k))
.collect();
let mut replacements = vec![];
for key in new_keys {
let example = format!("# {key} =");
if !doc.to_string().lines().any(|l| l.starts_with(&example)) {
continue;
}
if let Some(item) = doc.remove(&key)
&& let Some(v) = item.as_value()
{
replacements.push((example, format!("{key} = {}", v.to_string().trim())));
}
}
let mut out = doc.to_string();
for (example, line) in replacements {
let mut done = false;
out = out
.lines()
.map(|l| {
if !done && l.starts_with(&example) {
done = true;
line.clone()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
}
out
}
#[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 new_values_replace_their_commented_example() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
set_value(&path, "theme", "nord").unwrap();
set_bool(&path, "auto_sync", true).unwrap();
set_value(&path, "theme", "gruvbox-dark").unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.starts_with("# shdo"), "header stays first:\n{raw}");
assert!(raw.contains("# Theme, also set with `t` in the board\ntheme = \"gruvbox-dark\""));
assert!(!raw.contains("# theme ="));
assert!(raw.contains("auto_sync = true"));
set_value(&path, "custom", "x").unwrap();
let cfg = AppConfig::load(&path).unwrap();
assert_eq!(cfg.theme.as_deref(), Some("gruvbox-dark"));
assert_eq!(cfg.auto_sync, Some(true));
assert!(
fs::read_to_string(&path)
.unwrap()
.contains("custom = \"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_value(&path, "default_bucket", "work").unwrap();
let cfg = AppConfig::load(&path).unwrap();
assert_eq!(cfg.default_bucket.as_deref(), Some("work"));
assert_eq!(cfg.theme.as_deref(), Some("gruvbox"));
}
}