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)]
179#[allow(clippy::unwrap_used, clippy::expect_used)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn save_and_read_credentials() {
185 let temp_dir = {
186 std::fs::create_dir_all("tmp").ok();
187 tempfile::TempDir::new_in("tmp").unwrap()
188 };
189 let settings_path = temp_dir.path().join("settings.json");
190
191 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
193 fs::write(&settings_path, existing).unwrap();
194
195 let content = fs::read_to_string(&settings_path).unwrap();
197 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
198 val["env"]["ATLASSIAN_INSTANCE_URL"] =
199 serde_json::Value::String("https://test.atlassian.net".to_string());
200 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
201 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
202 let formatted = serde_json::to_string_pretty(&val).unwrap();
203 fs::write(&settings_path, formatted).unwrap();
204
205 let content = fs::read_to_string(&settings_path).unwrap();
207 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
208 assert_eq!(val["env"]["SOME_KEY"], "value");
209 assert_eq!(
210 val["env"]["ATLASSIAN_INSTANCE_URL"],
211 "https://test.atlassian.net"
212 );
213 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
214 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
215 }
216
217 #[test]
218 fn load_credentials_normalizes_trailing_slash() {
219 let url = "https://env.atlassian.net/";
221 let normalized = url.trim_end_matches('/').to_string();
222 assert_eq!(normalized, "https://env.atlassian.net");
223 }
224
225 #[test]
226 fn constant_key_names() {
227 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
228 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
229 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
230 }
231
232 #[test]
233 fn credentials_struct_clone_and_debug() {
234 let creds = AtlassianCredentials {
235 instance_url: "https://org.atlassian.net".to_string(),
236 email: "user@test.com".to_string(),
237 api_token: "token".to_string(),
238 };
239 let cloned = creds.clone();
240 assert_eq!(cloned.instance_url, creds.instance_url);
241 assert_eq!(cloned.email, creds.email);
242 assert_eq!(cloned.api_token, creds.api_token);
243 let debug = format!("{creds:?}");
245 assert!(debug.contains("AtlassianCredentials"));
246 }
247
248 static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
252
253 struct EnvGuard {
258 _lock: std::sync::MutexGuard<'static, ()>,
259 snapshot: Vec<(&'static str, Option<String>)>,
260 }
261
262 impl EnvGuard {
263 fn take() -> Self {
264 let lock = AUTH_ENV_MUTEX
265 .lock()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 let keys = [
268 "HOME",
269 ATLASSIAN_INSTANCE_URL,
270 ATLASSIAN_EMAIL,
271 ATLASSIAN_API_TOKEN,
272 ];
273 let snapshot = keys
274 .into_iter()
275 .map(|k| (k, std::env::var(k).ok()))
276 .collect();
277 Self {
278 _lock: lock,
279 snapshot,
280 }
281 }
282 }
283
284 impl Drop for EnvGuard {
285 fn drop(&mut self) {
286 for (k, v) in &self.snapshot {
287 match v {
288 Some(val) => std::env::set_var(k, val),
289 None => std::env::remove_var(k),
290 }
291 }
292 }
293 }
294
295 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
296 let dir = {
297 std::fs::create_dir_all("tmp").ok();
298 tempfile::TempDir::new_in("tmp").unwrap()
299 };
300 std::env::set_var("HOME", dir.path());
301 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
302 std::env::remove_var(ATLASSIAN_EMAIL);
303 std::env::remove_var(ATLASSIAN_API_TOKEN);
304 dir
305 }
306
307 #[test]
308 fn status_reports_all_false_when_nothing_configured() {
309 let guard = EnvGuard::take();
310 let _dir = with_empty_home(&guard);
311
312 let status = status();
313 assert_eq!(status.scopes.len(), 1);
314 let scope = &status.scopes[0];
315 assert_eq!(scope.name, "default");
316 assert!(!scope.has_email);
317 assert!(!scope.has_token);
318 assert_eq!(scope.instance_url, None);
319 }
320
321 #[test]
322 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
323 let guard = EnvGuard::take();
324 let dir = with_empty_home(&guard);
325 let omni_dir = dir.path().join(".omni-dev");
326 fs::create_dir_all(&omni_dir).unwrap();
327 fs::write(
328 omni_dir.join("settings.json"),
329 r#"{"env":{
330 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
331 "ATLASSIAN_EMAIL":"person@example.com",
332 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
333 }}"#,
334 )
335 .unwrap();
336
337 let status = status();
338 assert_eq!(status.scopes.len(), 1);
339 let scope = &status.scopes[0];
340 assert!(scope.has_email);
341 assert!(scope.has_token);
342 assert_eq!(
343 scope.instance_url.as_deref(),
344 Some("https://status.atlassian.net")
345 );
346
347 let yaml = serde_yaml::to_string(&status).unwrap();
348 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
349 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
350 }
351
352 #[test]
353 fn status_returns_instance_url_from_env_without_trailing_slash() {
354 let guard = EnvGuard::take();
355 let _dir = with_empty_home(&guard);
356 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
357
358 let status = status();
359 let scope = &status.scopes[0];
360 assert_eq!(
361 scope.instance_url.as_deref(),
362 Some("https://env.atlassian.net")
363 );
364 assert!(!scope.has_email);
365 assert!(!scope.has_token);
366 }
367
368 #[test]
371 fn save_credentials_creates_and_preserves() {
372 let _guard = EnvGuard::take();
375 let original_home = std::env::var("HOME").ok();
376
377 {
379 let temp_dir = {
380 std::fs::create_dir_all("tmp").ok();
381 tempfile::TempDir::new_in("tmp").unwrap()
382 };
383 std::env::set_var("HOME", temp_dir.path());
384
385 let creds = AtlassianCredentials {
386 instance_url: "https://save.atlassian.net".to_string(),
387 email: "save@example.com".to_string(),
388 api_token: "save-token".to_string(),
389 };
390 save_credentials(&creds).unwrap();
391
392 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
393 assert!(settings_path.exists());
394 let content = fs::read_to_string(&settings_path).unwrap();
395 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
396 assert_eq!(
397 val["env"]["ATLASSIAN_INSTANCE_URL"],
398 "https://save.atlassian.net"
399 );
400 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
401 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
402 }
403
404 {
406 let temp_dir = {
407 std::fs::create_dir_all("tmp").ok();
408 tempfile::TempDir::new_in("tmp").unwrap()
409 };
410 let omni_dir = temp_dir.path().join(".omni-dev");
411 fs::create_dir_all(&omni_dir).unwrap();
412 let settings_path = omni_dir.join("settings.json");
413 fs::write(
414 &settings_path,
415 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
416 )
417 .unwrap();
418
419 std::env::set_var("HOME", temp_dir.path());
420
421 let creds = AtlassianCredentials {
422 instance_url: "https://org.atlassian.net".to_string(),
423 email: "user@test.com".to_string(),
424 api_token: "token".to_string(),
425 };
426 save_credentials(&creds).unwrap();
427
428 let content = fs::read_to_string(&settings_path).unwrap();
429 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
430 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
431 assert_eq!(val["extra"], true);
432 assert_eq!(
433 val["env"]["ATLASSIAN_INSTANCE_URL"],
434 "https://org.atlassian.net"
435 );
436 }
437
438 if let Some(home) = original_home {
440 std::env::set_var("HOME", home);
441 }
442 }
443}