use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rust_i18n::t;
use toml_edit::{DocumentMut, value};
use crate::config;
const TEMPLATE: &str = "# shdo – remembered view state (filters, shown columns); safe to delete\n";
const KEYS: [&str; 4] = ["show_done", "show_calendar", "buckets", "tags"];
#[derive(Debug, Default, serde::Deserialize)]
pub struct ViewState {
pub show_done: Option<bool>,
pub show_calendar: Option<bool>,
#[serde(default)]
pub buckets: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
}
pub fn path() -> PathBuf {
if let Some(dir) = std::env::var_os("SHDO_CONFIG_DIR").filter(|v| !v.is_empty()) {
return PathBuf::from(dir).join("state.toml");
}
let base = match std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
Some(x) => PathBuf::from(x),
None => config::expand_tilde("~/.local/state"),
};
base.join("shdo").join("state.toml")
}
impl ViewState {
pub fn load(path: &Path) -> Result<ViewState> {
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(ViewState::default()),
Err(e) => Err(e).with_context(|| t!("io.read", path = path.display()).into_owned()),
}
}
}
fn update(path: &Path, f: impl FnOnce(&mut DocumentMut)) -> Result<()> {
if path.exists() {
return config::update_file(path, TEMPLATE, f);
}
let mut doc = DocumentMut::new();
f(&mut doc);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
fs::write(path, format!("{TEMPLATE}{doc}"))
.with_context(|| t!("io.write", path = path.display()).into_owned())
}
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 fn migrate(cfg_path: &Path, state_path: &Path) -> Result<()> {
let Ok(raw) = fs::read_to_string(cfg_path) else {
return Ok(());
};
let cfg: DocumentMut = raw
.parse()
.with_context(|| t!("io.invalid", path = cfg_path.display()).into_owned())?;
let found: Vec<(&str, toml_edit::Item)> = KEYS
.iter()
.filter_map(|k| cfg.get(k).map(|item| (*k, item.clone())))
.collect();
if found.is_empty() {
return Ok(());
}
update(state_path, |doc| {
for (key, item) in &found {
if !doc.contains_key(key) {
doc[*key] = item.clone();
}
}
})?;
config::update_file(cfg_path, config::DEFAULT_CONFIG, |doc| {
for (key, _) in &found {
doc.remove(key);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_and_read() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state/state.toml");
assert!(ViewState::load(&path).unwrap().show_done.is_none());
set_bool(&path, "show_done", false).unwrap();
set_list(&path, "buckets", &["work".into(), "-".into()]).unwrap();
set_list(&path, "tags", &["dev".into()]).unwrap();
let s = ViewState::load(&path).unwrap();
assert_eq!(s.show_done, Some(false));
assert_eq!(s.buckets, ["work", "-"]);
assert_eq!(s.tags, ["dev"]);
assert!(fs::read_to_string(&path).unwrap().starts_with("# shdo"));
}
#[test]
fn migrates_old_config_keys() {
let tmp = tempfile::tempdir().unwrap();
let cfg_path = tmp.path().join("config.toml");
let state_path = tmp.path().join("state.toml");
fs::write(
&cfg_path,
"language = \"de\"\nshow_done = false\nbuckets = [\"work\"]\ntheme = \"nord\" # mine\n",
)
.unwrap();
set_list(&state_path, "buckets", &["home".into()]).unwrap();
migrate(&cfg_path, &state_path).unwrap();
let raw = fs::read_to_string(&cfg_path).unwrap();
assert!(
!raw.contains("show_done") && !raw.contains("buckets"),
"{raw}"
);
assert!(raw.contains("theme = \"nord\" # mine"));
let s = ViewState::load(&state_path).unwrap();
assert_eq!(s.show_done, Some(false));
assert_eq!(s.buckets, ["home"]);
let before = fs::read_to_string(&cfg_path).unwrap();
migrate(&cfg_path, &state_path).unwrap();
assert_eq!(fs::read_to_string(&cfg_path).unwrap(), before);
}
}