1use std::path::Path;
2
3use crate::{Result, cli_util, env_file};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum ProfileTokenSource {
7 None,
8 Profile,
9 EnvFallback { env_name: String },
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ProfileTokenResolution {
14 pub bearer_token: Option<String>,
15 pub token_name: String,
16 pub source: ProfileTokenSource,
17}
18
19#[derive(Debug, Clone, Copy)]
20pub struct ProfileTokenConfig<'a> {
21 pub token_name_arg: Option<&'a str>,
22 pub token_name_env_var: &'a str,
23 pub token_name_file_var: &'a str,
24 pub token_var_prefix: &'a str,
25 pub tokens_env: &'a Path,
26 pub tokens_local: &'a Path,
27 pub tokens_files: &'a [&'a Path],
28 pub missing_profile_hint: &'a str,
29 pub env_fallback_keys: &'a [&'a str],
30}
31
32pub fn resolve_profile_or_env_fallback(
33 config: ProfileTokenConfig<'_>,
34) -> Result<ProfileTokenResolution> {
35 let token_name_arg = config.token_name_arg.and_then(cli_util::trim_non_empty);
36 let token_name_env = std::env::var(config.token_name_env_var)
37 .ok()
38 .and_then(|s| cli_util::trim_non_empty(&s));
39 let token_name_file = if !config.tokens_files.is_empty() {
40 env_file::read_var_last_wins(config.token_name_file_var, config.tokens_files)?
41 } else {
42 None
43 };
44
45 let token_profile_selected =
46 token_name_arg.is_some() || token_name_env.is_some() || token_name_file.is_some();
47 let token_name = token_name_arg
48 .or(token_name_env)
49 .or(token_name_file)
50 .unwrap_or_else(|| "default".to_string())
51 .to_ascii_lowercase();
52
53 if token_profile_selected {
54 let token_key = cli_util::to_env_key(&token_name);
55 let token_var = format!("{}{}", config.token_var_prefix, token_key);
56 let bearer_token = env_file::read_var_last_wins(&token_var, config.tokens_files)?;
57 let Some(bearer_token) = bearer_token else {
58 let available = available_token_profiles(
59 config.tokens_env,
60 config.tokens_local,
61 config.token_var_prefix,
62 );
63 anyhow::bail!(
64 "Token profile '{token_name}' is empty/missing (available: {available}). {}",
65 config.missing_profile_hint
66 );
67 };
68
69 return Ok(ProfileTokenResolution {
70 bearer_token: Some(bearer_token),
71 token_name,
72 source: ProfileTokenSource::Profile,
73 });
74 }
75
76 if let Some((token, env_name)) = resolve_env_fallback(config.env_fallback_keys) {
77 return Ok(ProfileTokenResolution {
78 bearer_token: Some(token),
79 token_name,
80 source: ProfileTokenSource::EnvFallback { env_name },
81 });
82 }
83
84 Ok(ProfileTokenResolution {
85 bearer_token: None,
86 token_name,
87 source: ProfileTokenSource::None,
88 })
89}
90
91fn available_token_profiles(
92 tokens_env: &Path,
93 tokens_local: &Path,
94 token_var_prefix: &str,
95) -> String {
96 let mut available = cli_util::list_available_suffixes(tokens_env, token_var_prefix);
97 if tokens_local.is_file() {
98 available.extend(cli_util::list_available_suffixes(
99 tokens_local,
100 token_var_prefix,
101 ));
102 available.sort();
103 available.dedup();
104 }
105 available.retain(|name| name != "name");
106 if available.is_empty() {
107 "none".to_string()
108 } else {
109 available.join(" ")
110 }
111}
112
113pub fn resolve_env_fallback(keys: &[&str]) -> Option<(String, String)> {
114 for &key in keys {
115 let Ok(value) = std::env::var(key) else {
116 continue;
117 };
118 if let Some(token) = cli_util::trim_non_empty(&value) {
119 return Some((token, key.to_string()));
120 }
121 }
122 None
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use nils_test_support::{EnvGuard, GlobalStateLock};
129 use tempfile::TempDir;
130
131 fn write_file(path: &Path, contents: &str) {
132 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
133 std::fs::write(path, contents).expect("write");
134 }
135
136 #[test]
137 fn resolve_env_fallback_prefers_order() {
138 let lock = GlobalStateLock::new();
139 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "access");
140 let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
141
142 assert_eq!(
143 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
144 Some(("access".to_string(), "ACCESS_TOKEN".to_string()))
145 );
146 }
147
148 #[test]
149 fn resolve_env_fallback_skips_empty_and_whitespace() {
150 let lock = GlobalStateLock::new();
151 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", " ");
152 let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
153
154 assert_eq!(
155 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
156 Some(("service".to_string(), "SERVICE_TOKEN".to_string()))
157 );
158 }
159
160 #[test]
161 fn resolve_env_fallback_returns_none_when_missing() {
162 let lock = GlobalStateLock::new();
163 let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
164 let _service = EnvGuard::remove(&lock, "SERVICE_TOKEN");
165
166 assert_eq!(
167 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
168 None
169 );
170 }
171
172 #[test]
173 fn resolve_profile_or_env_fallback_prefers_selected_profile() {
174 let lock = GlobalStateLock::new();
175 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
176 let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
177
178 let tmp = TempDir::new().expect("tmp");
179 let tokens_env = tmp.path().join("tokens.env");
180 let tokens_local = tmp.path().join("tokens.local.env");
181 write_file(&tokens_env, "REST_TOKEN_SVC=svc-token\n");
182
183 let files = [&tokens_env as &Path, &tokens_local as &Path];
184 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
185 token_name_arg: Some("svc"),
186 token_name_env_var: "REST_TOKEN_NAME",
187 token_name_file_var: "REST_TOKEN_NAME",
188 token_var_prefix: "REST_TOKEN_",
189 tokens_env: &tokens_env,
190 tokens_local: &tokens_local,
191 tokens_files: &files,
192 missing_profile_hint: "hint",
193 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
194 })
195 .expect("profile token resolution");
196
197 assert_eq!(resolved.bearer_token.as_deref(), Some("svc-token"));
198 assert_eq!(resolved.token_name, "svc");
199 assert_eq!(resolved.source, ProfileTokenSource::Profile);
200 }
201
202 #[test]
203 fn resolve_profile_or_env_fallback_uses_env_fallback_without_profile_selection() {
204 let lock = GlobalStateLock::new();
205 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
206 let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
207
208 let tmp = TempDir::new().expect("tmp");
209 let tokens_env = tmp.path().join("tokens.env");
210 let tokens_local = tmp.path().join("tokens.local.env");
211 let files = [&tokens_env as &Path, &tokens_local as &Path];
212
213 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
214 token_name_arg: None,
215 token_name_env_var: "REST_TOKEN_NAME",
216 token_name_file_var: "REST_TOKEN_NAME",
217 token_var_prefix: "REST_TOKEN_",
218 tokens_env: &tokens_env,
219 tokens_local: &tokens_local,
220 tokens_files: &files,
221 missing_profile_hint: "hint",
222 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
223 })
224 .expect("fallback resolution");
225
226 assert_eq!(resolved.bearer_token.as_deref(), Some("env-token"));
227 assert_eq!(resolved.token_name, "default");
228 assert_eq!(
229 resolved.source,
230 ProfileTokenSource::EnvFallback {
231 env_name: "ACCESS_TOKEN".to_string()
232 }
233 );
234 }
235
236 #[test]
237 fn resolve_profile_or_env_fallback_reports_available_profiles_when_missing() {
238 let lock = GlobalStateLock::new();
239 let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "missing");
240 let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
241
242 let tmp = TempDir::new().expect("tmp");
243 let tokens_env = tmp.path().join("tokens.env");
244 let tokens_local = tmp.path().join("tokens.local.env");
245 write_file(
246 &tokens_env,
247 "REST_TOKEN_SVC=svc-token\nREST_TOKEN_DEV=dev-token\n",
248 );
249 let files = [&tokens_env as &Path, &tokens_local as &Path];
250
251 let err = resolve_profile_or_env_fallback(ProfileTokenConfig {
252 token_name_arg: None,
253 token_name_env_var: "REST_TOKEN_NAME",
254 token_name_file_var: "REST_TOKEN_NAME",
255 token_var_prefix: "REST_TOKEN_",
256 tokens_env: &tokens_env,
257 tokens_local: &tokens_local,
258 tokens_files: &files,
259 missing_profile_hint: "Set it in setup/rest/tokens.local.env.",
260 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
261 })
262 .expect_err("missing profile should error");
263
264 let text = err.to_string();
265 assert!(text.contains("Token profile 'missing' is empty/missing"));
266 assert!(text.contains("svc dev") || text.contains("dev svc"));
267 assert!(text.contains("setup/rest/tokens.local.env"));
268 }
269
270 #[test]
271 fn resolve_profile_or_env_fallback_prefers_env_over_file_for_name() {
272 let lock = GlobalStateLock::new();
273 let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "prod");
274
275 let tmp = TempDir::new().expect("tmp");
276 let tokens_env = tmp.path().join("tokens.env");
277 let tokens_local = tmp.path().join("tokens.local.env");
278 write_file(
279 &tokens_env,
280 "REST_TOKEN_NAME=staging\nREST_TOKEN_PROD=prod-token\n",
281 );
282 let files = [&tokens_env as &Path, &tokens_local as &Path];
283
284 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
285 token_name_arg: None,
286 token_name_env_var: "REST_TOKEN_NAME",
287 token_name_file_var: "REST_TOKEN_NAME",
288 token_var_prefix: "REST_TOKEN_",
289 tokens_env: &tokens_env,
290 tokens_local: &tokens_local,
291 tokens_files: &files,
292 missing_profile_hint: "hint",
293 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
294 })
295 .expect("env token name resolution");
296
297 assert_eq!(resolved.bearer_token.as_deref(), Some("prod-token"));
298 assert_eq!(resolved.token_name, "prod");
299 assert_eq!(resolved.source, ProfileTokenSource::Profile);
300 }
301}