devops_cli/
config.rs

1use anyhow::Context;
2use std::env::VarError;
3use std::path::PathBuf;
4
5#[derive(clap::Args, Debug, Clone)]
6pub struct ConfigArgs {
7    #[clap(short, long, global = true)]
8    /// The profile to load
9    pub profile: Option<String>,
10}
11
12pub fn load_env_files(app_name: &str, args: &ConfigArgs) -> anyhow::Result<()> {
13    let config_dir = {
14        let xdg_config_dir = std::env::var("XDG_CONFIG_HOME")
15            .map(|config_path| PathBuf::from(config_path).join("dcli"))
16            .ok()
17            .filter(|path| std::fs::exists(path).is_ok_and(|exists| exists));
18
19        let home_config_dir = std::env::var("HOME")
20            .map(|home_dir| PathBuf::from(home_dir).join(".dcli"))
21            .ok()
22            .filter(|path| std::fs::exists(path).is_ok_and(|exists| exists));
23
24        match (xdg_config_dir, home_config_dir) {
25            (Some(xdg_config_dir), Some(home_config_dir)) => {
26                anyhow::bail!(
27                    "found configurations in both {} and {}. Please merge configurations in one location",
28                    xdg_config_dir.display(),
29                    home_config_dir.display()
30                )
31            }
32            (Some(xdg_config_dir), None) => {
33                tracing::info!("Using XDG_CONFIG_HOME={}", xdg_config_dir.display());
34                xdg_config_dir
35            }
36            (None, Some(home_config_dir)) => {
37                tracing::info!("Using HOME={}", home_config_dir.display());
38                home_config_dir
39            }
40            (None, None) => {
41                anyhow::bail!(
42                    "Unable to find configuration in either $HOME/.dcli or $XDG_CONFIG_HOME/dcli. Please create one of them and try again."
43                );
44            }
45        }
46    };
47
48    // <profile>.env is mandatory if selected. otherwise try to load fallback profile
49    if let Some(profile) = args.profile.as_deref() {
50        load_file(config_dir.join(format!("{profile}.env")))
51            .context("Unable to load configuration file for profile")?;
52    } else {
53        load_file_opt(config_dir.join("default.env"))?;
54    }
55
56    // per-tool config (optional)
57    load_file_opt(config_dir.join(format!("{app_name}.env")))?;
58
59    // global config (optional)
60    load_file_opt(config_dir.join("global.env"))?;
61
62    Ok(())
63}
64
65fn load_file(path: PathBuf) -> anyhow::Result<()> {
66    tracing::debug!(file = %path.display(), "loading mandatory file");
67    dotenvy::from_path(path).context("File is required")
68}
69
70fn load_file_opt(path: PathBuf) -> anyhow::Result<()> {
71    tracing::debug!(file = %path.display(), "loading optional file");
72    if let Err(e) = dotenvy::from_path(&path)
73        && !e.not_found()
74    {
75        tracing::debug!(file = %path.display(), "file does not exist, ignoring");
76        Err(e)?;
77    }
78
79    Ok(())
80}
81
82pub fn get(name: &str) -> anyhow::Result<String> {
83    get_opt(name)?.ok_or_else(|| is_mandatory_err(name))
84}
85
86pub fn get_opt(name: &str) -> anyhow::Result<Option<String>> {
87    let plain = read_env_var(name)?;
88
89    // TODO: Add encryption support
90
91    Ok(plain)
92}
93
94pub fn get_bool(name: &str) -> anyhow::Result<bool> {
95    get_bool_opt(name)?.ok_or_else(|| is_mandatory_err(name))
96}
97
98pub fn get_bool_opt(name: &str) -> anyhow::Result<Option<bool>> {
99    let Some(value) = get_opt(name)? else {
100        return Ok(None);
101    };
102    match value.as_str() {
103        "yes" | "true" | "1" | "on" | "enable" | "enabled" => Ok(Some(true)),
104        "no" | "false" | "0" | "off" | "disable" | "disabled" => Ok(Some(false)),
105        _ => Err(anyhow::anyhow!(
106            "Invalid boolean value for {name}: `{value}`"
107        ))?,
108    }
109}
110
111pub fn get_json<T: serde::de::DeserializeOwned>(name: &str) -> anyhow::Result<T> {
112    get_json_opt(name)?.ok_or_else(|| is_mandatory_err(name))
113}
114
115pub fn get_json_opt<T: serde::de::DeserializeOwned>(name: &str) -> anyhow::Result<Option<T>> {
116    let Some(json) = get_opt(name)? else {
117        return Ok(None);
118    };
119
120    serde_json::from_str(&json).context(format!("Invalid JSON for {name}: `{json}`"))
121}
122
123fn read_env_var(name: &str) -> anyhow::Result<Option<String>> {
124    match std::env::var(name) {
125        Ok(value) => Ok(Some(value).filter(|value| !value.is_empty())),
126        Err(VarError::NotPresent) => Ok(None),
127        Err(e) => Err(e).context(format!(
128            "Environment variable value can not be parsed as UTF-8 ({name})"
129        )),
130    }
131}
132
133fn is_mandatory_err(name: &str) -> anyhow::Error {
134    anyhow::anyhow!("{name} is not set, but it is required")
135}