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