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 let settings = Settings::load().unwrap_or(Settings {
42 env: HashMap::new(),
43 });
44
45 let instance_url = settings
46 .get_env_var(ATLASSIAN_INSTANCE_URL)
47 .ok_or(AtlassianError::CredentialsNotFound)?;
48 let email = settings
49 .get_env_var(ATLASSIAN_EMAIL)
50 .ok_or(AtlassianError::CredentialsNotFound)?;
51 let api_token = settings
52 .get_env_var(ATLASSIAN_API_TOKEN)
53 .ok_or(AtlassianError::CredentialsNotFound)?;
54
55 let instance_url = instance_url.trim_end_matches('/').to_string();
57
58 Ok(AtlassianCredentials {
59 instance_url,
60 email,
61 api_token,
62 })
63}
64
65#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
70pub struct AtlassianScopeStatus {
71 pub name: String,
74 pub has_email: bool,
76 pub has_token: bool,
78 #[serde(skip_serializing_if = "Option::is_none")]
82 pub instance_url: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87pub struct AuthStatus {
88 pub scopes: Vec<AtlassianScopeStatus>,
91}
92
93pub fn status() -> AuthStatus {
101 let settings = Settings::load().unwrap_or(Settings {
102 env: HashMap::new(),
103 });
104
105 let instance_url = settings
106 .get_env_var(ATLASSIAN_INSTANCE_URL)
107 .map(|v| v.trim_end_matches('/').to_string());
108 let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
109 let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
110
111 AuthStatus {
112 scopes: vec![AtlassianScopeStatus {
113 name: "default".to_string(),
114 has_email,
115 has_token,
116 instance_url,
117 }],
118 }
119}
120
121pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
126 let settings_path = Settings::get_settings_path()?;
127
128 let mut settings_value: serde_json::Value = if settings_path.exists() {
130 let content = fs::read_to_string(&settings_path)
131 .with_context(|| format!("Failed to read {}", settings_path.display()))?;
132 serde_json::from_str(&content)
133 .with_context(|| format!("Failed to parse {}", settings_path.display()))?
134 } else {
135 serde_json::json!({})
136 };
137
138 if !settings_value
140 .get("env")
141 .is_some_and(serde_json::Value::is_object)
142 {
143 settings_value["env"] = serde_json::json!({});
144 }
145
146 let Some(env) = settings_value["env"].as_object_mut() else {
148 anyhow::bail!("Internal error: env key is not an object after initialization");
149 };
150 env.insert(
151 ATLASSIAN_INSTANCE_URL.to_string(),
152 serde_json::Value::String(credentials.instance_url.clone()),
153 );
154 env.insert(
155 ATLASSIAN_EMAIL.to_string(),
156 serde_json::Value::String(credentials.email.clone()),
157 );
158 env.insert(
159 ATLASSIAN_API_TOKEN.to_string(),
160 serde_json::Value::String(credentials.api_token.clone()),
161 );
162
163 if let Some(parent) = settings_path.parent() {
165 fs::create_dir_all(parent)
166 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
167 }
168
169 let formatted = serde_json::to_string_pretty(&settings_value)
171 .context("Failed to serialize settings JSON")?;
172 fs::write(&settings_path, formatted)
173 .with_context(|| format!("Failed to write {}", settings_path.display()))?;
174
175 Ok(())
176}
177
178#[cfg(test)]
184#[allow(clippy::unwrap_used, clippy::expect_used)]
185pub(crate) mod test_util {
186 use super::{ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL};
187
188 pub(crate) static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
192
193 pub(crate) struct EnvGuard {
198 _lock: std::sync::MutexGuard<'static, ()>,
199 snapshot: Vec<(&'static str, Option<String>)>,
200 }
201
202 impl EnvGuard {
203 pub(crate) fn take() -> Self {
204 let lock = AUTH_ENV_MUTEX
205 .lock()
206 .unwrap_or_else(std::sync::PoisonError::into_inner);
207 let keys = [
208 "HOME",
209 ATLASSIAN_INSTANCE_URL,
210 ATLASSIAN_EMAIL,
211 ATLASSIAN_API_TOKEN,
212 ];
213 let snapshot = keys
214 .into_iter()
215 .map(|k| (k, std::env::var(k).ok()))
216 .collect();
217 Self {
218 _lock: lock,
219 snapshot,
220 }
221 }
222
223 pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
228 let dir = {
229 std::fs::create_dir_all("tmp").ok();
230 tempfile::TempDir::new_in("tmp").unwrap()
231 };
232 std::env::set_var("HOME", dir.path());
233 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
234 std::env::remove_var(ATLASSIAN_EMAIL);
235 std::env::remove_var(ATLASSIAN_API_TOKEN);
236 dir
237 }
238
239 pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
244 let dir = {
245 std::fs::create_dir_all("tmp").ok();
246 tempfile::TempDir::new_in("tmp").unwrap()
247 };
248 std::env::set_var("HOME", dir.path());
249 std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
250 std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
251 std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
252 dir
253 }
254 }
255
256 impl Drop for EnvGuard {
257 fn drop(&mut self) {
258 for (k, v) in &self.snapshot {
259 match v {
260 Some(val) => std::env::set_var(k, val),
261 None => std::env::remove_var(k),
262 }
263 }
264 }
265 }
266}
267
268#[cfg(test)]
269#[allow(clippy::unwrap_used, clippy::expect_used)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn save_and_read_credentials() {
275 let temp_dir = {
276 std::fs::create_dir_all("tmp").ok();
277 tempfile::TempDir::new_in("tmp").unwrap()
278 };
279 let settings_path = temp_dir.path().join("settings.json");
280
281 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
283 fs::write(&settings_path, existing).unwrap();
284
285 let content = fs::read_to_string(&settings_path).unwrap();
287 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
288 val["env"]["ATLASSIAN_INSTANCE_URL"] =
289 serde_json::Value::String("https://test.atlassian.net".to_string());
290 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
291 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
292 let formatted = serde_json::to_string_pretty(&val).unwrap();
293 fs::write(&settings_path, formatted).unwrap();
294
295 let content = fs::read_to_string(&settings_path).unwrap();
297 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
298 assert_eq!(val["env"]["SOME_KEY"], "value");
299 assert_eq!(
300 val["env"]["ATLASSIAN_INSTANCE_URL"],
301 "https://test.atlassian.net"
302 );
303 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
304 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
305 }
306
307 #[test]
308 fn load_credentials_normalizes_trailing_slash() {
309 let url = "https://env.atlassian.net/";
311 let normalized = url.trim_end_matches('/').to_string();
312 assert_eq!(normalized, "https://env.atlassian.net");
313 }
314
315 #[test]
316 fn constant_key_names() {
317 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
318 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
319 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
320 }
321
322 #[test]
323 fn credentials_struct_clone_and_debug() {
324 let creds = AtlassianCredentials {
325 instance_url: "https://org.atlassian.net".to_string(),
326 email: "user@test.com".to_string(),
327 api_token: "token".to_string(),
328 };
329 let cloned = creds.clone();
330 assert_eq!(cloned.instance_url, creds.instance_url);
331 assert_eq!(cloned.email, creds.email);
332 assert_eq!(cloned.api_token, creds.api_token);
333 let debug = format!("{creds:?}");
335 assert!(debug.contains("AtlassianCredentials"));
336 }
337
338 use super::test_util::EnvGuard;
339
340 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
341 let dir = {
342 std::fs::create_dir_all("tmp").ok();
343 tempfile::TempDir::new_in("tmp").unwrap()
344 };
345 std::env::set_var("HOME", dir.path());
346 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
347 std::env::remove_var(ATLASSIAN_EMAIL);
348 std::env::remove_var(ATLASSIAN_API_TOKEN);
349 dir
350 }
351
352 #[test]
353 fn status_reports_all_false_when_nothing_configured() {
354 let guard = EnvGuard::take();
355 let _dir = with_empty_home(&guard);
356
357 let status = status();
358 assert_eq!(status.scopes.len(), 1);
359 let scope = &status.scopes[0];
360 assert_eq!(scope.name, "default");
361 assert!(!scope.has_email);
362 assert!(!scope.has_token);
363 assert_eq!(scope.instance_url, None);
364 }
365
366 #[test]
367 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
368 let guard = EnvGuard::take();
369 let dir = with_empty_home(&guard);
370 let omni_dir = dir.path().join(".omni-dev");
371 fs::create_dir_all(&omni_dir).unwrap();
372 fs::write(
373 omni_dir.join("settings.json"),
374 r#"{"env":{
375 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
376 "ATLASSIAN_EMAIL":"person@example.com",
377 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
378 }}"#,
379 )
380 .unwrap();
381
382 let status = status();
383 assert_eq!(status.scopes.len(), 1);
384 let scope = &status.scopes[0];
385 assert!(scope.has_email);
386 assert!(scope.has_token);
387 assert_eq!(
388 scope.instance_url.as_deref(),
389 Some("https://status.atlassian.net")
390 );
391
392 let yaml = serde_yaml::to_string(&status).unwrap();
393 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
394 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
395 }
396
397 #[test]
398 fn status_returns_instance_url_from_env_without_trailing_slash() {
399 let guard = EnvGuard::take();
400 let _dir = with_empty_home(&guard);
401 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
402
403 let status = status();
404 let scope = &status.scopes[0];
405 assert_eq!(
406 scope.instance_url.as_deref(),
407 Some("https://env.atlassian.net")
408 );
409 assert!(!scope.has_email);
410 assert!(!scope.has_token);
411 }
412
413 #[test]
416 fn save_credentials_creates_and_preserves() {
417 let _guard = EnvGuard::take();
420 let original_home = std::env::var("HOME").ok();
421
422 {
424 let temp_dir = {
425 std::fs::create_dir_all("tmp").ok();
426 tempfile::TempDir::new_in("tmp").unwrap()
427 };
428 std::env::set_var("HOME", temp_dir.path());
429
430 let creds = AtlassianCredentials {
431 instance_url: "https://save.atlassian.net".to_string(),
432 email: "save@example.com".to_string(),
433 api_token: "save-token".to_string(),
434 };
435 save_credentials(&creds).unwrap();
436
437 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
438 assert!(settings_path.exists());
439 let content = fs::read_to_string(&settings_path).unwrap();
440 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
441 assert_eq!(
442 val["env"]["ATLASSIAN_INSTANCE_URL"],
443 "https://save.atlassian.net"
444 );
445 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
446 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
447 }
448
449 {
451 let temp_dir = {
452 std::fs::create_dir_all("tmp").ok();
453 tempfile::TempDir::new_in("tmp").unwrap()
454 };
455 let omni_dir = temp_dir.path().join(".omni-dev");
456 fs::create_dir_all(&omni_dir).unwrap();
457 let settings_path = omni_dir.join("settings.json");
458 fs::write(
459 &settings_path,
460 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
461 )
462 .unwrap();
463
464 std::env::set_var("HOME", temp_dir.path());
465
466 let creds = AtlassianCredentials {
467 instance_url: "https://org.atlassian.net".to_string(),
468 email: "user@test.com".to_string(),
469 api_token: "token".to_string(),
470 };
471 save_credentials(&creds).unwrap();
472
473 let content = fs::read_to_string(&settings_path).unwrap();
474 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
475 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
476 assert_eq!(val["extra"], true);
477 assert_eq!(
478 val["env"]["ATLASSIAN_INSTANCE_URL"],
479 "https://org.atlassian.net"
480 );
481 }
482
483 if let Some(home) = original_home {
485 std::env::set_var("HOME", home);
486 }
487 }
488}