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