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 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
95fn 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
108const ENV_PROJECT_ORG: &str = "ITO_BACKEND_PROJECT_ORG";
110const ENV_PROJECT_REPO: &str = "ITO_BACKEND_PROJECT_REPO";
112
113fn resolve_project_namespace(config: &BackendApiConfig) -> CoreResult<(String, String)> {
121 resolve_project_namespace_with_env(config, ENV_PROJECT_ORG, ENV_PROJECT_REPO)
122}
123
124fn 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
173pub fn is_retriable_status(status: u16) -> bool {
178 match status {
179 429 => true,
180 s if s >= 500 => true,
181 _ => false,
182 }
183}
184
185pub 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 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 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 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 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 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 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 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 #[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 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 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 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(); 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 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 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 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 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 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); }
494}