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