1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4#[derive(Debug, Clone, Serialize, JsonSchema)]
25#[serde(untagged)]
26pub enum EnvFilesConfig {
27 List(Vec<String>),
29 TokenFiles(EnvFilesTokenConfig),
31}
32
33impl<'de> Deserialize<'de> for EnvFilesConfig {
34 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
35 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
36 match &value {
37 serde_yaml_ng::Value::Sequence(_) => {
38 let list: Vec<String> =
39 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
40 Ok(EnvFilesConfig::List(list))
41 }
42 serde_yaml_ng::Value::Mapping(_) => {
43 let tokens: EnvFilesTokenConfig =
44 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
45 Ok(EnvFilesConfig::TokenFiles(tokens))
46 }
47 _ => Err(serde::de::Error::custom(
48 "env_files must be an array of file paths or a mapping with token file paths",
49 )),
50 }
51 }
52}
53
54impl EnvFilesConfig {
55 pub fn as_list(&self) -> Option<&[String]> {
57 match self {
58 EnvFilesConfig::List(files) => Some(files),
59 EnvFilesConfig::TokenFiles(_) => None,
60 }
61 }
62
63 pub fn as_token_files(&self) -> Option<&EnvFilesTokenConfig> {
65 match self {
66 EnvFilesConfig::List(_) => None,
67 EnvFilesConfig::TokenFiles(tokens) => Some(tokens),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
80#[serde(default, deny_unknown_fields)]
81pub struct EnvFilesTokenConfig {
82 pub github_token: Option<String>,
84 pub gitlab_token: Option<String>,
86 pub gitea_token: Option<String>,
88}
89
90pub fn read_token_file(path: &str) -> Result<Option<String>, String> {
95 read_token_file_with_env(path, &crate::ProcessEnvSource)
96}
97
98pub fn read_token_file_with_env<E: crate::EnvSource + ?Sized>(
104 path: &str,
105 env: &E,
106) -> Result<Option<String>, String> {
107 let expanded = crate::path_util::expand_tilde_with_env(path, env);
108
109 match std::fs::read_to_string(expanded.as_ref()) {
110 Ok(content) => {
111 let token = content.lines().next().unwrap_or("").trim().to_string();
112 if token.is_empty() {
113 Ok(None)
114 } else {
115 Ok(Some(token))
116 }
117 }
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
119 Err(e) => Err(format!("failed to read token file '{}': {}", path, e)),
120 }
121}
122
123pub fn load_token_files(
133 config: &EnvFilesTokenConfig,
134 log: &crate::log::StageLogger,
135) -> Result<std::collections::HashMap<String, String>, String> {
136 load_token_files_with_env(config, log, &crate::ProcessEnvSource)
137}
138
139pub fn load_token_files_with_env<E: crate::EnvSource + ?Sized>(
146 config: &EnvFilesTokenConfig,
147 log: &crate::log::StageLogger,
148 env: &E,
149) -> Result<std::collections::HashMap<String, String>, String> {
150 let mut vars = std::collections::HashMap::new();
151
152 let github_candidates: Vec<&str> = match config.github_token.as_deref() {
156 Some(p) => vec![p],
157 None => vec![
158 "~/.config/anodizer/github_token",
159 "~/.config/goreleaser/github_token",
160 ],
161 };
162 let gitlab_candidates: Vec<&str> = match config.gitlab_token.as_deref() {
163 Some(p) => vec![p],
164 None => vec![
165 "~/.config/anodizer/gitlab_token",
166 "~/.config/goreleaser/gitlab_token",
167 ],
168 };
169 let gitea_candidates: Vec<&str> = match config.gitea_token.as_deref() {
170 Some(p) => vec![p],
171 None => vec![
172 "~/.config/anodizer/gitea_token",
173 "~/.config/goreleaser/gitea_token",
174 ],
175 };
176 let mappings: [(&str, &[&str]); 3] = [
177 ("GITHUB_TOKEN", &github_candidates),
178 ("GITLAB_TOKEN", &gitlab_candidates),
179 ("GITEA_TOKEN", &gitea_candidates),
180 ];
181
182 for (env_name, candidates) in &mappings {
183 if env.var(env_name).filter(|v| !v.is_empty()).is_some() {
185 log.verbose(&format!("using {} from process environment", env_name));
186 continue;
187 }
188 for file_path in candidates.iter() {
189 match read_token_file_with_env(file_path, env) {
190 Ok(Some(token)) => {
191 log.verbose(&format!("loaded {} from {}", env_name, file_path));
192 vars.insert(env_name.to_string(), token);
193 break;
194 }
195 Ok(None) => {
196 }
198 Err(e) => {
199 return Err(e);
200 }
201 }
202 }
203 }
204
205 Ok(vars)
206}
207
208pub fn load_env_files(
214 files: &[String],
215 log: &crate::log::StageLogger,
216 strict: bool,
217) -> Result<std::collections::HashMap<String, String>, String> {
218 let mut vars = std::collections::HashMap::new();
219 for file_path in files {
220 let content = match std::fs::read_to_string(file_path) {
221 Ok(c) => c,
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
223 if strict {
224 return Err(format!("env file '{}' not found (strict mode)", file_path));
225 }
226 log.warn(&format!("skipped env file '{}' — not found", file_path));
227 continue;
228 }
229 Err(e) => {
230 return Err(format!("failed to read env file '{}': {}", file_path, e));
231 }
232 };
233 for line in content.lines() {
234 let trimmed = line.trim();
235 if trimmed.is_empty() || trimmed.starts_with('#') {
236 continue;
237 }
238 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
240 if let Some((key, value)) = trimmed.split_once('=') {
241 let key = key.trim();
242 if key.is_empty() {
243 log.warn(&format!(
244 "skipped line — empty key in '{}': {}",
245 file_path,
246 line.trim()
247 ));
248 continue;
249 }
250 let value = value.trim();
251 let value = if value.len() >= 2
253 && ((value.starts_with('"') && value.ends_with('"'))
254 || (value.starts_with('\'') && value.ends_with('\'')))
255 {
256 &value[1..value.len() - 1]
257 } else {
258 value
259 };
260 vars.insert(key.to_string(), value.to_string());
261 } else {
262 log.warn(&format!(
263 "skipped line — no '=' in '{}': {}",
264 file_path, trimmed
265 ));
266 }
267 }
268 }
269 Ok(vars)
270}
271
272pub use crate::env::{parse_env_entries, render_env_entries, split_env_entry};
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn test_log() -> crate::log::StageLogger {
288 crate::log::StageLogger::new("test", crate::log::Verbosity::Normal)
289 }
290
291 #[test]
294 fn env_files_scalar_input_is_rejected() {
295 let r: Result<EnvFilesConfig, _> = serde_yaml_ng::from_str("just-a-string");
298 assert!(r.is_err(), "a scalar env_files value must be rejected");
299 }
300
301 #[test]
302 fn as_list_and_as_token_files_are_mutually_exclusive() {
303 let list = EnvFilesConfig::List(vec![".env".into()]);
304 assert_eq!(list.as_list(), Some([".env".to_string()].as_slice()));
305 assert!(list.as_token_files().is_none());
306
307 let tokens = EnvFilesConfig::TokenFiles(EnvFilesTokenConfig::default());
308 assert!(tokens.as_list().is_none());
309 assert!(tokens.as_token_files().is_some());
310 }
311
312 #[test]
315 fn unset_field_prefers_anodizer_path_over_goreleaser() {
316 let dir = tempfile::TempDir::new().unwrap();
320 let home = dir.path();
321 let anod = home.join(".config/anodizer/github_token");
322 let gore = home.join(".config/goreleaser/github_token");
323 std::fs::create_dir_all(anod.parent().unwrap()).unwrap();
324 std::fs::create_dir_all(gore.parent().unwrap()).unwrap();
325 std::fs::write(&anod, "anodizer_tok\n").unwrap();
326 std::fs::write(&gore, "goreleaser_tok\n").unwrap();
327
328 let env = crate::MapEnvSource::new().with("HOME", home.to_string_lossy().to_string());
329 let vars =
330 load_token_files_with_env(&EnvFilesTokenConfig::default(), &test_log(), &env).unwrap();
331 assert_eq!(vars.get("GITHUB_TOKEN").unwrap(), "anodizer_tok");
332 }
333
334 #[test]
335 fn unset_field_falls_back_to_goreleaser_when_anodizer_absent() {
336 let dir = tempfile::TempDir::new().unwrap();
338 let home = dir.path();
339 let gore = home.join(".config/goreleaser/gitlab_token");
340 std::fs::create_dir_all(gore.parent().unwrap()).unwrap();
341 std::fs::write(&gore, "gl_fallback\n").unwrap();
342
343 let env = crate::MapEnvSource::new().with("HOME", home.to_string_lossy().to_string());
344 let vars =
345 load_token_files_with_env(&EnvFilesTokenConfig::default(), &test_log(), &env).unwrap();
346 assert_eq!(vars.get("GITLAB_TOKEN").unwrap(), "gl_fallback");
347 }
348
349 #[test]
352 fn strict_mode_errors_on_missing_env_file() {
353 let r = load_env_files(
354 &["/nonexistent/anodizer/strict/.env".to_string()],
355 &test_log(),
356 true,
357 );
358 let err = r.unwrap_err();
359 assert!(
360 err.contains("strict mode"),
361 "expected strict-mode error, got: {err}"
362 );
363 }
364}