rskit_config/source/
dotenv.rs1use 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#[derive(Debug, Clone)]
11pub enum Profile {
12 Name(String),
14 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#[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 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}