1use serde::{Deserialize, Serialize};
8use std::env;
9use std::fs;
10#[cfg(target_os = "windows")]
11use std::path::Path;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone)]
15pub struct GlobalXbpPaths {
16 pub root_dir: PathBuf,
17 pub config_file: PathBuf,
18 pub ssh_dir: PathBuf,
19 pub cache_dir: PathBuf,
20 pub logs_dir: PathBuf,
21 pub versioning_files_file: PathBuf,
22 pub package_name_files_file: PathBuf,
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
26pub struct SshConfig {
27 pub password: Option<String>,
28 pub username: Option<String>,
29 pub host: Option<String>,
30 pub project_dir: Option<String>,
31 pub openrouter_api_key: Option<String>,
32 pub github_oauth2_key: Option<String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SecretProvider {
37 OpenRouter,
38 Github,
39}
40
41impl SecretProvider {
42 pub fn from_key(key: &str) -> Option<Self> {
43 match key.trim().to_ascii_lowercase().as_str() {
44 "openrouter" => Some(Self::OpenRouter),
45 "github" => Some(Self::Github),
46 _ => None,
47 }
48 }
49
50 pub fn as_key(&self) -> &'static str {
51 match self {
52 SecretProvider::OpenRouter => "openrouter",
53 SecretProvider::Github => "github",
54 }
55 }
56
57 pub fn config_field(&self) -> &'static str {
58 match self {
59 SecretProvider::OpenRouter => "openrouter_api_key",
60 SecretProvider::Github => "github_oauth2_key",
61 }
62 }
63}
64
65impl Default for SshConfig {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl SshConfig {
72 pub fn new() -> Self {
73 SshConfig {
74 password: None,
75 username: None,
76 host: None,
77 project_dir: None,
78 openrouter_api_key: None,
79 github_oauth2_key: None,
80 }
81 }
82
83 pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
84 match provider {
85 SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
86 SecretProvider::Github => self.github_oauth2_key.as_deref(),
87 }
88 }
89
90 pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
91 match provider {
92 SecretProvider::OpenRouter => self.openrouter_api_key = value,
93 SecretProvider::Github => self.github_oauth2_key = value,
94 }
95 }
96
97 pub fn load() -> Result<Self, String> {
98 let config_path = get_config_path();
99 let legacy_path = legacy_config_path();
100 let path_to_read = if config_path.exists() {
101 config_path
102 } else if legacy_path.exists() {
103 legacy_path
104 } else {
105 return Ok(SshConfig::new());
106 };
107
108 let content = fs::read_to_string(&path_to_read)
109 .map_err(|e| format!("Failed to read config file: {}", e))?;
110 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
111 }
112
113 pub fn save(&self) -> Result<(), String> {
114 let config_path = get_config_path();
115 let config_dir = config_path.parent().ok_or("Invalid config path")?;
116 fs::create_dir_all(config_dir)
117 .map_err(|e| format!("Failed to create config directory: {}", e))?;
118
119 let content = serde_yaml::to_string(self)
120 .map_err(|e| format!("Failed to serialize config: {}", e))?;
121 fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
122 }
123}
124
125#[derive(Debug, Serialize, Deserialize, Clone)]
126pub struct VersioningFilesConfig {
127 #[serde(default = "default_versioning_files")]
128 pub files: Vec<String>,
129}
130
131impl Default for VersioningFilesConfig {
132 fn default() -> Self {
133 Self {
134 files: default_versioning_files(),
135 }
136 }
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
140pub struct PackageNameLookup {
141 pub file: String,
142 pub format: String,
143 pub key: String,
144 pub registry: String,
145}
146
147#[derive(Debug, Serialize, Deserialize, Clone)]
148pub struct PackageNameFilesConfig {
149 #[serde(default = "default_package_name_lookups")]
150 pub lookups: Vec<PackageNameLookup>,
151}
152
153impl Default for PackageNameFilesConfig {
154 fn default() -> Self {
155 Self {
156 lookups: default_package_name_lookups(),
157 }
158 }
159}
160
161pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
162 let root_dir = preferred_global_root_dir();
163
164 let paths = GlobalXbpPaths {
165 config_file: root_dir.join("config.yaml"),
166 ssh_dir: root_dir.join("ssh"),
167 cache_dir: root_dir.join("cache"),
168 logs_dir: root_dir.join("logs"),
169 versioning_files_file: root_dir.join("versioning-files.yaml"),
170 package_name_files_file: root_dir.join("package-name-files.yaml"),
171 root_dir,
172 };
173
174 for dir in [
175 &paths.root_dir,
176 &paths.ssh_dir,
177 &paths.cache_dir,
178 &paths.logs_dir,
179 ] {
180 fs::create_dir_all(dir)
181 .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
182 }
183
184 maybe_migrate_legacy_windows_files(&paths)?;
185
186 if !paths.config_file.exists() {
187 fs::write(
188 &paths.config_file,
189 "password: null\nusername: null\nhost: null\nproject_dir: null\nopenrouter_api_key: null\ngithub_oauth2_key: null\n",
190 )
191 .map_err(|e| {
192 format!(
193 "Failed to initialize config file {}: {}",
194 paths.config_file.display(),
195 e
196 )
197 })?;
198 }
199
200 sync_versioning_files_registry_at(&paths.versioning_files_file)?;
201 sync_package_name_files_registry_at(&paths.package_name_files_file)?;
202
203 Ok(paths)
204}
205
206pub fn resolve_openrouter_api_key() -> Option<String> {
207 env::var("OPENROUTER_API_KEY")
208 .ok()
209 .map(|value| value.trim().to_string())
210 .filter(|value| !value.is_empty())
211 .or_else(|| {
212 SshConfig::load()
213 .ok()
214 .and_then(|cfg| {
215 cfg.get_secret(SecretProvider::OpenRouter)
216 .map(str::to_string)
217 })
218 .map(|value| value.trim().to_string())
219 .filter(|value| !value.is_empty())
220 })
221}
222
223pub fn resolve_github_oauth2_key() -> Option<String> {
224 for env_var in [
225 "GITHUB_TOKEN",
226 "GITHUB_OAUTH2_KEY",
227 "GITHUB_OAUTH2_TOKEN",
228 "GITHUB_OAUTH_TOKEN",
229 ] {
230 if let Ok(value) = env::var(env_var) {
231 let token = value.trim();
232 if !token.is_empty() {
233 return Some(token.to_string());
234 }
235 }
236 }
237
238 SshConfig::load()
239 .ok()
240 .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
241 .map(|value| value.trim().to_string())
242 .filter(|value| !value.is_empty())
243}
244
245pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
246 ensure_global_xbp_paths()
247}
248
249pub fn get_config_path() -> PathBuf {
250 ensure_global_xbp_paths()
251 .map(|paths| paths.config_file)
252 .unwrap_or_else(|_| legacy_config_path())
253}
254
255#[cfg(target_os = "windows")]
256fn legacy_config_path() -> PathBuf {
257 dirs::config_dir()
258 .unwrap_or_else(|| PathBuf::from("."))
259 .join("xbp")
260 .join("config.yaml")
261}
262
263#[cfg(not(target_os = "windows"))]
264fn legacy_config_path() -> PathBuf {
265 dirs::home_dir()
266 .unwrap_or_else(|| PathBuf::from("."))
267 .join(".xbp")
268 .join("config.yaml")
269}
270
271#[cfg(target_os = "windows")]
272fn preferred_global_root_dir() -> PathBuf {
273 let fallback = dirs::config_dir()
274 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
275 .unwrap_or_else(|| PathBuf::from("."))
276 .join("xbp");
277
278 let Some(home_dir) = resolve_windows_home_dir() else {
279 return fallback;
280 };
281 let c_drive = Path::new(r"C:\");
282
283 if c_drive.exists() {
285 if windows_drive_letter(&home_dir) == Some('C') {
286 return home_dir.join(".xbp");
287 }
288
289 if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
290 let c_profile_candidate = c_drive.join(relative_profile_path);
291 if c_profile_candidate.exists() {
292 return c_profile_candidate.join(".xbp");
293 }
294 }
295 }
296
297 home_dir.join(".xbp")
298}
299
300#[cfg(not(target_os = "windows"))]
301fn preferred_global_root_dir() -> PathBuf {
302 dirs::config_dir()
303 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
304 .unwrap_or_else(|| PathBuf::from("."))
305 .join("xbp")
306}
307
308#[cfg(target_os = "windows")]
309fn resolve_windows_home_dir() -> Option<PathBuf> {
310 dirs::home_dir()
311 .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
312 .or_else(|| {
313 let drive = env::var_os("HOMEDRIVE")?;
314 let path = env::var_os("HOMEPATH")?;
315 Some(PathBuf::from(drive).join(path))
316 })
317}
318
319#[cfg(target_os = "windows")]
320fn windows_drive_letter(path: &Path) -> Option<char> {
321 let normalized = path.to_string_lossy().replace('/', "\\");
322 let bytes = normalized.as_bytes();
323 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
324 Some((bytes[0] as char).to_ascii_uppercase())
325 } else {
326 None
327 }
328}
329
330#[cfg(target_os = "windows")]
331fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
332 let normalized = path.to_string_lossy().replace('/', "\\");
333 let bytes = normalized.as_bytes();
334 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
335 let tail = &normalized[3..];
336 if tail.is_empty() {
337 Some(PathBuf::new())
338 } else {
339 Some(PathBuf::from(tail))
340 }
341 } else {
342 None
343 }
344}
345
346#[cfg(target_os = "windows")]
347fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
348 let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
349 return Ok(());
350 };
351
352 migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
353 migrate_legacy_windows_file_if_missing(
354 &legacy_root.join("versioning-files.yaml"),
355 &paths.versioning_files_file,
356 )?;
357 migrate_legacy_windows_file_if_missing(
358 &legacy_root.join("package-name-files.yaml"),
359 &paths.package_name_files_file,
360 )?;
361
362 Ok(())
363}
364
365#[cfg(not(target_os = "windows"))]
366fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
367 Ok(())
368}
369
370#[cfg(target_os = "windows")]
371fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
372 if to.exists() || !from.exists() {
373 return Ok(());
374 }
375
376 if let Some(parent) = to.parent() {
377 fs::create_dir_all(parent).map_err(|e| {
378 format!(
379 "Failed to create directory for migrated file {}: {}",
380 parent.display(),
381 e
382 )
383 })?;
384 }
385
386 fs::copy(from, to).map_err(|e| {
387 format!(
388 "Failed to migrate legacy config file {} -> {}: {}",
389 from.display(),
390 to.display(),
391 e
392 )
393 })?;
394
395 Ok(())
396}
397
398pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
399 let paths = global_xbp_paths()?;
400 Ok(vec![
401 ("root".to_string(), paths.root_dir),
402 ("config".to_string(), paths.config_file),
403 ("ssh".to_string(), paths.ssh_dir),
404 ("cache".to_string(), paths.cache_dir),
405 ("logs".to_string(), paths.logs_dir),
406 ("versioning".to_string(), paths.versioning_files_file),
407 ("package-names".to_string(), paths.package_name_files_file),
408 ])
409}
410
411pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
412 let paths = ensure_global_xbp_paths()?;
413 Ok(paths.versioning_files_file)
414}
415
416pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
417 let registry_path = sync_versioning_files_registry()?;
418 let content = fs::read_to_string(®istry_path).map_err(|e| {
419 format!(
420 "Failed to read versioning registry {}: {}",
421 registry_path.display(),
422 e
423 )
424 })?;
425
426 let config: VersioningFilesConfig = serde_yaml::from_str(&content)
427 .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
428
429 Ok(config.files)
430}
431
432pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
433 let paths = ensure_global_xbp_paths()?;
434 Ok(paths.package_name_files_file)
435}
436
437pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
438 let registry_path = sync_package_name_files_registry()?;
439 let content = fs::read_to_string(®istry_path).map_err(|e| {
440 format!(
441 "Failed to read package-name registry {}: {}",
442 registry_path.display(),
443 e
444 )
445 })?;
446
447 let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
448 .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
449
450 Ok(config.lookups)
451}
452
453fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
454 let mut config = if path.exists() {
455 let content = fs::read_to_string(path).map_err(|e| {
456 format!(
457 "Failed to read versioning registry {}: {}",
458 path.display(),
459 e
460 )
461 })?;
462 serde_yaml::from_str::<VersioningFilesConfig>(&content)
463 .unwrap_or_else(|_| VersioningFilesConfig::default())
464 } else {
465 VersioningFilesConfig::default()
466 };
467
468 let mut changed = false;
469 for default_file in default_versioning_files() {
470 if !config
471 .files
472 .iter()
473 .any(|existing| existing == &default_file)
474 {
475 config.files.push(default_file);
476 changed = true;
477 }
478 }
479
480 if changed || !path.exists() {
481 let content = serde_yaml::to_string(&config)
482 .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
483 fs::write(path, content).map_err(|e| {
484 format!(
485 "Failed to write versioning registry {}: {}",
486 path.display(),
487 e
488 )
489 })?;
490 }
491
492 Ok(())
493}
494
495fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
496 let mut config = if path.exists() {
497 let content = fs::read_to_string(path).map_err(|e| {
498 format!(
499 "Failed to read package-name registry {}: {}",
500 path.display(),
501 e
502 )
503 })?;
504 serde_yaml::from_str::<PackageNameFilesConfig>(&content)
505 .unwrap_or_else(|_| PackageNameFilesConfig::default())
506 } else {
507 PackageNameFilesConfig::default()
508 };
509
510 let mut changed = false;
511 for default_lookup in default_package_name_lookups() {
512 if !config
513 .lookups
514 .iter()
515 .any(|existing| existing == &default_lookup)
516 {
517 config.lookups.push(default_lookup);
518 changed = true;
519 }
520 }
521
522 if changed || !path.exists() {
523 let content = serde_yaml::to_string(&config)
524 .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
525 fs::write(path, content).map_err(|e| {
526 format!(
527 "Failed to write package-name registry {}: {}",
528 path.display(),
529 e
530 )
531 })?;
532 }
533
534 Ok(())
535}
536
537fn default_versioning_files() -> Vec<String> {
538 vec![
539 "README.md".to_string(),
540 "openapi.yaml".to_string(),
541 "openapi.yml".to_string(),
542 "package.json".to_string(),
543 "package-lock.json".to_string(),
544 "Cargo.toml".to_string(),
545 "Cargo.lock".to_string(),
546 "pyproject.toml".to_string(),
547 "composer.json".to_string(),
548 "deno.json".to_string(),
549 "deno.jsonc".to_string(),
550 "Chart.yaml".to_string(),
551 "app.json".to_string(),
552 "manifest.json".to_string(),
553 "pom.xml".to_string(),
554 "build.gradle".to_string(),
555 "build.gradle.kts".to_string(),
556 "mix.exs".to_string(),
557 "xbp.yaml".to_string(),
558 "xbp.yml".to_string(),
559 "xbp.json".to_string(),
560 ".xbp/xbp.json".to_string(),
561 ".xbp/xbp.yaml".to_string(),
562 ".xbp/xbp.yml".to_string(),
563 ]
564}
565
566fn default_package_name_lookups() -> Vec<PackageNameLookup> {
567 vec![
568 PackageNameLookup {
569 file: "package.json".to_string(),
570 format: "json".to_string(),
571 key: "name".to_string(),
572 registry: "npm".to_string(),
573 },
574 PackageNameLookup {
575 file: "Cargo.toml".to_string(),
576 format: "toml".to_string(),
577 key: "package.name".to_string(),
578 registry: "crates.io".to_string(),
579 },
580 ]
581}
582
583const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
584
585#[derive(Debug, Clone)]
587pub struct ApiConfig {
588 base_url: String,
589}
590
591impl ApiConfig {
592 pub fn load() -> Self {
594 let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
595 let base_url = Self::normalize_base_url(&raw_url);
596 ApiConfig { base_url }
597 }
598
599 pub fn base_url(&self) -> &str {
601 &self.base_url
602 }
603
604 pub fn version_endpoint(&self, project_name: &str) -> String {
606 format!("{}/version?project_name={}", self.base_url, project_name)
607 }
608
609 pub fn increment_endpoint(&self) -> String {
611 format!("{}/version/increment", self.base_url)
612 }
613
614 fn normalize_base_url(raw: &str) -> String {
615 let trimmed = raw.trim();
616 if trimmed.is_empty() {
617 return DEFAULT_API_XBP_URL.to_string();
618 }
619
620 let trimmed = trimmed.trim_end_matches('/');
621 if trimmed.is_empty() {
622 return DEFAULT_API_XBP_URL.to_string();
623 }
624
625 trimmed.to_string()
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::{
632 default_package_name_lookups, default_versioning_files, resolve_github_oauth2_key,
633 resolve_openrouter_api_key, sync_package_name_files_registry_at,
634 sync_versioning_files_registry_at, ApiConfig, PackageNameFilesConfig, SecretProvider,
635 SshConfig, VersioningFilesConfig,
636 };
637 use std::fs;
638 use std::path::PathBuf;
639 use std::time::{SystemTime, UNIX_EPOCH};
640
641 fn temp_path(label: &str) -> PathBuf {
642 let nanos = SystemTime::now()
643 .duration_since(UNIX_EPOCH)
644 .expect("time")
645 .as_nanos();
646 std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
647 }
648
649 #[test]
650 fn versioning_registry_defaults_include_core_files() {
651 let defaults = default_versioning_files();
652 assert!(defaults.contains(&"README.md".to_string()));
653 assert!(defaults.contains(&"Cargo.toml".to_string()));
654 assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
655 }
656
657 #[test]
658 fn versioning_registry_default_config_populates_files() {
659 let config = VersioningFilesConfig::default();
660 assert!(!config.files.is_empty());
661 }
662
663 #[test]
664 fn versioning_registry_defaults_do_not_contain_duplicates() {
665 let defaults = default_versioning_files();
666 let mut deduped = defaults.clone();
667 deduped.sort();
668 deduped.dedup();
669 assert_eq!(defaults.len(), deduped.len());
670 }
671
672 #[test]
673 fn syncing_registry_creates_file_with_defaults() {
674 let path = temp_path("defaults");
675 sync_versioning_files_registry_at(&path).expect("sync");
676
677 let content = fs::read_to_string(&path).expect("read");
678 assert!(content.contains("README.md"));
679 assert!(content.contains("Cargo.toml"));
680
681 let _ = fs::remove_file(path);
682 }
683
684 #[test]
685 fn syncing_registry_preserves_user_added_entries() {
686 let path = temp_path("preserve");
687 fs::write(&path, "files:\n - custom.file\n").expect("write registry");
688
689 sync_versioning_files_registry_at(&path).expect("sync");
690
691 let content = fs::read_to_string(&path).expect("read");
692 assert!(content.contains("custom.file"));
693 assert!(content.contains("README.md"));
694
695 let _ = fs::remove_file(path);
696 }
697
698 #[test]
699 fn api_config_normalizes_trailing_slashes() {
700 assert_eq!(
701 ApiConfig::normalize_base_url("https://api.xbp.app///"),
702 "https://api.xbp.app".to_string()
703 );
704 }
705
706 #[test]
707 fn api_config_uses_default_for_blank_values() {
708 assert_eq!(
709 ApiConfig::normalize_base_url(" "),
710 "https://api.xbp.app".to_string()
711 );
712 }
713
714 #[test]
715 fn api_config_builds_version_endpoints() {
716 let config = ApiConfig {
717 base_url: "https://api.test.xbp".to_string(),
718 };
719 let endpoint = config.version_endpoint("demo");
720 let increment = config.increment_endpoint();
721
722 assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
723 assert_eq!(increment, "https://api.test.xbp/version/increment");
724 }
725
726 #[test]
727 fn package_lookup_defaults_include_npm_and_crates() {
728 let defaults = default_package_name_lookups();
729 assert!(defaults.iter().any(|entry| {
730 entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
731 }));
732 assert!(defaults.iter().any(|entry| {
733 entry.file == "Cargo.toml"
734 && entry.registry == "crates.io"
735 && entry.key == "package.name"
736 }));
737 }
738
739 #[test]
740 fn package_lookup_default_config_populates_entries() {
741 let config = PackageNameFilesConfig::default();
742 assert!(!config.lookups.is_empty());
743 }
744
745 #[test]
746 fn syncing_package_lookup_registry_creates_defaults() {
747 let path = temp_path("package-lookup-defaults");
748 sync_package_name_files_registry_at(&path).expect("sync");
749
750 let content = fs::read_to_string(&path).expect("read");
751 assert!(content.contains("package.json"));
752 assert!(content.contains("Cargo.toml"));
753
754 let _ = fs::remove_file(path);
755 }
756
757 #[test]
758 fn syncing_package_lookup_registry_preserves_custom_entries() {
759 let path = temp_path("package-lookup-custom");
760 fs::write(
761 &path,
762 "lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
763 )
764 .expect("write package lookup registry");
765
766 sync_package_name_files_registry_at(&path).expect("sync");
767 let content = fs::read_to_string(&path).expect("read");
768 assert!(content.contains("custom.yaml"));
769 assert!(content.contains("package.json"));
770
771 let _ = fs::remove_file(path);
772 }
773
774 #[test]
775 fn secret_provider_parses_supported_keys() {
776 assert_eq!(
777 SecretProvider::from_key("openrouter"),
778 Some(SecretProvider::OpenRouter)
779 );
780 assert_eq!(
781 SecretProvider::from_key("github"),
782 Some(SecretProvider::Github)
783 );
784 assert_eq!(SecretProvider::from_key("unknown"), None);
785 }
786
787 #[test]
788 fn ssh_config_secret_get_set_roundtrip() {
789 let mut cfg = SshConfig::new();
790 cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
791 cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
792
793 assert_eq!(
794 cfg.get_secret(SecretProvider::OpenRouter),
795 Some("or-test-123")
796 );
797 assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
798 }
799
800 #[test]
801 fn resolve_secret_prefers_environment_value() {
802 std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
803 std::env::set_var("GITHUB_TOKEN", "env-github");
804
805 assert_eq!(
806 resolve_openrouter_api_key(),
807 Some("env-openrouter".to_string())
808 );
809 assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
810
811 std::env::remove_var("OPENROUTER_API_KEY");
812 std::env::remove_var("GITHUB_TOKEN");
813 }
814}