ito-core 0.1.33

Core functionality and business logic for Ito
Documentation
//! Backend API client factory and runtime.
//!
//! Creates and configures an HTTP client for the Ito backend API when
//! backend mode is enabled in the resolved configuration. The client
//! handles authentication, timeouts, and retry logic for transient failures.

use std::path::PathBuf;
use std::time::Duration;

use ito_config::types::BackendApiConfig;

use crate::errors::{CoreError, CoreResult};

/// Resolved backend runtime settings ready for client construction.
///
/// Constructed from [`BackendApiConfig`] with environment variable resolution
/// and validation applied. This type is only created when backend mode is
/// enabled and all required settings are present.
#[derive(Debug, Clone)]
pub struct BackendRuntime {
    /// Base URL for the backend API.
    pub base_url: String,
    /// Resolved bearer token for authentication.
    pub token: String,
    /// Request timeout.
    pub timeout: Duration,
    /// Maximum retry attempts for transient failures.
    pub max_retries: u32,
    /// Directory for artifact backup snapshots.
    pub backup_dir: PathBuf,
    /// Organization namespace for project-scoped routes.
    pub org: String,
    /// Repository namespace for project-scoped routes.
    pub repo: String,
}

impl BackendRuntime {
    /// Returns the project-scoped API path prefix: `/api/v1/projects/{org}/{repo}`.
    pub fn project_api_prefix(&self) -> String {
        format!(
            "{}/api/v1/projects/{}/{}",
            self.base_url, self.org, self.repo
        )
    }
}

/// Resolve backend runtime settings from config.
///
/// Returns `Ok(None)` when backend mode is disabled. Returns `Err` when
/// backend mode is enabled but required values (e.g., token) are missing.
pub fn resolve_backend_runtime(config: &BackendApiConfig) -> CoreResult<Option<BackendRuntime>> {
    if !config.enabled {
        return Ok(None);
    }

    let token = resolve_token(config)?;
    let backup_dir = resolve_backup_dir(config);
    let timeout = Duration::from_millis(config.timeout_ms);
    let (org, repo) = resolve_project_namespace(config)?;

    Ok(Some(BackendRuntime {
        base_url: config.url.clone(),
        token,
        timeout,
        max_retries: config.max_retries,
        backup_dir,
        org,
        repo,
    }))
}

/// Resolve the bearer token from explicit config or environment variable.
fn resolve_token(config: &BackendApiConfig) -> CoreResult<String> {
    let env_var = &config.token_env_var;
    match std::env::var(env_var) {
        Ok(val) if !val.trim().is_empty() => return Ok(val.trim().to_string()),
        Ok(_) => {
            return Err(CoreError::validation(format!(
                "Backend mode is enabled but environment variable '{env_var}' is empty. \
                 Set the token via '{env_var}' or 'backend.token' in config."
            )));
        }
        Err(_) => {}
    }

    if let Some(token) = &config.token {
        let token = token.trim();
        if !token.is_empty() {
            return Ok(token.to_string());
        }
    }

    Err(CoreError::validation(format!(
        "Backend mode is enabled but environment variable '{env_var}' is not set. \
         Set the token via '{env_var}' or 'backend.token' in config."
    )))
}

/// Resolve the backup directory, falling back to `$HOME/.ito/backups`.
fn resolve_backup_dir(config: &BackendApiConfig) -> PathBuf {
    if let Some(dir) = &config.backup_dir {
        return PathBuf::from(dir);
    }

    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/tmp".to_string());

    PathBuf::from(home).join(".ito").join("backups")
}

/// Environment variable name for overriding the project organization namespace.
const ENV_PROJECT_ORG: &str = "ITO_BACKEND_PROJECT_ORG";
/// Environment variable name for overriding the project repository namespace.
const ENV_PROJECT_REPO: &str = "ITO_BACKEND_PROJECT_REPO";

/// Resolve the project namespace (org, repo) from env vars with config fallbacks.
///
/// Resolution order for each field:
/// 1. Environment variable (`ITO_BACKEND_PROJECT_ORG` / `ITO_BACKEND_PROJECT_REPO`)
/// 2. Explicit config value (`backend.project.org` / `backend.project.repo`)
///
/// Returns `Err` if either value is missing after fallback resolution.
fn resolve_project_namespace(config: &BackendApiConfig) -> CoreResult<(String, String)> {
    resolve_project_namespace_with_env(config, ENV_PROJECT_ORG, ENV_PROJECT_REPO)
}

/// Inner implementation that accepts env var names for testability.
fn resolve_project_namespace_with_env(
    config: &BackendApiConfig,
    org_env_var: &str,
    repo_env_var: &str,
) -> CoreResult<(String, String)> {
    let org = std::env::var(org_env_var)
        .ok()
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.trim().to_string())
        .or_else(|| {
            config
                .project
                .org
                .as_deref()
                .filter(|s| !s.is_empty())
                .map(String::from)
        });

    let repo = std::env::var(repo_env_var)
        .ok()
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.trim().to_string())
        .or_else(|| {
            config
                .project
                .repo
                .as_deref()
                .filter(|s| !s.is_empty())
                .map(String::from)
        });

    let Some(org) = org else {
        return Err(CoreError::validation(format!(
            "Backend mode is enabled but 'backend.project.org' is not set. \
             Set it in config or via the {org_env_var} environment variable."
        )));
    };

    let Some(repo) = repo else {
        return Err(CoreError::validation(format!(
            "Backend mode is enabled but 'backend.project.repo' is not set. \
             Set it in config or via the {repo_env_var} environment variable."
        )));
    };

    Ok((org, repo))
}

/// Determines whether a backend error status code is retriable.
///
/// Returns `true` for server errors (5xx) and rate limiting (429).
/// Client errors (4xx other than 429) are not retriable.
pub fn is_retriable_status(status: u16) -> bool {
    match status {
        429 => true,
        s if s >= 500 => true,
        _ => false,
    }
}

/// Generate a unique idempotency key for a backend operation.
///
/// The key combines a UUID v4 prefix with the operation name for
/// traceability in server logs.
pub fn idempotency_key(operation: &str) -> String {
    format!("{}-{operation}", uuid::Uuid::new_v4())
}

#[cfg(test)]
#[path = "backend_client_tests.rs"]
mod backend_client_tests;