mecha10_cli/services/
credentials.rs

1//! Credentials service for managing user authentication credentials
2//!
3//! Handles loading, saving, and deleting credentials stored at ~/.mecha10/credentials.json
4
5use crate::types::credentials::{AuthError, Credentials};
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8
9/// Default auth URL for production
10pub const DEFAULT_AUTH_URL: &str = "https://api.mecha.industries/auth";
11
12/// Environment variable name for custom auth URL
13pub const AUTH_URL_ENV_VAR: &str = "MECHA10_AUTH_URL";
14
15/// Get the auth URL, checking environment variable first
16///
17/// Priority:
18/// 1. MECHA10_AUTH_URL environment variable
19/// 2. DEFAULT_AUTH_URL constant
20pub fn get_auth_url() -> String {
21    std::env::var(AUTH_URL_ENV_VAR).unwrap_or_else(|_| DEFAULT_AUTH_URL.to_string())
22}
23
24/// Service for managing user credentials
25pub struct CredentialsService {
26    /// Path to credentials file
27    credentials_path: PathBuf,
28}
29
30impl CredentialsService {
31    /// Create a new CredentialsService with default path (~/.mecha10/credentials.json)
32    pub fn new() -> Self {
33        Self {
34            credentials_path: Self::default_credentials_path(),
35        }
36    }
37
38    /// Create a CredentialsService with a custom credentials path (for testing)
39    #[allow(dead_code)]
40    pub fn with_path(path: PathBuf) -> Self {
41        Self { credentials_path: path }
42    }
43
44    /// Get the default credentials path (~/.mecha10/credentials.json)
45    pub fn default_credentials_path() -> PathBuf {
46        dirs::home_dir()
47            .unwrap_or_else(|| PathBuf::from("."))
48            .join(".mecha10")
49            .join("credentials.json")
50    }
51
52    /// Get the path to the credentials file
53    pub fn credentials_path(&self) -> &PathBuf {
54        &self.credentials_path
55    }
56
57    /// Load credentials from disk
58    ///
59    /// Returns None if credentials file doesn't exist
60    pub fn load(&self) -> Result<Option<Credentials>> {
61        if !self.credentials_path.exists() {
62            return Ok(None);
63        }
64
65        let content = std::fs::read_to_string(&self.credentials_path)
66            .with_context(|| format!("Failed to read credentials from {}", self.credentials_path.display()))?;
67
68        let credentials: Credentials = serde_json::from_str(&content).map_err(|e| AuthError::InvalidCredentials {
69            message: format!("Failed to parse credentials: {}", e),
70        })?;
71
72        Ok(Some(credentials))
73    }
74
75    /// Save credentials to disk
76    ///
77    /// Creates the ~/.mecha10 directory if it doesn't exist
78    pub fn save(&self, credentials: &Credentials) -> Result<()> {
79        // Create parent directory if needed
80        if let Some(parent) = self.credentials_path.parent() {
81            std::fs::create_dir_all(parent)
82                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
83        }
84
85        let content = serde_json::to_string_pretty(credentials).with_context(|| "Failed to serialize credentials")?;
86
87        std::fs::write(&self.credentials_path, content)
88            .with_context(|| format!("Failed to write credentials to {}", self.credentials_path.display()))?;
89
90        // Set file permissions to user-only on Unix
91        #[cfg(unix)]
92        {
93            use std::os::unix::fs::PermissionsExt;
94            let permissions = std::fs::Permissions::from_mode(0o600);
95            std::fs::set_permissions(&self.credentials_path, permissions)
96                .with_context(|| "Failed to set credentials file permissions")?;
97        }
98
99        Ok(())
100    }
101
102    /// Delete credentials from disk
103    pub fn delete(&self) -> Result<()> {
104        if self.credentials_path.exists() {
105            std::fs::remove_file(&self.credentials_path)
106                .with_context(|| format!("Failed to delete credentials from {}", self.credentials_path.display()))?;
107        }
108        Ok(())
109    }
110
111    /// Get API key from stored credentials
112    ///
113    /// Returns None if not logged in or credentials are invalid
114    pub fn get_api_key(&self) -> Result<Option<String>> {
115        match self.load()? {
116            Some(creds) if creds.is_valid() => Ok(Some(creds.api_key)),
117            _ => Ok(None),
118        }
119    }
120
121    /// Check if user is logged in (has valid credentials)
122    pub fn is_logged_in(&self) -> bool {
123        self.load()
124            .map(|creds| creds.map(|c| c.is_valid()).unwrap_or(false))
125            .unwrap_or(false)
126    }
127
128    /// Get user info if logged in
129    pub fn get_user_info(&self) -> Result<Option<(String, String, Option<String>)>> {
130        match self.load()? {
131            Some(creds) if creds.is_valid() => Ok(Some((creds.user_id, creds.email, creds.name))),
132            _ => Ok(None),
133        }
134    }
135}
136
137impl Default for CredentialsService {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use chrono::Utc;
147    use tempfile::TempDir;
148
149    fn create_test_credentials() -> Credentials {
150        Credentials {
151            api_key: "mecha_test123abc456def".to_string(),
152            user_id: "usr_test123".to_string(),
153            email: "test@example.com".to_string(),
154            name: Some("Test User".to_string()),
155            authenticated_at: Utc::now(),
156            auth_url: DEFAULT_AUTH_URL.to_string(),
157        }
158    }
159
160    #[test]
161    fn test_save_and_load_credentials() {
162        let temp_dir = TempDir::new().unwrap();
163        let creds_path = temp_dir.path().join("credentials.json");
164
165        let service = CredentialsService::with_path(creds_path);
166        let creds = create_test_credentials();
167
168        // Save
169        service.save(&creds).unwrap();
170
171        // Load
172        let loaded = service.load().unwrap().unwrap();
173        assert_eq!(loaded.api_key, creds.api_key);
174        assert_eq!(loaded.user_id, creds.user_id);
175        assert_eq!(loaded.email, creds.email);
176    }
177
178    #[test]
179    fn test_delete_credentials() {
180        let temp_dir = TempDir::new().unwrap();
181        let creds_path = temp_dir.path().join("credentials.json");
182
183        let service = CredentialsService::with_path(creds_path.clone());
184        let creds = create_test_credentials();
185
186        // Save then delete
187        service.save(&creds).unwrap();
188        assert!(creds_path.exists());
189
190        service.delete().unwrap();
191        assert!(!creds_path.exists());
192
193        // Load should return None
194        let loaded = service.load().unwrap();
195        assert!(loaded.is_none());
196    }
197
198    #[test]
199    fn test_get_api_key() {
200        let temp_dir = TempDir::new().unwrap();
201        let creds_path = temp_dir.path().join("credentials.json");
202
203        let service = CredentialsService::with_path(creds_path);
204        let creds = create_test_credentials();
205
206        // No credentials
207        assert!(service.get_api_key().unwrap().is_none());
208
209        // With credentials
210        service.save(&creds).unwrap();
211        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
212    }
213
214    #[test]
215    fn test_is_logged_in() {
216        let temp_dir = TempDir::new().unwrap();
217        let creds_path = temp_dir.path().join("credentials.json");
218
219        let service = CredentialsService::with_path(creds_path);
220        let creds = create_test_credentials();
221
222        // Not logged in
223        assert!(!service.is_logged_in());
224
225        // Logged in
226        service.save(&creds).unwrap();
227        assert!(service.is_logged_in());
228    }
229
230    #[test]
231    fn test_credentials_validity() {
232        let valid = Credentials {
233            api_key: "mecha_valid123".to_string(),
234            user_id: "usr_test".to_string(),
235            email: "test@example.com".to_string(),
236            name: None,
237            authenticated_at: Utc::now(),
238            auth_url: DEFAULT_AUTH_URL.to_string(),
239        };
240        assert!(valid.is_valid());
241
242        let invalid_prefix = Credentials {
243            api_key: "invalid_key".to_string(),
244            ..valid.clone()
245        };
246        assert!(!invalid_prefix.is_valid());
247
248        let empty_key = Credentials {
249            api_key: "".to_string(),
250            ..valid.clone()
251        };
252        assert!(!empty_key.is_valid());
253    }
254}