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 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    // Prefer C:\...\.xbp when that profile path is valid; otherwise use the real profile path.
325    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(&registry_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(&registry_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/// Simple API configuration for the XBP version endpoints.
628#[derive(Debug, Clone)]
629pub struct ApiConfig {
630    base_url: String,
631}
632
633impl ApiConfig {
634    /// Load the API configuration from API_XBP_URL, falling back to the default.
635    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    /// Return the normalized base URL that downstream callers should use.
642    pub fn base_url(&self) -> &str {
643        &self.base_url
644    }
645
646    /// Build the version query endpoint.
647    pub fn version_endpoint(&self, project_name: &str) -> String {
648        format!("{}/version?project_name={}", self.base_url, project_name)
649    }
650
651    /// Build the endpoint that increments a version.
652    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}