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