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};