Skip to main content

xbp_cli/
config.rs

1//! configuration management module
2//!
3//! handles ssh configuration and yaml config file management
4//! provides loading and saving of configuration files
5//! supports home directory based config storage
6
7use 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    // Prefer C:\...\.xbp when that profile path is valid; otherwise use the real profile path.
479    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(&registry_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(&registry_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/// Simple API configuration for the XBP version endpoints.
782#[derive(Debug, Clone)]
783pub struct ApiConfig {
784    base_url: String,
785}
786
787impl ApiConfig {
788    /// Load the API configuration from API_XBP_URL, falling back to the default.
789    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    /// Return the normalized base URL that downstream callers should use.
800    pub fn base_url(&self) -> &str {
801        &self.base_url
802    }
803
804    /// Build the version query endpoint.
805    pub fn version_endpoint(&self, project_name: &str) -> String {
806        format!("{}/version?project_name={}", self.base_url, project_name)
807    }
808
809    /// Build the endpoint that increments a version.
810    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}