mecha10-auth 0.6.3

Authentication services for Mecha10 - shared between CLI and launcher
Documentation
//! Credentials service for managing user authentication credentials
//!
//! Handles loading, saving, and deleting credentials stored at ~/.mecha10/credentials.json

use crate::types::{AuthError, Credentials};
use anyhow::{Context, Result};
use mecha10_core::fs_utils::home_subpath;
use std::path::PathBuf;

/// Get the default credentials path (~/.mecha10/credentials.json)
pub fn default_credentials_path() -> PathBuf {
    home_subpath(&[".mecha10", "credentials.json"])
}

/// Service for managing user credentials
pub struct CredentialsService {
    /// Path to credentials file
    credentials_path: PathBuf,
}

impl CredentialsService {
    /// Create a new CredentialsService with default path (~/.mecha10/credentials.json)
    pub fn new() -> Self {
        Self {
            credentials_path: default_credentials_path(),
        }
    }

    /// Create a CredentialsService with a custom credentials path (for testing)
    pub fn with_path(path: PathBuf) -> Self {
        Self { credentials_path: path }
    }

    /// Get the path to the credentials file
    pub fn credentials_path(&self) -> &PathBuf {
        &self.credentials_path
    }

    /// Load credentials from disk
    ///
    /// Returns None if credentials file doesn't exist
    pub fn load(&self) -> Result<Option<Credentials>> {
        if !self.credentials_path.exists() {
            return Ok(None);
        }

        let content = std::fs::read_to_string(&self.credentials_path)
            .with_context(|| format!("Failed to read credentials from {}", self.credentials_path.display()))?;

        let credentials: Credentials = serde_json::from_str(&content).map_err(|e| AuthError::InvalidCredentials {
            message: format!("Failed to parse credentials: {}", e),
        })?;

        Ok(Some(credentials))
    }

    /// Save credentials to disk
    ///
    /// Creates the ~/.mecha10 directory if it doesn't exist
    pub fn save(&self, credentials: &Credentials) -> Result<()> {
        // Create parent directory if needed
        if let Some(parent) = self.credentials_path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
        }

        let content = serde_json::to_string_pretty(credentials).with_context(|| "Failed to serialize credentials")?;

        std::fs::write(&self.credentials_path, content)
            .with_context(|| format!("Failed to write credentials to {}", self.credentials_path.display()))?;

        // Set file permissions to user-only on Unix
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let permissions = std::fs::Permissions::from_mode(0o600);
            std::fs::set_permissions(&self.credentials_path, permissions)
                .with_context(|| "Failed to set credentials file permissions")?;
        }

        Ok(())
    }

    /// Delete credentials from disk
    pub fn delete(&self) -> Result<()> {
        if self.credentials_path.exists() {
            std::fs::remove_file(&self.credentials_path)
                .with_context(|| format!("Failed to delete credentials from {}", self.credentials_path.display()))?;
        }
        Ok(())
    }

    /// Get API key from stored credentials
    ///
    /// Returns None if not logged in or credentials are invalid
    pub fn get_api_key(&self) -> Result<Option<String>> {
        match self.load()? {
            Some(creds) if creds.is_valid() => Ok(Some(creds.api_key)),
            _ => Ok(None),
        }
    }

    /// Check if user is logged in (has valid credentials)
    pub fn is_logged_in(&self) -> bool {
        self.load()
            .map(|creds| creds.map(|c| c.is_valid()).unwrap_or(false))
            .unwrap_or(false)
    }

    /// Get user info if logged in
    pub fn get_user_info(&self) -> Result<Option<(String, String, Option<String>)>> {
        match self.load()? {
            Some(creds) if creds.is_valid() => Ok(Some((creds.user_id, creds.email, creds.name))),
            _ => Ok(None),
        }
    }
}

impl Default for CredentialsService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::DEFAULT_AUTH_URL;
    use chrono::Utc;
    use tempfile::TempDir;

    fn create_test_credentials() -> Credentials {
        Credentials {
            api_key: "mecha_test123abc456def".to_string(),
            user_id: "usr_test123".to_string(),
            email: "test@example.com".to_string(),
            name: Some("Test User".to_string()),
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        }
    }

    #[test]
    fn test_save_and_load_credentials() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        // Save
        service.save(&creds).unwrap();

        // Load
        let loaded = service.load().unwrap().unwrap();
        assert_eq!(loaded.api_key, creds.api_key);
        assert_eq!(loaded.user_id, creds.user_id);
        assert_eq!(loaded.email, creds.email);
    }

    #[test]
    fn test_delete_credentials() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path.clone());
        let creds = create_test_credentials();

        // Save then delete
        service.save(&creds).unwrap();
        assert!(creds_path.exists());

        service.delete().unwrap();
        assert!(!creds_path.exists());

        // Load should return None
        let loaded = service.load().unwrap();
        assert!(loaded.is_none());
    }

    #[test]
    fn test_get_api_key() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        // No credentials
        assert!(service.get_api_key().unwrap().is_none());

        // With credentials
        service.save(&creds).unwrap();
        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
    }

    #[test]
    fn test_is_logged_in() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        // Not logged in
        assert!(!service.is_logged_in());

        // Logged in
        service.save(&creds).unwrap();
        assert!(service.is_logged_in());
    }

    #[test]
    fn test_credentials_validity() {
        let valid = Credentials {
            api_key: "mecha_valid123".to_string(),
            user_id: "usr_test".to_string(),
            email: "test@example.com".to_string(),
            name: None,
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        };
        assert!(valid.is_valid());

        let invalid_prefix = Credentials {
            api_key: "invalid_key".to_string(),
            ..valid.clone()
        };
        assert!(!invalid_prefix.is_valid());

        let empty_key = Credentials {
            api_key: "".to_string(),
            ..valid.clone()
        };
        assert!(!empty_key.is_valid());
    }
}