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