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