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