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