Skip to main content

anodizer_core/config/
env_files.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4// ---------------------------------------------------------------------------
5// EnvFilesConfig — accepts list of .env paths OR structured token file paths
6// ---------------------------------------------------------------------------
7
8/// Environment file configuration.
9///
10/// Accepts two forms:
11/// - **List form** (anodizer extension): array of `.env` file paths loaded as KEY=VALUE.
12///   ```yaml
13///   env_files:
14///     - .env
15///     - .release.env
16///   ```
17/// - **Struct form**: paths to files containing provider tokens.
18///   ```yaml
19///   env_files:
20///     github_token: ~/.config/goreleaser/github_token
21///     gitlab_token: ~/.config/goreleaser/gitlab_token
22///     gitea_token: ~/.config/goreleaser/gitea_token
23///   ```
24#[derive(Debug, Clone, Serialize, JsonSchema)]
25#[serde(untagged)]
26pub enum EnvFilesConfig {
27    /// List of `.env` file paths to load (KEY=VALUE format).
28    List(Vec<String>),
29    /// Structured token file paths.
30    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    /// Returns the list of .env file paths if this is the List variant.
56    pub fn as_list(&self) -> Option<&[String]> {
57        match self {
58            EnvFilesConfig::List(files) => Some(files),
59            EnvFilesConfig::TokenFiles(_) => None,
60        }
61    }
62
63    /// Returns the token files config if this is the TokenFiles variant.
64    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/// Structured token file paths for provider authentication.
73///
74/// Each field points to a file containing a single-line token. When present,
75/// the file is read and the corresponding environment variable is set
76/// (e.g., `github_token` file -> `GITHUB_TOKEN` env var).
77///
78/// Token-file path overrides.
79#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
80#[serde(default, deny_unknown_fields)]
81pub struct EnvFilesTokenConfig {
82    /// Path to file containing the GitHub token. Default: `~/.config/goreleaser/github_token`.
83    pub github_token: Option<String>,
84    /// Path to file containing the GitLab token. Default: `~/.config/goreleaser/gitlab_token`.
85    pub gitlab_token: Option<String>,
86    /// Path to file containing the Gitea token. Default: `~/.config/goreleaser/gitea_token`.
87    pub gitea_token: Option<String>,
88}
89
90/// Read a single token from a file, returning the first line trimmed.
91///
92/// Returns `Ok(None)` if the file does not exist.
93/// Returns `Err` if the file exists but cannot be read.
94pub fn read_token_file(path: &str) -> Result<Option<String>, String> {
95    read_token_file_with_env(path, &crate::ProcessEnvSource)
96}
97
98/// [`EnvSource`](crate::EnvSource)-injecting form of [`read_token_file`].
99///
100/// Tilde expansion resolves the home directory from `env` instead of the
101/// process environment, so tests can point `~` at a fixture directory
102/// without mutating global `HOME`.
103pub 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
123/// Load tokens from structured `env_files` config.
124///
125/// For each configured token file path, reads the file and returns the
126/// corresponding environment variable name and token value.
127/// Falls back to the default token-file paths (`~/.config/goreleaser/...`) when
128/// a field is not specified.
129///
130/// Only returns entries where the corresponding process env var is NOT already
131/// set (env var takes precedence).
132pub 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
139/// [`EnvSource`](crate::EnvSource)-injecting form of [`load_token_files`].
140///
141/// The "process env var takes precedence over the token file" check and the
142/// `~`-expansion of candidate paths both read through `env`, so tests can
143/// drive token precedence and home-relative paths deterministically without
144/// mutating the process environment.
145pub 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    // Per-token candidate paths. The user's explicit `github_token` / etc.
153    // config value wins if present; otherwise we try anodizer-native first,
154    // then the conventional path for users migrating in.
155    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        // Skip if the env var is already set in the process environment
184        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                    // File doesn't exist or is empty — try next candidate
197                }
198                Err(e) => {
199                    return Err(e);
200                }
201            }
202        }
203    }
204
205    Ok(vars)
206}
207
208/// Load environment variables from .env-style files.
209/// Each file is read as KEY=VALUE lines. Lines starting with # and empty lines are skipped.
210/// Returns a HashMap of parsed key-value pairs. Does NOT mutate the process
211/// environment — callers should inject these into the template context via
212/// `set_env()` and pass them to subprocesses via `Command::envs()`.
213pub 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            // Strip `export ` prefix (common in .env files)
239            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                // Strip surrounding quotes from value if present
252                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
272// ---------------------------------------------------------------------------
273// env helpers — Vec<String> of "KEY=VAL" entries
274// ---------------------------------------------------------------------------
275//
276// Lifted to `crate::env` so they are reachable as
277// `anodizer_core::env::*` directly. The re-exports below preserve the
278// historical `anodizer_core::config::*` import paths used by stages and
279// publishers.
280
281pub use crate::env::{parse_env_entries, render_env_entries, split_env_entry};