use std::path::PathBuf;
use serde::Deserialize;
use crate::icons::{self, Icons};
use crate::tree::{Filter, Sort};
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct Raw {
sort: Option<String>,
sort_reversed: Option<bool>,
filter: Option<String>,
wrap: Option<bool>,
icons: Option<String>,
animate: Option<bool>,
single_pane_below: Option<u16>,
}
#[derive(Debug, Clone, Copy)]
pub struct Config {
pub sort: Sort,
pub sort_reversed: bool,
pub filter: Filter,
pub wrap: bool,
pub icons: Icons,
pub animate: bool,
pub single_pane_below: u16,
}
impl Default for Config {
fn default() -> Self {
Self {
sort: Sort::Priority,
sort_reversed: false,
filter: Filter::Pending,
wrap: true,
icons: icons::UNICODE,
animate: true,
single_pane_below: 80,
}
}
}
pub fn project_path() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join(".git").exists() {
return Some(dir.join(".dextui.toml"));
}
if !dir.pop() {
return None;
}
}
}
pub fn path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
};
Some(base.join("dextui").join("config.toml"))
}
fn apply(cfg: &mut Config, raw: Raw, problems: &mut Vec<String>) {
if let Some(v) = raw.sort.as_deref() {
match parse_sort(v) {
Some(s) => cfg.sort = s,
None => problems.push(format!("unknown sort {v:?}")),
}
}
if let Some(v) = raw.filter.as_deref() {
match parse_filter(v) {
Some(f) => cfg.filter = f,
None => problems.push(format!("unknown filter {v:?}")),
}
}
if let Some(v) = raw.icons.as_deref() {
match parse_icons(v) {
Some(i) => cfg.icons = i,
None => problems.push(format!("unknown icons {v:?}")),
}
}
if let Some(v) = raw.sort_reversed {
cfg.sort_reversed = v;
}
if let Some(v) = raw.wrap {
cfg.wrap = v;
}
if let Some(v) = raw.animate {
cfg.animate = v;
}
if let Some(v) = raw.single_pane_below {
cfg.single_pane_below = v;
}
}
fn read(path: Option<&PathBuf>, problems: &mut Vec<String>) -> Option<Raw> {
let p = path?;
let text = std::fs::read_to_string(p).ok()?;
match toml::from_str::<Raw>(&text) {
Ok(raw) => Some(raw),
Err(e) => {
problems.push(format!("{}: {}", p.display(), first_line(&e.to_string())));
None
}
}
}
pub fn load() -> (Config, Option<String>) {
let mut cfg = Config::default();
let mut problems: Vec<String> = Vec::new();
if let Some(raw) = read(path().as_ref(), &mut problems) {
apply(&mut cfg, raw, &mut problems);
}
if let Some(raw) = read(project_path().as_ref(), &mut problems) {
apply(&mut cfg, raw, &mut problems);
}
if let Some(i) = std::env::var("DEXTUI_ICONS").ok().and_then(|v| parse_icons(&v)) {
cfg.icons = i;
}
if let Some(v) = std::env::var("DEXTUI_ANIMATE").ok().and_then(|v| parse_bool(&v)) {
cfg.animate = v;
}
let problem = (!problems.is_empty()).then(|| problems.join("; "));
(cfg, problem)
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).trim().to_string()
}
pub fn parse_sort(v: &str) -> Option<Sort> {
match v.trim().to_ascii_lowercase().as_str() {
"priority" => Some(Sort::Priority),
"updated" => Some(Sort::Updated),
"created" => Some(Sort::Created),
"name" => Some(Sort::Name),
_ => None,
}
}
pub fn parse_filter(v: &str) -> Option<Filter> {
match v.trim().to_ascii_lowercase().as_str() {
"pending" => Some(Filter::Pending),
"active" | "in-progress" | "in_progress" => Some(Filter::InProgress),
"all" => Some(Filter::All),
_ => None,
}
}
pub fn parse_bool(v: &str) -> Option<bool> {
match v.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
pub fn parse_icons(v: &str) -> Option<Icons> {
match v.trim().to_ascii_lowercase().as_str() {
"nerd" => Some(icons::NERD),
"unicode" => Some(icons::UNICODE),
"ascii" => Some(icons::ASCII),
_ => None,
}
}
pub const EXAMPLE: &str = r#"# Starting values only. w / o / O / f still toggle freely at runtime; nothing
# is written back, so this file stays exactly as you left it.
#
# Layered defaults < global < project < environment:
# global ~/.config/dextui/config.toml
# project .dextui.toml at the git root
# A project file need only mention what it changes.
# priority | updated | created | name
sort = "priority"
# Flips the order's natural direction: newest->oldest, updated->stalest.
sort_reversed = false
# pending | active | all
filter = "pending"
# Wrap long lines in the detail pane. Turn off to scroll wide tables sideways.
wrap = true
# nerd | unicode | ascii (DEXTUI_ICONS overrides this for one run)
icons = "unicode"
# Breathe the marker on in-progress tasks. Costs nothing while nothing is
# running. (DEXTUI_ANIMATE overrides this for one run)
animate = true
# Below this terminal width, show one pane at a time instead of squeezing both:
# Enter or Right opens the detail full-width, Left or Tab goes back. Two panes
# narrower than this leave no room for either. 0 always splits.
single_pane_below = 80
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Global,
Project,
}
pub fn target(scope: Scope) -> Result<PathBuf, String> {
match scope {
Scope::Global => path().ok_or_else(|| "could not resolve a config path (is HOME set?)".into()),
Scope::Project => project_path()
.ok_or_else(|| "not inside a git repository, so there is no project config".into()),
}
}
pub fn init(scope: Scope, force: bool) -> Result<PathBuf, String> {
let p = target(scope)?;
if p.exists() && !force {
return Err(format!(
"{} already exists; pass --force to overwrite it",
p.display()
));
}
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let template = match scope {
Scope::Global => EXAMPLE,
Scope::Project => PROJECT_EXAMPLE,
};
std::fs::write(&p, template).map_err(|e| format!("{}: {e}", p.display()))?;
Ok(p)
}
pub fn path_for_editing(scope: Scope) -> Result<PathBuf, String> {
let p = target(scope)?;
if !p.exists() {
init(scope, false)?;
}
Ok(p)
}
pub const PROJECT_EXAMPLE: &str = r#"# .dextui.toml — settings for this repository only.
#
# Layered over ~/.config/dextui/config.toml, so mention only what differs.
# Anything left out falls through to the global file. See `dextui --config`.
# wrap = false # this repo's descriptions have wide tables
# sort = "updated" # priority | updated | created | name
# sort_reversed = false
# filter = "pending" # pending | active | all
# icons = "unicode" # nerd | unicode | ascii
# animate = false # no pulse on in-progress rows in this repo
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_behaviour_before_a_config_existed() {
let c = Config::default();
assert_eq!(c.sort, Sort::Priority);
assert!(!c.sort_reversed);
assert_eq!(c.filter, Filter::Pending);
assert!(c.wrap);
assert_eq!(c.icons.tier, icons::UNICODE.tier);
}
#[test]
fn every_documented_value_parses() {
for v in ["priority", "updated", "created", "name"] {
assert!(parse_sort(v).is_some(), "sort {v}");
}
for v in ["pending", "active", "all"] {
assert!(parse_filter(v).is_some(), "filter {v}");
}
for v in ["nerd", "unicode", "ascii"] {
assert!(parse_icons(v).is_some(), "icons {v}");
}
}
#[test]
fn values_are_case_and_whitespace_insensitive() {
assert_eq!(parse_sort(" Updated "), Some(Sort::Updated));
assert_eq!(parse_filter("ALL"), Some(Filter::All));
}
#[test]
fn unknown_values_are_rejected_rather_than_guessed() {
assert!(parse_sort("priorty").is_none());
assert!(parse_filter("done").is_none());
assert!(parse_icons("emoji").is_none());
}
#[test]
fn a_full_file_round_trips_through_the_parser() {
let raw: Raw = toml::from_str(
r#"
sort = "updated"
sort_reversed = true
filter = "all"
wrap = false
icons = "nerd"
"#,
)
.expect("valid config should parse");
assert_eq!(raw.sort.as_deref(), Some("updated"));
assert_eq!(raw.sort_reversed, Some(true));
assert_eq!(raw.wrap, Some(false));
}
#[test]
fn the_example_file_is_valid_and_uses_only_known_keys() {
let raw: Raw = toml::from_str(EXAMPLE).expect("EXAMPLE must parse");
assert_eq!(raw.sort.as_deref(), Some("priority"));
assert_eq!(raw.icons.as_deref(), Some("unicode"));
}
#[test]
fn an_unknown_key_is_an_error_not_a_silent_no_op() {
assert!(toml::from_str::<Raw>("colour_scheme = \"dark\"").is_err());
}
#[test]
fn a_partial_file_leaves_the_other_defaults_alone() {
let raw: Raw = toml::from_str("wrap = false").unwrap();
assert_eq!(raw.wrap, Some(false));
assert!(raw.sort.is_none());
}
fn raw(toml_src: &str) -> Raw {
toml::from_str(toml_src).expect("test fixture should parse")
}
#[test]
fn a_project_file_overrides_only_what_it_mentions() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("sort = \"updated\"\nfilter = \"all\""), &mut problems);
apply(&mut cfg, raw("wrap = false"), &mut problems);
assert!(!cfg.wrap, "project value not applied");
assert_eq!(cfg.sort, Sort::Updated, "global value was lost");
assert_eq!(cfg.filter, Filter::All, "global value was lost");
assert!(problems.is_empty());
}
#[test]
fn the_later_layer_wins_on_the_same_key() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("sort = \"name\""), &mut problems);
apply(&mut cfg, raw("sort = \"created\""), &mut problems);
assert_eq!(cfg.sort, Sort::Created);
}
#[test]
fn an_empty_layer_changes_nothing() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("sort = \"name\""), &mut problems);
apply(&mut cfg, raw(""), &mut problems);
assert_eq!(cfg.sort, Sort::Name);
}
#[test]
fn a_bad_value_is_reported_and_leaves_the_layer_beneath_intact() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("sort = \"updated\""), &mut problems);
apply(&mut cfg, raw("sort = \"nonsense\""), &mut problems);
assert_eq!(cfg.sort, Sort::Updated, "a typo should not reset to the default");
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("nonsense"));
}
#[test]
fn animation_is_on_by_default() {
assert!(Config::default().animate);
}
#[test]
fn animate_can_be_turned_off_in_a_file() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("animate = false"), &mut problems);
assert!(!cfg.animate);
assert!(problems.is_empty());
}
#[test]
fn a_project_file_can_override_animate() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("animate = true"), &mut problems);
apply(&mut cfg, raw("animate = false"), &mut problems);
assert!(!cfg.animate);
}
#[test]
fn the_example_file_documents_animate() {
let raw: Raw = toml::from_str(EXAMPLE).expect("EXAMPLE must parse");
assert_eq!(raw.animate, Some(true));
}
#[test]
fn parse_bool_accepts_the_usual_spellings() {
for v in ["true", "1", "yes", "on", " TRUE ", "On"] {
assert_eq!(parse_bool(v), Some(true), "{v:?}");
}
for v in ["false", "0", "no", "off", " FALSE ", "Off"] {
assert_eq!(parse_bool(v), Some(false), "{v:?}");
}
for v in ["maybe", "", "2"] {
assert_eq!(parse_bool(v), None, "{v:?}");
}
}
#[test]
fn problems_from_several_layers_are_all_reported() {
let mut cfg = Config::default();
let mut problems = Vec::new();
apply(&mut cfg, raw("sort = \"bogus\""), &mut problems);
apply(&mut cfg, raw("filter = \"bogus\""), &mut problems);
assert_eq!(problems.len(), 2);
}
}