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/// Resolve the global `~/.xbp` (or platform equivalent) root.
1071///
1072/// On WSL this prefers an already-established Windows profile root when it is
1073/// reachable via `/mnt/<drive>/Users/...`, so worktree-watch spoils and config
1074/// stay reconcilable with native Windows installs of the same machine.
1075pub fn resolve_global_xbp_root_dir() -> PathBuf {
1076    preferred_global_root_dir()
1077}
1078
1079#[cfg(test)]
1080mod global_root_tests {
1081    use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
1082    use std::path::Path;
1083
1084    #[test]
1085    fn maps_windows_and_wsl_paths_both_ways() {
1086        assert_eq!(
1087            map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
1088            Some("/mnt/c/Users/szymon/.xbp".to_string())
1089        );
1090        assert_eq!(
1091            map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
1092            Some("C:/Users/szymon/.xbp".to_string())
1093        );
1094    }
1095
1096    #[test]
1097    fn scores_missing_root_as_zero() {
1098        assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
1099    }
1100}
1101
1102fn preferred_global_root_dir() -> PathBuf {
1103    if let Some(explicit) = explicit_xbp_home_from_env() {
1104        return explicit;
1105    }
1106
1107    let candidates = collect_global_xbp_root_candidates();
1108    if let Some(best) = pick_established_xbp_root(&candidates) {
1109        return best;
1110    }
1111
1112    // Fresh install defaults.
1113    #[cfg(target_os = "windows")]
1114    {
1115        if let Some(home_dir) = resolve_windows_home_dir() {
1116            let c_drive = Path::new(r"C:\");
1117            if c_drive.exists() {
1118                if windows_drive_letter(&home_dir) == Some('C') {
1119                    return home_dir.join(".xbp");
1120                }
1121                if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
1122                    let c_profile_candidate = c_drive.join(relative_profile_path);
1123                    if c_profile_candidate.exists() {
1124                        return c_profile_candidate.join(".xbp");
1125                    }
1126                }
1127            }
1128            return home_dir.join(".xbp");
1129        }
1130        return dirs::config_dir()
1131            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
1132            .unwrap_or_else(|| PathBuf::from("."))
1133            .join("xbp");
1134    }
1135
1136    #[cfg(not(target_os = "windows"))]
1137    {
1138        // Prefer classic ~/.xbp over ~/.config/xbp so WSL/Linux matches Windows
1139        // layout and worktree-watch spoils stay discoverable.
1140        if let Some(home) = dirs::home_dir() {
1141            return home.join(".xbp");
1142        }
1143        dirs::config_dir()
1144            .unwrap_or_else(|| PathBuf::from("."))
1145            .join("xbp")
1146    }
1147}
1148
1149fn explicit_xbp_home_from_env() -> Option<PathBuf> {
1150    for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
1151        if let Ok(value) = env::var(key) {
1152            let trimmed = value.trim();
1153            if !trimmed.is_empty() {
1154                return Some(PathBuf::from(trimmed));
1155            }
1156        }
1157    }
1158    None
1159}
1160
1161fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
1162    let mut candidates: Vec<PathBuf> = Vec::new();
1163    let mut push = |path: PathBuf| {
1164        if candidates
1165            .iter()
1166            .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1167        {
1168            return;
1169        }
1170        candidates.push(path);
1171    };
1172
1173    if let Some(explicit) = explicit_xbp_home_from_env() {
1174        push(explicit);
1175    }
1176
1177    #[cfg(target_os = "windows")]
1178    {
1179        if let Some(home_dir) = resolve_windows_home_dir() {
1180            push(home_dir.join(".xbp"));
1181            if let Some(relative) = windows_path_without_drive(&home_dir) {
1182                push(Path::new(r"C:\").join(relative).join(".xbp"));
1183            }
1184        }
1185        if let Some(config_dir) = dirs::config_dir() {
1186            push(config_dir.join("xbp"));
1187        }
1188    }
1189
1190    #[cfg(not(target_os = "windows"))]
1191    {
1192        if let Some(home) = dirs::home_dir() {
1193            push(home.join(".xbp"));
1194            push(home.join(".config").join("xbp"));
1195        }
1196        if let Some(config_dir) = dirs::config_dir() {
1197            push(config_dir.join("xbp"));
1198        }
1199
1200        // WSL: discover the Windows user profile .xbp that native Windows uses.
1201        for windows_home in discover_windows_profile_homes_from_unix() {
1202            push(windows_home.join(".xbp"));
1203        }
1204    }
1205
1206    candidates
1207}
1208
1209fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
1210    candidates
1211        .iter()
1212        .map(|path| (score_xbp_root(path), path.clone()))
1213        .filter(|(score, _)| *score > 0)
1214        .max_by(|left, right| left.0.cmp(&right.0).then_with(|| {
1215            // Prefer Windows-drive-backed roots when scores tie (shared WSL/Windows).
1216            let left_windows = is_windows_drive_backed_path(&left.1);
1217            let right_windows = is_windows_drive_backed_path(&right.1);
1218            right_windows.cmp(&left_windows)
1219        }))
1220        .map(|(_, path)| path)
1221}
1222
1223fn score_xbp_root(root: &Path) -> u64 {
1224    if !root.exists() {
1225        return 0;
1226    }
1227
1228    let mut score = 1u64;
1229    if root.join("config.yaml").is_file() {
1230        score += 100;
1231    }
1232    if root.join("config.yml").is_file() {
1233        score += 80;
1234    }
1235    let mutations = root.join("mutations");
1236    if mutations.is_dir() {
1237        score += 50;
1238        score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
1239    }
1240    if root.join("ssh").is_dir() {
1241        score += 10;
1242    }
1243    if root.join("cache").is_dir() {
1244        score += 5;
1245    }
1246    // Strong signal that this is the shared Windows profile used from WSL.
1247    if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
1248        score += 40;
1249    }
1250    score
1251}
1252
1253fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
1254    let Ok(entries) = fs::read_dir(path) else {
1255        return 0;
1256    };
1257    let mut count = 0u64;
1258    for _entry in entries.flatten() {
1259        count += 1;
1260        if count >= cap {
1261            break;
1262        }
1263    }
1264    count
1265}
1266
1267fn is_windows_drive_backed_path(path: &Path) -> bool {
1268    let normalized = path.to_string_lossy().replace('\\', "/");
1269    if cfg!(windows) {
1270        return windows_drive_letter(path).is_some();
1271    }
1272    // WSL mounts of Windows drives: /mnt/c/Users/...
1273    let lower = normalized.to_ascii_lowercase();
1274    lower.starts_with("/mnt/")
1275        && lower.len() >= 6
1276        && lower.as_bytes().get(5).is_some_and(|b| b.is_ascii_alphabetic())
1277}
1278
1279fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
1280    normalize_path_key(left) == normalize_path_key(right)
1281}
1282
1283fn normalize_path_key(path: &Path) -> String {
1284    let mut value = path.to_string_lossy().replace('\\', "/");
1285    // Collapse Windows drive letters to a comparable form with WSL /mnt/<drive>/.
1286    if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
1287        value = mapped;
1288    } else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
1289        // Normalize both directions to the /mnt form for comparison on Unix.
1290        if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
1291            value = mnt;
1292        } else {
1293            value = mapped;
1294        }
1295    }
1296    if cfg!(windows) {
1297        value.to_ascii_lowercase()
1298    } else {
1299        value
1300    }
1301}
1302
1303/// `C:\Users\foo` → `/mnt/c/Users/foo` (for comparison / WSL access).
1304pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
1305    let normalized = path.trim().replace('\\', "/");
1306    let bytes = normalized.as_bytes();
1307    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1308        let drive = (bytes[0] as char).to_ascii_lowercase();
1309        let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
1310        if rest.is_empty() {
1311            return Some(format!("/mnt/{drive}"));
1312        }
1313        return Some(format!("/mnt/{drive}/{rest}"));
1314    }
1315    None
1316}
1317
1318/// `/mnt/c/Users/foo` → `C:/Users/foo`.
1319pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
1320    let normalized = path.trim().replace('\\', "/");
1321    let rest = normalized.strip_prefix("/mnt/")?;
1322    let mut parts = rest.splitn(2, '/');
1323    let drive = parts.next()?.chars().next()?;
1324    if !drive.is_ascii_alphabetic() {
1325        return None;
1326    }
1327    let tail = parts.next().unwrap_or("");
1328    if tail.is_empty() {
1329        Some(format!("{}:", drive.to_ascii_uppercase()))
1330    } else {
1331        Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
1332    }
1333}
1334
1335#[cfg(not(target_os = "windows"))]
1336fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
1337    let mut homes: Vec<PathBuf> = Vec::new();
1338    let mut push_home = |path: PathBuf| {
1339        if path.is_dir()
1340            && !homes
1341                .iter()
1342                .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1343        {
1344            homes.push(path);
1345        }
1346    };
1347
1348    // Explicit Windows profile exported into the WSL environment.
1349    for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
1350        if let Ok(value) = env::var(key) {
1351            let trimmed = value.trim();
1352            if trimmed.is_empty() {
1353                continue;
1354            }
1355            if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
1356                push_home(PathBuf::from(mnt));
1357            }
1358            // Already a WSL path.
1359            if trimmed.starts_with("/mnt/") {
1360                push_home(PathBuf::from(trimmed));
1361            }
1362        }
1363    }
1364
1365    // `cmd.exe /c echo %USERPROFILE%` is the most reliable user mapping on WSL.
1366    if let Some(profile) = query_windows_userprofile_via_cmd() {
1367        if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
1368            push_home(PathBuf::from(mnt));
1369        }
1370    }
1371
1372    // Heuristic: /mnt/c/Users/<name> matching Linux username or common aliases.
1373    if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
1374        for drive in b'c'..=b'z' {
1375            let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
1376            if !users_dir.is_dir() {
1377                continue;
1378            }
1379            let direct = users_dir.join(&linux_user);
1380            if direct.is_dir() {
1381                push_home(direct);
1382            }
1383            // Case-insensitive scan for a single matching profile when names differ
1384            // only by case (Windows vs Linux username).
1385            if let Ok(entries) = fs::read_dir(&users_dir) {
1386                for entry in entries.flatten() {
1387                    let name = entry.file_name().to_string_lossy().to_string();
1388                    if name.eq_ignore_ascii_case(&linux_user) {
1389                        push_home(entry.path());
1390                    }
1391                }
1392            }
1393            // Only probe C: thoroughly; other drives are rare for profiles.
1394            if drive == b'c' {
1395                break;
1396            }
1397        }
1398    }
1399
1400    homes
1401}
1402
1403#[cfg(not(target_os = "windows"))]
1404fn query_windows_userprofile_via_cmd() -> Option<String> {
1405    // Only attempt on WSL-like environments to avoid hanging on pure Linux.
1406    let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
1407        || env::var_os("WSL_INTEROP").is_some()
1408        || fs::read_to_string("/proc/version")
1409            .map(|content| {
1410                let lower = content.to_ascii_lowercase();
1411                lower.contains("microsoft") || lower.contains("wsl")
1412            })
1413            .unwrap_or(false);
1414    if !is_wsl {
1415        return None;
1416    }
1417
1418    let output = std::process::Command::new("cmd.exe")
1419        .args(["/C", "echo %USERPROFILE%"])
1420        .output()
1421        .ok()?;
1422    if !output.status.success() {
1423        return None;
1424    }
1425    let stdout = String::from_utf8_lossy(&output.stdout);
1426    let profile = stdout
1427        .lines()
1428        .map(str::trim)
1429        .find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
1430        .to_string();
1431    if profile.is_empty() {
1432        None
1433    } else {
1434        Some(profile)
1435    }
1436}
1437
1438#[cfg(target_os = "windows")]
1439fn resolve_windows_home_dir() -> Option<PathBuf> {
1440    dirs::home_dir()
1441        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
1442        .or_else(|| {
1443            let drive = env::var_os("HOMEDRIVE")?;
1444            let path = env::var_os("HOMEPATH")?;
1445            Some(PathBuf::from(drive).join(path))
1446        })
1447}
1448
1449fn windows_drive_letter(path: &Path) -> Option<char> {
1450    let normalized = path.to_string_lossy().replace('/', "\\");
1451    let bytes = normalized.as_bytes();
1452    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1453        Some((bytes[0] as char).to_ascii_uppercase())
1454    } else {
1455        None
1456    }
1457}
1458
1459#[cfg(target_os = "windows")]
1460fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
1461    let normalized = path.to_string_lossy().replace('/', "\\");
1462    let bytes = normalized.as_bytes();
1463    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
1464        let tail = &normalized[3..];
1465        if tail.is_empty() {
1466            Some(PathBuf::new())
1467        } else {
1468            Some(PathBuf::from(tail))
1469        }
1470    } else {
1471        None
1472    }
1473}
1474
1475#[cfg(target_os = "windows")]
1476fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
1477    let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
1478        return Ok(());
1479    };
1480
1481    migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
1482    migrate_legacy_windows_file_if_missing(
1483        &legacy_root.join("versioning-files.yaml"),
1484        &paths.versioning_files_file,
1485    )?;
1486    migrate_legacy_windows_file_if_missing(
1487        &legacy_root.join("package-name-files.yaml"),
1488        &paths.package_name_files_file,
1489    )?;
1490
1491    Ok(())
1492}
1493
1494#[cfg(not(target_os = "windows"))]
1495fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
1496    Ok(())
1497}
1498
1499#[cfg(target_os = "windows")]
1500fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
1501    if to.exists() || !from.exists() {
1502        return Ok(());
1503    }
1504
1505    if let Some(parent) = to.parent() {
1506        fs::create_dir_all(parent).map_err(|e| {
1507            format!(
1508                "Failed to create directory for migrated file {}: {}",
1509                parent.display(),
1510                e
1511            )
1512        })?;
1513    }
1514
1515    fs::copy(from, to).map_err(|e| {
1516        format!(
1517            "Failed to migrate legacy config file {} -> {}: {}",
1518            from.display(),
1519            to.display(),
1520            e
1521        )
1522    })?;
1523
1524    Ok(())
1525}
1526
1527pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
1528    let paths = global_xbp_paths()?;
1529    Ok(vec![
1530        ("root".to_string(), paths.root_dir),
1531        ("config".to_string(), paths.config_file),
1532        ("ssh".to_string(), paths.ssh_dir),
1533        ("cache".to_string(), paths.cache_dir),
1534        ("logs".to_string(), paths.logs_dir),
1535        ("versioning".to_string(), paths.versioning_files_file),
1536        ("package-names".to_string(), paths.package_name_files_file),
1537    ])
1538}
1539
1540pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
1541    let paths = ensure_global_xbp_paths()?;
1542    Ok(paths.versioning_files_file)
1543}
1544
1545pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
1546    let registry_path = sync_versioning_files_registry()?;
1547    let content = fs::read_to_string(&registry_path).map_err(|e| {
1548        format!(
1549            "Failed to read versioning registry {}: {}",
1550            registry_path.display(),
1551            e
1552        )
1553    })?;
1554
1555    let config: VersioningFilesConfig = serde_yaml::from_str(&content)
1556        .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
1557
1558    Ok(config.files)
1559}
1560
1561pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
1562    let paths = ensure_global_xbp_paths()?;
1563    Ok(paths.package_name_files_file)
1564}
1565
1566pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
1567    let registry_path = sync_package_name_files_registry()?;
1568    let content = fs::read_to_string(&registry_path).map_err(|e| {
1569        format!(
1570            "Failed to read package-name registry {}: {}",
1571            registry_path.display(),
1572            e
1573        )
1574    })?;
1575
1576    let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
1577        .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
1578
1579    Ok(config.lookups)
1580}
1581
1582fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
1583    let mut config = if path.exists() {
1584        let content = fs::read_to_string(path).map_err(|e| {
1585            format!(
1586                "Failed to read versioning registry {}: {}",
1587                path.display(),
1588                e
1589            )
1590        })?;
1591        serde_yaml::from_str::<VersioningFilesConfig>(&content)
1592            .unwrap_or_else(|_| VersioningFilesConfig::default())
1593    } else {
1594        VersioningFilesConfig::default()
1595    };
1596
1597    let mut changed = false;
1598    for default_file in default_versioning_files() {
1599        if !config
1600            .files
1601            .iter()
1602            .any(|existing| existing == &default_file)
1603        {
1604            config.files.push(default_file);
1605            changed = true;
1606        }
1607    }
1608
1609    if changed || !path.exists() {
1610        let content = serde_yaml::to_string(&config)
1611            .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
1612        fs::write(path, content).map_err(|e| {
1613            format!(
1614                "Failed to write versioning registry {}: {}",
1615                path.display(),
1616                e
1617            )
1618        })?;
1619    }
1620
1621    Ok(())
1622}
1623
1624fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
1625    let mut config = if path.exists() {
1626        let content = fs::read_to_string(path).map_err(|e| {
1627            format!(
1628                "Failed to read package-name registry {}: {}",
1629                path.display(),
1630                e
1631            )
1632        })?;
1633        serde_yaml::from_str::<PackageNameFilesConfig>(&content)
1634            .unwrap_or_else(|_| PackageNameFilesConfig::default())
1635    } else {
1636        PackageNameFilesConfig::default()
1637    };
1638
1639    let mut changed = false;
1640    for default_lookup in default_package_name_lookups() {
1641        if !config
1642            .lookups
1643            .iter()
1644            .any(|existing| existing == &default_lookup)
1645        {
1646            config.lookups.push(default_lookup);
1647            changed = true;
1648        }
1649    }
1650
1651    if changed || !path.exists() {
1652        let content = serde_yaml::to_string(&config)
1653            .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
1654        fs::write(path, content).map_err(|e| {
1655            format!(
1656                "Failed to write package-name registry {}: {}",
1657                path.display(),
1658                e
1659            )
1660        })?;
1661    }
1662
1663    Ok(())
1664}
1665
1666fn default_versioning_files() -> Vec<String> {
1667    vec![
1668        "README.md".to_string(),
1669        "openapi.yaml".to_string(),
1670        "openapi.yml".to_string(),
1671        "openapi.json".to_string(),
1672        "package.json".to_string(),
1673        "package-lock.json".to_string(),
1674        "Cargo.toml".to_string(),
1675        "Cargo.lock".to_string(),
1676        "pyproject.toml".to_string(),
1677        "composer.json".to_string(),
1678        "deno.json".to_string(),
1679        "deno.jsonc".to_string(),
1680        "Chart.yaml".to_string(),
1681        "app.json".to_string(),
1682        "manifest.json".to_string(),
1683        "pom.xml".to_string(),
1684        "build.gradle".to_string(),
1685        "build.gradle.kts".to_string(),
1686        "mix.exs".to_string(),
1687        "xbp.yaml".to_string(),
1688        "xbp.yml".to_string(),
1689        "xbp.json".to_string(),
1690        ".xbp/xbp.json".to_string(),
1691        ".xbp/xbp.yaml".to_string(),
1692        ".xbp/xbp.yml".to_string(),
1693    ]
1694}
1695
1696fn default_package_name_lookups() -> Vec<PackageNameLookup> {
1697    vec![
1698        PackageNameLookup {
1699            file: "package.json".to_string(),
1700            format: "json".to_string(),
1701            key: "name".to_string(),
1702            registry: "npm".to_string(),
1703        },
1704        PackageNameLookup {
1705            file: "Cargo.toml".to_string(),
1706            format: "toml".to_string(),
1707            key: "package.name".to_string(),
1708            registry: "crates.io".to_string(),
1709        },
1710    ]
1711}
1712
1713const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
1714
1715/// Simple API configuration for the XBP version endpoints.
1716#[derive(Debug, Clone)]
1717pub struct ApiConfig {
1718    base_url: String,
1719}
1720
1721impl ApiConfig {
1722    /// Load the API configuration from API_XBP_URL, falling back to the default.
1723    pub fn load() -> Self {
1724        let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
1725        Self::from_base_url(&raw_url)
1726    }
1727
1728    pub fn from_env() -> Self {
1729        Self::load()
1730    }
1731
1732    pub fn from_base_url(raw_url: &str) -> Self {
1733        let base_url = Self::normalize_base_url(raw_url);
1734        ApiConfig { base_url }
1735    }
1736
1737    /// Return the normalized base URL that downstream callers should use.
1738    pub fn base_url(&self) -> &str {
1739        &self.base_url
1740    }
1741
1742    /// Build the version query endpoint.
1743    pub fn version_endpoint(&self, project_name: &str) -> String {
1744        format!("{}/version?project_name={}", self.base_url, project_name)
1745    }
1746
1747    /// Build the endpoint that increments a version.
1748    pub fn increment_endpoint(&self) -> String {
1749        format!("{}/version/increment", self.base_url)
1750    }
1751
1752    pub fn web_base_url(&self) -> String {
1753        Self::derive_web_base_url(&self.base_url)
1754    }
1755
1756    pub fn cli_auth_request_endpoint(&self) -> String {
1757        format!("{}/api/cli/auth/request", self.web_base_url())
1758    }
1759
1760    pub fn cli_auth_poll_endpoint(&self) -> String {
1761        format!("{}/api/cli/auth/poll", self.web_base_url())
1762    }
1763
1764    pub fn cli_auth_session_endpoint(&self) -> String {
1765        format!("{}/api/cli/auth/session", self.web_base_url())
1766    }
1767
1768    pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
1769        format!("{}/cli/login/{}", self.web_base_url(), flow_id)
1770    }
1771
1772    pub fn cli_linear_key_endpoint(&self) -> String {
1773        format!("{}/api/cli/linear/key", self.web_base_url())
1774    }
1775
1776    pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
1777        format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
1778    }
1779
1780    pub fn cloudflare_settings_url(&self) -> String {
1781        format!("{}/settings/cloudflare", self.web_base_url())
1782    }
1783
1784    pub fn cli_version_activity_endpoint(&self) -> String {
1785        format!("{}/api/cli/version/activity", self.web_base_url())
1786    }
1787
1788    pub fn cli_cursor_ingest_endpoint(&self) -> String {
1789        format!("{}/api/cli/cursor/ingest", self.web_base_url())
1790    }
1791
1792    pub fn cli_projects_register_endpoint(&self) -> String {
1793        format!("{}/api/cli/projects/register", self.web_base_url())
1794    }
1795
1796    pub fn cli_worktree_mutations_endpoint(&self) -> String {
1797        format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
1798    }
1799
1800    pub fn cli_secrets_sync_endpoint(&self) -> String {
1801        format!("{}/api/cli/secrets/sync", self.web_base_url())
1802    }
1803
1804    pub fn cli_secrets_list_endpoint(&self) -> String {
1805        format!("{}/api/cli/secrets", self.web_base_url())
1806    }
1807
1808    fn normalize_base_url(raw: &str) -> String {
1809        let trimmed = raw.trim();
1810        if trimmed.is_empty() {
1811            return DEFAULT_API_XBP_URL.to_string();
1812        }
1813
1814        let trimmed = trimmed.trim_end_matches('/');
1815        if trimmed.is_empty() {
1816            return DEFAULT_API_XBP_URL.to_string();
1817        }
1818
1819        trimmed.to_string()
1820    }
1821
1822    fn derive_web_base_url(base_url: &str) -> String {
1823        let Ok(mut url) = Url::parse(base_url) else {
1824            return base_url.to_string();
1825        };
1826
1827        if let Some(host) = url.host_str().map(str::to_string) {
1828            if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
1829                let _ = url.set_host(Some("xbp.app"));
1830            } else if let Some(stripped) = host.strip_prefix("api.") {
1831                let _ = url.set_host(Some(stripped));
1832            }
1833        }
1834
1835        url.set_path("");
1836        url.set_query(None);
1837        url.set_fragment(None);
1838
1839        url.to_string().trim_end_matches('/').to_string()
1840    }
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845    use super::{
1846        cloudflare_account_id_from_project_env, default_package_name_lookups,
1847        default_versioning_files, resolve_cloudflare_account_id, resolve_github_oauth2_key,
1848        resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
1849        sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
1850        OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig, VersioningFilesConfig,
1851    };
1852    use crate::openrouter::{
1853        DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1854    };
1855    use std::env;
1856    use std::fs;
1857    use std::path::PathBuf;
1858    use std::time::{SystemTime, UNIX_EPOCH};
1859
1860    fn temp_path(label: &str) -> PathBuf {
1861        let nanos = SystemTime::now()
1862            .duration_since(UNIX_EPOCH)
1863            .expect("time")
1864            .as_nanos();
1865        std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
1866    }
1867
1868    #[test]
1869    fn versioning_registry_defaults_include_core_files() {
1870        let defaults = default_versioning_files();
1871        assert!(defaults.contains(&"README.md".to_string()));
1872        assert!(defaults.contains(&"Cargo.toml".to_string()));
1873        assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
1874    }
1875
1876    #[test]
1877    fn versioning_registry_default_config_populates_files() {
1878        let config = VersioningFilesConfig::default();
1879        assert!(!config.files.is_empty());
1880    }
1881
1882    #[test]
1883    fn versioning_registry_defaults_do_not_contain_duplicates() {
1884        let defaults = default_versioning_files();
1885        let mut deduped = defaults.clone();
1886        deduped.sort();
1887        deduped.dedup();
1888        assert_eq!(defaults.len(), deduped.len());
1889    }
1890
1891    #[test]
1892    fn syncing_registry_creates_file_with_defaults() {
1893        let path = temp_path("defaults");
1894        sync_versioning_files_registry_at(&path).expect("sync");
1895
1896        let content = fs::read_to_string(&path).expect("read");
1897        assert!(content.contains("README.md"));
1898        assert!(content.contains("Cargo.toml"));
1899
1900        let _ = fs::remove_file(path);
1901    }
1902
1903    #[test]
1904    fn syncing_registry_preserves_user_added_entries() {
1905        let path = temp_path("preserve");
1906        fs::write(&path, "files:\n  - custom.file\n").expect("write registry");
1907
1908        sync_versioning_files_registry_at(&path).expect("sync");
1909
1910        let content = fs::read_to_string(&path).expect("read");
1911        assert!(content.contains("custom.file"));
1912        assert!(content.contains("README.md"));
1913
1914        let _ = fs::remove_file(path);
1915    }
1916
1917    #[test]
1918    fn api_config_normalizes_trailing_slashes() {
1919        assert_eq!(
1920            ApiConfig::normalize_base_url("https://api.xbp.app///"),
1921            "https://api.xbp.app".to_string()
1922        );
1923    }
1924
1925    #[test]
1926    fn api_config_uses_default_for_blank_values() {
1927        assert_eq!(
1928            ApiConfig::normalize_base_url("   "),
1929            "https://api.xbp.app".to_string()
1930        );
1931    }
1932
1933    #[test]
1934    fn api_config_builds_version_endpoints() {
1935        let config = ApiConfig {
1936            base_url: "https://api.test.xbp".to_string(),
1937        };
1938        let endpoint = config.version_endpoint("demo");
1939        let increment = config.increment_endpoint();
1940
1941        assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
1942        assert_eq!(increment, "https://api.test.xbp/version/increment");
1943    }
1944
1945    #[test]
1946    fn api_config_exposes_cli_projects_register_endpoint() {
1947        let config = ApiConfig {
1948            base_url: "https://api.xbp.app".to_string(),
1949        };
1950        assert_eq!(
1951            config.cli_projects_register_endpoint(),
1952            "https://xbp.app/api/cli/projects/register"
1953        );
1954    }
1955
1956    #[test]
1957    fn api_config_derives_browser_base_url_from_api_subdomain() {
1958        assert_eq!(
1959            ApiConfig::derive_web_base_url("https://api.xbp.app"),
1960            "https://xbp.app".to_string()
1961        );
1962        assert_eq!(
1963            ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
1964            "https://xbp.app".to_string()
1965        );
1966        assert_eq!(
1967            ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
1968            "https://xbp.app".to_string()
1969        );
1970        assert_eq!(
1971            ApiConfig::derive_web_base_url("https://api.internal.example.com"),
1972            "https://internal.example.com".to_string()
1973        );
1974        assert_eq!(
1975            ApiConfig::derive_web_base_url("http://localhost:3000"),
1976            "http://localhost:3000".to_string()
1977        );
1978    }
1979
1980    #[test]
1981    fn package_lookup_defaults_include_npm_and_crates() {
1982        let defaults = default_package_name_lookups();
1983        assert!(defaults.iter().any(|entry| {
1984            entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
1985        }));
1986        assert!(defaults.iter().any(|entry| {
1987            entry.file == "Cargo.toml"
1988                && entry.registry == "crates.io"
1989                && entry.key == "package.name"
1990        }));
1991    }
1992
1993    #[test]
1994    fn package_lookup_default_config_populates_entries() {
1995        let config = PackageNameFilesConfig::default();
1996        assert!(!config.lookups.is_empty());
1997    }
1998
1999    #[test]
2000    fn syncing_package_lookup_registry_creates_defaults() {
2001        let path = temp_path("package-lookup-defaults");
2002        sync_package_name_files_registry_at(&path).expect("sync");
2003
2004        let content = fs::read_to_string(&path).expect("read");
2005        assert!(content.contains("package.json"));
2006        assert!(content.contains("Cargo.toml"));
2007
2008        let _ = fs::remove_file(path);
2009    }
2010
2011    #[test]
2012    fn syncing_package_lookup_registry_preserves_custom_entries() {
2013        let path = temp_path("package-lookup-custom");
2014        fs::write(
2015            &path,
2016            "lookups:\n  - file: custom.yaml\n    format: yaml\n    key: app.name\n    registry: npm\n",
2017        )
2018        .expect("write package lookup registry");
2019
2020        sync_package_name_files_registry_at(&path).expect("sync");
2021        let content = fs::read_to_string(&path).expect("read");
2022        assert!(content.contains("custom.yaml"));
2023        assert!(content.contains("package.json"));
2024
2025        let _ = fs::remove_file(path);
2026    }
2027
2028    #[test]
2029    fn secret_provider_parses_supported_keys() {
2030        assert_eq!(
2031            SecretProvider::from_key("openrouter"),
2032            Some(SecretProvider::OpenRouter)
2033        );
2034        assert_eq!(
2035            SecretProvider::from_key("github"),
2036            Some(SecretProvider::Github)
2037        );
2038        assert_eq!(
2039            SecretProvider::from_key("linear"),
2040            Some(SecretProvider::Linear)
2041        );
2042        assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
2043        assert_eq!(
2044            SecretProvider::from_key("crates"),
2045            Some(SecretProvider::Crates)
2046        );
2047        assert_eq!(SecretProvider::from_key("unknown"), None);
2048    }
2049
2050    #[test]
2051    fn ssh_config_secret_get_set_roundtrip() {
2052        let mut cfg = SshConfig::new();
2053        cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
2054        cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
2055        cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
2056        cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
2057        cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
2058
2059        assert_eq!(
2060            cfg.get_secret(SecretProvider::OpenRouter),
2061            Some("or-test-123")
2062        );
2063        assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
2064        assert_eq!(
2065            cfg.get_secret(SecretProvider::Linear),
2066            Some("lin_api_test_789")
2067        );
2068        assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
2069        assert_eq!(
2070            cfg.get_secret(SecretProvider::Crates),
2071            Some("crate_test_token")
2072        );
2073        assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
2074    }
2075
2076    #[test]
2077    fn default_openrouter_config_populates_models_and_prompts() {
2078        let config = OpenRouterConfig::default();
2079
2080        assert_eq!(config.commit_model, DEFAULT_MODEL);
2081        assert_eq!(config.release_notes_model, DEFAULT_MODEL);
2082        assert_eq!(
2083            config.commit_system_prompt,
2084            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2085        );
2086        assert_eq!(
2087            config.release_notes_system_prompt,
2088            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2089        );
2090    }
2091
2092    #[test]
2093    fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
2094        let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
2095
2096        assert!(yaml.contains("openrouter:"));
2097        assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
2098        assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
2099        assert!(yaml.contains("commit_system_prompt"));
2100        assert!(yaml.contains("release_notes_system_prompt"));
2101    }
2102
2103    #[test]
2104    fn ssh_config_new_uses_openrouter_defaults() {
2105        let config = SshConfig::new();
2106
2107        assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
2108        assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
2109        assert_eq!(
2110            config.openrouter.commit_system_prompt,
2111            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2112        );
2113        assert_eq!(
2114            config.openrouter.release_notes_system_prompt,
2115            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2116        );
2117    }
2118
2119    #[test]
2120    fn syncing_global_config_backfills_openrouter_generation_defaults() {
2121        let path = temp_path("global-config");
2122        fs::write(
2123            &path,
2124            "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",
2125        )
2126        .expect("write config");
2127
2128        sync_global_config_defaults_at(&path).expect("sync config defaults");
2129
2130        let content = fs::read_to_string(&path).expect("read config");
2131        assert!(content.contains("openrouter:"));
2132        assert!(content.contains("commit_model: openai/gpt-4o-mini"));
2133        assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
2134        assert!(content.contains("commit_system_prompt"));
2135        assert!(content.contains("release_notes_system_prompt"));
2136
2137        let _ = fs::remove_file(path);
2138    }
2139
2140    #[test]
2141    fn resolve_secret_prefers_environment_value() {
2142        std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
2143        std::env::set_var("GITHUB_TOKEN", "env-github");
2144        std::env::set_var("XBP_API_TOKEN", "env-xbp");
2145
2146        assert_eq!(
2147            resolve_openrouter_api_key(),
2148            Some("env-openrouter".to_string())
2149        );
2150        assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
2151        assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
2152
2153        std::env::remove_var("OPENROUTER_API_KEY");
2154        std::env::remove_var("GITHUB_TOKEN");
2155        std::env::remove_var("XBP_API_TOKEN");
2156    }
2157
2158    #[test]
2159    fn resolve_cloudflare_account_id_reads_project_env_files() {
2160        use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
2161
2162        let dir = temp_path("cf-account-env");
2163        fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
2164        fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
2165        fs::write(
2166            dir.join(".env"),
2167            "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
2168        )
2169        .expect("write env");
2170
2171        let previous = env::current_dir().expect("cwd");
2172        env::set_current_dir(&dir).expect("chdir");
2173        for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
2174            env::remove_var(key);
2175        }
2176
2177        assert_eq!(
2178            cloudflare_account_id_from_project_env().as_deref(),
2179            Some("acc-from-project-env")
2180        );
2181        assert_eq!(
2182            resolve_cloudflare_account_id().as_deref(),
2183            Some("acc-from-project-env")
2184        );
2185
2186        env::set_current_dir(previous).expect("restore cwd");
2187        let _ = fs::remove_dir_all(dir);
2188    }
2189}