1use chrono::{DateTime, Utc};
8use reqwest::Url;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::env;
12use std::fs;
13#[cfg(target_os = "windows")]
14use std::path::Path;
15use std::path::PathBuf;
16
17#[derive(Debug, Clone)]
18pub struct GlobalXbpPaths {
19 pub root_dir: PathBuf,
20 pub config_file: PathBuf,
21 pub ssh_dir: PathBuf,
22 pub cache_dir: PathBuf,
23 pub logs_dir: PathBuf,
24 pub versioning_files_file: PathBuf,
25 pub package_name_files_file: PathBuf,
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
29pub struct LinearReleaseConfig {
30 #[serde(default)]
31 pub enabled: Option<bool>,
32 #[serde(default)]
33 pub initiative_ids: Option<Vec<String>>,
34 #[serde(default, alias = "org_name")]
35 pub organization_name: Option<String>,
36 #[serde(default)]
37 pub health: Option<String>,
38}
39
40#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
41pub struct LinearConfig {
42 #[serde(default)]
43 pub release: Option<LinearReleaseConfig>,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
47pub struct SecretMetadata {
48 #[serde(default)]
49 pub added_at: Option<DateTime<Utc>>,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone)]
53pub struct SshConfig {
54 pub password: Option<String>,
55 pub username: Option<String>,
56 pub host: Option<String>,
57 pub project_dir: Option<String>,
58 #[serde(default)]
59 pub xbp_api_token: Option<String>,
60 pub openrouter_api_key: Option<String>,
61 pub github_oauth2_key: Option<String>,
62 #[serde(default)]
63 pub cloudflare_api_token: Option<String>,
64 #[serde(default)]
65 pub cloudflare_account_id: Option<String>,
66 pub linear_api_key: Option<String>,
67 #[serde(default)]
68 pub npm_token: Option<String>,
69 #[serde(default)]
70 pub crates_token: Option<String>,
71 #[serde(default)]
72 pub linear: Option<LinearConfig>,
73 #[serde(default)]
74 pub secret_metadata: BTreeMap<String, SecretMetadata>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum SecretProvider {
79 OpenRouter,
80 Github,
81 Cloudflare,
82 Linear,
83 Npm,
84 Crates,
85}
86
87impl SecretProvider {
88 pub fn from_key(key: &str) -> Option<Self> {
89 match key.trim().to_ascii_lowercase().as_str() {
90 "openrouter" => Some(Self::OpenRouter),
91 "github" => Some(Self::Github),
92 "cloudflare" => Some(Self::Cloudflare),
93 "linear" => Some(Self::Linear),
94 "npm" | "npmjs" => Some(Self::Npm),
95 "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
96 _ => None,
97 }
98 }
99
100 pub fn as_key(&self) -> &'static str {
101 match self {
102 SecretProvider::OpenRouter => "openrouter",
103 SecretProvider::Github => "github",
104 SecretProvider::Cloudflare => "cloudflare",
105 SecretProvider::Linear => "linear",
106 SecretProvider::Npm => "npm",
107 SecretProvider::Crates => "crates",
108 }
109 }
110
111 pub fn config_field(&self) -> &'static str {
112 match self {
113 SecretProvider::OpenRouter => "openrouter_api_key",
114 SecretProvider::Github => "github_oauth2_key",
115 SecretProvider::Cloudflare => "cloudflare_api_token",
116 SecretProvider::Linear => "linear_api_key",
117 SecretProvider::Npm => "npm_token",
118 SecretProvider::Crates => "crates_token",
119 }
120 }
121}
122
123impl Default for SshConfig {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl SshConfig {
130 pub fn new() -> Self {
131 SshConfig {
132 password: None,
133 username: None,
134 host: None,
135 project_dir: None,
136 xbp_api_token: None,
137 openrouter_api_key: None,
138 github_oauth2_key: None,
139 cloudflare_api_token: None,
140 cloudflare_account_id: None,
141 linear_api_key: None,
142 npm_token: None,
143 crates_token: None,
144 linear: None,
145 secret_metadata: BTreeMap::new(),
146 }
147 }
148
149 pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
150 match provider {
151 SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
152 SecretProvider::Github => self.github_oauth2_key.as_deref(),
153 SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
154 SecretProvider::Linear => self.linear_api_key.as_deref(),
155 SecretProvider::Npm => self.npm_token.as_deref(),
156 SecretProvider::Crates => self.crates_token.as_deref(),
157 }
158 }
159
160 pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
161 match provider {
162 SecretProvider::OpenRouter => self.openrouter_api_key = value,
163 SecretProvider::Github => self.github_oauth2_key = value,
164 SecretProvider::Cloudflare => self.cloudflare_api_token = value,
165 SecretProvider::Linear => self.linear_api_key = value,
166 SecretProvider::Npm => self.npm_token = value,
167 SecretProvider::Crates => self.crates_token = value,
168 }
169
170 match self.get_secret(provider) {
171 Some(_) => {
172 self.secret_metadata.insert(
173 provider.as_key().to_string(),
174 SecretMetadata {
175 added_at: Some(Utc::now()),
176 },
177 );
178 }
179 None => {
180 self.secret_metadata.remove(provider.as_key());
181 }
182 }
183 }
184
185 pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
186 self.secret_metadata.get(provider.as_key())
187 }
188
189 pub fn load() -> Result<Self, String> {
190 let config_path = get_config_path();
191 let legacy_path = legacy_config_path();
192 let path_to_read = if config_path.exists() {
193 config_path
194 } else if legacy_path.exists() {
195 legacy_path
196 } else {
197 return Ok(SshConfig::new());
198 };
199
200 let content = fs::read_to_string(&path_to_read)
201 .map_err(|e| format!("Failed to read config file: {}", e))?;
202 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
203 }
204
205 pub fn save(&self) -> Result<(), String> {
206 let config_path = get_config_path();
207 let config_dir = config_path.parent().ok_or("Invalid config path")?;
208 fs::create_dir_all(config_dir)
209 .map_err(|e| format!("Failed to create config directory: {}", e))?;
210
211 let content = serde_yaml::to_string(self)
212 .map_err(|e| format!("Failed to serialize config: {}", e))?;
213 fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
214 }
215}
216
217#[derive(Debug, Serialize, Deserialize, Clone)]
218pub struct VersioningFilesConfig {
219 #[serde(default = "default_versioning_files")]
220 pub files: Vec<String>,
221}
222
223impl Default for VersioningFilesConfig {
224 fn default() -> Self {
225 Self {
226 files: default_versioning_files(),
227 }
228 }
229}
230
231#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
232pub struct PackageNameLookup {
233 pub file: String,
234 pub format: String,
235 pub key: String,
236 pub registry: String,
237}
238
239#[derive(Debug, Serialize, Deserialize, Clone)]
240pub struct PackageNameFilesConfig {
241 #[serde(default = "default_package_name_lookups")]
242 pub lookups: Vec<PackageNameLookup>,
243}
244
245impl Default for PackageNameFilesConfig {
246 fn default() -> Self {
247 Self {
248 lookups: default_package_name_lookups(),
249 }
250 }
251}
252
253pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
254 let root_dir = preferred_global_root_dir();
255
256 let paths = GlobalXbpPaths {
257 config_file: root_dir.join("config.yaml"),
258 ssh_dir: root_dir.join("ssh"),
259 cache_dir: root_dir.join("cache"),
260 logs_dir: root_dir.join("logs"),
261 versioning_files_file: root_dir.join("versioning-files.yaml"),
262 package_name_files_file: root_dir.join("package-name-files.yaml"),
263 root_dir,
264 };
265
266 for dir in [
267 &paths.root_dir,
268 &paths.ssh_dir,
269 &paths.cache_dir,
270 &paths.logs_dir,
271 ] {
272 fs::create_dir_all(dir)
273 .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
274 }
275
276 maybe_migrate_legacy_windows_files(&paths)?;
277
278 if !paths.config_file.exists() {
279 fs::write(
280 &paths.config_file,
281 "password: null\nusername: null\nhost: null\nproject_dir: null\nxbp_api_token: null\nopenrouter_api_key: null\ngithub_oauth2_key: null\ncloudflare_api_token: null\ncloudflare_account_id: null\nlinear_api_key: null\nnpm_token: null\ncrates_token: null\nlinear: null\nsecret_metadata: {}\n",
282 )
283 .map_err(|e| {
284 format!(
285 "Failed to initialize config file {}: {}",
286 paths.config_file.display(),
287 e
288 )
289 })?;
290 }
291
292 sync_versioning_files_registry_at(&paths.versioning_files_file)?;
293 sync_package_name_files_registry_at(&paths.package_name_files_file)?;
294
295 Ok(paths)
296}
297
298pub fn resolve_openrouter_api_key() -> Option<String> {
299 env::var("OPENROUTER_API_KEY")
300 .ok()
301 .map(|value| value.trim().to_string())
302 .filter(|value| !value.is_empty())
303 .or_else(|| {
304 SshConfig::load()
305 .ok()
306 .and_then(|cfg| {
307 cfg.get_secret(SecretProvider::OpenRouter)
308 .map(str::to_string)
309 })
310 .map(|value| value.trim().to_string())
311 .filter(|value| !value.is_empty())
312 })
313}
314
315pub fn resolve_xbp_api_token() -> Option<String> {
316 env::var("XBP_API_TOKEN")
317 .ok()
318 .map(|value| value.trim().to_string())
319 .filter(|value| !value.is_empty())
320 .or_else(|| {
321 SshConfig::load()
322 .ok()
323 .and_then(|cfg| cfg.xbp_api_token)
324 .map(|value| value.trim().to_string())
325 .filter(|value| !value.is_empty())
326 })
327}
328
329pub fn resolve_github_oauth2_key() -> Option<String> {
330 for env_var in [
331 "GITHUB_TOKEN",
332 "GITHUB_OAUTH2_KEY",
333 "GITHUB_OAUTH2_TOKEN",
334 "GITHUB_OAUTH_TOKEN",
335 ] {
336 if let Ok(value) = env::var(env_var) {
337 let token = value.trim();
338 if !token.is_empty() {
339 return Some(token.to_string());
340 }
341 }
342 }
343
344 SshConfig::load()
345 .ok()
346 .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
347 .map(|value| value.trim().to_string())
348 .filter(|value| !value.is_empty())
349}
350
351pub fn resolve_cloudflare_api_token() -> Option<String> {
352 env::var("CLOUDFLARE_API_TOKEN")
353 .ok()
354 .map(|value| value.trim().to_string())
355 .filter(|value| !value.is_empty())
356 .or_else(|| {
357 SshConfig::load()
358 .ok()
359 .and_then(|cfg| {
360 cfg.get_secret(SecretProvider::Cloudflare)
361 .map(str::to_string)
362 })
363 .map(|value| value.trim().to_string())
364 .filter(|value| !value.is_empty())
365 })
366}
367
368pub fn resolve_cloudflare_account_id() -> Option<String> {
369 env::var("CLOUDFLARE_ACCOUNT_ID")
370 .ok()
371 .map(|value| value.trim().to_string())
372 .filter(|value| !value.is_empty())
373 .or_else(|| {
374 SshConfig::load()
375 .ok()
376 .and_then(|cfg| cfg.cloudflare_account_id)
377 .map(|value| value.trim().to_string())
378 .filter(|value| !value.is_empty())
379 })
380}
381
382pub fn resolve_linear_api_key() -> Option<String> {
383 SshConfig::load()
384 .ok()
385 .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
386 .map(|value| value.trim().to_string())
387 .filter(|value| !value.is_empty())
388}
389
390pub fn resolve_npm_token() -> Option<String> {
391 for env_var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] {
392 if let Ok(value) = env::var(env_var) {
393 let token = value.trim();
394 if !token.is_empty() {
395 return Some(token.to_string());
396 }
397 }
398 }
399
400 SshConfig::load()
401 .ok()
402 .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
403 .map(|value| value.trim().to_string())
404 .filter(|value| !value.is_empty())
405}
406
407pub fn resolve_crates_token() -> Option<String> {
408 for env_var in ["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"] {
409 if let Ok(value) = env::var(env_var) {
410 let token = value.trim();
411 if !token.is_empty() {
412 return Some(token.to_string());
413 }
414 }
415 }
416
417 SshConfig::load()
418 .ok()
419 .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
420 .map(|value| value.trim().to_string())
421 .filter(|value| !value.is_empty())
422}
423
424pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
425 let mut config = SshConfig::load()?;
426 config.cloudflare_account_id = value;
427 config.save()
428}
429
430pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
431 Ok(SshConfig::load()?.cloudflare_account_id)
432}
433
434pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
435 SshConfig::load()
436 .ok()
437 .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
438}
439
440pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
441 ensure_global_xbp_paths()
442}
443
444pub fn get_config_path() -> PathBuf {
445 ensure_global_xbp_paths()
446 .map(|paths| paths.config_file)
447 .unwrap_or_else(|_| legacy_config_path())
448}
449
450#[cfg(target_os = "windows")]
451fn legacy_config_path() -> PathBuf {
452 dirs::config_dir()
453 .unwrap_or_else(|| PathBuf::from("."))
454 .join("xbp")
455 .join("config.yaml")
456}
457
458#[cfg(not(target_os = "windows"))]
459fn legacy_config_path() -> PathBuf {
460 dirs::home_dir()
461 .unwrap_or_else(|| PathBuf::from("."))
462 .join(".xbp")
463 .join("config.yaml")
464}
465
466#[cfg(target_os = "windows")]
467fn preferred_global_root_dir() -> PathBuf {
468 let fallback = dirs::config_dir()
469 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
470 .unwrap_or_else(|| PathBuf::from("."))
471 .join("xbp");
472
473 let Some(home_dir) = resolve_windows_home_dir() else {
474 return fallback;
475 };
476 let c_drive = Path::new(r"C:\");
477
478 if c_drive.exists() {
480 if windows_drive_letter(&home_dir) == Some('C') {
481 return home_dir.join(".xbp");
482 }
483
484 if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
485 let c_profile_candidate = c_drive.join(relative_profile_path);
486 if c_profile_candidate.exists() {
487 return c_profile_candidate.join(".xbp");
488 }
489 }
490 }
491
492 home_dir.join(".xbp")
493}
494
495#[cfg(not(target_os = "windows"))]
496fn preferred_global_root_dir() -> PathBuf {
497 dirs::config_dir()
498 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
499 .unwrap_or_else(|| PathBuf::from("."))
500 .join("xbp")
501}
502
503#[cfg(target_os = "windows")]
504fn resolve_windows_home_dir() -> Option<PathBuf> {
505 dirs::home_dir()
506 .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
507 .or_else(|| {
508 let drive = env::var_os("HOMEDRIVE")?;
509 let path = env::var_os("HOMEPATH")?;
510 Some(PathBuf::from(drive).join(path))
511 })
512}
513
514#[cfg(target_os = "windows")]
515fn windows_drive_letter(path: &Path) -> Option<char> {
516 let normalized = path.to_string_lossy().replace('/', "\\");
517 let bytes = normalized.as_bytes();
518 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
519 Some((bytes[0] as char).to_ascii_uppercase())
520 } else {
521 None
522 }
523}
524
525#[cfg(target_os = "windows")]
526fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
527 let normalized = path.to_string_lossy().replace('/', "\\");
528 let bytes = normalized.as_bytes();
529 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
530 let tail = &normalized[3..];
531 if tail.is_empty() {
532 Some(PathBuf::new())
533 } else {
534 Some(PathBuf::from(tail))
535 }
536 } else {
537 None
538 }
539}
540
541#[cfg(target_os = "windows")]
542fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
543 let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
544 return Ok(());
545 };
546
547 migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
548 migrate_legacy_windows_file_if_missing(
549 &legacy_root.join("versioning-files.yaml"),
550 &paths.versioning_files_file,
551 )?;
552 migrate_legacy_windows_file_if_missing(
553 &legacy_root.join("package-name-files.yaml"),
554 &paths.package_name_files_file,
555 )?;
556
557 Ok(())
558}
559
560#[cfg(not(target_os = "windows"))]
561fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
562 Ok(())
563}
564
565#[cfg(target_os = "windows")]
566fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
567 if to.exists() || !from.exists() {
568 return Ok(());
569 }
570
571 if let Some(parent) = to.parent() {
572 fs::create_dir_all(parent).map_err(|e| {
573 format!(
574 "Failed to create directory for migrated file {}: {}",
575 parent.display(),
576 e
577 )
578 })?;
579 }
580
581 fs::copy(from, to).map_err(|e| {
582 format!(
583 "Failed to migrate legacy config file {} -> {}: {}",
584 from.display(),
585 to.display(),
586 e
587 )
588 })?;
589
590 Ok(())
591}
592
593pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
594 let paths = global_xbp_paths()?;
595 Ok(vec![
596 ("root".to_string(), paths.root_dir),
597 ("config".to_string(), paths.config_file),
598 ("ssh".to_string(), paths.ssh_dir),
599 ("cache".to_string(), paths.cache_dir),
600 ("logs".to_string(), paths.logs_dir),
601 ("versioning".to_string(), paths.versioning_files_file),
602 ("package-names".to_string(), paths.package_name_files_file),
603 ])
604}
605
606pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
607 let paths = ensure_global_xbp_paths()?;
608 Ok(paths.versioning_files_file)
609}
610
611pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
612 let registry_path = sync_versioning_files_registry()?;
613 let content = fs::read_to_string(®istry_path).map_err(|e| {
614 format!(
615 "Failed to read versioning registry {}: {}",
616 registry_path.display(),
617 e
618 )
619 })?;
620
621 let config: VersioningFilesConfig = serde_yaml::from_str(&content)
622 .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
623
624 Ok(config.files)
625}
626
627pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
628 let paths = ensure_global_xbp_paths()?;
629 Ok(paths.package_name_files_file)
630}
631
632pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
633 let registry_path = sync_package_name_files_registry()?;
634 let content = fs::read_to_string(®istry_path).map_err(|e| {
635 format!(
636 "Failed to read package-name registry {}: {}",
637 registry_path.display(),
638 e
639 )
640 })?;
641
642 let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
643 .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
644
645 Ok(config.lookups)
646}
647
648fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
649 let mut config = if path.exists() {
650 let content = fs::read_to_string(path).map_err(|e| {
651 format!(
652 "Failed to read versioning registry {}: {}",
653 path.display(),
654 e
655 )
656 })?;
657 serde_yaml::from_str::<VersioningFilesConfig>(&content)
658 .unwrap_or_else(|_| VersioningFilesConfig::default())
659 } else {
660 VersioningFilesConfig::default()
661 };
662
663 let mut changed = false;
664 for default_file in default_versioning_files() {
665 if !config
666 .files
667 .iter()
668 .any(|existing| existing == &default_file)
669 {
670 config.files.push(default_file);
671 changed = true;
672 }
673 }
674
675 if changed || !path.exists() {
676 let content = serde_yaml::to_string(&config)
677 .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
678 fs::write(path, content).map_err(|e| {
679 format!(
680 "Failed to write versioning registry {}: {}",
681 path.display(),
682 e
683 )
684 })?;
685 }
686
687 Ok(())
688}
689
690fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
691 let mut config = if path.exists() {
692 let content = fs::read_to_string(path).map_err(|e| {
693 format!(
694 "Failed to read package-name registry {}: {}",
695 path.display(),
696 e
697 )
698 })?;
699 serde_yaml::from_str::<PackageNameFilesConfig>(&content)
700 .unwrap_or_else(|_| PackageNameFilesConfig::default())
701 } else {
702 PackageNameFilesConfig::default()
703 };
704
705 let mut changed = false;
706 for default_lookup in default_package_name_lookups() {
707 if !config
708 .lookups
709 .iter()
710 .any(|existing| existing == &default_lookup)
711 {
712 config.lookups.push(default_lookup);
713 changed = true;
714 }
715 }
716
717 if changed || !path.exists() {
718 let content = serde_yaml::to_string(&config)
719 .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
720 fs::write(path, content).map_err(|e| {
721 format!(
722 "Failed to write package-name registry {}: {}",
723 path.display(),
724 e
725 )
726 })?;
727 }
728
729 Ok(())
730}
731
732fn default_versioning_files() -> Vec<String> {
733 vec![
734 "README.md".to_string(),
735 "openapi.yaml".to_string(),
736 "openapi.yml".to_string(),
737 "openapi.json".to_string(),
738 "package.json".to_string(),
739 "package-lock.json".to_string(),
740 "Cargo.toml".to_string(),
741 "Cargo.lock".to_string(),
742 "pyproject.toml".to_string(),
743 "composer.json".to_string(),
744 "deno.json".to_string(),
745 "deno.jsonc".to_string(),
746 "Chart.yaml".to_string(),
747 "app.json".to_string(),
748 "manifest.json".to_string(),
749 "pom.xml".to_string(),
750 "build.gradle".to_string(),
751 "build.gradle.kts".to_string(),
752 "mix.exs".to_string(),
753 "xbp.yaml".to_string(),
754 "xbp.yml".to_string(),
755 "xbp.json".to_string(),
756 ".xbp/xbp.json".to_string(),
757 ".xbp/xbp.yaml".to_string(),
758 ".xbp/xbp.yml".to_string(),
759 ]
760}
761
762fn default_package_name_lookups() -> Vec<PackageNameLookup> {
763 vec![
764 PackageNameLookup {
765 file: "package.json".to_string(),
766 format: "json".to_string(),
767 key: "name".to_string(),
768 registry: "npm".to_string(),
769 },
770 PackageNameLookup {
771 file: "Cargo.toml".to_string(),
772 format: "toml".to_string(),
773 key: "package.name".to_string(),
774 registry: "crates.io".to_string(),
775 },
776 ]
777}
778
779const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
780
781#[derive(Debug, Clone)]
783pub struct ApiConfig {
784 base_url: String,
785}
786
787impl ApiConfig {
788 pub fn load() -> Self {
790 let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
791 Self::from_base_url(&raw_url)
792 }
793
794 pub fn from_base_url(raw_url: &str) -> Self {
795 let base_url = Self::normalize_base_url(raw_url);
796 ApiConfig { base_url }
797 }
798
799 pub fn base_url(&self) -> &str {
801 &self.base_url
802 }
803
804 pub fn version_endpoint(&self, project_name: &str) -> String {
806 format!("{}/version?project_name={}", self.base_url, project_name)
807 }
808
809 pub fn increment_endpoint(&self) -> String {
811 format!("{}/version/increment", self.base_url)
812 }
813
814 pub fn web_base_url(&self) -> String {
815 Self::derive_web_base_url(&self.base_url)
816 }
817
818 pub fn cli_auth_request_endpoint(&self) -> String {
819 format!("{}/api/cli/auth/request", self.web_base_url())
820 }
821
822 pub fn cli_auth_poll_endpoint(&self) -> String {
823 format!("{}/api/cli/auth/poll", self.web_base_url())
824 }
825
826 pub fn cli_auth_session_endpoint(&self) -> String {
827 format!("{}/api/cli/auth/session", self.web_base_url())
828 }
829
830 pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
831 format!("{}/cli/login/{}", self.web_base_url(), flow_id)
832 }
833
834 fn normalize_base_url(raw: &str) -> String {
835 let trimmed = raw.trim();
836 if trimmed.is_empty() {
837 return DEFAULT_API_XBP_URL.to_string();
838 }
839
840 let trimmed = trimmed.trim_end_matches('/');
841 if trimmed.is_empty() {
842 return DEFAULT_API_XBP_URL.to_string();
843 }
844
845 trimmed.to_string()
846 }
847
848 fn derive_web_base_url(base_url: &str) -> String {
849 let Ok(mut url) = Url::parse(base_url) else {
850 return base_url.to_string();
851 };
852
853 if let Some(host) = url.host_str().map(str::to_string) {
854 if let Some(stripped) = host.strip_prefix("api.") {
855 let _ = url.set_host(Some(stripped));
856 }
857 }
858
859 url.set_path("");
860 url.set_query(None);
861 url.set_fragment(None);
862
863 url.to_string().trim_end_matches('/').to_string()
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::{
870 default_package_name_lookups, default_versioning_files, resolve_github_oauth2_key,
871 resolve_openrouter_api_key, resolve_xbp_api_token, sync_package_name_files_registry_at,
872 sync_versioning_files_registry_at, ApiConfig, PackageNameFilesConfig, SecretProvider,
873 SshConfig, VersioningFilesConfig,
874 };
875 use std::fs;
876 use std::path::PathBuf;
877 use std::time::{SystemTime, UNIX_EPOCH};
878
879 fn temp_path(label: &str) -> PathBuf {
880 let nanos = SystemTime::now()
881 .duration_since(UNIX_EPOCH)
882 .expect("time")
883 .as_nanos();
884 std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
885 }
886
887 #[test]
888 fn versioning_registry_defaults_include_core_files() {
889 let defaults = default_versioning_files();
890 assert!(defaults.contains(&"README.md".to_string()));
891 assert!(defaults.contains(&"Cargo.toml".to_string()));
892 assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
893 }
894
895 #[test]
896 fn versioning_registry_default_config_populates_files() {
897 let config = VersioningFilesConfig::default();
898 assert!(!config.files.is_empty());
899 }
900
901 #[test]
902 fn versioning_registry_defaults_do_not_contain_duplicates() {
903 let defaults = default_versioning_files();
904 let mut deduped = defaults.clone();
905 deduped.sort();
906 deduped.dedup();
907 assert_eq!(defaults.len(), deduped.len());
908 }
909
910 #[test]
911 fn syncing_registry_creates_file_with_defaults() {
912 let path = temp_path("defaults");
913 sync_versioning_files_registry_at(&path).expect("sync");
914
915 let content = fs::read_to_string(&path).expect("read");
916 assert!(content.contains("README.md"));
917 assert!(content.contains("Cargo.toml"));
918
919 let _ = fs::remove_file(path);
920 }
921
922 #[test]
923 fn syncing_registry_preserves_user_added_entries() {
924 let path = temp_path("preserve");
925 fs::write(&path, "files:\n - custom.file\n").expect("write registry");
926
927 sync_versioning_files_registry_at(&path).expect("sync");
928
929 let content = fs::read_to_string(&path).expect("read");
930 assert!(content.contains("custom.file"));
931 assert!(content.contains("README.md"));
932
933 let _ = fs::remove_file(path);
934 }
935
936 #[test]
937 fn api_config_normalizes_trailing_slashes() {
938 assert_eq!(
939 ApiConfig::normalize_base_url("https://api.xbp.app///"),
940 "https://api.xbp.app".to_string()
941 );
942 }
943
944 #[test]
945 fn api_config_uses_default_for_blank_values() {
946 assert_eq!(
947 ApiConfig::normalize_base_url(" "),
948 "https://api.xbp.app".to_string()
949 );
950 }
951
952 #[test]
953 fn api_config_builds_version_endpoints() {
954 let config = ApiConfig {
955 base_url: "https://api.test.xbp".to_string(),
956 };
957 let endpoint = config.version_endpoint("demo");
958 let increment = config.increment_endpoint();
959
960 assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
961 assert_eq!(increment, "https://api.test.xbp/version/increment");
962 }
963
964 #[test]
965 fn api_config_derives_browser_base_url_from_api_subdomain() {
966 assert_eq!(
967 ApiConfig::derive_web_base_url("https://api.xbp.app"),
968 "https://xbp.app".to_string()
969 );
970 assert_eq!(
971 ApiConfig::derive_web_base_url("http://localhost:3000"),
972 "http://localhost:3000".to_string()
973 );
974 }
975
976 #[test]
977 fn package_lookup_defaults_include_npm_and_crates() {
978 let defaults = default_package_name_lookups();
979 assert!(defaults.iter().any(|entry| {
980 entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
981 }));
982 assert!(defaults.iter().any(|entry| {
983 entry.file == "Cargo.toml"
984 && entry.registry == "crates.io"
985 && entry.key == "package.name"
986 }));
987 }
988
989 #[test]
990 fn package_lookup_default_config_populates_entries() {
991 let config = PackageNameFilesConfig::default();
992 assert!(!config.lookups.is_empty());
993 }
994
995 #[test]
996 fn syncing_package_lookup_registry_creates_defaults() {
997 let path = temp_path("package-lookup-defaults");
998 sync_package_name_files_registry_at(&path).expect("sync");
999
1000 let content = fs::read_to_string(&path).expect("read");
1001 assert!(content.contains("package.json"));
1002 assert!(content.contains("Cargo.toml"));
1003
1004 let _ = fs::remove_file(path);
1005 }
1006
1007 #[test]
1008 fn syncing_package_lookup_registry_preserves_custom_entries() {
1009 let path = temp_path("package-lookup-custom");
1010 fs::write(
1011 &path,
1012 "lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
1013 )
1014 .expect("write package lookup registry");
1015
1016 sync_package_name_files_registry_at(&path).expect("sync");
1017 let content = fs::read_to_string(&path).expect("read");
1018 assert!(content.contains("custom.yaml"));
1019 assert!(content.contains("package.json"));
1020
1021 let _ = fs::remove_file(path);
1022 }
1023
1024 #[test]
1025 fn secret_provider_parses_supported_keys() {
1026 assert_eq!(
1027 SecretProvider::from_key("openrouter"),
1028 Some(SecretProvider::OpenRouter)
1029 );
1030 assert_eq!(
1031 SecretProvider::from_key("github"),
1032 Some(SecretProvider::Github)
1033 );
1034 assert_eq!(
1035 SecretProvider::from_key("linear"),
1036 Some(SecretProvider::Linear)
1037 );
1038 assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
1039 assert_eq!(
1040 SecretProvider::from_key("crates"),
1041 Some(SecretProvider::Crates)
1042 );
1043 assert_eq!(SecretProvider::from_key("unknown"), None);
1044 }
1045
1046 #[test]
1047 fn ssh_config_secret_get_set_roundtrip() {
1048 let mut cfg = SshConfig::new();
1049 cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
1050 cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
1051 cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
1052 cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
1053 cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
1054
1055 assert_eq!(
1056 cfg.get_secret(SecretProvider::OpenRouter),
1057 Some("or-test-123")
1058 );
1059 assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
1060 assert_eq!(
1061 cfg.get_secret(SecretProvider::Linear),
1062 Some("lin_api_test_789")
1063 );
1064 assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
1065 assert_eq!(
1066 cfg.get_secret(SecretProvider::Crates),
1067 Some("crate_test_token")
1068 );
1069 assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
1070 }
1071
1072 #[test]
1073 fn resolve_secret_prefers_environment_value() {
1074 std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
1075 std::env::set_var("GITHUB_TOKEN", "env-github");
1076 std::env::set_var("XBP_API_TOKEN", "env-xbp");
1077
1078 assert_eq!(
1079 resolve_openrouter_api_key(),
1080 Some("env-openrouter".to_string())
1081 );
1082 assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
1083 assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
1084
1085 std::env::remove_var("OPENROUTER_API_KEY");
1086 std::env::remove_var("GITHUB_TOKEN");
1087 std::env::remove_var("XBP_API_TOKEN");
1088 }
1089}