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}
32
33/// Resolve backend runtime settings from config.
34///
35/// Returns `Ok(None)` when backend mode is disabled. Returns `Err` when
36/// backend mode is enabled but required values (e.g., token) are missing.
37pub fn resolve_backend_runtime(config: &BackendApiConfig) -> CoreResult<Option<BackendRuntime>> {
38    if !config.enabled {
39        return Ok(None);
40    }
41
42    let token = resolve_token(config)?;
43    let backup_dir = resolve_backup_dir(config);
44    let timeout = Duration::from_millis(config.timeout_ms);
45
46    Ok(Some(BackendRuntime {
47        base_url: config.url.clone(),
48        token,
49        timeout,
50        max_retries: config.max_retries,
51        backup_dir,
52    }))
53}
54
55/// Resolve the bearer token from explicit config or environment variable.
56fn resolve_token(config: &BackendApiConfig) -> CoreResult<String> {
57    if let Some(token) = &config.token {
58        let token = token.trim();
59        if !token.is_empty() {
60            return Ok(token.to_string());
61        }
62    }
63
64    let env_var = &config.token_env_var;
65    match std::env::var(env_var) {
66        Ok(val) if !val.trim().is_empty() => Ok(val.trim().to_string()),
67        Ok(_) => Err(CoreError::validation(format!(
68            "Backend mode is enabled but environment variable '{env_var}' is empty. \
69             Set the token via '{env_var}' or 'backend.token' in config."
70        ))),
71        Err(_) => Err(CoreError::validation(format!(
72            "Backend mode is enabled but environment variable '{env_var}' is not set. \
73             Set the token via '{env_var}' or 'backend.token' in config."
74        ))),
75    }
76}
77
78/// Resolve the backup directory, falling back to `$HOME/.ito/backups`.
79fn resolve_backup_dir(config: &BackendApiConfig) -> PathBuf {
80    if let Some(dir) = &config.backup_dir {
81        return PathBuf::from(dir);
82    }
83
84    let home = std::env::var("HOME")
85        .or_else(|_| std::env::var("USERPROFILE"))
86        .unwrap_or_else(|_| "/tmp".to_string());
87
88    PathBuf::from(home).join(".ito").join("backups")
89}
90
91/// Determines whether a backend error status code is retriable.
92///
93/// Returns `true` for server errors (5xx) and rate limiting (429).
94/// Client errors (4xx other than 429) are not retriable.
95pub fn is_retriable_status(status: u16) -> bool {
96    match status {
97        429 => true,
98        s if s >= 500 => true,
99        _ => false,
100    }
101}
102
103/// Generate a unique idempotency key for a backend operation.
104///
105/// The key combines a UUID v4 prefix with the operation name for
106/// traceability in server logs.
107pub fn idempotency_key(operation: &str) -> String {
108    format!("{}-{operation}", uuid::Uuid::new_v4())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn disabled_backend_returns_none() {
117        let config = BackendApiConfig::default();
118        assert!(!config.enabled);
119
120        let result = resolve_backend_runtime(&config).unwrap();
121        assert!(result.is_none());
122    }
123
124    #[test]
125    fn enabled_backend_with_explicit_token_resolves() {
126        let config = BackendApiConfig {
127            enabled: true,
128            token: Some("test-token-123".to_string()),
129            ..BackendApiConfig::default()
130        };
131
132        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
133        assert_eq!(runtime.token, "test-token-123");
134        assert_eq!(runtime.base_url, "http://127.0.0.1:9010");
135        assert_eq!(runtime.max_retries, 3);
136        assert_eq!(runtime.timeout, Duration::from_millis(30_000));
137    }
138
139    #[test]
140    fn enabled_backend_with_env_var_token_resolves() {
141        let env_var = "ITO_TEST_BACKEND_TOKEN_1";
142        // SAFETY: test-only, single-threaded access to this unique env var.
143        unsafe { std::env::set_var(env_var, "env-token-456") };
144
145        let config = BackendApiConfig {
146            enabled: true,
147            token: None,
148            token_env_var: env_var.to_string(),
149            ..BackendApiConfig::default()
150        };
151
152        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
153        assert_eq!(runtime.token, "env-token-456");
154
155        // SAFETY: test-only cleanup.
156        unsafe { std::env::remove_var(env_var) };
157    }
158
159    #[test]
160    fn enabled_backend_missing_token_fails() {
161        let env_var = "ITO_TEST_NONEXISTENT_TOKEN_VAR";
162        // SAFETY: test-only cleanup of unique env var.
163        unsafe { std::env::remove_var(env_var) };
164
165        let config = BackendApiConfig {
166            enabled: true,
167            token: None,
168            token_env_var: env_var.to_string(),
169            ..BackendApiConfig::default()
170        };
171
172        let err = resolve_backend_runtime(&config).unwrap_err();
173        let msg = err.to_string();
174        assert!(msg.contains(env_var), "error should mention env var: {msg}");
175        assert!(
176            msg.contains("not set"),
177            "error should mention 'not set': {msg}"
178        );
179    }
180
181    #[test]
182    fn enabled_backend_empty_token_fails() {
183        let env_var = "ITO_TEST_EMPTY_TOKEN_VAR";
184        // SAFETY: test-only, single-threaded access to this unique env var.
185        unsafe { std::env::set_var(env_var, "") };
186
187        let config = BackendApiConfig {
188            enabled: true,
189            token: None,
190            token_env_var: env_var.to_string(),
191            ..BackendApiConfig::default()
192        };
193
194        let err = resolve_backend_runtime(&config).unwrap_err();
195        let msg = err.to_string();
196        assert!(msg.contains("empty"), "error should mention 'empty': {msg}");
197
198        // SAFETY: test-only cleanup.
199        unsafe { std::env::remove_var(env_var) };
200    }
201
202    #[test]
203    fn custom_backup_dir_is_used() {
204        let config = BackendApiConfig {
205            enabled: true,
206            token: Some("t".to_string()),
207            backup_dir: Some("/custom/backups".to_string()),
208            ..BackendApiConfig::default()
209        };
210
211        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
212        assert_eq!(runtime.backup_dir, PathBuf::from("/custom/backups"));
213    }
214
215    #[test]
216    fn default_backup_dir_uses_home() {
217        let config = BackendApiConfig {
218            enabled: true,
219            token: Some("t".to_string()),
220            backup_dir: None,
221            ..BackendApiConfig::default()
222        };
223
224        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
225        // Should end with .ito/backups regardless of the home directory
226        let path_str = runtime.backup_dir.to_string_lossy();
227        assert!(
228            path_str.ends_with(".ito/backups"),
229            "unexpected backup dir: {path_str}"
230        );
231    }
232
233    #[test]
234    fn is_retriable_status_checks() {
235        assert!(is_retriable_status(429));
236        assert!(is_retriable_status(500));
237        assert!(is_retriable_status(502));
238        assert!(is_retriable_status(503));
239        assert!(!is_retriable_status(400));
240        assert!(!is_retriable_status(401));
241        assert!(!is_retriable_status(404));
242        assert!(!is_retriable_status(200));
243    }
244
245    #[test]
246    fn idempotency_key_includes_operation() {
247        let key = idempotency_key("push");
248        assert!(key.ends_with("-push"));
249        assert!(key.len() > 5); // UUID prefix + separator + operation
250    }
251}