wme-cli 0.1.3

CLI tool for the Wikimedia Enterprise API
//! HTTP client builder and error mapping for the Wikimedia Enterprise CLI.
//!
//! This module provides functions to build and configure the `WmeClient` from
//! stored configuration or explicit credentials. It also maps client library
//! errors to user-friendly error messages.
//!
//! # Authentication Priority
//!
//! When building a client, the following authentication methods are tried in order:
//!
//! 1. CLI token override (`--token` flag)
//! 2. Stored tokens from config file
//! 3. Error if no authentication is available
//!
//! # Example
//!
//! ```rust
//! use wme_cli::client::build_client;
//! use wme_cli::config::Config;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let config = Config::load(None)?;
//! let client = build_client(&config, None).await?;
//! # Ok(())
//! # }
//! ```

use anyhow::{Context, Result};
use std::time::{Duration, Instant};
use wme_client::{Tokens, WmeClient};

use crate::config::Config;

/// Build a WmeClient from config and optional token override.
///
/// This function creates a client that uses credentials from the config.
/// If no credentials are available but tokens exist in config, it attempts
/// to restore the session from those tokens.
///
/// Priority:
/// 1. CLI token override (passed via --token)
/// 2. Stored tokens from config (access_token + refresh_token)
/// 3. Credentials from config (if available)
/// 4. Error if none of the above
///
/// Note: Passwords are NOT stored in config for security. If credentials
/// are needed, the user must provide them interactively.
pub async fn build_client(config: &Config, token_override: Option<String>) -> Result<WmeClient> {
    // Priority 1: CLI token override
    if let Some(token) = token_override {
        return build_client_with_token(token).await;
    }

    // Priority 2: Stored tokens from config
    if let (Some(access_token), Some(refresh_token), Some(expires_at_str), Some(username)) = (
        &config.access_token,
        &config.refresh_token,
        &config.token_expires_at,
        &config.username,
    ) {
        // Parse the expiration time
        let expires_at = chrono::DateTime::parse_from_rfc3339(expires_at_str)
            .map_err(|e| anyhow::anyhow!("Invalid token expiration time in config: {}", e))?
            .with_timezone(&chrono::Utc);

        // Convert to Instant for Tokens struct
        // Note: We calculate the remaining duration from now to expires_at
        let now = chrono::Utc::now();
        let expires_in = if expires_at > now {
            expires_at
                .signed_duration_since(now)
                .to_std()
                .unwrap_or(Duration::from_secs(0))
        } else {
            Duration::from_secs(0)
        };
        let expires_at_instant = Instant::now() + expires_in;

        // Build a client with the actual username from config
        // The password is not needed since we're using stored tokens.
        // We pass "unused" as a placeholder - the TokenManager will use the
        // stored tokens for authentication and refresh when needed.
        let client = WmeClient::builder()
            .credentials(username, "unused")
            .build()
            .await
            .context("Failed to create WME client")?;

        // Set the tokens on the client's token manager
        if let Some(token_manager) = client.token_manager() {
            let tokens = Tokens {
                id_token: String::new(), // We don't store ID tokens
                access_token: access_token.clone(),
                refresh_token: refresh_token.clone(),
                expires_at: expires_at_instant,
            };
            token_manager.set_tokens(tokens);
        }

        return Ok(client);
    }

    // Priority 3: Error - no auth available
    Err(anyhow::anyhow!(
        "No authentication token available.\n\
         Please run 'wme auth login' or provide a token with --token"
    ))
}

