Skip to main content

faucet_cli/
env_loader.rs

1//! Resolve a `.env` file path and auto-discover a pipeline config file.
2//!
3//! Both helpers take an explicit "from" directory so unit tests can run in a
4//! tempdir without touching process cwd. Production call-sites pass
5//! `std::env::current_dir()?` as the third argument.
6
7use crate::error::{CliError, CliResult};
8use std::path::{Path, PathBuf};
9
10/// Resolve the `.env` file to load (if any). Precedence:
11///
12/// 1. If `no_env_file` is set, return `Ok(None)`.
13/// 2. If `explicit` is `Some(path)`, the file must exist — otherwise error.
14/// 3. Otherwise, return `Some(dir/.env)` if it exists, else `None`.
15pub fn resolve_env_file(
16    explicit: Option<&Path>,
17    no_env_file: bool,
18    dir: &Path,
19) -> CliResult<Option<PathBuf>> {
20    if no_env_file {
21        return Ok(None);
22    }
23    if let Some(p) = explicit {
24        if !p.exists() {
25            return Err(CliError::EnvFileNotFound {
26                path: p.to_path_buf(),
27            });
28        }
29        return Ok(Some(p.to_path_buf()));
30    }
31    let candidate = dir.join(".env");
32    Ok(candidate.exists().then_some(candidate))
33}
34
35/// Load the resolved `.env` via `dotenvy::from_path`. No-op when `path` is
36/// `None`. Process-env values always win over `.env`-supplied ones — that's
37/// the dotenvy default and we deliberately don't override it.
38pub fn load_env_file_if_present(path: Option<&Path>) -> CliResult<()> {
39    if let Some(p) = path {
40        dotenvy::from_path(p).map_err(|source| CliError::ReadConfig {
41            path: p.to_path_buf(),
42            source: std::io::Error::other(source),
43        })?;
44    }
45    Ok(())
46}
47
48/// Probe `dir` for `faucet.yaml`, `faucet.yml`, then `faucet.json`. Returns the
49/// first match in priority order, or `None`. When more than one candidate is
50/// present, emits a `tracing::warn!` naming the runner-ups so a stale
51/// `faucet.yml` next to the canonical `faucet.yaml` doesn't go unnoticed.
52pub fn discover_config_path(dir: &Path) -> Option<PathBuf> {
53    const CANDIDATES: &[&str] = &["faucet.yaml", "faucet.yml", "faucet.json"];
54    let mut chosen: Option<PathBuf> = None;
55    let mut also: Vec<PathBuf> = Vec::new();
56    for name in CANDIDATES {
57        let p = dir.join(name);
58        if p.exists() {
59            if chosen.is_none() {
60                chosen = Some(p);
61            } else {
62                also.push(p);
63            }
64        }
65    }
66    if !also.is_empty()
67        && let Some(ref c) = chosen
68    {
69        let extras: Vec<String> = also.iter().map(|p| p.display().to_string()).collect();
70        tracing::warn!(
71            chosen = %c.display(),
72            also_present = ?extras,
73            "multiple faucet.* config files in cwd; picking the first in priority order (yaml > yml > json)"
74        );
75    }
76    chosen
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use tempfile::tempdir;
83
84    #[test]
85    fn resolve_env_file_explicit_wins() {
86        let dir = tempdir().unwrap();
87        let p = dir.path().join("secrets.env");
88        std::fs::write(&p, "X=1\n").unwrap();
89        let resolved = resolve_env_file(Some(&p), false, dir.path()).unwrap();
90        assert_eq!(resolved, Some(p));
91    }
92
93    #[test]
94    fn resolve_env_file_explicit_missing_errors() {
95        let dir = tempdir().unwrap();
96        let missing = dir.path().join("nope.env");
97        let err = resolve_env_file(Some(&missing), false, dir.path()).unwrap_err();
98        assert!(matches!(err, CliError::EnvFileNotFound { .. }));
99    }
100
101    #[test]
102    fn resolve_env_file_auto_finds_dotenv_in_cwd() {
103        let dir = tempdir().unwrap();
104        let dotenv = dir.path().join(".env");
105        std::fs::write(&dotenv, "X=1\n").unwrap();
106        assert_eq!(
107            resolve_env_file(None, false, dir.path()).unwrap(),
108            Some(dotenv)
109        );
110    }
111
112    #[test]
113    fn resolve_env_file_none_when_missing_and_not_explicit() {
114        let dir = tempdir().unwrap();
115        assert_eq!(resolve_env_file(None, false, dir.path()).unwrap(), None);
116    }
117
118    #[test]
119    fn resolve_env_file_no_env_file_skips_auto_discovery() {
120        let dir = tempdir().unwrap();
121        std::fs::write(dir.path().join(".env"), "X=1\n").unwrap();
122        assert_eq!(resolve_env_file(None, true, dir.path()).unwrap(), None);
123    }
124
125    #[test]
126    fn resolve_env_file_no_env_file_also_skips_explicit_path() {
127        // --no-env-file conflicts with --env-file at the clap level, but be
128        // defensive at the function boundary too.
129        let dir = tempdir().unwrap();
130        let p = dir.path().join("explicit.env");
131        std::fs::write(&p, "X=1\n").unwrap();
132        assert_eq!(resolve_env_file(Some(&p), true, dir.path()).unwrap(), None);
133    }
134
135    #[test]
136    fn discover_config_path_prefers_yaml() {
137        let dir = tempdir().unwrap();
138        std::fs::write(dir.path().join("faucet.yaml"), "version: 1\n").unwrap();
139        std::fs::write(dir.path().join("faucet.yml"), "version: 1\n").unwrap();
140        std::fs::write(dir.path().join("faucet.json"), "{}").unwrap();
141        let found = discover_config_path(dir.path());
142        assert_eq!(found, Some(dir.path().join("faucet.yaml")));
143    }
144
145    #[test]
146    fn discover_config_path_falls_through_to_yml() {
147        let dir = tempdir().unwrap();
148        std::fs::write(dir.path().join("faucet.yml"), "v: 1\n").unwrap();
149        std::fs::write(dir.path().join("faucet.json"), "{}").unwrap();
150        assert_eq!(
151            discover_config_path(dir.path()),
152            Some(dir.path().join("faucet.yml"))
153        );
154    }
155
156    #[test]
157    fn discover_config_path_falls_through_to_json() {
158        let dir = tempdir().unwrap();
159        std::fs::write(dir.path().join("faucet.json"), "{}").unwrap();
160        assert_eq!(
161            discover_config_path(dir.path()),
162            Some(dir.path().join("faucet.json"))
163        );
164    }
165
166    #[test]
167    fn discover_config_path_none_when_missing() {
168        let dir = tempdir().unwrap();
169        assert_eq!(discover_config_path(dir.path()), None);
170    }
171
172    #[test]
173    fn load_env_file_if_present_is_noop_for_none() {
174        load_env_file_if_present(None).unwrap();
175    }
176
177    #[test]
178    fn load_env_file_if_present_propagates_parse_failure_as_readconfig() {
179        // Write a malformed .env (lines must be KEY=VALUE).
180        let dir = tempdir().unwrap();
181        let p = dir.path().join("bad.env");
182        std::fs::write(&p, "no equals here\n").unwrap();
183        let err = load_env_file_if_present(Some(&p)).unwrap_err();
184        assert!(matches!(err, CliError::ReadConfig { .. }));
185    }
186}