1use std::collections::HashMap;
7use std::fs;
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::atlassian::error::AtlassianError;
13use crate::utils::settings::Settings;
14
15pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
17
18pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
20
21pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
23
24#[derive(Debug, Clone)]
26pub struct AtlassianCredentials {
27 pub instance_url: String,
29
30 pub email: String,
32
33 pub api_token: String,
35}
36
37pub fn load_credentials() -> Result<AtlassianCredentials> {
41 load_credentials_with_instance(None)
42}
43
44pub fn load_credentials_with_instance(
53 instance_override: Option<&str>,
54) -> Result<AtlassianCredentials> {
55 let settings = Settings::load().unwrap_or(Settings {
56 env: HashMap::new(),
57 });
58
59 let instance_url = match instance_override {
60 Some(url) => url.to_string(),
61 None => settings
62 .get_env_var(ATLASSIAN_INSTANCE_URL)
63 .ok_or(AtlassianError::CredentialsNotFound)?,
64 };
65 let email = settings
66 .get_env_var(ATLASSIAN_EMAIL)
67 .ok_or(AtlassianError::CredentialsNotFound)?;
68 let api_token = settings
69 .get_env_var(ATLASSIAN_API_TOKEN)
70 .ok_or(AtlassianError::CredentialsNotFound)?;
71
72 let instance_url = instance_url.trim_end_matches('/').to_string();
74
75 Ok(AtlassianCredentials {
76 instance_url,
77 email,
78 api_token,
79 })
80}
81
82#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87pub struct AtlassianScopeStatus {
88 pub name: String,
91 pub has_email: bool,
93 pub has_token: bool,
95 #[serde(skip_serializing_if = "Option::is_none")]
99 pub instance_url: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
104pub struct AuthStatus {
105 pub scopes: Vec<AtlassianScopeStatus>,
108}
109
110pub fn status() -> AuthStatus {
118 let settings = Settings::load().unwrap_or(Settings {
119 env: HashMap::new(),
120 });
121
122 let instance_url = settings
123 .get_env_var(ATLASSIAN_INSTANCE_URL)
124 .map(|v| v.trim_end_matches('/').to_string());
125 let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
126 let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
127
128 AuthStatus {
129 scopes: vec![AtlassianScopeStatus {
130 name: "default".to_string(),
131 has_email,
132 has_token,
133 instance_url,
134 }],
135 }
136}
137
138pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
143 let settings_path = Settings::get_settings_path()?;
144
145 let mut settings_value: serde_json::Value = if settings_path.exists() {
147 let content = fs::read_to_string(&settings_path)
148 .with_context(|| format!("Failed to read {}", settings_path.display()))?;
149 serde_json::from_str(&content)
150 .with_context(|| format!("Failed to parse {}", settings_path.display()))?
151 } else {
152 serde_json::json!({})
153 };
154
155 if !settings_value
157 .get("env")
158 .is_some_and(serde_json::Value::is_object)
159 {
160 settings_value["env"] = serde_json::json!({});
161 }
162
163 let Some(env) = settings_value["env"].as_object_mut() else {
165 anyhow::bail!("Internal error: env key is not an object after initialization");
166 };
167 env.insert(
168 ATLASSIAN_INSTANCE_URL.to_string(),
169 serde_json::Value::String(credentials.instance_url.clone()),
170 );
171 env.insert(
172 ATLASSIAN_EMAIL.to_string(),
173 serde_json::Value::String(credentials.email.clone()),
174 );
175 env.insert(
176 ATLASSIAN_API_TOKEN.to_string(),
177 serde_json::Value::String(credentials.api_token.clone()),
178 );
179
180 if let Some(parent) = settings_path.parent() {
182 fs::create_dir_all(parent)
183 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
184 }
185
186 let formatted = serde_json::to_string_pretty(&settings_value)
188 .context("Failed to serialize settings JSON")?;
189 fs::write(&settings_path, formatted)
190 .with_context(|| format!("Failed to write {}", settings_path.display()))?;
191
192 Ok(())
193}
194
195#[cfg(test)]
201#[allow(clippy::unwrap_used, clippy::expect_used)]
202pub(crate) mod test_util {
203 use super::{ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL};
204
205 pub(crate) static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
209
210 pub(crate) struct EnvGuard {
215 _lock: std::sync::MutexGuard<'static, ()>,
216 snapshot: Vec<(&'static str, Option<String>)>,
217 }
218
219 impl EnvGuard {
220 pub(crate) fn take() -> Self {
221 let lock = AUTH_ENV_MUTEX
222 .lock()
223 .unwrap_or_else(std::sync::PoisonError::into_inner);
224 let keys = [
225 "HOME",
226 ATLASSIAN_INSTANCE_URL,
227 ATLASSIAN_EMAIL,
228 ATLASSIAN_API_TOKEN,
229 ];
230 let snapshot = keys
231 .into_iter()
232 .map(|k| (k, std::env::var(k).ok()))
233 .collect();
234 Self {
235 _lock: lock,
236 snapshot,
237 }
238 }
239
240 pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
245 let dir = {
246 std::fs::create_dir_all("tmp").ok();
247 tempfile::TempDir::new_in("tmp").unwrap()
248 };
249 std::env::set_var("HOME", dir.path());
250 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
251 std::env::remove_var(ATLASSIAN_EMAIL);
252 std::env::remove_var(ATLASSIAN_API_TOKEN);
253 dir
254 }
255
256 pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
261 let dir = {
262 std::fs::create_dir_all("tmp").ok();
263 tempfile::TempDir::new_in("tmp").unwrap()
264 };
265 std::env::set_var("HOME", dir.path());
266 std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
267 std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
268 std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
269 dir
270 }
271 }
272
273 impl Drop for EnvGuard {
274 fn drop(&mut self) {
275 for (k, v) in &self.snapshot {
276 match v {
277 Some(val) => std::env::set_var(k, val),
278 None => std::env::remove_var(k),
279 }
280 }
281 }
282 }
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used, clippy::expect_used)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn save_and_read_credentials() {
292 let temp_dir = {
293 std::fs::create_dir_all("tmp").ok();
294 tempfile::TempDir::new_in("tmp").unwrap()
295 };
296 let settings_path = temp_dir.path().join("settings.json");
297
298 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
300 fs::write(&settings_path, existing).unwrap();
301
302 let content = fs::read_to_string(&settings_path).unwrap();
304 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
305 val["env"]["ATLASSIAN_INSTANCE_URL"] =
306 serde_json::Value::String("https://test.atlassian.net".to_string());
307 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
308 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
309 let formatted = serde_json::to_string_pretty(&val).unwrap();
310 fs::write(&settings_path, formatted).unwrap();
311
312 let content = fs::read_to_string(&settings_path).unwrap();
314 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
315 assert_eq!(val["env"]["SOME_KEY"], "value");
316 assert_eq!(
317 val["env"]["ATLASSIAN_INSTANCE_URL"],
318 "https://test.atlassian.net"
319 );
320 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
321 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
322 }
323
324 #[test]
325 fn load_credentials_normalizes_trailing_slash() {
326 let url = "https://env.atlassian.net/";
328 let normalized = url.trim_end_matches('/').to_string();
329 assert_eq!(normalized, "https://env.atlassian.net");
330 }
331
332 #[test]
333 fn constant_key_names() {
334 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
335 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
336 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
337 }
338
339 #[test]
340 fn credentials_struct_clone_and_debug() {
341 let creds = AtlassianCredentials {
342 instance_url: "https://org.atlassian.net".to_string(),
343 email: "user@test.com".to_string(),
344 api_token: "token".to_string(),
345 };
346 let cloned = creds.clone();
347 assert_eq!(cloned.instance_url, creds.instance_url);
348 assert_eq!(cloned.email, creds.email);
349 assert_eq!(cloned.api_token, creds.api_token);
350 let debug = format!("{creds:?}");
352 assert!(debug.contains("AtlassianCredentials"));
353 }
354
355 use super::test_util::EnvGuard;
356
357 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
358 let dir = {
359 std::fs::create_dir_all("tmp").ok();
360 tempfile::TempDir::new_in("tmp").unwrap()
361 };
362 std::env::set_var("HOME", dir.path());
363 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
364 std::env::remove_var(ATLASSIAN_EMAIL);
365 std::env::remove_var(ATLASSIAN_API_TOKEN);
366 dir
367 }
368
369 #[test]
370 fn status_reports_all_false_when_nothing_configured() {
371 let guard = EnvGuard::take();
372 let _dir = with_empty_home(&guard);
373
374 let status = status();
375 assert_eq!(status.scopes.len(), 1);
376 let scope = &status.scopes[0];
377 assert_eq!(scope.name, "default");
378 assert!(!scope.has_email);
379 assert!(!scope.has_token);
380 assert_eq!(scope.instance_url, None);
381 }
382
383 #[test]
384 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
385 let guard = EnvGuard::take();
386 let dir = with_empty_home(&guard);
387 let omni_dir = dir.path().join(".omni-dev");
388 fs::create_dir_all(&omni_dir).unwrap();
389 fs::write(
390 omni_dir.join("settings.json"),
391 r#"{"env":{
392 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
393 "ATLASSIAN_EMAIL":"person@example.com",
394 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
395 }}"#,
396 )
397 .unwrap();
398
399 let status = status();
400 assert_eq!(status.scopes.len(), 1);
401 let scope = &status.scopes[0];
402 assert!(scope.has_email);
403 assert!(scope.has_token);
404 assert_eq!(
405 scope.instance_url.as_deref(),
406 Some("https://status.atlassian.net")
407 );
408
409 let yaml = serde_yaml::to_string(&status).unwrap();
410 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
411 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
412 }
413
414 #[test]
415 fn status_returns_instance_url_from_env_without_trailing_slash() {
416 let guard = EnvGuard::take();
417 let _dir = with_empty_home(&guard);
418 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
419
420 let status = status();
421 let scope = &status.scopes[0];
422 assert_eq!(
423 scope.instance_url.as_deref(),
424 Some("https://env.atlassian.net")
425 );
426 assert!(!scope.has_email);
427 assert!(!scope.has_token);
428 }
429
430 #[test]
433 fn save_credentials_creates_and_preserves() {
434 let _guard = EnvGuard::take();
437 let original_home = std::env::var("HOME").ok();
438
439 {
441 let temp_dir = {
442 std::fs::create_dir_all("tmp").ok();
443 tempfile::TempDir::new_in("tmp").unwrap()
444 };
445 std::env::set_var("HOME", temp_dir.path());
446
447 let creds = AtlassianCredentials {
448 instance_url: "https://save.atlassian.net".to_string(),
449 email: "save@example.com".to_string(),
450 api_token: "save-token".to_string(),
451 };
452 save_credentials(&creds).unwrap();
453
454 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
455 assert!(settings_path.exists());
456 let content = fs::read_to_string(&settings_path).unwrap();
457 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
458 assert_eq!(
459 val["env"]["ATLASSIAN_INSTANCE_URL"],
460 "https://save.atlassian.net"
461 );
462 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
463 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
464 }
465
466 {
468 let temp_dir = {
469 std::fs::create_dir_all("tmp").ok();
470 tempfile::TempDir::new_in("tmp").unwrap()
471 };
472 let omni_dir = temp_dir.path().join(".omni-dev");
473 fs::create_dir_all(&omni_dir).unwrap();
474 let settings_path = omni_dir.join("settings.json");
475 fs::write(
476 &settings_path,
477 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
478 )
479 .unwrap();
480
481 std::env::set_var("HOME", temp_dir.path());
482
483 let creds = AtlassianCredentials {
484 instance_url: "https://org.atlassian.net".to_string(),
485 email: "user@test.com".to_string(),
486 api_token: "token".to_string(),
487 };
488 save_credentials(&creds).unwrap();
489
490 let content = fs::read_to_string(&settings_path).unwrap();
491 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
492 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
493 assert_eq!(val["extra"], true);
494 assert_eq!(
495 val["env"]["ATLASSIAN_INSTANCE_URL"],
496 "https://org.atlassian.net"
497 );
498 }
499
500 if let Some(home) = original_home {
502 std::env::set_var("HOME", home);
503 }
504 }
505
506 #[test]
507 fn load_credentials_with_instance_override_supplies_instance_url() {
508 let guard = EnvGuard::take();
512 let _dir = with_empty_home(&guard);
513 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
514 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
515
516 let creds =
517 load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
518 assert_eq!(creds.instance_url, "https://override.atlassian.net");
519 assert_eq!(creds.email, "person@example.com");
520 assert_eq!(creds.api_token, "token");
521 }
522
523 #[test]
524 fn load_credentials_with_instance_none_requires_env_instance() {
525 let guard = EnvGuard::take();
528 let _dir = with_empty_home(&guard);
529 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
530 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
531
532 assert!(load_credentials_with_instance(None).is_err());
533 }
534}