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