1use std::path::PathBuf;
8use std::time::Duration;
9
10use ito_config::types::BackendApiConfig;
11
12use crate::errors::{CoreError, CoreResult};
13
14#[derive(Debug, Clone)]
20pub struct BackendRuntime {
21 pub base_url: String,
23 pub token: String,
25 pub timeout: Duration,
27 pub max_retries: u32,
29 pub backup_dir: PathBuf,
31 pub org: String,
33 pub repo: String,
35}
36
37impl BackendRuntime {
38 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
47pub 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
72fn 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
99fn 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
112const ENV_PROJECT_ORG: &str = "ITO_BACKEND_PROJECT_ORG";
114const ENV_PROJECT_REPO: &str = "ITO_BACKEND_PROJECT_REPO";
116
117fn resolve_project_namespace(config: &BackendApiConfig) -> CoreResult<(String, String)> {
125 resolve_project_namespace_with_env(config, ENV_PROJECT_ORG, ENV_PROJECT_REPO)
126}
127
128fn 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
177pub fn is_retriable_status(status: u16) -> bool {
182 match status {
183 429 => true,
184 s if s >= 500 => true,
185 _ => false,
186 }
187}
188
189pub 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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(); 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 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 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 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 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 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); }
517}