/// Build a client with a specific token.
///
/// For token override (--token flag), we skip expiry tracking entirely.
/// The token is assumed to be valid, and we let the API return 401 if expired.
/// This avoids attempting refresh with dummy credentials.
async fn build_client_with_token(token: String) -> Result<WmeClient> {
    // Build a client with dummy credentials - these are only used if refresh is needed
    let client = WmeClient::builder()
        .credentials("token_auth", "unused")
        .build()
        .await
        .context("Failed to create WME client")?;

    // Create tokens with a far future expiration to avoid automatic refresh.
    // For CLI token override, we skip expiry tracking and let the API return 401.
    // The user can then provide a new token via --token flag.
    if let Some(token_manager) = client.token_manager() {
        let tokens = Tokens {
            id_token: String::new(),
            access_token: token,
            refresh_token: String::new(), // No refresh token for CLI override
            expires_at: Instant::now() + Duration::from_secs(365 * 24 * 60 * 60), // ~1 year
        };
        token_manager.set_tokens(tokens);
    }

    Ok(client)
}

/// Build a client with explicit credentials for authentication commands.
///
/// This function creates a client using the provided username and password
/// for authentication. It's primarily used by the `auth login` command.
///
/// # Arguments
///
/// * `username` - The API username
/// * `password` - The API password
///
/// # Errors
///
/// Returns an error if the client cannot be created.
///
/// # Example
///
/// ```rust
/// use wme_cli::client::build_client_with_credentials;
///
/// # async fn example() -> anyhow::Result<()> {
/// let client = build_client_with_credentials("myuser", "mypass").await?;
/// # Ok(())
/// # }
/// ```
pub async fn build_client_with_credentials(
    username: impl Into<String>,
    password: impl Into<String>,
) -> Result<WmeClient> {
    let client = WmeClient::builder()
        .credentials(username, password)
        .build()
        .await
        .context("Failed to create WME client")?;

    Ok(client)
}

/// Convert client library errors to user-friendly anyhow errors.
///
/// Maps `wme_client::ClientError` variants to human-readable error messages
/// with actionable guidance. This function is used to transform internal
/// errors into messages suitable for CLI display.
///
/// # Error Mappings
///
/// - `Auth` → "Authentication failed: {msg}"
/// - `TokenExpired` → "Token expired. Please run 'wme auth login' to re-authenticate."
/// - `SnapshotNotFound` → "Snapshot '{id}' not found"
/// - `ArticleNotFound` → "Article '{name}' not found"
/// - `RateLimited` → "Rate limited. Please retry after {secs} seconds"
/// - `Network` → "Network error: {msg}"
/// - `Http` → "HTTP error: {msg}"
/// - `JsonParse` → "Parse error: {msg}"
/// - `Io` → "IO error: {msg}"
/// - `Stream` → "Stream error: {msg}"
/// - `Config` → "Configuration error: {msg}"
pub fn map_client_error(e: wme_client::ClientError) -> anyhow::Error {
    match e {
        wme_client::ClientError::Auth(msg) => anyhow::anyhow!("Authentication failed: {}", msg),
        wme_client::ClientError::TokenExpired => {
            anyhow::anyhow!("Token expired. Please run 'wme auth login' to re-authenticate.")
        }
        wme_client::ClientError::SnapshotNotFound { id } => {
            anyhow::anyhow!("Snapshot '{}' not found", id)
        }
        wme_client::ClientError::ArticleNotFound { name } => {
            anyhow::anyhow!("Article '{}' not found", name)
        }
        wme_client::ClientError::RateLimited { retry_after } => {
            if let Some(secs) = retry_after {
                anyhow::anyhow!("Rate limited. Please retry after {} seconds", secs)
            } else {
                anyhow::anyhow!("Rate limited. Please retry later")
            }
        }
        wme_client::ClientError::Network(msg) => anyhow::anyhow!("Network error: {}", msg),
        wme_client::ClientError::Http(msg) => anyhow::anyhow!("HTTP error: {}", msg),
        wme_client::ClientError::JsonParse(msg) => anyhow::anyhow!("Parse error: {}", msg),
        wme_client::ClientError::Io(msg) => anyhow::anyhow!("IO error: {}", msg),
        wme_client::ClientError::Stream(msg) => anyhow::anyhow!("Stream error: {}", msg),
        wme_client::ClientError::Config(msg) => anyhow::anyhow!("Configuration error: {}", msg),
    }
}