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