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 crate::codetime::{
8    collect_system_inventory as collect_codetime_system_inventory, SystemInventory,
9    SystemInventoryOptions,
10};
11use crate::dns_inventory_cache::DnsInventoryCache;
12use crate::openrouter::{
13    DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
14};
15use chrono::{DateTime, Duration as ChronoDuration, Utc};
16use reqwest::Url;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::env;
20use std::fs;
21use std::path::Path;
22use std::path::PathBuf;
23
24#[derive(Debug, Clone)]
25pub struct GlobalXbpPaths {
26    pub root_dir: PathBuf,
27    pub config_file: PathBuf,
28    pub ssh_dir: PathBuf,
29    pub cache_dir: PathBuf,
30    pub logs_dir: PathBuf,
31    pub versioning_files_file: PathBuf,
32    pub package_name_files_file: PathBuf,
33}
34
35#[derive(Debug, Clone)]
36pub struct SystemInventorySyncResult {
37    pub inventory: SystemInventory,
38    pub config_path: PathBuf,
39    pub refreshed: bool,
40}
41
42#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
43pub struct DeviceIdentity {
44    #[serde(default)]
45    pub hardware_id: String,
46    #[serde(default)]
47    pub created_at: Option<DateTime<Utc>>,
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
51pub struct LinearReleaseConfig {
52    #[serde(default)]
53    pub enabled: Option<bool>,
54    #[serde(default)]
55    pub initiative_ids: Option<Vec<String>>,
56    #[serde(default, alias = "org_name")]
57    pub organization_name: Option<String>,
58    #[serde(default)]
59    pub health: Option<String>,
60}
61
62#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
63pub struct LinearConfig {
64    #[serde(default)]
65    pub release: Option<LinearReleaseConfig>,
66}
67
68#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
69pub struct SecretMetadata {
70    #[serde(default)]
71    pub added_at: Option<DateTime<Utc>>,
72}
73
74#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
75pub struct CliAuthState {
76    #[serde(default)]
77    pub user_id: Option<String>,
78    #[serde(default)]
79    pub user_name: Option<String>,
80    #[serde(default)]
81    pub user_email: Option<String>,
82    #[serde(default)]
83    pub token_label: Option<String>,
84    #[serde(default)]
85    pub token_prefix: Option<String>,
86    #[serde(default)]
87    pub token_created_at: Option<DateTime<Utc>>,
88    #[serde(default)]
89    pub token_expires_at: Option<DateTime<Utc>>,
90    #[serde(default)]
91    pub last_verified_at: Option<DateTime<Utc>>,
92}
93
94#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
95pub struct CursorIngestState {
96    #[serde(default)]
97    pub last_status: Option<String>,
98    #[serde(default)]
99    pub last_trigger: Option<String>,
100    #[serde(default)]
101    pub last_mode: Option<String>,
102    #[serde(default)]
103    pub last_attempted_at: Option<DateTime<Utc>>,
104    #[serde(default)]
105    pub last_succeeded_at: Option<DateTime<Utc>>,
106    #[serde(default)]
107    pub last_error: Option<String>,
108    #[serde(default)]
109    pub last_workspace_count: Option<usize>,
110    #[serde(default)]
111    pub last_entry_count: Option<usize>,
112    #[serde(default)]
113    pub last_entries_skipped: Option<usize>,
114}
115
116#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
117pub struct SystemInventoryRefreshState {
118    #[serde(default)]
119    pub last_status: Option<String>,
120    #[serde(default)]
121    pub last_trigger: Option<String>,
122    #[serde(default)]
123    pub last_mode: Option<String>,
124    #[serde(default)]
125    pub last_attempted_at: Option<DateTime<Utc>>,
126    #[serde(default)]
127    pub last_succeeded_at: Option<DateTime<Utc>>,
128    #[serde(default)]
129    pub last_error: Option<String>,
130    #[serde(default)]
131    pub include_cursor: Option<bool>,
132    #[serde(default)]
133    pub refreshed: Option<bool>,
134}
135
136#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
137pub struct OpenRouterConfig {
138    #[serde(default = "default_openrouter_commit_model")]
139    pub commit_model: String,
140    #[serde(default = "default_openrouter_release_notes_model")]
141    pub release_notes_model: String,
142    #[serde(default = "default_openrouter_commit_system_prompt")]
143    pub commit_system_prompt: String,
144    #[serde(default = "default_openrouter_release_notes_system_prompt")]
145    pub release_notes_system_prompt: String,
146}
147
148impl Default for OpenRouterConfig {
149    fn default() -> Self {
150        Self {
151            commit_model: default_openrouter_commit_model(),
152            release_notes_model: default_openrouter_release_notes_model(),
153            commit_system_prompt: default_openrouter_commit_system_prompt(),
154            release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
155        }
156    }
157}
158
159#[derive(Debug, Serialize, Deserialize, Clone)]
160pub struct SshConfig {
161    pub password: Option<String>,
162    pub username: Option<String>,
163    pub host: Option<String>,
164    pub project_dir: Option<String>,
165    #[serde(default)]
166    pub xbp_api_token: Option<String>,
167    pub openrouter_api_key: Option<String>,
168    pub github_oauth2_key: Option<String>,
169    #[serde(default)]
170    pub cloudflare_api_token: Option<String>,
171    #[serde(default)]
172    pub cloudflare_account_id: Option<String>,
173    pub linear_api_key: Option<String>,
174    #[serde(default)]
175    pub npm_token: Option<String>,
176    #[serde(default)]
177    pub crates_token: Option<String>,
178    #[serde(default)]
179    pub openrouter: OpenRouterConfig,
180    #[serde(default)]
181    pub linear: Option<LinearConfig>,
182    #[serde(default)]
183    pub cli_auth: Option<CliAuthState>,
184    #[serde(default)]
185    pub device: Option<DeviceIdentity>,
186    #[serde(default)]
187    pub secret_metadata: BTreeMap<String, SecretMetadata>,
188    #[serde(default)]
189    pub system_inventory: Option<SystemInventory>,
190    #[serde(default)]
191    pub cursor_ingest: Option<CursorIngestState>,
192    #[serde(default)]
193    pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
194    #[serde(default)]
195    pub dns_inventory: Option<DnsInventoryCache>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum SecretProvider {
200    OpenRouter,
201    Github,
202    Cloudflare,
203    Linear,
204    Npm,
205    Crates,
206}
207
208impl SecretProvider {
209    pub fn from_key(key: &str) -> Option<Self> {
210        match key.trim().to_ascii_lowercase().as_str() {
211            "openrouter" => Some(Self::OpenRouter),
212            "github" => Some(Self::Github),
213            "cloudflare" => Some(Self::Cloudflare),
214            "linear" => Some(Self::Linear),
215            "npm" | "npmjs" => Some(Self::Npm),
216            "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
217            _ => None,
218        }
219    }
220
221    pub fn as_key(&self) -> &'static str {
222        match self {
223            SecretProvider::OpenRouter => "openrouter",
224            SecretProvider::Github => "github",
225            SecretProvider::Cloudflare => "cloudflare",
226            SecretProvider::Linear => "linear",
227            SecretProvider::Npm => "npm",
228            SecretProvider::Crates => "crates",
229        }
230    }
231
232    pub fn config_field(&self) -> &'static str {
233        match self {
234            SecretProvider::OpenRouter => "openrouter_api_key",
235            SecretProvider::Github => "github_oauth2_key",
236            SecretProvider::Cloudflare => "cloudflare_api_token",
237            SecretProvider::Linear => "linear_api_key",
238            SecretProvider::Npm => "npm_token",
239            SecretProvider::Crates => "crates_token",
240        }
241    }
242}
243
244impl Default for SshConfig {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250impl SshConfig {
251    pub fn new() -> Self {
252        SshConfig {
253            password: None,
254            username: None,
255            host: None,
256            project_dir: None,
257            xbp_api_token: None,
258            openrouter_api_key: None,
259            github_oauth2_key: None,
260            cloudflare_api_token: None,
261            cloudflare_account_id: None,
262            linear_api_key: None,
263            npm_token: None,
264            crates_token: None,
265            openrouter: OpenRouterConfig::default(),
266            linear: None,
267            cli_auth: None,
268            device: None,
269            secret_metadata: BTreeMap::new(),
270            system_inventory: None,
271            cursor_ingest: None,
272            system_inventory_refresh: None,
273            dns_inventory: None,
274        }
275    }
276
277    pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
278        match provider {
279            SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
280            SecretProvider::Github => self.github_oauth2_key.as_deref(),
281            SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
282            SecretProvider::Linear => self.linear_api_key.as_deref(),
283            SecretProvider::Npm => self.npm_token.as_deref(),
284            SecretProvider::Crates => self.crates_token.as_deref(),
285        }
286    }
287
288    pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
289        match provider {
290            SecretProvider::OpenRouter => self.openrouter_api_key = value,
291            SecretProvider::Github => self.github_oauth2_key = value,
292            SecretProvider::Cloudflare => self.cloudflare_api_token = value,
293            SecretProvider::Linear => self.linear_api_key = value,
294            SecretProvider::Npm => self.npm_token = value,
295            SecretProvider::Crates => self.crates_token = value,
296        }
297
298        match self.get_secret(provider) {
299            Some(_) => {
300                self.secret_metadata.insert(
301                    provider.as_key().to_string(),
302                    SecretMetadata {
303                        added_at: Some(Utc::now()),
304                    },
305                );
306            }
307            None => {
308                self.secret_metadata.remove(provider.as_key());
309            }
310        }
311    }
312
313    pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
314        self.secret_metadata.get(provider.as_key())
315    }
316
317    pub fn load() -> Result<Self, String> {
318        let config_path = get_config_path();
319        let legacy_path = legacy_config_path();
320        let path_to_read = if config_path.exists() {
321            config_path
322        } else if legacy_path.exists() {
323            legacy_path
324        } else {
325            return Ok(SshConfig::new());
326        };
327
328        let content = fs::read_to_string(&path_to_read)
329            .map_err(|e| format!("Failed to read config file: {}", e))?;
330        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
331    }
332
333    pub fn save(&self) -> Result<(), String> {
334        let config_path = get_config_path();
335        let config_dir = config_path.parent().ok_or("Invalid config path")?;
336        fs::create_dir_all(config_dir)
337            .map_err(|e| format!("Failed to create config directory: {}", e))?;
338
339        let content = serde_yaml::to_string(self)
340            .map_err(|e| format!("Failed to serialize config: {}", e))?;
341        fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
342    }
343}
344
345#[derive(Debug, Serialize, Deserialize, Clone)]
346pub struct VersioningFilesConfig {
347    #[serde(default = "default_versioning_files")]
348    pub files: Vec<String>,
349}
350
351impl Default for VersioningFilesConfig {
352    fn default() -> Self {
353        Self {
354            files: default_versioning_files(),
355        }
356    }
357}
358
359#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
360pub struct PackageNameLookup {
361    pub file: String,
362    pub format: String,
363    pub key: String,
364    pub registry: String,
365}
366
367#[derive(Debug, Serialize, Deserialize, Clone)]
368pub struct PackageNameFilesConfig {
369    #[serde(default = "default_package_name_lookups")]
370    pub lookups: Vec<PackageNameLookup>,
371}
372
373impl Default for PackageNameFilesConfig {
374    fn default() -> Self {
375        Self {
376            lookups: default_package_name_lookups(),
377        }
378    }
379}
380
381pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
382    let root_dir = preferred_global_root_dir();
383
384    let paths = GlobalXbpPaths {
385        config_file: root_dir.join("config.yaml"),
386        ssh_dir: root_dir.join("ssh"),
387        cache_dir: root_dir.join("cache"),
388        logs_dir: root_dir.join("logs"),
389        versioning_files_file: root_dir.join("versioning-files.yaml"),
390        package_name_files_file: root_dir.join("package-name-files.yaml"),
391        root_dir,
392    };
393
394    for dir in [
395        &paths.root_dir,
396        &paths.ssh_dir,
397        &paths.cache_dir,
398        &paths.logs_dir,
399    ] {
400        fs::create_dir_all(dir)
401            .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
402    }
403
404    maybe_migrate_legacy_windows_files(&paths)?;
405
406    if !paths.config_file.exists() {
407        let default_config = serde_yaml::to_string(&SshConfig::new())
408            .map_err(|e| format!("Failed to serialize default config: {}", e))?;
409        fs::write(&paths.config_file, default_config).map_err(|e| {
410            format!(
411                "Failed to initialize config file {}: {}",
412                paths.config_file.display(),
413                e
414            )
415        })?;
416    }
417    sync_global_config_defaults_at(&paths.config_file)?;
418
419    sync_versioning_files_registry_at(&paths.versioning_files_file)?;
420    sync_package_name_files_registry_at(&paths.package_name_files_file)?;
421
422    Ok(paths)
423}
424
425fn default_openrouter_commit_model() -> String {
426    DEFAULT_MODEL.to_string()
427}
428
429fn default_openrouter_release_notes_model() -> String {
430    DEFAULT_MODEL.to_string()
431}
432
433fn default_openrouter_commit_system_prompt() -> String {
434    DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
435}
436
437fn default_openrouter_release_notes_system_prompt() -> String {
438    DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
439}
440
441const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
442
443fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
444where
445    F: FnOnce(&OpenRouterConfig) -> &str,
446{
447    SshConfig::load()
448        .ok()
449        .map(|cfg| selector(&cfg.openrouter).trim().to_string())
450        .filter(|value| !value.is_empty())
451        .unwrap_or_else(|| fallback.to_string())
452}
453
454fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
455    let content = fs::read_to_string(config_path).map_err(|e| {
456        format!(
457            "Failed to read config file {}: {}",
458            config_path.display(),
459            e
460        )
461    })?;
462
463    if content.contains("openrouter:")
464        && content.contains("commit_model:")
465        && content.contains("release_notes_model:")
466        && content.contains("commit_system_prompt:")
467        && content.contains("release_notes_system_prompt:")
468    {
469        return Ok(());
470    }
471
472    let config: SshConfig = serde_yaml::from_str(&content)
473        .map_err(|e| format!("Failed to parse config file: {}", e))?;
474    let normalized =
475        serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
476
477    if normalized != content {
478        fs::write(config_path, normalized).map_err(|e| {
479            format!(
480                "Failed to write config file {}: {}",
481                config_path.display(),
482                e
483            )
484        })?;
485    }
486
487    Ok(())
488}
489
490pub fn resolve_openrouter_api_key() -> Option<String> {
491    env::var("OPENROUTER_API_KEY")
492        .ok()
493        .map(|value| value.trim().to_string())
494        .filter(|value| !value.is_empty())
495        .or_else(|| {
496            SshConfig::load()
497                .ok()
498                .and_then(|cfg| {
499                    cfg.get_secret(SecretProvider::OpenRouter)
500                        .map(str::to_string)
501                })
502                .map(|value| value.trim().to_string())
503                .filter(|value| !value.is_empty())
504        })
505}
506
507pub fn resolve_openrouter_commit_model() -> String {
508    resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
509}
510
511pub fn resolve_openrouter_release_notes_model() -> String {
512    resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
513}
514
515pub fn resolve_openrouter_commit_system_prompt() -> String {
516    resolve_openrouter_config_value(
517        |cfg| cfg.commit_system_prompt.as_str(),
518        DEFAULT_COMMIT_SYSTEM_PROMPT,
519    )
520}
521
522pub fn resolve_openrouter_release_notes_system_prompt() -> String {
523    resolve_openrouter_config_value(
524        |cfg| cfg.release_notes_system_prompt.as_str(),
525        DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
526    )
527}
528
529pub fn resolve_xbp_api_token() -> Option<String> {
530    env::var("XBP_API_TOKEN")
531        .ok()
532        .map(|value| value.trim().to_string())
533        .filter(|value| !value.is_empty())
534        .or_else(|| {
535            SshConfig::load()
536                .ok()
537                .and_then(|cfg| cfg.xbp_api_token)
538                .map(|value| value.trim().to_string())
539                .filter(|value| !value.is_empty())
540        })
541}
542
543pub fn resolve_github_oauth2_key() -> Option<String> {
544    for env_var in [
545        "GITHUB_TOKEN",
546        "GITHUB_OAUTH2_KEY",
547        "GITHUB_OAUTH2_TOKEN",
548        "GITHUB_OAUTH_TOKEN",
549    ] {
550        if let Ok(value) = env::var(env_var) {
551            let token = value.trim();
552            if !token.is_empty() {
553                return Some(token.to_string());
554            }
555        }
556    }
557
558    SshConfig::load()
559        .ok()
560        .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
561        .map(|value| value.trim().to_string())
562        .filter(|value| !value.is_empty())
563}
564
565pub fn resolve_cloudflare_api_token() -> Option<String> {
566    env::var("CLOUDFLARE_API_TOKEN")
567        .ok()
568        .map(|value| value.trim().to_string())
569        .filter(|value| !value.is_empty())
570        .or_else(|| {
571            SshConfig::load()
572                .ok()
573                .and_then(|cfg| {
574                    cfg.get_secret(SecretProvider::Cloudflare)
575                        .map(str::to_string)
576                })
577                .map(|value| value.trim().to_string())
578                .filter(|value| !value.is_empty())
579        })
580}
581
582pub fn resolve_cloudflare_account_id() -> Option<String> {
583    env::var("CLOUDFLARE_ACCOUNT_ID")
584        .ok()
585        .map(|value| value.trim().to_string())
586        .filter(|value| !value.is_empty())
587        .or_else(|| {
588            SshConfig::load()
589                .ok()
590                .and_then(|cfg| cfg.cloudflare_account_id)
591                .map(|value| value.trim().to_string())
592                .filter(|value| !value.is_empty())
593        })
594}
595
596pub fn resolve_linear_api_key() -> Option<String> {
597    SshConfig::load()
598        .ok()
599        .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
600        .map(|value| value.trim().to_string())
601        .filter(|value| !value.is_empty())
602}
603
604pub fn resolve_npm_token() -> Option<String> {
605    for env_var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] {
606        if let Ok(value) = env::var(env_var) {
607            let token = value.trim();
608            if !token.is_empty() {
609                return Some(token.to_string());
610            }
611        }
612    }
613
614    SshConfig::load()
615        .ok()
616        .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
617        .map(|value| value.trim().to_string())
618        .filter(|value| !value.is_empty())
619}
620
621pub fn resolve_crates_token() -> Option<String> {
622    for env_var in ["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"] {
623        if let Ok(value) = env::var(env_var) {
624            let token = value.trim();
625            if !token.is_empty() {
626                return Some(token.to_string());
627            }
628        }
629    }
630
631    SshConfig::load()
632        .ok()
633        .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
634        .map(|value| value.trim().to_string())
635        .filter(|value| !value.is_empty())
636}
637
638pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
639    let mut config = SshConfig::load()?;
640    config.cloudflare_account_id = value;
641    config.save()
642}
643
644pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
645    Ok(SshConfig::load()?.cloudflare_account_id)
646}
647
648pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
649    SshConfig::load()
650        .ok()
651        .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
652}
653
654pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
655    ensure_global_xbp_paths()?;
656    let mut config = SshConfig::load()?;
657    if let Some(device) = config.device.clone() {
658        if !device.hardware_id.trim().is_empty() {
659            return Ok(device);
660        }
661    }
662
663    let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
664    let device = DeviceIdentity {
665        hardware_id,
666        created_at: Some(Utc::now()),
667    };
668    config.device = Some(device.clone());
669    config.save()?;
670    Ok(device)
671}
672
673pub fn reserve_cursor_ingest_slot(
674    trigger: &str,
675    mode: &str,
676    min_interval: ChronoDuration,
677) -> Result<bool, String> {
678    ensure_global_xbp_paths()?;
679    let mut config = SshConfig::load()?;
680    let now = Utc::now();
681
682    if let Some(state) = config.cursor_ingest.as_ref() {
683        if let Some(last_attempted_at) = state.last_attempted_at {
684            if now - last_attempted_at < min_interval {
685                return Ok(false);
686            }
687        }
688    }
689
690    let mut state = config.cursor_ingest.unwrap_or_default();
691    state.last_status = Some("scheduled".to_string());
692    state.last_trigger = Some(trigger.to_string());
693    state.last_mode = Some(mode.to_string());
694    state.last_attempted_at = Some(now);
695    state.last_error = None;
696    config.cursor_ingest = Some(state);
697    config.save()?;
698    Ok(true)
699}
700
701pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
702    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
703    let mut state = config.cursor_ingest.unwrap_or_default();
704    state.last_status = Some("running".to_string());
705    state.last_trigger = Some(trigger.to_string());
706    state.last_mode = Some(mode.to_string());
707    state.last_attempted_at = Some(Utc::now());
708    state.last_error = None;
709    config.cursor_ingest = Some(state);
710    config.save()
711}
712
713pub fn record_cursor_ingest_success(
714    trigger: &str,
715    mode: &str,
716    workspace_count: usize,
717    entry_count: usize,
718    entries_skipped: usize,
719) -> Result<(), String> {
720    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
721    let mut state = config.cursor_ingest.unwrap_or_default();
722    let completed_at = Utc::now();
723    state.last_status = Some("success".to_string());
724    state.last_trigger = Some(trigger.to_string());
725    state.last_mode = Some(mode.to_string());
726    state.last_attempted_at = Some(completed_at);
727    state.last_succeeded_at = Some(completed_at);
728    state.last_error = None;
729    state.last_workspace_count = Some(workspace_count);
730    state.last_entry_count = Some(entry_count);
731    state.last_entries_skipped = Some(entries_skipped);
732    config.cursor_ingest = Some(state);
733    config.save()
734}
735
736pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
737    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
738    let mut state = config.cursor_ingest.unwrap_or_default();
739    state.last_status = Some("failed".to_string());
740    state.last_trigger = Some(trigger.to_string());
741    state.last_mode = Some(mode.to_string());
742    state.last_attempted_at = Some(Utc::now());
743    state.last_error = Some(error.chars().take(400).collect());
744    config.cursor_ingest = Some(state);
745    config.save()
746}
747
748pub fn reserve_system_inventory_refresh_slot(
749    trigger: &str,
750    mode: &str,
751    include_cursor: bool,
752    min_interval: ChronoDuration,
753) -> Result<bool, String> {
754    ensure_global_xbp_paths()?;
755    let mut config = SshConfig::load()?;
756    let now = Utc::now();
757
758    if let Some(existing) = config.system_inventory.as_ref() {
759        if !system_inventory_needs_refresh(existing, include_cursor) {
760            return Ok(false);
761        }
762    }
763
764    if let Some(state) = config.system_inventory_refresh.as_ref() {
765        if let Some(last_attempted_at) = state.last_attempted_at {
766            if now - last_attempted_at < min_interval {
767                return Ok(false);
768            }
769        }
770    }
771
772    let mut state = config.system_inventory_refresh.unwrap_or_default();
773    state.last_status = Some("scheduled".to_string());
774    state.last_trigger = Some(trigger.to_string());
775    state.last_mode = Some(mode.to_string());
776    state.last_attempted_at = Some(now);
777    state.last_error = None;
778    state.include_cursor = Some(include_cursor);
779    config.system_inventory_refresh = Some(state);
780    config.save()?;
781    Ok(true)
782}
783
784pub fn record_system_inventory_refresh_started(
785    trigger: &str,
786    mode: &str,
787    include_cursor: bool,
788) -> Result<(), String> {
789    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
790    let mut state = config.system_inventory_refresh.unwrap_or_default();
791    state.last_status = Some("running".to_string());
792    state.last_trigger = Some(trigger.to_string());
793    state.last_mode = Some(mode.to_string());
794    state.last_attempted_at = Some(Utc::now());
795    state.last_error = None;
796    state.include_cursor = Some(include_cursor);
797    config.system_inventory_refresh = Some(state);
798    config.save()
799}
800
801pub fn record_system_inventory_refresh_success(
802    trigger: &str,
803    mode: &str,
804    include_cursor: bool,
805    refreshed: bool,
806) -> Result<(), String> {
807    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
808    let mut state = config.system_inventory_refresh.unwrap_or_default();
809    let completed_at = Utc::now();
810    state.last_status = Some("success".to_string());
811    state.last_trigger = Some(trigger.to_string());
812    state.last_mode = Some(mode.to_string());
813    state.last_attempted_at = Some(completed_at);
814    state.last_succeeded_at = Some(completed_at);
815    state.last_error = None;
816    state.include_cursor = Some(include_cursor);
817    state.refreshed = Some(refreshed);
818    config.system_inventory_refresh = Some(state);
819    config.save()
820}
821
822pub fn record_system_inventory_refresh_failure(
823    trigger: &str,
824    mode: &str,
825    include_cursor: bool,
826    error: &str,
827) -> Result<(), String> {
828    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
829    let mut state = config.system_inventory_refresh.unwrap_or_default();
830    state.last_status = Some("failed".to_string());
831    state.last_trigger = Some(trigger.to_string());
832    state.last_mode = Some(mode.to_string());
833    state.last_attempted_at = Some(Utc::now());
834    state.last_error = Some(error.chars().take(400).collect());
835    state.include_cursor = Some(include_cursor);
836    config.system_inventory_refresh = Some(state);
837    config.save()
838}
839
840pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
841where
842    F: FnOnce(&mut DnsInventoryCache),
843{
844    ensure_global_xbp_paths()?;
845    let mut config = SshConfig::load()?;
846    let mut cache = config.dns_inventory.unwrap_or_default();
847    updater(&mut cache);
848    config.dns_inventory = Some(cache);
849    config.save()
850}
851
852pub fn sync_system_inventory(
853    force: bool,
854    include_cursor: bool,
855    current_dir: Option<&Path>,
856) -> Result<SystemInventorySyncResult, String> {
857    let paths = ensure_global_xbp_paths()?;
858    let mut config = SshConfig::load()?;
859
860    if !force {
861        if let Some(existing) = config.system_inventory.clone() {
862            if !system_inventory_needs_refresh(&existing, include_cursor) {
863                return Ok(SystemInventorySyncResult {
864                    inventory: existing,
865                    config_path: paths.config_file,
866                    refreshed: false,
867                });
868            }
869        }
870    }
871
872    let mut options = SystemInventoryOptions {
873        include_cursor,
874        current_dir: current_dir.map(Path::to_path_buf),
875        xbp_global_root: Some(paths.root_dir.clone()),
876    };
877    if options.current_dir.is_none() {
878        options.current_dir = env::current_dir().ok();
879    }
880
881    let mut inventory = collect_codetime_system_inventory(&options);
882    if !include_cursor {
883        if let Some(existing_cursor) = config
884            .system_inventory
885            .as_ref()
886            .and_then(|existing| existing.cursor.clone())
887        {
888            inventory.cursor = Some(existing_cursor);
889        }
890    }
891
892    config.system_inventory = Some(inventory.clone());
893    config.save()?;
894
895    Ok(SystemInventorySyncResult {
896        inventory,
897        config_path: paths.config_file,
898        refreshed: true,
899    })
900}
901
902fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
903    if include_cursor && inventory.cursor.is_none() {
904        return true;
905    }
906
907    match inventory.collected_at {
908        Some(collected_at) => {
909            Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
910        }
911        None => true,
912    }
913}
914
915pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
916    ensure_global_xbp_paths()
917}
918
919pub fn get_config_path() -> PathBuf {
920    ensure_global_xbp_paths()
921        .map(|paths| paths.config_file)
922        .unwrap_or_else(|_| legacy_config_path())
923}
924
925#[cfg(target_os = "windows")]
926fn legacy_config_path() -> PathBuf {
927    dirs::config_dir()
928        .unwrap_or_else(|| PathBuf::from("."))
929        .join("xbp")
930        .join("config.yaml")
931}
932
933#[cfg(not(target_os = "windows"))]
934fn legacy_config_path() -> PathBuf {
935    dirs::home_dir()
936        .unwrap_or_else(|| PathBuf::from("."))
937        .join(".xbp")
938        .join("config.yaml")
939}
940
941#[cfg(target_os = "windows")]
942fn preferred_global_root_dir() -> PathBuf {
943    let fallback = dirs::config_dir()
944        .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
945        .unwrap_or_else(|| PathBuf::from("."))
946        .join("xbp");
947
948    let Some(home_dir) = resolve_windows_home_dir() else {
949        return fallback;
950    };
951    let c_drive = Path::new(r"C:\");
952
953    // Prefer C:\...\.xbp when that profile path is valid; otherwise use the real profile path.
954    if c_drive.exists() {
955        if windows_drive_letter(&home_dir) == Some('C') {
956            return home_dir.join(".xbp");
957        }
958
959        if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
960            let c_profile_candidate = c_drive.join(relative_profile_path);
961            if c_profile_candidate.exists() {
962                return c_profile_candidate.join(".xbp");
963            }
964        }
965    }
966
967    home_dir.join(".xbp")
968}
969
970#[cfg(not(target_os = "windows"))]
971fn preferred_global_root_dir() -> PathBuf {
972    dirs::config_dir()
973        .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
974        .unwrap_or_else(|| PathBuf::from("."))
975        .join("xbp")
976}
977
978#[cfg(target_os = "windows")]
979fn resolve_windows_home_dir() -> Option<PathBuf> {
980    dirs::home_dir()
981        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
982        .or_else(|| {
983            let drive = env::var_os("HOMEDRIVE")?;
984            let path = env::var_os("HOMEPATH")?;
985            Some(PathBuf::from(drive).join(path))
986        })
987}
988
989#[cfg(target_os = "windows")]
990fn windows_drive_letter(path: &Path) -> Option<char> {
991    let normalized = path.to_string_lossy().replace('/', "\\");
992    let bytes = normalized.as_bytes();
993    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
994        Some((bytes[0] as char).to_ascii_uppercase())
995    } else {
996        None
997    }
998}
999
1000#[cfg(target_os = "windows")]
1001fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
1002    let normalized = path.to_string_lossy().replace('/', "\\");
1003    let bytes = normalized.as_bytes();
1004    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
1005        let tail = &normalized[3..];
1006        if tail.is_empty() {
1007            Some(PathBuf::new())
1008        } else {
1009            Some(PathBuf::from(tail))
1010        }
1011    } else {
1012        None
1013    }
1014}
1015
1016#[cfg(target_os = "windows")]
1017fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
1018    let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
1019        return Ok(());
1020    };
1021
1022    migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
1023    migrate_legacy_windows_file_if_missing(
1024        &legacy_root.join("versioning-files.yaml"),
1025        &paths.versioning_files_file,
1026    )?;
1027    migrate_legacy_windows_file_if_missing(
1028        &legacy_root.join("package-name-files.yaml"),
1029        &paths.package_name_files_file,
1030    )?;
1031
1032    Ok(())
1033}
1034
1035#[cfg(not(target_os = "windows"))]
1036fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
1037    Ok(())
1038}
1039
1040#[cfg(target_os = "windows")]
1041fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
1042    if to.exists() || !from.exists() {
1043        return Ok(());
1044    }
1045
1046    if let Some(parent) = to.parent() {
1047        fs::create_dir_all(parent).map_err(|e| {
1048            format!(
1049                "Failed to create directory for migrated file {}: {}",
1050                parent.display(),
1051                e
1052            )
1053        })?;
1054    }
1055
1056    fs::copy(from, to).map_err(|e| {
1057        format!(
1058            "Failed to migrate legacy config file {} -> {}: {}",
1059            from.display(),
1060            to.display(),
1061            e
1062        )
1063    })?;
1064
1065    Ok(())
1066}
1067
1068pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
1069    let paths = global_xbp_paths()?;
1070    Ok(vec![
1071        ("root".to_string(), paths.root_dir),
1072        ("config".to_string(), paths.config_file),
1073        ("ssh".to_string(), paths.ssh_dir),
1074        ("cache".to_string(), paths.cache_dir),
1075        ("logs".to_string(), paths.logs_dir),
1076        ("versioning".to_string(), paths.versioning_files_file),
1077        ("package-names".to_string(), paths.package_name_files_file),
1078    ])
1079}
1080
1081pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
1082    let paths = ensure_global_xbp_paths()?;
1083    Ok(paths.versioning_files_file)
1084}
1085
1086pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
1087    let registry_path = sync_versioning_files_registry()?;
1088    let content = fs::read_to_string(&registry_path).map_err(|e| {
1089        format!(
1090            "Failed to read versioning registry {}: {}",
1091            registry_path.display(),
1092            e
1093        )
1094    })?;
1095
1096    let config: VersioningFilesConfig = serde_yaml::from_str(&content)
1097        .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
1098
1099    Ok(config.files)
1100}
1101
1102pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
1103    let paths = ensure_global_xbp_paths()?;
1104    Ok(paths.package_name_files_file)
1105}
1106
1107pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
1108    let registry_path = sync_package_name_files_registry()?;
1109    let content = fs::read_to_string(&registry_path).map_err(|e| {
1110        format!(
1111            "Failed to read package-name registry {}: {}",
1112            registry_path.display(),
1113            e
1114        )
1115    })?;
1116
1117    let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
1118        .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
1119
1120    Ok(config.lookups)
1121}
1122
1123fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
1124    let mut config = if path.exists() {
1125        let content = fs::read_to_string(path).map_err(|e| {
1126            format!(
1127                "Failed to read versioning registry {}: {}",
1128                path.display(),
1129                e
1130            )
1131        })?;
1132        serde_yaml::from_str::<VersioningFilesConfig>(&content)
1133            .unwrap_or_else(|_| VersioningFilesConfig::default())
1134    } else {
1135        VersioningFilesConfig::default()
1136    };
1137
1138    let mut changed = false;
1139    for default_file in default_versioning_files() {
1140        if !config
1141            .files
1142            .iter()
1143            .any(|existing| existing == &default_file)
1144        {
1145            config.files.push(default_file);
1146            changed = true;
1147        }
1148    }
1149
1150    if changed || !path.exists() {
1151        let content = serde_yaml::to_string(&config)
1152            .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
1153        fs::write(path, content).map_err(|e| {
1154            format!(
1155                "Failed to write versioning registry {}: {}",
1156                path.display(),
1157                e
1158            )
1159        })?;
1160    }
1161
1162    Ok(())
1163}
1164
1165fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
1166    let mut config = if path.exists() {
1167        let content = fs::read_to_string(path).map_err(|e| {
1168            format!(
1169                "Failed to read package-name registry {}: {}",
1170                path.display(),
1171                e
1172            )
1173        })?;
1174        serde_yaml::from_str::<PackageNameFilesConfig>(&content)
1175            .unwrap_or_else(|_| PackageNameFilesConfig::default())
1176    } else {
1177        PackageNameFilesConfig::default()
1178    };
1179
1180    let mut changed = false;
1181    for default_lookup in default_package_name_lookups() {
1182        if !config
1183            .lookups
1184            .iter()
1185            .any(|existing| existing == &default_lookup)
1186        {
1187            config.lookups.push(default_lookup);
1188            changed = true;
1189        }
1190    }
1191
1192    if changed || !path.exists() {
1193        let content = serde_yaml::to_string(&config)
1194            .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
1195        fs::write(path, content).map_err(|e| {
1196            format!(
1197                "Failed to write package-name registry {}: {}",
1198                path.display(),
1199                e
1200            )
1201        })?;
1202    }
1203
1204    Ok(())
1205}
1206
1207fn default_versioning_files() -> Vec<String> {
1208    vec![
1209        "README.md".to_string(),
1210        "openapi.yaml".to_string(),
1211        "openapi.yml".to_string(),
1212        "openapi.json".to_string(),
1213        "package.json".to_string(),
1214        "package-lock.json".to_string(),
1215        "Cargo.toml".to_string(),
1216        "Cargo.lock".to_string(),
1217        "pyproject.toml".to_string(),
1218        "composer.json".to_string(),
1219        "deno.json".to_string(),
1220        "deno.jsonc".to_string(),
1221        "Chart.yaml".to_string(),
1222        "app.json".to_string(),
1223        "manifest.json".to_string(),
1224        "pom.xml".to_string(),
1225        "build.gradle".to_string(),
1226        "build.gradle.kts".to_string(),
1227        "mix.exs".to_string(),
1228        "xbp.yaml".to_string(),
1229        "xbp.yml".to_string(),
1230        "xbp.json".to_string(),
1231        ".xbp/xbp.json".to_string(),
1232        ".xbp/xbp.yaml".to_string(),
1233        ".xbp/xbp.yml".to_string(),
1234    ]
1235}
1236
1237fn default_package_name_lookups() -> Vec<PackageNameLookup> {
1238    vec![
1239        PackageNameLookup {
1240            file: "package.json".to_string(),
1241            format: "json".to_string(),
1242            key: "name".to_string(),
1243            registry: "npm".to_string(),
1244        },
1245        PackageNameLookup {
1246            file: "Cargo.toml".to_string(),
1247            format: "toml".to_string(),
1248            key: "package.name".to_string(),
1249            registry: "crates.io".to_string(),
1250        },
1251    ]
1252}
1253
1254const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
1255
1256/// Simple API configuration for the XBP version endpoints.
1257#[derive(Debug, Clone)]
1258pub struct ApiConfig {
1259    base_url: String,
1260}
1261
1262impl ApiConfig {
1263    /// Load the API configuration from API_XBP_URL, falling back to the default.
1264    pub fn load() -> Self {
1265        let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
1266        Self::from_base_url(&raw_url)
1267    }
1268
1269    pub fn from_env() -> Self {
1270        Self::load()
1271    }
1272
1273    pub fn from_base_url(raw_url: &str) -> Self {
1274        let base_url = Self::normalize_base_url(raw_url);
1275        ApiConfig { base_url }
1276    }
1277
1278    /// Return the normalized base URL that downstream callers should use.
1279    pub fn base_url(&self) -> &str {
1280        &self.base_url
1281    }
1282
1283    /// Build the version query endpoint.
1284    pub fn version_endpoint(&self, project_name: &str) -> String {
1285        format!("{}/version?project_name={}", self.base_url, project_name)
1286    }
1287
1288    /// Build the endpoint that increments a version.
1289    pub fn increment_endpoint(&self) -> String {
1290        format!("{}/version/increment", self.base_url)
1291    }
1292
1293    pub fn web_base_url(&self) -> String {
1294        Self::derive_web_base_url(&self.base_url)
1295    }
1296
1297    pub fn cli_auth_request_endpoint(&self) -> String {
1298        format!("{}/api/cli/auth/request", self.web_base_url())
1299    }
1300
1301    pub fn cli_auth_poll_endpoint(&self) -> String {
1302        format!("{}/api/cli/auth/poll", self.web_base_url())
1303    }
1304
1305    pub fn cli_auth_session_endpoint(&self) -> String {
1306        format!("{}/api/cli/auth/session", self.web_base_url())
1307    }
1308
1309    pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
1310        format!("{}/cli/login/{}", self.web_base_url(), flow_id)
1311    }
1312
1313    pub fn cli_linear_key_endpoint(&self) -> String {
1314        format!("{}/api/cli/linear/key", self.web_base_url())
1315    }
1316
1317    pub fn cli_version_activity_endpoint(&self) -> String {
1318        format!("{}/api/cli/version/activity", self.web_base_url())
1319    }
1320
1321    pub fn cli_cursor_ingest_endpoint(&self) -> String {
1322        format!("{}/api/cli/cursor/ingest", self.web_base_url())
1323    }
1324
1325    fn normalize_base_url(raw: &str) -> String {
1326        let trimmed = raw.trim();
1327        if trimmed.is_empty() {
1328            return DEFAULT_API_XBP_URL.to_string();
1329        }
1330
1331        let trimmed = trimmed.trim_end_matches('/');
1332        if trimmed.is_empty() {
1333            return DEFAULT_API_XBP_URL.to_string();
1334        }
1335
1336        trimmed.to_string()
1337    }
1338
1339    fn derive_web_base_url(base_url: &str) -> String {
1340        let Ok(mut url) = Url::parse(base_url) else {
1341            return base_url.to_string();
1342        };
1343
1344        if let Some(host) = url.host_str().map(str::to_string) {
1345            if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
1346                let _ = url.set_host(Some("xbp.app"));
1347            } else if let Some(stripped) = host.strip_prefix("api.") {
1348                let _ = url.set_host(Some(stripped));
1349            }
1350        }
1351
1352        url.set_path("");
1353        url.set_query(None);
1354        url.set_fragment(None);
1355
1356        url.to_string().trim_end_matches('/').to_string()
1357    }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use super::{
1363        default_package_name_lookups, default_versioning_files, resolve_github_oauth2_key,
1364        resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
1365        sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
1366        OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig, VersioningFilesConfig,
1367    };
1368    use crate::openrouter::{
1369        DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1370    };
1371    use std::fs;
1372    use std::path::PathBuf;
1373    use std::time::{SystemTime, UNIX_EPOCH};
1374
1375    fn temp_path(label: &str) -> PathBuf {
1376        let nanos = SystemTime::now()
1377            .duration_since(UNIX_EPOCH)
1378            .expect("time")
1379            .as_nanos();
1380        std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
1381    }
1382
1383    #[test]
1384    fn versioning_registry_defaults_include_core_files() {
1385        let defaults = default_versioning_files();
1386        assert!(defaults.contains(&"README.md".to_string()));
1387        assert!(defaults.contains(&"Cargo.toml".to_string()));
1388        assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
1389    }
1390
1391    #[test]
1392    fn versioning_registry_default_config_populates_files() {
1393        let config = VersioningFilesConfig::default();
1394        assert!(!config.files.is_empty());
1395    }
1396
1397    #[test]
1398    fn versioning_registry_defaults_do_not_contain_duplicates() {
1399        let defaults = default_versioning_files();
1400        let mut deduped = defaults.clone();
1401        deduped.sort();
1402        deduped.dedup();
1403        assert_eq!(defaults.len(), deduped.len());
1404    }
1405
1406    #[test]
1407    fn syncing_registry_creates_file_with_defaults() {
1408        let path = temp_path("defaults");
1409        sync_versioning_files_registry_at(&path).expect("sync");
1410
1411        let content = fs::read_to_string(&path).expect("read");
1412        assert!(content.contains("README.md"));
1413        assert!(content.contains("Cargo.toml"));
1414
1415        let _ = fs::remove_file(path);
1416    }
1417
1418    #[test]
1419    fn syncing_registry_preserves_user_added_entries() {
1420        let path = temp_path("preserve");
1421        fs::write(&path, "files:\n  - custom.file\n").expect("write registry");
1422
1423        sync_versioning_files_registry_at(&path).expect("sync");
1424
1425        let content = fs::read_to_string(&path).expect("read");
1426        assert!(content.contains("custom.file"));
1427        assert!(content.contains("README.md"));
1428
1429        let _ = fs::remove_file(path);
1430    }
1431
1432    #[test]
1433    fn api_config_normalizes_trailing_slashes() {
1434        assert_eq!(
1435            ApiConfig::normalize_base_url("https://api.xbp.app///"),
1436            "https://api.xbp.app".to_string()
1437        );
1438    }
1439
1440    #[test]
1441    fn api_config_uses_default_for_blank_values() {
1442        assert_eq!(
1443            ApiConfig::normalize_base_url("   "),
1444            "https://api.xbp.app".to_string()
1445        );
1446    }
1447
1448    #[test]
1449    fn api_config_builds_version_endpoints() {
1450        let config = ApiConfig {
1451            base_url: "https://api.test.xbp".to_string(),
1452        };
1453        let endpoint = config.version_endpoint("demo");
1454        let increment = config.increment_endpoint();
1455
1456        assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
1457        assert_eq!(increment, "https://api.test.xbp/version/increment");
1458    }
1459
1460    #[test]
1461    fn api_config_derives_browser_base_url_from_api_subdomain() {
1462        assert_eq!(
1463            ApiConfig::derive_web_base_url("https://api.xbp.app"),
1464            "https://xbp.app".to_string()
1465        );
1466        assert_eq!(
1467            ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
1468            "https://xbp.app".to_string()
1469        );
1470        assert_eq!(
1471            ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
1472            "https://xbp.app".to_string()
1473        );
1474        assert_eq!(
1475            ApiConfig::derive_web_base_url("https://api.internal.example.com"),
1476            "https://internal.example.com".to_string()
1477        );
1478        assert_eq!(
1479            ApiConfig::derive_web_base_url("http://localhost:3000"),
1480            "http://localhost:3000".to_string()
1481        );
1482    }
1483
1484    #[test]
1485    fn package_lookup_defaults_include_npm_and_crates() {
1486        let defaults = default_package_name_lookups();
1487        assert!(defaults.iter().any(|entry| {
1488            entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
1489        }));
1490        assert!(defaults.iter().any(|entry| {
1491            entry.file == "Cargo.toml"
1492                && entry.registry == "crates.io"
1493                && entry.key == "package.name"
1494        }));
1495    }
1496
1497    #[test]
1498    fn package_lookup_default_config_populates_entries() {
1499        let config = PackageNameFilesConfig::default();
1500        assert!(!config.lookups.is_empty());
1501    }
1502
1503    #[test]
1504    fn syncing_package_lookup_registry_creates_defaults() {
1505        let path = temp_path("package-lookup-defaults");
1506        sync_package_name_files_registry_at(&path).expect("sync");
1507
1508        let content = fs::read_to_string(&path).expect("read");
1509        assert!(content.contains("package.json"));
1510        assert!(content.contains("Cargo.toml"));
1511
1512        let _ = fs::remove_file(path);
1513    }
1514
1515    #[test]
1516    fn syncing_package_lookup_registry_preserves_custom_entries() {
1517        let path = temp_path("package-lookup-custom");
1518        fs::write(
1519            &path,
1520            "lookups:\n  - file: custom.yaml\n    format: yaml\n    key: app.name\n    registry: npm\n",
1521        )
1522        .expect("write package lookup registry");
1523
1524        sync_package_name_files_registry_at(&path).expect("sync");
1525        let content = fs::read_to_string(&path).expect("read");
1526        assert!(content.contains("custom.yaml"));
1527        assert!(content.contains("package.json"));
1528
1529        let _ = fs::remove_file(path);
1530    }
1531
1532    #[test]
1533    fn secret_provider_parses_supported_keys() {
1534        assert_eq!(
1535            SecretProvider::from_key("openrouter"),
1536            Some(SecretProvider::OpenRouter)
1537        );
1538        assert_eq!(
1539            SecretProvider::from_key("github"),
1540            Some(SecretProvider::Github)
1541        );
1542        assert_eq!(
1543            SecretProvider::from_key("linear"),
1544            Some(SecretProvider::Linear)
1545        );
1546        assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
1547        assert_eq!(
1548            SecretProvider::from_key("crates"),
1549            Some(SecretProvider::Crates)
1550        );
1551        assert_eq!(SecretProvider::from_key("unknown"), None);
1552    }
1553
1554    #[test]
1555    fn ssh_config_secret_get_set_roundtrip() {
1556        let mut cfg = SshConfig::new();
1557        cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
1558        cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
1559        cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
1560        cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
1561        cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
1562
1563        assert_eq!(
1564            cfg.get_secret(SecretProvider::OpenRouter),
1565            Some("or-test-123")
1566        );
1567        assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
1568        assert_eq!(
1569            cfg.get_secret(SecretProvider::Linear),
1570            Some("lin_api_test_789")
1571        );
1572        assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
1573        assert_eq!(
1574            cfg.get_secret(SecretProvider::Crates),
1575            Some("crate_test_token")
1576        );
1577        assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
1578    }
1579
1580    #[test]
1581    fn default_openrouter_config_populates_models_and_prompts() {
1582        let config = OpenRouterConfig::default();
1583
1584        assert_eq!(config.commit_model, DEFAULT_MODEL);
1585        assert_eq!(config.release_notes_model, DEFAULT_MODEL);
1586        assert_eq!(
1587            config.commit_system_prompt,
1588            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1589        );
1590        assert_eq!(
1591            config.release_notes_system_prompt,
1592            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1593        );
1594    }
1595
1596    #[test]
1597    fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
1598        let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
1599
1600        assert!(yaml.contains("openrouter:"));
1601        assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
1602        assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
1603        assert!(yaml.contains("commit_system_prompt"));
1604        assert!(yaml.contains("release_notes_system_prompt"));
1605    }
1606
1607    #[test]
1608    fn ssh_config_new_uses_openrouter_defaults() {
1609        let config = SshConfig::new();
1610
1611        assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
1612        assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
1613        assert_eq!(
1614            config.openrouter.commit_system_prompt,
1615            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1616        );
1617        assert_eq!(
1618            config.openrouter.release_notes_system_prompt,
1619            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1620        );
1621    }
1622
1623    #[test]
1624    fn syncing_global_config_backfills_openrouter_generation_defaults() {
1625        let path = temp_path("global-config");
1626        fs::write(
1627            &path,
1628            "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\ncli_auth: null\nsecret_metadata: {}\n",
1629        )
1630        .expect("write config");
1631
1632        sync_global_config_defaults_at(&path).expect("sync config defaults");
1633
1634        let content = fs::read_to_string(&path).expect("read config");
1635        assert!(content.contains("openrouter:"));
1636        assert!(content.contains("commit_model: openai/gpt-4o-mini"));
1637        assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
1638        assert!(content.contains("commit_system_prompt"));
1639        assert!(content.contains("release_notes_system_prompt"));
1640
1641        let _ = fs::remove_file(path);
1642    }
1643
1644    #[test]
1645    fn resolve_secret_prefers_environment_value() {
1646        std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
1647        std::env::set_var("GITHUB_TOKEN", "env-github");
1648        std::env::set_var("XBP_API_TOKEN", "env-xbp");
1649
1650        assert_eq!(
1651            resolve_openrouter_api_key(),
1652            Some("env-openrouter".to_string())
1653        );
1654        assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
1655        assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
1656
1657        std::env::remove_var("OPENROUTER_API_KEY");
1658        std::env::remove_var("GITHUB_TOKEN");
1659        std::env::remove_var("XBP_API_TOKEN");
1660    }
1661}