Skip to main content

rskit_config/source/
dotenv.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use rskit_errors::{AppError, AppResult};
5
6use super::contract::ConfigSource;
7use super::env::{normalize_env_key, parse_env_value};
8
9/// Profile environment-file selection.
10#[derive(Debug, Clone)]
11pub enum Profile {
12    /// Use the given profile name.
13    Name(String),
14    /// Read the profile name from `ENVIRONMENT`.
15    FromEnvironment,
16}
17
18impl Profile {
19    pub(crate) fn resolve(&self) -> AppResult<Cow<'_, str>> {
20        let profile_name = match self {
21            Profile::Name(name) => Cow::Borrowed(name.as_str()),
22            Profile::FromEnvironment => std::env::var("ENVIRONMENT")
23                .map_err(|_| {
24                    AppError::invalid_input(
25                        "config",
26                        "ENVIRONMENT must be set when profile is resolved from the environment",
27                    )
28                })?
29                .trim()
30                .to_owned()
31                .into(),
32        };
33
34        if profile_name.trim().is_empty() {
35            return Err(AppError::invalid_input(
36                "config",
37                "profile name cannot be empty",
38            ));
39        }
40
41        Ok(profile_name)
42    }
43}
44
45/// Dotenv file source.
46#[derive(Debug, Clone)]
47pub struct DotenvFileSource {
48    path: PathBuf,
49    env_prefix: String,
50    label: &'static str,
51    ignore_malformed: bool,
52}
53
54impl DotenvFileSource {
55    /// Create a fail-closed dotenv file source.
56    pub fn required(path: impl Into<PathBuf>, env_prefix: impl Into<String>) -> Self {
57        Self {
58            path: path.into(),
59            env_prefix: env_prefix.into(),
60            label: "env file",
61            ignore_malformed: false,
62        }
63    }
64
65    pub(crate) fn auto_discovered(path: impl Into<PathBuf>, env_prefix: impl Into<String>) -> Self {
66        Self {
67            path: path.into(),
68            env_prefix: env_prefix.into(),
69            label: ".env file",
70            ignore_malformed: true,
71        }
72    }
73
74    pub(crate) fn profile(path: impl Into<PathBuf>, env_prefix: impl Into<String>) -> Self {
75        Self {
76            path: path.into(),
77            env_prefix: env_prefix.into(),
78            label: "profile env file",
79            ignore_malformed: false,
80        }
81    }
82}
83
84impl ConfigSource for DotenvFileSource {
85    fn collect(&self) -> AppResult<config::Config> {
86        match dotenv_config_from_path(&self.path, &self.env_prefix, self.label) {
87            Ok(config) => Ok(config),
88            Err(err) if self.ignore_malformed => {
89                tracing::warn!(error = %err, "failed to load auto-discovered .env file");
90                config::Config::builder()
91                    .build()
92                    .map_err(|e| AppError::invalid_input("config", e.to_string()))
93            }
94            Err(err) => Err(err),
95        }
96    }
97}
98
99pub(crate) fn find_profile_env_file(profile: &str) -> Option<PathBuf> {
100    [
101        format!("./config/profiles/{profile}.env"),
102        format!("../config/profiles/{profile}.env"),
103        format!("../../config/profiles/{profile}.env"),
104    ]
105    .into_iter()
106    .map(PathBuf::from)
107    .find(|path| path.exists())
108}
109
110pub(crate) fn find_default_env_file() -> Option<PathBuf> {
111    let cwd = std::env::current_dir().ok()?;
112    rskit_fs::find_in_ancestors(&cwd, ".env")
113}
114
115fn dotenv_config_from_path(path: &Path, prefix: &str, label: &str) -> AppResult<config::Config> {
116    let iter = dotenvy::from_path_iter(path).map_err(|e| {
117        AppError::invalid_input(
118            "config",
119            format!("failed to load {label} '{}': {e}", path.display()),
120        )
121    })?;
122
123    let mut builder = config::Config::builder();
124    for item in iter {
125        let (key, value) = item.map_err(|e| {
126            AppError::invalid_input(
127                "config",
128                format!("failed to parse {label} '{}': {e}", path.display()),
129            )
130        })?;
131        if let Some(key) = normalize_env_key(prefix, &key) {
132            builder = builder
133                .set_override(key, parse_env_value(value))
134                .map_err(|e| AppError::invalid_input("config", e.to_string()))?;
135        }
136    }
137
138    builder
139        .build()
140        .map_err(|e| AppError::invalid_input("config", e.to_string()))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn profile_name_resolution_rejects_empty_names() {
149        assert_eq!(Profile::Name("dev".to_string()).resolve().unwrap(), "dev");
150        assert!(Profile::Name("  ".to_string()).resolve().is_err());
151    }
152
153    #[test]
154    fn dotenv_constructors_load_prefixes_and_ignore_auto_discovered_errors() {
155        let dir = tempfile::tempdir().unwrap();
156        let env_file = dir.path().join(".env");
157        std::fs::write(
158            &env_file,
159            "APP__SERVICE__PORT=8080\nAPP__SERVICE__DEBUG=true\nOTHER__VALUE=ignored\n",
160        )
161        .unwrap();
162
163        let required = DotenvFileSource::required(&env_file, "APP")
164            .collect()
165            .unwrap();
166        assert_eq!(required.get_int("service.port").unwrap(), 8080);
167        assert!(required.get_bool("service.debug").unwrap());
168        assert!(required.get_string("other.value").is_err());
169
170        let profile = DotenvFileSource::profile(&env_file, "APP")
171            .collect()
172            .unwrap();
173        assert_eq!(profile.get_int("service.port").unwrap(), 8080);
174
175        let malformed = dir.path().join("bad.env");
176        std::fs::write(&malformed, "not valid dotenv '").unwrap();
177        let ignored = DotenvFileSource::auto_discovered(&malformed, "")
178            .collect()
179            .unwrap();
180        assert!(ignored.get_string("anything").is_err());
181    }
182}