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)]
198mod tests {
199    use super::*;
200    use ito_config::types::BackendProjectConfig;
201
202    /// Create an enabled config with token, org, and repo pre-populated.
203    fn enabled_config() -> BackendApiConfig {
204        BackendApiConfig {
205            enabled: true,
206            token: Some("test-token-123".to_string()),
207            project: BackendProjectConfig {
208                org: Some("acme".to_string()),
209                repo: Some("widgets".to_string()),
210            },
211            ..BackendApiConfig::default()
212        }
213    }
214
215    #[test]
216    fn disabled_backend_returns_none() {
217        let config = BackendApiConfig::default();
218        assert!(!config.enabled);
219
220        let result = resolve_backend_runtime(&config).unwrap();
221        assert!(result.is_none());
222    }
223
224    #[test]
225    fn enabled_backend_with_explicit_token_resolves() {
226        let config = enabled_config();
227
228        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
229        assert_eq!(runtime.token, "test-token-123");
230        assert_eq!(runtime.base_url, "http://127.0.0.1:9010");
231        assert_eq!(runtime.max_retries, 3);
232        assert_eq!(runtime.timeout, Duration::from_millis(30_000));
233        assert_eq!(runtime.org, "acme");
234        assert_eq!(runtime.repo, "widgets");
235    }
236
237    #[test]
238    fn enabled_backend_with_env_var_token_resolves() {
239        let env_var = "ITO_TEST_BACKEND_TOKEN_1";
240        // SAFETY: test-only, single-threaded access to this unique env var.
241        unsafe { std::env::set_var(env_var, "env-token-456") };
242
243        let config = BackendApiConfig {
244            token: None,
245            token_env_var: env_var.to_string(),
246            ..enabled_config()
247        };
248
249        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
250        assert_eq!(runtime.token, "env-token-456");
251
252        // SAFETY: test-only cleanup.
253        unsafe { std::env::remove_var(env_var) };
254    }
255
256    #[test]
257    fn env_var_token_takes_precedence_over_config_token() {
258        let env_var = "ITO_TEST_BACKEND_TOKEN_PREC";
259        // SAFETY: test-only, single-threaded access to this unique env var.
260        unsafe { std::env::set_var(env_var, "env-token-override") };
261
262        let config = BackendApiConfig {
263            token: Some("config-token".to_string()),
264            token_env_var: env_var.to_string(),
265            ..enabled_config()
266        };
267
268        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
269        assert_eq!(runtime.token, "env-token-override");
270
271        // SAFETY: test-only cleanup.
272        unsafe { std::env::remove_var(env_var) };
273    }
274
275    #[test]
276    fn enabled_backend_missing_token_fails() {
277        let env_var = "ITO_TEST_NONEXISTENT_TOKEN_VAR";
278        // SAFETY: test-only cleanup of unique env var.
279        unsafe { std::env::remove_var(env_var) };
280
281        let config = BackendApiConfig {
282            token: None,
283            token_env_var: env_var.to_string(),
284            ..enabled_config()
285        };
286
287        let err = resolve_backend_runtime(&config).unwrap_err();
288        let msg = err.to_string();
289        assert!(msg.contains(env_var), "error should mention env var: {msg}");
290        assert!(
291            msg.contains("not set"),
292            "error should mention 'not set': {msg}"
293        );
294    }
295
296    #[test]
297    fn enabled_backend_empty_token_fails() {
298        let env_var = "ITO_TEST_EMPTY_TOKEN_VAR";
299        // SAFETY: test-only, single-threaded access to this unique env var.
300        unsafe { std::env::set_var(env_var, "") };
301
302        let config = BackendApiConfig {
303            token: None,
304            token_env_var: env_var.to_string(),
305            ..enabled_config()
306        };
307
308        let err = resolve_backend_runtime(&config).unwrap_err();
309        let msg = err.to_string();
310        assert!(msg.contains("empty"), "error should mention 'empty': {msg}");
311
312        // SAFETY: test-only cleanup.
313        unsafe { std::env::remove_var(env_var) };
314    }
315
316    #[test]
317    fn custom_backup_dir_is_used() {
318        let config = BackendApiConfig {
319            backup_dir: Some("/custom/backups".to_string()),
320            ..enabled_config()
321        };
322
323        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
324        assert_eq!(runtime.backup_dir, PathBuf::from("/custom/backups"));
325    }
326
327    #[test]
328    fn default_backup_dir_uses_home() {
329        let config = BackendApiConfig {
330            backup_dir: None,
331            ..enabled_config()
332        };
333
334        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
335        // Should end with .ito/backups regardless of the home directory
336        let path_str = runtime.backup_dir.to_string_lossy();
337        assert!(
338            path_str.ends_with(".ito/backups"),
339            "unexpected backup dir: {path_str}"
340        );
341    }
342
343    // ── Project namespace resolution tests ──────────────────────────
344    //
345    // These tests use `resolve_project_namespace_with_env` with unique
346    // env var names per test to avoid parallel-test races on global
347    // process environment.
348
349    #[test]
350    fn project_namespace_from_config() {
351        let config = enabled_config();
352        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
353        assert_eq!(runtime.org, "acme");
354        assert_eq!(runtime.repo, "widgets");
355    }
356
357    #[test]
358    fn project_namespace_from_env_vars() {
359        let org_var = "ITO_TEST_NS_FROM_ENV_ORG";
360        let repo_var = "ITO_TEST_NS_FROM_ENV_REPO";
361        // SAFETY: test-only, single-threaded access to unique env vars.
362        unsafe {
363            std::env::set_var(org_var, "env-org");
364            std::env::set_var(repo_var, "env-repo");
365        }
366
367        let config = BackendApiConfig {
368            project: BackendProjectConfig {
369                org: None,
370                repo: None,
371            },
372            ..enabled_config()
373        };
374
375        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
376        assert_eq!(org, "env-org");
377        assert_eq!(repo, "env-repo");
378
379        // SAFETY: test-only cleanup.
380        unsafe {
381            std::env::remove_var(org_var);
382            std::env::remove_var(repo_var);
383        }
384    }
385
386    #[test]
387    fn project_namespace_env_takes_precedence_over_config() {
388        let org_var = "ITO_TEST_NS_PREC_ORG";
389        let repo_var = "ITO_TEST_NS_PREC_REPO";
390        // SAFETY: test-only, single-threaded access to unique env vars.
391        unsafe {
392            std::env::set_var(org_var, "env-org");
393            std::env::set_var(repo_var, "env-repo");
394        }
395
396        let config = enabled_config(); // has org=acme, repo=widgets
397        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
398        assert_eq!(org, "env-org");
399        assert_eq!(repo, "env-repo");
400
401        // SAFETY: test-only cleanup.
402        unsafe {
403            std::env::remove_var(org_var);
404            std::env::remove_var(repo_var);
405        }
406    }
407
408    #[test]
409    fn project_namespace_missing_org_fails() {
410        let org_var = "ITO_TEST_NS_MISS_ORG";
411        let repo_var = "ITO_TEST_NS_MISS_ORG_REPO";
412        // SAFETY: test-only cleanup.
413        unsafe {
414            std::env::remove_var(org_var);
415            std::env::remove_var(repo_var);
416        }
417
418        let config = BackendApiConfig {
419            project: BackendProjectConfig {
420                org: None,
421                repo: Some("widgets".to_string()),
422            },
423            ..enabled_config()
424        };
425
426        let err = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap_err();
427        let msg = err.to_string();
428        assert!(
429            msg.contains("project.org"),
430            "error should mention project.org: {msg}"
431        );
432    }
433
434    #[test]
435    fn project_namespace_missing_repo_fails() {
436        let org_var = "ITO_TEST_NS_MISS_REPO_ORG";
437        let repo_var = "ITO_TEST_NS_MISS_REPO";
438        // SAFETY: test-only cleanup.
439        unsafe {
440            std::env::remove_var(org_var);
441            std::env::remove_var(repo_var);
442        }
443
444        let config = BackendApiConfig {
445            project: BackendProjectConfig {
446                org: Some("acme".to_string()),
447                repo: None,
448            },
449            ..enabled_config()
450        };
451
452        let err = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap_err();
453        let msg = err.to_string();
454        assert!(
455            msg.contains("project.repo"),
456            "error should mention project.repo: {msg}"
457        );
458    }
459
460    #[test]
461    fn project_namespace_empty_string_falls_through_to_env() {
462        let org_var = "ITO_TEST_NS_EMPTY_ORG";
463        let repo_var = "ITO_TEST_NS_EMPTY_REPO";
464        // SAFETY: test-only, single-threaded access to unique env vars.
465        unsafe {
466            std::env::set_var(org_var, "env-org");
467            std::env::set_var(repo_var, "env-repo");
468        }
469
470        let config = BackendApiConfig {
471            project: BackendProjectConfig {
472                org: Some("".to_string()),
473                repo: Some("".to_string()),
474            },
475            ..enabled_config()
476        };
477
478        let (org, repo) = resolve_project_namespace_with_env(&config, org_var, repo_var).unwrap();
479        assert_eq!(org, "env-org");
480        assert_eq!(repo, "env-repo");
481
482        // SAFETY: test-only cleanup.
483        unsafe {
484            std::env::remove_var(org_var);
485            std::env::remove_var(repo_var);
486        }
487    }
488
489    #[test]
490    fn project_api_prefix_formats_correctly() {
491        let config = enabled_config();
492        let runtime = resolve_backend_runtime(&config).unwrap().unwrap();
493        assert_eq!(
494            runtime.project_api_prefix(),
495            "http://127.0.0.1:9010/api/v1/projects/acme/widgets"
496        );
497    }
498
499    #[test]
500    fn is_retriable_status_checks() {
501        assert!(is_retriable_status(429));
502        assert!(is_retriable_status(500));
503        assert!(is_retriable_status(502));
504        assert!(is_retriable_status(503));
505        assert!(!is_retriable_status(400));
506        assert!(!is_retriable_status(401));
507        assert!(!is_retriable_status(404));
508        assert!(!is_retriable_status(200));
509    }
510
511    #[test]
512    fn idempotency_key_includes_operation() {
513        let key = idempotency_key("push");
514        assert!(key.ends_with("-push"));
515        assert!(key.len() > 5); // UUID prefix + separator + operation
516    }
517}