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    if let Some(token) = &config.token {
75        let token = token.trim();
76        if !token.is_empty() {
77            return Ok(token.to_string());
78        }
79    }
80
81    let env_var = &config.token_env_var;
82    match std::env::var(env_var) {
83        Ok(val) if !val.trim().is_empty() => Ok(val.trim().to_string()),
84        Ok(_) => Err(CoreError::validation(format!(
85            "Backend mode is enabled but environment variable '{env_var}' is empty. \
86             Set the token via '{env_var}' or 'backend.token' in config."
87        ))),
88        Err(_) => Err(CoreError::validation(format!(
89            "Backend mode is enabled but environment variable '{env_var}' is not set. \
90             Set the token via '{env_var}' or 'backend.token' in config."
91        ))),
92    }
93}
94
95/// Resolve the backup directory, falling back to `$HOME/.ito/backups`.
96fn resolve_backup_dir(config: &BackendApiConfig) -> PathBuf {
97    if let Some(dir) = &config.backup_dir {
98        return PathBuf::from(dir);
99    }
100
101    let home = std::env::var("HOME")
102        .or_else(|_| std::env::var("USERPROFILE"))
103        .unwrap_or_else(|_| "/tmp".to_string());
104
105    PathBuf::from(home).join(".ito").join("backups")
106}
107
108/// Environment variable name for overriding the project organization namespace.
109const ENV_PROJECT_ORG: &str = "ITO_BACKEND_PROJECT_ORG";
110/// Environment variable name for overriding the project repository namespace.
111const ENV_PROJECT_REPO: &str = "ITO_BACKEND_PROJECT_REPO";
112
113/// Resolve the project namespace (org, repo) from config with env var fallbacks.
114///
115/// Resolution order for each field:
116/// 1. Explicit config value (`backend.project.org` / `backend.project.repo`)
117/// 2. Environment variable (`ITO_BACKEND_PROJECT_ORG` / `ITO_BACKEND_PROJECT_REPO`)
118///
119/// Returns `Err` if either value is missing after fallback resolution.
120fn resolve_project_namespace(config: &BackendApiConfig) -> CoreResult<(String, String)> {
121    resolve_project_namespace_with_env(config, ENV_PROJECT_ORG, ENV_PROJECT_REPO)
122}
123
124/// Inner implementation that accepts env var names for testability.
125fn resolve_project_namespace_with_env(
126    config: &BackendApiConfig,
127    org_env_var: &str,
128    repo_env_var: &str,
129) -> CoreResult<(String, String)> {
130    let org = config
131        .project
132        .org
133        .as_deref()
134        .filter(|s| !s.is_empty())
135        .map(String::from)
136        .or_else(|| {
137            std::env::var(org_env_var)
138                .ok()
139                .filter(|s| !s.trim().is_empty())
140                .map(|s| s.trim().to_string())
141        });
142
143    let repo = config
144        .project
145        .repo
146        .as_deref()
147        .filter(|s| !s.is_empty())
148        .map(String::from)
149        .or_else(|| {
150            std::env::var(repo_env_var)
151                .ok()
152                .filter(|s| !s.trim().is_empty())
153                .map(|s| s.trim().to_string())
154        });
155
156    let Some(org) = org else {
157        return Err(CoreError::validation(format!(
158            "Backend mode is enabled but 'backend.project.org' is not set. \
159             Set it in config or via the {org_env_var} environment variable."
160        )));
161    };
162
163    let Some(repo) = repo else {
164        return Err(CoreError::validation(format!(
165            "Backend mode is enabled but 'backend.project.repo' is not set. \
166             Set it in config or via the {repo_env_var} environment variable."
167        )));
168    };
169
170    Ok((org, repo))
171}
172
173/// Determines whether a backend error status code is retriable.
174///
175/// Returns `true` for server errors (5xx) and rate limiting (429).
176/// Client errors (4xx other than 429) are not retriable.
177pub fn is_retriable_status(status: u16) -> bool {
178    match status {
179        429 => true,
180        s if s >= 500 => true,
181        _ => false,
182    }
183}
184
185/// Generate a unique idempotency key for a backend operation.
186///
187/// The key combines a UUID v4 prefix with the operation name for
188/// traceability in server logs.
189pub fn idempotency_key(operation: &str) -> String {
190    format!("{}-{operation}", uuid::Uuid::new_v4())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use ito_config::types::BackendProjectConfig;
197
198    /// Create an enabled config with token, org, and repo pre-populated.
199    fn enabled_config() -> BackendApiConfig {
200        BackendApiConfig {
201            enabled: true,
202            token: Some("test-token-123".to_string()),
203            project: BackendProjectConfig {
204                org: Some("acme".to_string()),
205                repo: Some("widgets".to_string()),
206            },
207            ..BackendApiConfig::default()
208        }
209    }
210
211    #[test]
212    fn disabled_backend_returns_none() {
213        let config = BackendApiConfig::default();
214        assert!(!config.enabled);
215
216        let result = resolve_backend_runtime(&config).unwrap();
217        assert!(result.is_none());
218    }
219
220    #[test]
221    fn enabled_backend_with_explicit_token_resolves() {
222        let config = enabled_config();
223
224        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
225        assert_eq!(runtime.token, "test-token-123");
226        assert_eq!(runtime.base_url, "http://127.0.0.1:9010");
227        assert_eq!(runtime.max_retries, 3);
228        assert_eq!(runtime.timeout, Duration::from_millis(30_000));
229        assert_eq!(runtime.org, "acme");
230        assert_eq!(runtime.repo, "widgets");
231    }
232
233    #[test]
234    fn enabled_backend_with_env_var_token_resolves() {
235        let env_var = "ITO_TEST_BACKEND_TOKEN_1";
236        // SAFETY: test-only, single-threaded access to this unique env var.
237        unsafe { std::env::set_var(env_var, "env-token-456") };
238
239        let config = BackendApiConfig {
240            token: None,
241            token_env_var: env_var.to_string(),
242            ..enabled_config()
243        };
244
245        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
246        assert_eq!(runtime.token, "env-token-456");
247
248        // SAFETY: test-only cleanup.
249        unsafe { std::env::remove_var(env_var) };
250    }
251
252    #[test]
253    fn enabled_backend_missing_token_fails() {
254        let env_var = "ITO_TEST_NONEXISTENT_TOKEN_VAR";
255        // SAFETY: test-only cleanup of unique env var.
256        unsafe { std::env::remove_var(env_var) };
257
258        let config = BackendApiConfig {
259            token: None,
260            token_env_var: env_var.to_string(),
261            ..enabled_config()
262        };
263
264        let err = resolve_backend_runtime(&config).unwrap_err();
265        let msg = err.to_string();
266        assert!(msg.contains(env_var), "error should mention env var: {msg}");
267        assert!(
268            msg.contains("not set"),
269            "error should mention 'not set': {msg}"
270        );
271    }
272
273    #[test]
274    fn enabled_backend_empty_token_fails() {
275        let env_var = "ITO_TEST_EMPTY_TOKEN_VAR";
276        // SAFETY: test-only, single-threaded access to this unique env var.
277        unsafe { std::env::set_var(env_var, "") };
278
279        let config = BackendApiConfig {
280            token: None,
281            token_env_var: env_var.to_string(),
282            ..enabled_config()
283        };
284
285        let err = resolve_backend_runtime(&config).unwrap_err();
286        let msg = err.to_string();
287        assert!(msg.contains("empty"), "error should mention 'empty': {msg}");
288
289        // SAFETY: test-only cleanup.
290        unsafe { std::env::remove_var(env_var) };
291    }
292
293    #[test]
294    fn custom_backup_dir_is_used() {
295        let config = BackendApiConfig {
296            backup_dir: Some("/custom/backups".to_string()),
297            ..enabled_config()
298        };
299
300        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
301        assert_eq!(runtime.backup_dir, PathBuf::from("/custom/backups"));
302    }
303
304    #[test]
305    fn default_backup_dir_uses_home() {
306        let config = BackendApiConfig {
307            backup_dir: None,
308            ..enabled_config()
309        };
310
311        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
312        // Should end with .ito/backups regardless of the home directory
313        let path_str = runtime.backup_dir.to_string_lossy();
314        assert!(
315            path_str.ends_with(".ito/backups"),
316            "unexpected backup dir: {path_str}"
317        );
318    }
319
320    // ── Project namespace resolution tests ──────────────────────────
321    //
322    // These tests use `resolve_project_namespace_with_env` with unique
323    // env var names per test to avoid parallel-test races on global
324    // process environment.
325
326    #[test]
327    fn project_namespace_from_config() {
328        let config = enabled_config();
329        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
330        assert_eq!(runtime.org, "acme");
331        assert_eq!(runtime.repo, "widgets");
332    }
333
334    #[test]
335    fn project_namespace_from_env_vars() {
336        let org_var = "ITO_TEST_NS_FROM_ENV_ORG";
337        let repo_var = "ITO_TEST_NS_FROM_ENV_REPO";
338        // SAFETY: test-only, single-threaded access to unique env vars.
339        unsafe {
340            std::env::set_var(org_var, "env-org");
341            std::env::set_var(repo_var, "env-repo");
342        }
343
344        let config = BackendApiConfig {
345            project: BackendProjectConfig {
346                org: None,
347                repo: None,
348            },
349            ..enabled_config()
350        };
351
352        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
353        assert_eq!(org, "env-org");
354        assert_eq!(repo, "env-repo");
355
356        // SAFETY: test-only cleanup.
357        unsafe {
358            std::env::remove_var(org_var);
359            std::env::remove_var(repo_var);
360        }
361    }
362
363    #[test]
364    fn project_namespace_config_takes_precedence_over_env() {
365        let org_var = "ITO_TEST_NS_PREC_ORG";
366        let repo_var = "ITO_TEST_NS_PREC_REPO";
367        // SAFETY: test-only, single-threaded access to unique env vars.
368        unsafe {
369            std::env::set_var(org_var, "env-org");
370            std::env::set_var(repo_var, "env-repo");
371        }
372
373        let config = enabled_config(); // has org=acme, repo=widgets
374        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
375        assert_eq!(org, "acme");
376        assert_eq!(repo, "widgets");
377
378        // SAFETY: test-only cleanup.
379        unsafe {
380            std::env::remove_var(org_var);
381            std::env::remove_var(repo_var);
382        }
383    }
384
385    #[test]
386    fn project_namespace_missing_org_fails() {
387        let org_var = "ITO_TEST_NS_MISS_ORG";
388        let repo_var = "ITO_TEST_NS_MISS_ORG_REPO";
389        // SAFETY: test-only cleanup.
390        unsafe {
391            std::env::remove_var(org_var);
392            std::env::remove_var(repo_var);
393        }
394
395        let config = BackendApiConfig {
396            project: BackendProjectConfig {
397                org: None,
398                repo: Some("widgets".to_string()),
399            },
400            ..enabled_config()
401        };
402
403        let err = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap_err();
404        let msg = err.to_string();
405        assert!(
406            msg.contains("project.org"),
407            "error should mention project.org: {msg}"
408        );
409    }
410
411    #[test]
412    fn project_namespace_missing_repo_fails() {
413        let org_var = "ITO_TEST_NS_MISS_REPO_ORG";
414        let repo_var = "ITO_TEST_NS_MISS_REPO";
415        // SAFETY: test-only cleanup.
416        unsafe {
417            std::env::remove_var(org_var);
418            std::env::remove_var(repo_var);
419        }
420
421        let config = BackendApiConfig {
422            project: BackendProjectConfig {
423                org: Some("acme".to_string()),
424                repo: None,
425            },
426            ..enabled_config()
427        };
428
429        let err = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap_err();
430        let msg = err.to_string();
431        assert!(
432            msg.contains("project.repo"),
433            "error should mention project.repo: {msg}"
434        );
435    }
436
437    #[test]
438    fn project_namespace_empty_string_falls_through_to_env() {
439        let org_var = "ITO_TEST_NS_EMPTY_ORG";
440        let repo_var = "ITO_TEST_NS_EMPTY_REPO";
441        // SAFETY: test-only, single-threaded access to unique env vars.
442        unsafe {
443            std::env::set_var(org_var, "env-org");
444            std::env::set_var(repo_var, "env-repo");
445        }
446
447        let config = BackendApiConfig {
448            project: BackendProjectConfig {
449                org: Some("".to_string()),
450                repo: Some("".to_string()),
451            },
452            ..enabled_config()
453        };
454
455        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
456        assert_eq!(org, "env-org");
457        assert_eq!(repo, "env-repo");
458
459        // SAFETY: test-only cleanup.
460        unsafe {
461            std::env::remove_var(org_var);
462            std::env::remove_var(repo_var);
463        }
464    }
465
466    #[test]
467    fn project_api_prefix_formats_correctly() {
468        let config = enabled_config();
469        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
470        assert_eq!(
471            runtime.project_api_prefix(),
472            "http://127.0.0.1:9010/api/v1/projects/acme/widgets"
473        );
474    }
475
476    #[test]
477    fn is_retriable_status_checks() {
478        assert!(is_retriable_status(429));
479        assert!(is_retriable_status(500));
480        assert!(is_retriable_status(502));
481        assert!(is_retriable_status(503));
482        assert!(!is_retriable_status(400));
483        assert!(!is_retriable_status(401));
484        assert!(!is_retriable_status(404));
485        assert!(!is_retriable_status(200));
486    }
487
488    #[test]
489    fn idempotency_key_includes_operation() {
490        let key = idempotency_key("push");
491        assert!(key.ends_with("-push"));
492        assert!(key.len() > 5); // UUID prefix + separator + operation
493    }
494}