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    let expanded = crate::path_util::expand_tilde(path);
96
97    match std::fs::read_to_string(expanded.as_ref()) {
98        Ok(content) => {
99            let token = content.lines().next().unwrap_or("").trim().to_string();
100            if token.is_empty() {
101                Ok(None)
102            } else {
103                Ok(Some(token))
104            }
105        }
106        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
107        Err(e) => Err(format!("failed to read token file '{}': {}", path, e)),
108    }
109}
110
111/// Load tokens from structured `env_files` config.
112///
113/// For each configured token file path, reads the file and returns the
114/// corresponding environment variable name and token value.
115/// Falls back to the default token-file paths (`~/.config/goreleaser/...`) when
116/// a field is not specified.
117///
118/// Only returns entries where the corresponding process env var is NOT already
119/// set (env var takes precedence).
120pub fn load_token_files(
121    config: &EnvFilesTokenConfig,
122    log: &crate::log::StageLogger,
123) -> Result<std::collections::HashMap<String, String>, String> {
124    let mut vars = std::collections::HashMap::new();
125
126    // Per-token candidate paths. The user's explicit `github_token` / etc.
127    // config value wins if present; otherwise we try anodizer-native first,
128    // then the conventional path for users migrating in.
129    let github_candidates: Vec<&str> = match config.github_token.as_deref() {
130        Some(p) => vec![p],
131        None => vec![
132            "~/.config/anodizer/github_token",
133            "~/.config/goreleaser/github_token",
134        ],
135    };
136    let gitlab_candidates: Vec<&str> = match config.gitlab_token.as_deref() {
137        Some(p) => vec![p],
138        None => vec![
139            "~/.config/anodizer/gitlab_token",
140            "~/.config/goreleaser/gitlab_token",
141        ],
142    };
143    let gitea_candidates: Vec<&str> = match config.gitea_token.as_deref() {
144        Some(p) => vec![p],
145        None => vec![
146            "~/.config/anodizer/gitea_token",
147            "~/.config/goreleaser/gitea_token",
148        ],
149    };
150    let mappings: [(&str, &[&str]); 3] = [
151        ("GITHUB_TOKEN", &github_candidates),
152        ("GITLAB_TOKEN", &gitlab_candidates),
153        ("GITEA_TOKEN", &gitea_candidates),
154    ];
155
156    for (env_name, candidates) in &mappings {
157        // Skip if the env var is already set in the process environment
158        if std::env::var(env_name)
159            .ok()
160            .filter(|v| !v.is_empty())
161            .is_some()
162        {
163            log.verbose(&format!("using {} from process environment", env_name));
164            continue;
165        }
166        for file_path in candidates.iter() {
167            match read_token_file(file_path) {
168                Ok(Some(token)) => {
169                    log.verbose(&format!("loaded {} from {}", env_name, file_path));
170                    vars.insert(env_name.to_string(), token);
171                    break;
172                }
173                Ok(None) => {
174                    // File doesn't exist or is empty — try next candidate
175                }
176                Err(e) => {
177                    return Err(e);
178                }
179            }
180        }
181    }
182
183    Ok(vars)
184}
185
186/// Load environment variables from .env-style files.
187/// Each file is read as KEY=VALUE lines. Lines starting with # and empty lines are skipped.
188/// Returns a HashMap of parsed key-value pairs. Does NOT mutate the process
189/// environment — callers should inject these into the template context via
190/// `set_env()` and pass them to subprocesses via `Command::envs()`.
191pub fn load_env_files(
192    files: &[String],
193    log: &crate::log::StageLogger,
194    strict: bool,
195) -> Result<std::collections::HashMap<String, String>, String> {
196    let mut vars = std::collections::HashMap::new();
197    for file_path in files {
198        let content = match std::fs::read_to_string(file_path) {
199            Ok(c) => c,
200            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
201                if strict {
202                    return Err(format!("env file '{}' not found (strict mode)", file_path));
203                }
204                log.warn(&format!("env file '{}' not found, skipping", file_path));
205                continue;
206            }
207            Err(e) => {
208                return Err(format!("failed to read env file '{}': {}", file_path, e));
209            }
210        };
211        for line in content.lines() {
212            let trimmed = line.trim();
213            if trimmed.is_empty() || trimmed.starts_with('#') {
214                continue;
215            }
216            // Strip `export ` prefix (common in .env files)
217            let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
218            if let Some((key, value)) = trimmed.split_once('=') {
219                let key = key.trim();
220                if key.is_empty() {
221                    log.warn(&format!(
222                        "skipping line with empty key in '{}': {}",
223                        file_path,
224                        line.trim()
225                    ));
226                    continue;
227                }
228                let value = value.trim();
229                // Strip surrounding quotes from value if present
230                let value = if value.len() >= 2
231                    && ((value.starts_with('"') && value.ends_with('"'))
232                        || (value.starts_with('\'') && value.ends_with('\'')))
233                {
234                    &value[1..value.len() - 1]
235                } else {
236                    value
237                };
238                vars.insert(key.to_string(), value.to_string());
239            } else {
240                log.warn(&format!(
241                    "skipping line without '=' in '{}': {}",
242                    file_path, trimmed
243                ));
244            }
245        }
246    }
247    Ok(vars)
248}
249
250// ---------------------------------------------------------------------------
251// env helpers — Vec<String> of "KEY=VAL" entries
252// ---------------------------------------------------------------------------
253//
254// Lifted to `crate::env` so they are reachable as
255// `anodizer_core::env::*` directly. The re-exports below preserve the
256// historical `anodizer_core::config::*` import paths used by stages and
257// publishers.
258
259pub use crate::env::{parse_env_entries, render_env_entries, split_env_entry};