config_dirs/
lib.rs

1//! Load a config file by trying out default config file locations:
2//!
3//! - `{NAME_SCREAMING_SNAKE_CASE}_CONFIG` envitonment variable
4//! - `~/.config/{name}/config.toml`
5//! - `/etc/{name}/config.toml`
6//! - `/usr/local/etc/{name}/config.toml`
7//! - `~/Library/Preferences/{name}/config.toml`
8//! - `/usr/local/etc/{name}/config.toml`
9//!
10//! ```no_run
11//! use serde::{Deserialize, Serialize};
12//!
13//! #[derive(Debug, Deserialize, Serialize)]
14//! struct Config {}
15//!
16//! let config: Config = config_dirs::load("my-app", toml::from_str).expect("Failed to load config");
17//! ```
18
19use std::{
20    env, fs, io,
21    path::{Path, PathBuf},
22};
23
24use convert_case::{Case, Casing};
25use genawaiter::{stack::let_gen, yield_};
26use thiserror::Error;
27use tracing::{error, info};
28
29#[derive(Debug, Error)]
30pub enum Error<ParseError: std::error::Error> {
31    #[error("Failed to read config file: {0:#}")]
32    Io(#[from] io::Error),
33    #[error("Failed to parse config: {0:#}")]
34    Parse(ParseError),
35    #[error("Failed to load config from paths")]
36    NoPath,
37}
38
39pub fn load<Config, E: std::error::Error>(
40    name: &str,
41    parse: impl Fn(&str) -> Result<Config, E> + Copy,
42) -> Result<Config, Error<E>> {
43    if let Ok(path) = env::var(format!("{}_CONFIG", name.to_case(Case::ScreamingSnake))) {
44        if let Some(path) = path_with_home_dir(&path) {
45            match load_from_path(path, parse) {
46                Err(Error::Io(e)) if matches!(e.kind(), io::ErrorKind::NotFound) => {}
47                v => return v,
48            }
49        }
50    }
51    let_gen!(paths, {
52        yield_!(format!("~/.config/{name}/config.toml"));
53        yield_!(format!("/etc/{name}/config.toml"));
54        yield_!(format!("/usr/local/etc/{name}/config.toml"));
55        yield_!(format!("~/Library/Preferences/{name}/config.toml"));
56        yield_!(format!("/Library/Preferences/{name}/config.toml"));
57    });
58    for path in paths {
59        if let Some(path) = path_with_home_dir(&path) {
60            match load_from_path(path, parse) {
61                Err(Error::Io(e)) if matches!(e.kind(), io::ErrorKind::NotFound) => {}
62                v => return v,
63            }
64        }
65    }
66    error!("Failed to load config");
67    Err(Error::NoPath)
68}
69
70pub fn load_from_path<Config, E: std::error::Error>(
71    path: impl AsRef<Path>,
72    parse: impl Fn(&str) -> Result<Config, E>,
73) -> Result<Config, Error<E>> {
74    info!("Loading config from {}", path.as_ref().to_string_lossy());
75    parse(&fs::read_to_string(path)?).map_err(Error::Parse)
76}
77
78fn path_with_home_dir(path: &str) -> Option<PathBuf> {
79    if path.starts_with("~/") {
80        dirs::home_dir().map(|v| v.join(&path[2..]))
81    } else {
82        Some(PathBuf::from(path))
83    }
84}