whycant 0.1.0

CLI tool to figure out why you can't do something.
Documentation
//! Configuration model and precedence. Merges built-in defaults, the config
//! file, `WHYCANT_*` environment variables, and CLI flags, lowest to highest.
//! A missing file means all defaults; only a malformed file is a hard error.

use crate::cli::{ColorMode, Format};
use anyhow::Result;
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Clone, Copy)]
pub enum Tristate {
    On,
    Off,
    Auto,
}

impl<'de> Deserialize<'de> for Tristate {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl serde::de::Visitor<'_> for V {
            type Value = Tristate;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("0|1|on|off|auto")
            }
            fn visit_bool<E>(self, v: bool) -> Result<Tristate, E> {
                Ok(if v { Tristate::On } else { Tristate::Off })
            }
            fn visit_i64<E>(self, v: i64) -> Result<Tristate, E> {
                Ok(if v != 0 { Tristate::On } else { Tristate::Off })
            }
            fn visit_u64<E>(self, v: u64) -> Result<Tristate, E> {
                Ok(if v != 0 { Tristate::On } else { Tristate::Off })
            }
            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Tristate, E> {
                match s.to_ascii_lowercase().as_str() {
                    "on" | "1" | "true" | "yes" => Ok(Tristate::On),
                    "off" | "0" | "false" | "no" => Ok(Tristate::Off),
                    "auto" => Ok(Tristate::Auto),
                    o => Err(E::custom(format!("invalid interactive value: {o}"))),
                }
            }
        }
        d.deserialize_any(V)
    }
}

#[derive(Deserialize, Default)]
pub struct Settings {
    pub color: Option<ColorMode>,
    pub format: Option<Format>,
    pub interactive: Option<Tristate>,
    pub print_fixes: Option<bool>,
    pub ascii: Option<bool>,
    pub all: Option<bool>,
}

fn config_dir() -> Option<PathBuf> {
    std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}

pub fn load() -> Result<Settings> {
    let mut b = ::config::Config::builder();
    if let Some(dir) = config_dir() {
        let path = dir.join("whycant").join("config.toml");
        b = b.add_source(
            ::config::File::from(path)
                .format(::config::FileFormat::Toml)
                .required(false),
        );
    }
    b = b.add_source(::config::Environment::with_prefix("WHYCANT").try_parsing(true));
    Ok(b.build()?.try_deserialize()?)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::ColorMode;
    use std::io::Write;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn temp_dir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!(
            "whycant-{tag}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::create_dir_all(d.join("whycant")).unwrap();
        d
    }

    #[test]
    fn env_beats_file_and_underscore_key() {
        let _g = ENV_LOCK.lock().unwrap();
        let dir = temp_dir("layer");
        let mut f = std::fs::File::create(dir.join("whycant").join("config.toml")).unwrap();
        writeln!(f, "color = \"always\"\nprint_fixes = false\n").unwrap();
        unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) };
        unsafe { std::env::set_var("WHYCANT_COLOR", "never") };
        unsafe { std::env::set_var("WHYCANT_PRINT_FIXES", "true") };
        unsafe { std::env::set_var("WHYCANT_INTERACTIVE", "1") };
        let s = load().unwrap();
        unsafe { std::env::remove_var("WHYCANT_COLOR") };
        unsafe { std::env::remove_var("WHYCANT_PRINT_FIXES") };
        unsafe { std::env::remove_var("WHYCANT_INTERACTIVE") };
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        std::fs::remove_dir_all(&dir).ok();
        assert!(matches!(s.color, Some(ColorMode::Never)));
        assert_eq!(s.print_fixes, Some(true));
        assert!(matches!(s.interactive, Some(Tristate::On)));
    }

    #[test]
    fn missing_file_is_defaults() {
        let _g = ENV_LOCK.lock().unwrap();
        let dir = temp_dir("empty");
        unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) };
        let s = load().unwrap();
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        std::fs::remove_dir_all(&dir).ok();
        assert!(s.color.is_none() && s.format.is_none() && s.interactive.is_none());
    }
}