Skip to main content

omni_dev/atlassian/
auth.rs

1//! Atlassian credential management.
2//!
3//! Loads and saves Atlassian Cloud API credentials from/to the
4//! `~/.omni-dev/settings.json` file using the existing `env` map.
5
6use std::collections::HashMap;
7use std::fs;
8
9use anyhow::{Context, Result};
10
11use crate::atlassian::error::AtlassianError;
12use crate::utils::settings::Settings;
13
14/// Environment variable / settings key for the Atlassian instance URL.
15pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
16
17/// Environment variable / settings key for the Atlassian user email.
18pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
19
20/// Environment variable / settings key for the Atlassian API token.
21pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
22
23/// Atlassian Cloud credentials.
24#[derive(Debug, Clone)]
25pub struct AtlassianCredentials {
26    /// Instance base URL (e.g., "https://myorg.atlassian.net").
27    pub instance_url: String,
28
29    /// User email address.
30    pub email: String,
31
32    /// API token.
33    pub api_token: String,
34}
35
36/// Loads Atlassian credentials from environment variables or settings.json.
37///
38/// Checks environment variables first, then falls back to the settings file.
39pub fn load_credentials() -> Result<AtlassianCredentials> {
40    let settings = Settings::load().unwrap_or(Settings {
41        env: HashMap::new(),
42    });
43
44    let instance_url = settings
45        .get_env_var(ATLASSIAN_INSTANCE_URL)
46        .ok_or(AtlassianError::CredentialsNotFound)?;
47    let email = settings
48        .get_env_var(ATLASSIAN_EMAIL)
49        .ok_or(AtlassianError::CredentialsNotFound)?;
50    let api_token = settings
51        .get_env_var(ATLASSIAN_API_TOKEN)
52        .ok_or(AtlassianError::CredentialsNotFound)?;
53
54    // Normalize: strip trailing slash from instance URL
55    let instance_url = instance_url.trim_end_matches('/').to_string();
56
57    Ok(AtlassianCredentials {
58        instance_url,
59        email,
60        api_token,
61    })
62}
63
64/// Saves Atlassian credentials to `~/.omni-dev/settings.json`.
65///
66/// Reads the existing settings file, merges the new credential keys into
67/// the `env` map, and writes back. Preserves all other settings.
68pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
69    let settings_path = Settings::get_settings_path()?;
70
71    // Read existing settings as a generic JSON value to preserve unknown fields
72    let mut settings_value: serde_json::Value = if settings_path.exists() {
73        let content = fs::read_to_string(&settings_path)
74            .with_context(|| format!("Failed to read {}", settings_path.display()))?;
75        serde_json::from_str(&content)
76            .with_context(|| format!("Failed to parse {}", settings_path.display()))?
77    } else {
78        serde_json::json!({})
79    };
80
81    // Ensure the "env" key exists as an object
82    if !settings_value
83        .get("env")
84        .is_some_and(serde_json::Value::is_object)
85    {
86        settings_value["env"] = serde_json::json!({});
87    }
88
89    // Merge credential keys — safe because we just ensured "env" is an object above
90    let Some(env) = settings_value["env"].as_object_mut() else {
91        anyhow::bail!("Internal error: env key is not an object after initialization");
92    };
93    env.insert(
94        ATLASSIAN_INSTANCE_URL.to_string(),
95        serde_json::Value::String(credentials.instance_url.clone()),
96    );
97    env.insert(
98        ATLASSIAN_EMAIL.to_string(),
99        serde_json::Value::String(credentials.email.clone()),
100    );
101    env.insert(
102        ATLASSIAN_API_TOKEN.to_string(),
103        serde_json::Value::String(credentials.api_token.clone()),
104    );
105
106    // Ensure parent directory exists
107    if let Some(parent) = settings_path.parent() {
108        fs::create_dir_all(parent)
109            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
110    }
111
112    // Write back
113    let formatted = serde_json::to_string_pretty(&settings_value)
114        .context("Failed to serialize settings JSON")?;
115    fs::write(&settings_path, formatted)
116        .with_context(|| format!("Failed to write {}", settings_path.display()))?;
117
118    Ok(())
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used, clippy::expect_used)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn save_and_read_credentials() {
128        let temp_dir = {
129            std::fs::create_dir_all("tmp").ok();
130            tempfile::TempDir::new_in("tmp").unwrap()
131        };
132        let settings_path = temp_dir.path().join("settings.json");
133
134        // Start with existing settings
135        let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
136        fs::write(&settings_path, existing).unwrap();
137
138        // Read it back as a value, add credentials, write
139        let content = fs::read_to_string(&settings_path).unwrap();
140        let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
141        val["env"]["ATLASSIAN_INSTANCE_URL"] =
142            serde_json::Value::String("https://test.atlassian.net".to_string());
143        val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
144        val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
145        let formatted = serde_json::to_string_pretty(&val).unwrap();
146        fs::write(&settings_path, formatted).unwrap();
147
148        // Verify existing keys are preserved
149        let content = fs::read_to_string(&settings_path).unwrap();
150        let val: serde_json::Value = serde_json::from_str(&content).unwrap();
151        assert_eq!(val["env"]["SOME_KEY"], "value");
152        assert_eq!(
153            val["env"]["ATLASSIAN_INSTANCE_URL"],
154            "https://test.atlassian.net"
155        );
156        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
157        assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
158    }
159
160    #[test]
161    fn load_credentials_normalizes_trailing_slash() {
162        // Test the trailing-slash normalization logic directly
163        let url = "https://env.atlassian.net/";
164        let normalized = url.trim_end_matches('/').to_string();
165        assert_eq!(normalized, "https://env.atlassian.net");
166    }
167
168    #[test]
169    fn constant_key_names() {
170        assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
171        assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
172        assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
173    }
174
175    #[test]
176    fn credentials_struct_clone_and_debug() {
177        let creds = AtlassianCredentials {
178            instance_url: "https://org.atlassian.net".to_string(),
179            email: "user@test.com".to_string(),
180            api_token: "token".to_string(),
181        };
182        let cloned = creds.clone();
183        assert_eq!(cloned.instance_url, creds.instance_url);
184        assert_eq!(cloned.email, creds.email);
185        assert_eq!(cloned.api_token, creds.api_token);
186        // Verify Debug is implemented
187        let debug = format!("{creds:?}");
188        assert!(debug.contains("AtlassianCredentials"));
189    }
190
191    /// Single test for save_credentials to avoid HOME env var race conditions.
192    /// Tests both fresh-file creation and merge-with-existing in sequence.
193    #[test]
194    fn save_credentials_creates_and_preserves() {
195        use std::sync::Mutex;
196        static HOME_MUTEX: Mutex<()> = Mutex::new(());
197        let _lock = HOME_MUTEX.lock().unwrap();
198
199        let original_home = std::env::var("HOME").ok();
200
201        // ── Part 1: creates file from scratch ──────────────────────
202        {
203            let temp_dir = {
204                std::fs::create_dir_all("tmp").ok();
205                tempfile::TempDir::new_in("tmp").unwrap()
206            };
207            std::env::set_var("HOME", temp_dir.path());
208
209            let creds = AtlassianCredentials {
210                instance_url: "https://save.atlassian.net".to_string(),
211                email: "save@example.com".to_string(),
212                api_token: "save-token".to_string(),
213            };
214            save_credentials(&creds).unwrap();
215
216            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
217            assert!(settings_path.exists());
218            let content = fs::read_to_string(&settings_path).unwrap();
219            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
220            assert_eq!(
221                val["env"]["ATLASSIAN_INSTANCE_URL"],
222                "https://save.atlassian.net"
223            );
224            assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
225            assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
226        }
227
228        // ── Part 2: preserves existing keys ────────────────────────
229        {
230            let temp_dir = {
231                std::fs::create_dir_all("tmp").ok();
232                tempfile::TempDir::new_in("tmp").unwrap()
233            };
234            let omni_dir = temp_dir.path().join(".omni-dev");
235            fs::create_dir_all(&omni_dir).unwrap();
236            let settings_path = omni_dir.join("settings.json");
237            fs::write(
238                &settings_path,
239                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
240            )
241            .unwrap();
242
243            std::env::set_var("HOME", temp_dir.path());
244
245            let creds = AtlassianCredentials {
246                instance_url: "https://org.atlassian.net".to_string(),
247                email: "user@test.com".to_string(),
248                api_token: "token".to_string(),
249            };
250            save_credentials(&creds).unwrap();
251
252            let content = fs::read_to_string(&settings_path).unwrap();
253            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
254            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
255            assert_eq!(val["extra"], true);
256            assert_eq!(
257                val["env"]["ATLASSIAN_INSTANCE_URL"],
258                "https://org.atlassian.net"
259            );
260        }
261
262        // Restore HOME
263        if let Some(home) = original_home {
264            std::env::set_var("HOME", home);
265        }
266    }
267}