Skip to main content

ito_core/
backend_client.rs

1//! Backend API client factory and runtime.
2//!
3//! Creates and configures an HTTP client for the Ito backend API when
4//! backend mode is enabled in the resolved configuration. The client
5//! handles authentication, timeouts, and retry logic for transient failures.
6
7use std::path::PathBuf;
8use std::time::Duration;
9
10use ito_config::types::BackendApiConfig;
11
12use crate::errors::{CoreError, CoreResult};
13
14/// Resolved backend runtime settings ready for client construction.
15///
16/// Constructed from [`BackendApiConfig`] with environment variable resolution
17/// and validation applied. This type is only created when backend mode is
18/// enabled and all required settings are present.
19#[derive(Debug, Clone)]
20pub struct BackendRuntime {
21    /// Base URL for the backend API.
22    pub base_url: String,
23    /// Resolved bearer token for authentication.
24    pub token: String,
25    /// Request timeout.
26    pub timeout: Duration,
27    /// Maximum retry attempts for transient failures.
28    pub max_retries: u32,
29    /// Directory for artifact backup snapshots.
30    pub backup_dir: PathBuf,
31    /// Organization namespace for project-scoped routes.
32    pub org: String,
33    /// Repository namespace for project-scoped routes.
34    pub repo: String,
35}
36
37impl BackendRuntime {
38    /// Returns the project-scoped API path prefix: `/api/v1/projects/{org}/{repo}`.
39    pub fn project_api_prefix(&self) -> String {
40        format!(
41            "{}/api/v1/projects/{}/{}",
42            self.base_url, self.org, self.repo
43        )
44    }
45}
46
47/// Resolve backend runtime settings from config.
48///
49/// Returns `Ok(None)` when backend mode is disabled. Returns `Err` when
50/// backend mode is enabled but required values (e.g., token) are missing.
51pub fn resolve_backend_runtime(config: &BackendApiConfig) -> CoreResult<Option<BackendRuntime>> {
52    if !config.enabled {
53        return Ok(None);
54    }
55
56    let token = resolve_token(config)?;
57    let backup_dir = resolve_backup_dir(config);
58    let timeout = Duration::from_millis(config.timeout_ms);
59    let (org, repo) = resolve_project_namespace(config)?;
60
61    Ok(Some(BackendRuntime {
62        base_url: config.url.clone(),
63        token,
64        timeout,
65        max_retries: config.max_retries,
66        backup_dir,
67        org,
68        repo,
69    }))
70}
71
72/// Resolve the bearer token from explicit config or environment variable.
73fn resolve_token(config: &BackendApiConfig) -> CoreResult<String> {
74    let env_var = &config.token_env_var;
75    match std::env::var(env_var) {
76        Ok(val) if !val.trim().is_empty() => return Ok(val.trim().to_string()),
77        Ok(_) => {
78            return Err(CoreError::validation(format!(
79                "Backend mode is enabled but environment variable '{env_var}' is empty. \
80                 Set the token via '{env_var}' or 'backend.token' in config."
81            )));
82        }
83        Err(_) => {}
84    }
85
86    if let Some(token) = &config.token {
87        let token = token.trim();
88        if !token.is_empty() {
89            return Ok(token.to_string());
90        }
91    }
92
93    Err(CoreError::validation(format!(
94        "Backend mode is enabled but environment variable '{env_var}' is not set. \
95         Set the token via '{env_var}' or 'backend.token' in config."
96    )))
97}
98
99/// Resolve the backup directory, falling back to `$HOME/.ito/backups`.
100fn resolve_backup_dir(config: &BackendApiConfig) -> PathBuf {
101    if let Some(dir) = &config.backup_dir {
102        return PathBuf::from(dir);
103    }
104
105    let home = std::env::var("HOME")
106        .or_else(|_| std::env::var("USERPROFILE"))
107        .unwrap_or_else(|_| "/tmp".to_string());
108
109    PathBuf::from(home).join(".ito").join("backups")
110}
111
112/// Environment variable name for overriding the project organization namespace.
113const ENV_PROJECT_ORG: &str = "ITO_BACKEND_PROJECT_ORG";
114/// Environment variable name for overriding the project repository namespace.
115const ENV_PROJECT_REPO: &str = "ITO_BACKEND_PROJECT_REPO";
116
117/// Resolve the project namespace (org, repo) from env vars with config fallbacks.
118///
119/// Resolution order for each field:
120/// 1. Environment variable (`ITO_BACKEND_PROJECT_ORG` / `ITO_BACKEND_PROJECT_REPO`)
121/// 2. Explicit config value (`backend.project.org` / `backend.project.repo`)
122///
123/// Returns `Err` if either value is missing after fallback resolution.
124fn resolve_project_namespace(config: &BackendApiConfig) -> CoreResult<(String, String)> {
125    resolve_project_namespace_with_env(config, ENV_PROJECT_ORG, ENV_PROJECT_REPO)
126}
127
128/// Inner implementation that accepts env var names for testability.
129fn resolve_project_namespace_with_env(
130    config: &BackendApiConfig,
131    org_env_var: &str,
132    repo_env_var: &str,
133) -> CoreResult<(String, String)> {
134    let org = std::env::var(org_env_var)
135        .ok()
136        .filter(|s| !s.trim().is_empty())
137        .map(|s| s.trim().to_string())
138        .or_else(|| {
139            config
140                .project
141                .org
142                .as_deref()
143                .filter(|s| !s.is_empty())
144                .map(String::from)
145        });
146
147    let repo = std::env::var(repo_env_var)
148        .ok()
149        .filter(|s| !s.trim().is_empty())
150        .map(|s| s.trim().to_string())
151        .or_else(|| {
152            config
153                .project
154                .repo
155                .as_deref()
156                .filter(|s| !s.is_empty())
157                .map(String::from)
158        });
159
160    let Some(org) = org else {
161        return Err(CoreError::validation(format!(
162            "Backend mode is enabled but 'backend.project.org' is not set. \
163             Set it in config or via the {org_env_var} environment variable."
164        )));
165    };
166
167    let Some(repo) = repo else {
168        return Err(CoreError::validation(format!(
169            "Backend mode is enabled but 'backend.project.repo' is not set. \
170             Set it in config or via the {repo_env_var} environment variable."
171        )));
172    };
173
174    Ok((org, repo))
175}
176
177/// Determines whether a backend error status code is retriable.
178///
179/// Returns `true` for server errors (5xx) and rate limiting (429).
180/// Client errors (4xx other than 429) are not retriable.
181pub fn is_retriable_status(status: u16) -> bool {
182    match status {
183        429 => true,
184        s if s >= 500 => true,
185        _ => false,
186    }
187}
188
189/// Generate a unique idempotency key for a backend operation.
190///
191/// The key combines a UUID v4 prefix with the operation name for
192/// traceability in server logs.
193pub fn idempotency_key(operation: &str) -> String {
194    format!("{}-{operation}", uuid::Uuid::new_v4())
195}
196
197#[cfg(test)]
198#[path = "backend_client_tests.rs"]
199mod backend_client_tests;