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    /// Preferred Linear team UUID for `xbp linear create` and TODO sync.
71    #[serde(default)]
72    pub default_team_id: Option<String>,
73    /// Preferred Linear team key (e.g. `XLX`) when `default_team_id` is unset.
74    #[serde(default)]
75    pub default_team_key: Option<String>,
76    /// Linear organization slug used when building human-readable initiative URLs.
77    #[serde(default, alias = "org_name")]
78    pub organization_name: Option<String>,
79}
80
81/// Project/global automation settings for `xbp todos scan|sync`.
82///
83/// Project (`.xbp/xbp.yaml`) overrides global (`~/.xbp/config.yaml`).
84///
85/// ```yaml
86/// todos:
87///   default_to: both          # linear | github | both
88///   auto_yes: true            # skip multi-select when syncing
89///   prompt_sync_after_scan: true
90///   linear_labels: [xbp-todo, tech-debt]
91///   github_labels: [xbp-todo]
92///   linear_assignee: me
93///   kinds: [TODO, FIXME, HACK]
94///   priority_by_kind:
95///     FIXME: 1
96///     HACK: 2
97///     TODO: 3
98///     XXX: 4
99///   annotate_source: false    # stamp `// XLX-99 (#42)` onto source TODO lines after create
100/// ```
101#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
102pub struct TodosConfig {
103    /// Default sync target: `linear`, `github`, or `both`.
104    #[serde(default)]
105    pub default_to: Option<String>,
106    /// When true, `xbp todos sync` files all candidates without multi-select.
107    #[serde(default)]
108    pub auto_yes: Option<bool>,
109    /// After `xbp todos scan`, interactively offer to run sync (default true).
110    #[serde(default)]
111    pub prompt_sync_after_scan: Option<bool>,
112    /// Linear label names applied to created issues (default: `xbp-todo`).
113    #[serde(default)]
114    pub linear_labels: Option<Vec<String>>,
115    /// GitHub label names applied to created issues (default: `xbp-todo`).
116    #[serde(default)]
117    pub github_labels: Option<Vec<String>>,
118    /// Linear assignee for created issues (`me`, name, email, or id).
119    #[serde(default)]
120    pub linear_assignee: Option<String>,
121    /// Only sync these marker kinds (default: all of TODO/FIXME/XXX/HACK).
122    #[serde(default)]
123    pub kinds: Option<Vec<String>>,
124    /// Map marker kind → Linear priority (0–4).
125    #[serde(default)]
126    pub priority_by_kind: Option<BTreeMap<String, i32>>,
127    /// When true, append created issue identifiers onto the source TODO line.
128    #[serde(default)]
129    pub annotate_source: Option<bool>,
130}
131
132/// Client-side filters for `xbp worktree-watch` (never sent to the server).
133///
134/// Configured in the global `~/.xbp/config.yaml` (or platform equivalent):
135///
136/// ```yaml
137/// worktree_watch:
138///   forbidden_paths:
139///     - secrets/
140///     - apps/web/.env.local
141///   forbidden_folders:
142///     - .cache
143///     - coverage
144///   banned_words:
145///     - credential
146///     - private-key
147/// ```
148#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
149pub struct WorktreeWatchConfig {
150    /// Relative path prefixes (repo-root relative). Nested under a watched tree is still blocked.
151    /// Examples: `secrets`, `secrets/`, `apps/web/.turbo`, `**/dist` is NOT glob — use folder names.
152    #[serde(default, alias = "exclude_paths", alias = "forbidden_path")]
153    pub forbidden_paths: Vec<String>,
154    /// Directory / path-segment names blocked anywhere in the relative path.
155    #[serde(default, alias = "exclude_folders", alias = "forbidden_folder")]
156    pub forbidden_folders: Vec<String>,
157    /// Case-insensitive substrings; if any appear in the relative path, the path is ignored.
158    #[serde(default, alias = "forbidden_words", alias = "banned_word")]
159    pub banned_words: Vec<String>,
160}
161
162#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
163pub struct SecretMetadata {
164    #[serde(default)]
165    pub added_at: Option<DateTime<Utc>>,
166}
167
168#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
169pub struct CliAuthState {
170    #[serde(default)]
171    pub user_id: Option<String>,
172    #[serde(default)]
173    pub user_name: Option<String>,
174    #[serde(default)]
175    pub user_email: Option<String>,
176    #[serde(default)]
177    pub token_label: Option<String>,
178    #[serde(default)]
179    pub token_prefix: Option<String>,
180    #[serde(default)]
181    pub token_created_at: Option<DateTime<Utc>>,
182    #[serde(default)]
183    pub token_expires_at: Option<DateTime<Utc>>,
184    #[serde(default)]
185    pub last_verified_at: Option<DateTime<Utc>>,
186}
187
188#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
189pub struct CursorIngestState {
190    #[serde(default)]
191    pub last_status: Option<String>,
192    #[serde(default)]
193    pub last_trigger: Option<String>,
194    #[serde(default)]
195    pub last_mode: Option<String>,
196    #[serde(default)]
197    pub last_attempted_at: Option<DateTime<Utc>>,
198    #[serde(default)]
199    pub last_succeeded_at: Option<DateTime<Utc>>,
200    #[serde(default)]
201    pub last_error: Option<String>,
202    #[serde(default)]
203    pub last_workspace_count: Option<usize>,
204    #[serde(default)]
205    pub last_entry_count: Option<usize>,
206    #[serde(default)]
207    pub last_entries_skipped: Option<usize>,
208}
209
210#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
211pub struct SystemInventoryRefreshState {
212    #[serde(default)]
213    pub last_status: Option<String>,
214    #[serde(default)]
215    pub last_trigger: Option<String>,
216    #[serde(default)]
217    pub last_mode: Option<String>,
218    #[serde(default)]
219    pub last_attempted_at: Option<DateTime<Utc>>,
220    #[serde(default)]
221    pub last_succeeded_at: Option<DateTime<Utc>>,
222    #[serde(default)]
223    pub last_error: Option<String>,
224    #[serde(default)]
225    pub include_cursor: Option<bool>,
226    #[serde(default)]
227    pub refreshed: Option<bool>,
228}
229
230#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
231pub struct ConfiguredRunnerHost {
232    pub runner_host_id: String,
233    #[serde(default)]
234    pub organization_id: Option<String>,
235    #[serde(default)]
236    pub hostname: Option<String>,
237    #[serde(default)]
238    pub platform: Option<RunnerPlatform>,
239    #[serde(default)]
240    pub runtime: Option<RunnerExecutionRuntime>,
241    #[serde(default)]
242    pub labels: Vec<String>,
243    #[serde(default)]
244    pub updated_at: Option<DateTime<Utc>>,
245}
246
247#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
248pub struct RunnerMachineConfig {
249    #[serde(default)]
250    pub runtime_root: Option<String>,
251    #[serde(default)]
252    pub preferred_runtime: Option<RunnerExecutionRuntime>,
253    #[serde(default)]
254    pub hosts: Vec<ConfiguredRunnerHost>,
255}
256
257#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
258pub struct OpenRouterConfig {
259    #[serde(default = "default_openrouter_commit_model")]
260    pub commit_model: String,
261    #[serde(default = "default_openrouter_release_notes_model")]
262    pub release_notes_model: String,
263    #[serde(default = "default_openrouter_commit_system_prompt")]
264    pub commit_system_prompt: String,
265    #[serde(default = "default_openrouter_release_notes_system_prompt")]
266    pub release_notes_system_prompt: String,
267}
268
269impl Default for OpenRouterConfig {
270    fn default() -> Self {
271        Self {
272            commit_model: default_openrouter_commit_model(),
273            release_notes_model: default_openrouter_release_notes_model(),
274            commit_system_prompt: default_openrouter_commit_system_prompt(),
275            release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
276        }
277    }
278}
279
280#[derive(Debug, Serialize, Deserialize, Clone)]
281pub struct SshConfig {
282    pub password: Option<String>,
283    pub username: Option<String>,
284    pub host: Option<String>,
285    pub project_dir: Option<String>,
286    #[serde(default)]
287    pub xbp_api_token: Option<String>,
288    pub openrouter_api_key: Option<String>,
289    pub github_oauth2_key: Option<String>,
290    #[serde(default)]
291    pub cloudflare_api_token: Option<String>,
292    #[serde(default)]
293    pub cloudflare_account_id: Option<String>,
294    pub linear_api_key: Option<String>,
295    #[serde(default)]
296    pub npm_token: Option<String>,
297    #[serde(default)]
298    pub crates_token: Option<String>,
299    #[serde(default)]
300    pub openrouter: OpenRouterConfig,
301    #[serde(default)]
302    pub linear: Option<LinearConfig>,
303    #[serde(default)]
304    pub cli_auth: Option<CliAuthState>,
305    #[serde(default)]
306    pub device: Option<DeviceIdentity>,
307    #[serde(default)]
308    pub secret_metadata: BTreeMap<String, SecretMetadata>,
309    #[serde(default)]
310    pub system_inventory: Option<SystemInventory>,
311    #[serde(default)]
312    pub cursor_ingest: Option<CursorIngestState>,
313    #[serde(default)]
314    pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
315    #[serde(default)]
316    pub dns_inventory: Option<DnsInventoryCache>,
317    #[serde(default)]
318    pub runners: Option<RunnerMachineConfig>,
319    /// Client-side worktree-watch path filters (paths, folders, banned words).
320    #[serde(default)]
321    pub worktree_watch: Option<WorktreeWatchConfig>,
322    /// Defaults for `xbp todos` automation (overridden by project `.xbp/xbp.yaml` `todos:`).
323    #[serde(default)]
324    pub todos: Option<TodosConfig>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum SecretProvider {
329    OpenRouter,
330    Github,
331    Cloudflare,
332    Linear,
333    Npm,
334    Crates,
335}
336
337impl SecretProvider {
338    pub fn from_key(key: &str) -> Option<Self> {
339        match key.trim().to_ascii_lowercase().as_str() {
340            "openrouter" => Some(Self::OpenRouter),
341            "github" => Some(Self::Github),
342            "cloudflare" => Some(Self::Cloudflare),
343            "linear" => Some(Self::Linear),
344            "npm" | "npmjs" => Some(Self::Npm),
345            "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
346            _ => None,
347        }
348    }
349
350    pub fn as_key(&self) -> &'static str {
351        match self {
352            SecretProvider::OpenRouter => "openrouter",
353            SecretProvider::Github => "github",
354            SecretProvider::Cloudflare => "cloudflare",
355            SecretProvider::Linear => "linear",
356            SecretProvider::Npm => "npm",
357            SecretProvider::Crates => "crates",
358        }
359    }
360
361    pub fn config_field(&self) -> &'static str {
362        match self {
363            SecretProvider::OpenRouter => "openrouter_api_key",
364            SecretProvider::Github => "github_oauth2_key",
365            SecretProvider::Cloudflare => "cloudflare_api_token",
366            SecretProvider::Linear => "linear_api_key",
367            SecretProvider::Npm => "npm_token",
368            SecretProvider::Crates => "crates_token",
369        }
370    }
371}
372
373impl Default for SshConfig {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl SshConfig {
380    pub fn new() -> Self {
381        SshConfig {
382            password: None,
383            username: None,
384            host: None,
385            project_dir: None,
386            xbp_api_token: None,
387            openrouter_api_key: None,
388            github_oauth2_key: None,
389            cloudflare_api_token: None,
390            cloudflare_account_id: None,
391            linear_api_key: None,
392            npm_token: None,
393            crates_token: None,
394            openrouter: OpenRouterConfig::default(),
395            linear: None,
396            cli_auth: None,
397            device: None,
398            secret_metadata: BTreeMap::new(),
399            system_inventory: None,
400            cursor_ingest: None,
401            system_inventory_refresh: None,
402            dns_inventory: None,
403            runners: None,
404            worktree_watch: None,
405            todos: None,
406        }
407    }
408
409    /// Load worktree-watch ignore rules from the global config (client-side only).
410    pub fn worktree_watch_config(&self) -> WorktreeWatchConfig {
411        self.worktree_watch.clone().unwrap_or_default()
412    }
413
414    pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
415        match provider {
416            SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
417            SecretProvider::Github => self.github_oauth2_key.as_deref(),
418            SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
419            SecretProvider::Linear => self.linear_api_key.as_deref(),
420            SecretProvider::Npm => self.npm_token.as_deref(),
421            SecretProvider::Crates => self.crates_token.as_deref(),
422        }
423    }
424
425    pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
426        match provider {
427            SecretProvider::OpenRouter => self.openrouter_api_key = value,
428            SecretProvider::Github => self.github_oauth2_key = value,
429            SecretProvider::Cloudflare => self.cloudflare_api_token = value,
430            SecretProvider::Linear => self.linear_api_key = value,
431            SecretProvider::Npm => self.npm_token = value,
432            SecretProvider::Crates => self.crates_token = value,
433        }
434
435        match self.get_secret(provider) {
436            Some(_) => {
437                self.secret_metadata.insert(
438                    provider.as_key().to_string(),
439                    SecretMetadata {
440                        added_at: Some(Utc::now()),
441                    },
442                );
443            }
444            None => {
445                self.secret_metadata.remove(provider.as_key());
446            }
447        }
448    }
449
450    pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
451        self.secret_metadata.get(provider.as_key())
452    }
453
454    pub fn load() -> Result<Self, String> {
455        let config_path = get_config_path();
456        let legacy_path = legacy_config_path();
457        let path_to_read = if config_path.exists() {
458            config_path
459        } else if legacy_path.exists() {
460            legacy_path
461        } else {
462            return Ok(SshConfig::new());
463        };
464
465        let content = fs::read_to_string(&path_to_read)
466            .map_err(|e| format!("Failed to read config file: {}", e))?;
467        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
468    }
469
470    pub fn save(&self) -> Result<(), String> {
471        let config_path = get_config_path();
472        let config_dir = config_path.parent().ok_or("Invalid config path")?;
473        fs::create_dir_all(config_dir)
474            .map_err(|e| format!("Failed to create config directory: {}", e))?;
475
476        let content = serde_yaml::to_string(self)
477            .map_err(|e| format!("Failed to serialize config: {}", e))?;
478        fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
479    }
480}
481
482/// Resolve worktree-watch filters from the global XBP config file (client-side only).
483pub fn resolve_worktree_watch_config() -> WorktreeWatchConfig {
484    SshConfig::load()
485        .ok()
486        .map(|cfg| cfg.worktree_watch_config())
487        .unwrap_or_default()
488}
489
490pub fn global_runner_root_dir() -> Result<PathBuf, String> {
491    let paths = ensure_global_xbp_paths()?;
492    let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
493    if let Some(path) = config
494        .runners
495        .as_ref()
496        .and_then(|runners| runners.runtime_root.as_deref())
497        .map(str::trim)
498        .filter(|value| !value.is_empty())
499    {
500        let candidate = PathBuf::from(path);
501        fs::create_dir_all(&candidate).map_err(|error| {
502            format!(
503                "Failed to create runner runtime root {}: {}",
504                candidate.display(),
505                error
506            )
507        })?;
508        return Ok(candidate);
509    }
510
511    let default_root = paths.root_dir.join("github").join("runners");
512    fs::create_dir_all(&default_root).map_err(|error| {
513        format!(
514            "Failed to create runner runtime root {}: {}",
515            default_root.display(),
516            error
517        )
518    })?;
519    Ok(default_root)
520}
521
522#[derive(Debug, Serialize, Deserialize, Clone)]
523pub struct VersioningFilesConfig {
524    #[serde(default = "default_versioning_files")]
525    pub files: Vec<String>,
526}
527
528impl Default for VersioningFilesConfig {
529    fn default() -> Self {
530        Self {
531            files: default_versioning_files(),
532        }
533    }
534}
535
536#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
537pub struct PackageNameLookup {
538    pub file: String,
539    pub format: String,
540    pub key: String,
541    pub registry: String,
542}
543
544#[derive(Debug, Serialize, Deserialize, Clone)]
545pub struct PackageNameFilesConfig {
546    #[serde(default = "default_package_name_lookups")]
547    pub lookups: Vec<PackageNameLookup>,
548}
549
550impl Default for PackageNameFilesConfig {
551    fn default() -> Self {
552        Self {
553            lookups: default_package_name_lookups(),
554        }
555    }
556}
557
558pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
559    let root_dir = preferred_global_root_dir();
560
561    let paths = GlobalXbpPaths {
562        config_file: root_dir.join("config.yaml"),
563        ssh_dir: root_dir.join("ssh"),
564        cache_dir: root_dir.join("cache"),
565        logs_dir: root_dir.join("logs"),
566        versioning_files_file: root_dir.join("versioning-files.yaml"),
567        package_name_files_file: root_dir.join("package-name-files.yaml"),
568        root_dir,
569    };
570
571    for dir in [
572        &paths.root_dir,
573        &paths.ssh_dir,
574        &paths.cache_dir,
575        &paths.logs_dir,
576    ] {
577        fs::create_dir_all(dir)
578            .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
579    }
580
581    maybe_migrate_legacy_windows_files(&paths)?;
582
583    if !paths.config_file.exists() {
584        let default_config = serde_yaml::to_string(&SshConfig::new())
585            .map_err(|e| format!("Failed to serialize default config: {}", e))?;
586        fs::write(&paths.config_file, default_config).map_err(|e| {
587            format!(
588                "Failed to initialize config file {}: {}",
589                paths.config_file.display(),
590                e
591            )
592        })?;
593    }
594    sync_global_config_defaults_at(&paths.config_file)?;
595
596    sync_versioning_files_registry_at(&paths.versioning_files_file)?;
597    sync_package_name_files_registry_at(&paths.package_name_files_file)?;
598
599    Ok(paths)
600}
601
602fn default_openrouter_commit_model() -> String {
603    DEFAULT_MODEL.to_string()
604}
605
606fn default_openrouter_release_notes_model() -> String {
607    DEFAULT_MODEL.to_string()
608}
609
610fn default_openrouter_commit_system_prompt() -> String {
611    DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
612}
613
614fn default_openrouter_release_notes_system_prompt() -> String {
615    DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
616}
617
618const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
619
620fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
621where
622    F: FnOnce(&OpenRouterConfig) -> &str,
623{
624    SshConfig::load()
625        .ok()
626        .map(|cfg| selector(&cfg.openrouter).trim().to_string())
627        .filter(|value| !value.is_empty())
628        .unwrap_or_else(|| fallback.to_string())
629}
630
631fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
632    let content = fs::read_to_string(config_path).map_err(|e| {
633        format!(
634            "Failed to read config file {}: {}",
635            config_path.display(),
636            e
637        )
638    })?;
639
640    if content.contains("openrouter:")
641        && content.contains("commit_model:")
642        && content.contains("release_notes_model:")
643        && content.contains("commit_system_prompt:")
644        && content.contains("release_notes_system_prompt:")
645    {
646        return Ok(());
647    }
648
649    let config: SshConfig = serde_yaml::from_str(&content)
650        .map_err(|e| format!("Failed to parse config file: {}", e))?;
651    let normalized =
652        serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
653
654    if normalized != content {
655        fs::write(config_path, normalized).map_err(|e| {
656            format!(
657                "Failed to write config file {}: {}",
658                config_path.display(),
659                e
660            )
661        })?;
662    }
663
664    Ok(())
665}
666
667pub fn resolve_openrouter_api_key() -> Option<String> {
668    env::var("OPENROUTER_API_KEY")
669        .ok()
670        .map(|value| value.trim().to_string())
671        .filter(|value| !value.is_empty())
672        .or_else(|| {
673            SshConfig::load()
674                .ok()
675                .and_then(|cfg| {
676                    cfg.get_secret(SecretProvider::OpenRouter)
677                        .map(str::to_string)
678                })
679                .map(|value| value.trim().to_string())
680                .filter(|value| !value.is_empty())
681        })
682}
683
684pub fn resolve_openrouter_commit_model() -> String {
685    resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
686}
687
688pub fn resolve_openrouter_release_notes_model() -> String {
689    resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
690}
691
692pub fn resolve_openrouter_commit_system_prompt() -> String {
693    resolve_openrouter_config_value(
694        |cfg| cfg.commit_system_prompt.as_str(),
695        DEFAULT_COMMIT_SYSTEM_PROMPT,
696    )
697}
698
699pub fn resolve_openrouter_release_notes_system_prompt() -> String {
700    resolve_openrouter_config_value(
701        |cfg| cfg.release_notes_system_prompt.as_str(),
702        DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
703    )
704}
705
706pub fn resolve_xbp_api_token() -> Option<String> {
707    env::var("XBP_API_TOKEN")
708        .ok()
709        .map(|value| value.trim().to_string())
710        .filter(|value| !value.is_empty())
711        .or_else(|| {
712            SshConfig::load()
713                .ok()
714                .and_then(|cfg| cfg.xbp_api_token)
715                .map(|value| value.trim().to_string())
716                .filter(|value| !value.is_empty())
717        })
718}
719
720pub fn resolve_github_oauth2_key() -> Option<String> {
721    for env_var in [
722        "GITHUB_TOKEN",
723        "GITHUB_OAUTH2_KEY",
724        "GITHUB_OAUTH2_TOKEN",
725        "GITHUB_OAUTH_TOKEN",
726    ] {
727        if let Ok(value) = env::var(env_var) {
728            let token = value.trim();
729            if !token.is_empty() {
730                return Some(token.to_string());
731            }
732        }
733    }
734
735    SshConfig::load()
736        .ok()
737        .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
738        .map(|value| value.trim().to_string())
739        .filter(|value| !value.is_empty())
740}
741
742pub fn resolve_cloudflare_api_token() -> Option<String> {
743    env::var("CLOUDFLARE_API_TOKEN")
744        .ok()
745        .map(|value| value.trim().to_string())
746        .filter(|value| !value.is_empty())
747        .or_else(|| {
748            SshConfig::load()
749                .ok()
750                .and_then(|cfg| {
751                    cfg.get_secret(SecretProvider::Cloudflare)
752                        .map(str::to_string)
753                })
754                .map(|value| value.trim().to_string())
755                .filter(|value| !value.is_empty())
756        })
757}
758
759pub fn cloudflare_account_id_from_process_env() -> Option<String> {
760    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
761        env::var(key)
762            .ok()
763            .map(|value| value.trim().to_string())
764            .filter(|value| !value.is_empty())
765    })
766}
767
768pub fn cloudflare_account_id_from_project_env() -> Option<String> {
769    let current_dir = env::current_dir().ok()?;
770    let found = find_xbp_config_upwards(&current_dir)?;
771    let lookup = load_env_lookup(&found.project_root);
772    first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
773}
774
775pub fn cloudflare_account_id_from_global_config() -> Option<String> {
776    SshConfig::load()
777        .ok()
778        .and_then(|cfg| cfg.cloudflare_account_id)
779        .map(|value| value.trim().to_string())
780        .filter(|value| !value.is_empty())
781}
782
783pub fn resolve_cloudflare_account_id() -> Option<String> {
784    cloudflare_account_id_from_process_env()
785        .or_else(cloudflare_account_id_from_project_env)
786        .or_else(cloudflare_account_id_from_global_config)
787}
788
789pub fn resolve_linear_api_key() -> Option<String> {
790    SshConfig::load()
791        .ok()
792        .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
793        .map(|value| value.trim().to_string())
794        .filter(|value| !value.is_empty())
795}
796
797pub fn resolve_npm_token() -> Option<String> {
798    for env_var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] {
799        if let Ok(value) = env::var(env_var) {
800            let token = value.trim();
801            if !token.is_empty() {
802                return Some(token.to_string());
803            }
804        }
805    }
806
807    SshConfig::load()
808        .ok()
809        .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
810        .map(|value| value.trim().to_string())
811        .filter(|value| !value.is_empty())
812}
813
814pub fn resolve_crates_token() -> Option<String> {
815    for env_var in ["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"] {
816        if let Ok(value) = env::var(env_var) {
817            let token = value.trim();
818            if !token.is_empty() {
819                return Some(token.to_string());
820            }
821        }
822    }
823
824    SshConfig::load()
825        .ok()
826        .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
827        .map(|value| value.trim().to_string())
828        .filter(|value| !value.is_empty())
829}
830
831pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
832    let mut config = SshConfig::load()?;
833    config.cloudflare_account_id = value;
834    config.save()
835}
836
837pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
838    Ok(SshConfig::load()?.cloudflare_account_id)
839}
840
841pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
842    SshConfig::load()
843        .ok()
844        .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
845}
846
847pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
848    ensure_global_xbp_paths()?;
849    let mut config = SshConfig::load()?;
850    if let Some(device) = config.device.clone() {
851        if !device.hardware_id.trim().is_empty() {
852            return Ok(device);
853        }
854    }
855
856    let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
857    let device = DeviceIdentity {
858        hardware_id,
859        created_at: Some(Utc::now()),
860    };
861    config.device = Some(device.clone());
862    config.save()?;
863    Ok(device)
864}
865
866pub fn reserve_cursor_ingest_slot(
867    trigger: &str,
868    mode: &str,
869    min_interval: ChronoDuration,
870) -> Result<bool, String> {
871    ensure_global_xbp_paths()?;
872    let mut config = SshConfig::load()?;
873    let now = Utc::now();
874
875    if let Some(state) = config.cursor_ingest.as_ref() {
876        if let Some(last_attempted_at) = state.last_attempted_at {
877            if now - last_attempted_at < min_interval {
878                return Ok(false);
879            }
880        }
881    }
882
883    let mut state = config.cursor_ingest.unwrap_or_default();
884    state.last_status = Some("scheduled".to_string());
885    state.last_trigger = Some(trigger.to_string());
886    state.last_mode = Some(mode.to_string());
887    state.last_attempted_at = Some(now);
888    state.last_error = None;
889    config.cursor_ingest = Some(state);
890    config.save()?;
891    Ok(true)
892}
893
894pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
895    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
896    let mut state = config.cursor_ingest.unwrap_or_default();
897    state.last_status = Some("running".to_string());
898    state.last_trigger = Some(trigger.to_string());
899    state.last_mode = Some(mode.to_string());
900    state.last_attempted_at = Some(Utc::now());
901    state.last_error = None;
902    config.cursor_ingest = Some(state);
903    config.save()
904}
905
906pub fn record_cursor_ingest_success(
907    trigger: &str,
908    mode: &str,
909    workspace_count: usize,
910    entry_count: usize,
911    entries_skipped: usize,
912) -> Result<(), String> {
913    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
914    let mut state = config.cursor_ingest.unwrap_or_default();
915    let completed_at = Utc::now();
916    state.last_status = Some("success".to_string());
917    state.last_trigger = Some(trigger.to_string());
918    state.last_mode = Some(mode.to_string());
919    state.last_attempted_at = Some(completed_at);
920    state.last_succeeded_at = Some(completed_at);
921    state.last_error = None;
922    state.last_workspace_count = Some(workspace_count);
923    state.last_entry_count = Some(entry_count);
924    state.last_entries_skipped = Some(entries_skipped);
925    config.cursor_ingest = Some(state);
926    config.save()
927}
928
929pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
930    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
931    let mut state = config.cursor_ingest.unwrap_or_default();
932    state.last_status = Some("failed".to_string());
933    state.last_trigger = Some(trigger.to_string());
934    state.last_mode = Some(mode.to_string());
935    state.last_attempted_at = Some(Utc::now());
936    state.last_error = Some(error.chars().take(400).collect());
937    config.cursor_ingest = Some(state);
938    config.save()
939}
940
941pub fn reserve_system_inventory_refresh_slot(
942    trigger: &str,
943    mode: &str,
944    include_cursor: bool,
945    min_interval: ChronoDuration,
946) -> Result<bool, String> {
947    ensure_global_xbp_paths()?;
948    let mut config = SshConfig::load()?;
949    let now = Utc::now();
950
951    if let Some(existing) = config.system_inventory.as_ref() {
952        if !system_inventory_needs_refresh(existing, include_cursor) {
953            return Ok(false);
954        }
955    }
956
957    if let Some(state) = config.system_inventory_refresh.as_ref() {
958        if let Some(last_attempted_at) = state.last_attempted_at {
959            if now - last_attempted_at < min_interval {
960                return Ok(false);
961            }
962        }
963    }
964
965    let mut state = config.system_inventory_refresh.unwrap_or_default();
966    state.last_status = Some("scheduled".to_string());
967    state.last_trigger = Some(trigger.to_string());
968    state.last_mode = Some(mode.to_string());
969    state.last_attempted_at = Some(now);
970    state.last_error = None;
971    state.include_cursor = Some(include_cursor);
972    config.system_inventory_refresh = Some(state);
973    config.save()?;
974    Ok(true)
975}
976
977pub fn record_system_inventory_refresh_started(
978    trigger: &str,
979    mode: &str,
980    include_cursor: bool,
981) -> Result<(), String> {
982    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
983    let mut state = config.system_inventory_refresh.unwrap_or_default();
984    state.last_status = Some("running".to_string());
985    state.last_trigger = Some(trigger.to_string());
986    state.last_mode = Some(mode.to_string());
987    state.last_attempted_at = Some(Utc::now());
988    state.last_error = None;
989    state.include_cursor = Some(include_cursor);
990    config.system_inventory_refresh = Some(state);
991    config.save()
992}
993
994pub fn record_system_inventory_refresh_success(
995    trigger: &str,
996    mode: &str,
997    include_cursor: bool,
998    refreshed: bool,
999) -> Result<(), String> {
1000    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1001    let mut state = config.system_inventory_refresh.unwrap_or_default();
1002    let completed_at = Utc::now();
1003    state.last_status = Some("success".to_string());
1004    state.last_trigger = Some(trigger.to_string());
1005    state.last_mode = Some(mode.to_string());
1006    state.last_attempted_at = Some(completed_at);
1007    state.last_succeeded_at = Some(completed_at);
1008    state.last_error = None;
1009    state.include_cursor = Some(include_cursor);
1010    state.refreshed = Some(refreshed);
1011    config.system_inventory_refresh = Some(state);
1012    config.save()
1013}
1014
1015pub fn record_system_inventory_refresh_failure(
1016    trigger: &str,
1017    mode: &str,
1018    include_cursor: bool,
1019    error: &str,
1020) -> Result<(), String> {
1021    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1022    let mut state = config.system_inventory_refresh.unwrap_or_default();
1023    state.last_status = Some("failed".to_string());
1024    state.last_trigger = Some(trigger.to_string());
1025    state.last_mode = Some(mode.to_string());
1026    state.last_attempted_at = Some(Utc::now());
1027    state.last_error = Some(error.chars().take(400).collect());
1028    state.include_cursor = Some(include_cursor);
1029    config.system_inventory_refresh = Some(state);
1030    config.save()
1031}
1032
1033pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
1034where
1035    F: FnOnce(&mut DnsInventoryCache),
1036{
1037    ensure_global_xbp_paths()?;
1038    let mut config = SshConfig::load()?;
1039    let mut cache = config.dns_inventory.unwrap_or_default();
1040    updater(&mut cache);
1041    config.dns_inventory = Some(cache);
1042    config.save()
1043}
1044
1045pub fn sync_system_inventory(
1046    force: bool,
1047    include_cursor: bool,
1048    current_dir: Option<&Path>,
1049) -> Result<SystemInventorySyncResult, String> {
1050    let paths = ensure_global_xbp_paths()?;
1051    let mut config = SshConfig::load()?;
1052
1053    if !force {
1054        if let Some(existing) = config.system_inventory.clone() {
1055            if !system_inventory_needs_refresh(&existing, include_cursor) {
1056                return Ok(SystemInventorySyncResult {
1057                    inventory: existing,
1058                    config_path: paths.config_file,
1059                    refreshed: false,
1060                });
1061            }
1062        }
1063    }
1064
1065    let mut options = SystemInventoryOptions {
1066        include_cursor,
1067        current_dir: current_dir.map(Path::to_path_buf),
1068        xbp_global_root: Some(paths.root_dir.clone()),
1069    };
1070    if options.current_dir.is_none() {
1071        options.current_dir = env::current_dir().ok();
1072    }
1073
1074    let mut inventory = collect_codetime_system_inventory(&options);
1075    if !include_cursor {
1076        if let Some(existing_cursor) = config
1077            .system_inventory
1078            .as_ref()
1079            .and_then(|existing| existing.cursor.clone())
1080        {
1081            inventory.cursor = Some(existing_cursor);
1082        }
1083    }
1084
1085    config.system_inventory = Some(inventory.clone());
1086    config.save()?;
1087
1088    Ok(SystemInventorySyncResult {
1089        inventory,
1090        config_path: paths.config_file,
1091        refreshed: true,
1092    })
1093}
1094
1095fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
1096    if include_cursor && inventory.cursor.is_none() {
1097        return true;
1098    }
1099
1100    match inventory.collected_at {
1101        Some(collected_at) => {
1102            Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
1103        }
1104        None => true,
1105    }
1106}
1107
1108pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
1109    ensure_global_xbp_paths()
1110}
1111
1112pub fn get_config_path() -> PathBuf {
1113    ensure_global_xbp_paths()
1114        .map(|paths| paths.config_file)
1115        .unwrap_or_else(|_| legacy_config_path())
1116}
1117
1118#[cfg(target_os = "windows")]
1119fn legacy_config_path() -> PathBuf {
1120    dirs::config_dir()
1121        .unwrap_or_else(|| PathBuf::from("."))
1122        .join("xbp")
1123        .join("config.yaml")
1124}
1125
1126#[cfg(not(target_os = "windows"))]
1127fn legacy_config_path() -> PathBuf {
1128    dirs::home_dir()
1129        .unwrap_or_else(|| PathBuf::from("."))
1130        .join(".xbp")
1131        .join("config.yaml")
1132}
1133
1134/// Resolve the global `~/.xbp` (or platform equivalent) root.
1135///
1136/// On WSL this prefers an already-established Windows profile root when it is
1137/// reachable via `/mnt/<drive>/Users/...`, so worktree-watch spoils and config
1138/// stay reconcilable with native Windows installs of the same machine.
1139pub fn resolve_global_xbp_root_dir() -> PathBuf {
1140    preferred_global_root_dir()
1141}
1142
1143#[cfg(test)]
1144mod global_root_tests {
1145    use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
1146    use std::path::Path;
1147
1148    #[test]
1149    fn maps_windows_and_wsl_paths_both_ways() {
1150        assert_eq!(
1151            map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
1152            Some("/mnt/c/Users/szymon/.xbp".to_string())
1153        );
1154        assert_eq!(
1155            map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
1156            Some("C:/Users/szymon/.xbp".to_string())
1157        );
1158    }
1159
1160    #[test]
1161    fn scores_missing_root_as_zero() {
1162        assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
1163    }
1164}
1165
1166fn preferred_global_root_dir() -> PathBuf {
1167    if let Some(explicit) = explicit_xbp_home_from_env() {
1168        return explicit;
1169    }
1170
1171    let candidates = collect_global_xbp_root_candidates();
1172    if let Some(best) = pick_established_xbp_root(&candidates) {
1173        return best;
1174    }
1175
1176    // Fresh install defaults.
1177    #[cfg(target_os = "windows")]
1178    {
1179        if let Some(home_dir) = resolve_windows_home_dir() {
1180            let c_drive = Path::new(r"C:\");
1181            if c_drive.exists() {
1182                if windows_drive_letter(&home_dir) == Some('C') {
1183                    return home_dir.join(".xbp");
1184                }
1185                if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
1186                    let c_profile_candidate = c_drive.join(relative_profile_path);
1187                    if c_profile_candidate.exists() {
1188                        return c_profile_candidate.join(".xbp");
1189                    }
1190                }
1191            }
1192            return home_dir.join(".xbp");
1193        }
1194        return dirs::config_dir()
1195            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
1196            .unwrap_or_else(|| PathBuf::from("."))
1197            .join("xbp");
1198    }
1199
1200    #[cfg(not(target_os = "windows"))]
1201    {
1202        // Prefer classic ~/.xbp over ~/.config/xbp so WSL/Linux matches Windows
1203        // layout and worktree-watch spoils stay discoverable.
1204        if let Some(home) = dirs::home_dir() {
1205            return home.join(".xbp");
1206        }
1207        dirs::config_dir()
1208            .unwrap_or_else(|| PathBuf::from("."))
1209            .join("xbp")
1210    }
1211}
1212
1213fn explicit_xbp_home_from_env() -> Option<PathBuf> {
1214    for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
1215        if let Ok(value) = env::var(key) {
1216            let trimmed = value.trim();
1217            if !trimmed.is_empty() {
1218                return Some(PathBuf::from(trimmed));
1219            }
1220        }
1221    }
1222    None
1223}
1224
1225fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
1226    let mut candidates: Vec<PathBuf> = Vec::new();
1227    let mut push = |path: PathBuf| {
1228        if candidates
1229            .iter()
1230            .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1231        {
1232            return;
1233        }
1234        candidates.push(path);
1235    };
1236
1237    if let Some(explicit) = explicit_xbp_home_from_env() {
1238        push(explicit);
1239    }
1240
1241    #[cfg(target_os = "windows")]
1242    {
1243        if let Some(home_dir) = resolve_windows_home_dir() {
1244            push(home_dir.join(".xbp"));
1245            if let Some(relative) = windows_path_without_drive(&home_dir) {
1246                push(Path::new(r"C:\").join(relative).join(".xbp"));
1247            }
1248        }
1249        if let Some(config_dir) = dirs::config_dir() {
1250            push(config_dir.join("xbp"));
1251        }
1252    }
1253
1254    #[cfg(not(target_os = "windows"))]
1255    {
1256        if let Some(home) = dirs::home_dir() {
1257            push(home.join(".xbp"));
1258            push(home.join(".config").join("xbp"));
1259        }
1260        if let Some(config_dir) = dirs::config_dir() {
1261            push(config_dir.join("xbp"));
1262        }
1263
1264        // WSL: discover the Windows user profile .xbp that native Windows uses.
1265        for windows_home in discover_windows_profile_homes_from_unix() {
1266            push(windows_home.join(".xbp"));
1267        }
1268    }
1269
1270    candidates
1271}
1272
1273fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
1274    candidates
1275        .iter()
1276        .map(|path| (score_xbp_root(path), path.clone()))
1277        .filter(|(score, _)| *score > 0)
1278        .max_by(|left, right| left.0.cmp(&right.0).then_with(|| {
1279            // Prefer Windows-drive-backed roots when scores tie (shared WSL/Windows).
1280            let left_windows = is_windows_drive_backed_path(&left.1);
1281            let right_windows = is_windows_drive_backed_path(&right.1);
1282            right_windows.cmp(&left_windows)
1283        }))
1284        .map(|(_, path)| path)
1285}
1286
1287fn score_xbp_root(root: &Path) -> u64 {
1288    if !root.exists() {
1289        return 0;
1290    }
1291
1292    let mut score = 1u64;
1293    if root.join("config.yaml").is_file() {
1294        score += 100;
1295    }
1296    if root.join("config.yml").is_file() {
1297        score += 80;
1298    }
1299    let mutations = root.join("mutations");
1300    if mutations.is_dir() {
1301        score += 50;
1302        score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
1303    }
1304    if root.join("ssh").is_dir() {
1305        score += 10;
1306    }
1307    if root.join("cache").is_dir() {
1308        score += 5;
1309    }
1310    // Strong signal that this is the shared Windows profile used from WSL.
1311    if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
1312        score += 40;
1313    }
1314    score
1315}
1316
1317fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
1318    let Ok(entries) = fs::read_dir(path) else {
1319        return 0;
1320    };
1321    let mut count = 0u64;
1322    for _entry in entries.flatten() {
1323        count += 1;
1324        if count >= cap {
1325            break;
1326        }
1327    }
1328    count
1329}
1330
1331fn is_windows_drive_backed_path(path: &Path) -> bool {
1332    let normalized = path.to_string_lossy().replace('\\', "/");
1333    if cfg!(windows) {
1334        return windows_drive_letter(path).is_some();
1335    }
1336    // WSL mounts of Windows drives: /mnt/c/Users/...
1337    let lower = normalized.to_ascii_lowercase();
1338    lower.starts_with("/mnt/")
1339        && lower.len() >= 6
1340        && lower.as_bytes().get(5).is_some_and(|b| b.is_ascii_alphabetic())
1341}
1342
1343fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
1344    normalize_path_key(left) == normalize_path_key(right)
1345}
1346
1347fn normalize_path_key(path: &Path) -> String {
1348    let mut value = path.to_string_lossy().replace('\\', "/");
1349    // Collapse Windows drive letters to a comparable form with WSL /mnt/<drive>/.
1350    if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
1351        value = mapped;
1352    } else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
1353        // Normalize both directions to the /mnt form for comparison on Unix.
1354        if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
1355            value = mnt;
1356        } else {
1357            value = mapped;
1358        }
1359    }
1360    if cfg!(windows) {
1361        value.to_ascii_lowercase()
1362    } else {
1363        value
1364    }
1365}
1366
1367/// `C:\Users\foo` → `/mnt/c/Users/foo` (for comparison / WSL access).
1368pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
1369    let normalized = path.trim().replace('\\', "/");
1370    let bytes = normalized.as_bytes();
1371    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1372        let drive = (bytes[0] as char).to_ascii_lowercase();
1373        let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
1374        if rest.is_empty() {
1375            return Some(format!("/mnt/{drive}"));
1376        }
1377        return Some(format!("/mnt/{drive}/{rest}"));
1378    }
1379    None
1380}
1381
1382/// `/mnt/c/Users/foo` → `C:/Users/foo`.
1383pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
1384    let normalized = path.trim().replace('\\', "/");
1385    let rest = normalized.strip_prefix("/mnt/")?;
1386    let mut parts = rest.splitn(2, '/');
1387    let drive = parts.next()?.chars().next()?;
1388    if !drive.is_ascii_alphabetic() {
1389        return None;
1390    }
1391    let tail = parts.next().unwrap_or("");
1392    if tail.is_empty() {
1393        Some(format!("{}:", drive.to_ascii_uppercase()))
1394    } else {
1395        Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
1396    }
1397}
1398
1399#[cfg(not(target_os = "windows"))]
1400fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
1401    let mut homes: Vec<PathBuf> = Vec::new();
1402    let mut push_home = |path: PathBuf| {
1403        if path.is_dir()
1404            && !homes
1405                .iter()
1406                .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1407        {
1408            homes.push(path);
1409        }
1410    };
1411
1412    // Explicit Windows profile exported into the WSL environment.
1413    for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
1414        if let Ok(value) = env::var(key) {
1415            let trimmed = value.trim();
1416            if trimmed.is_empty() {
1417                continue;
1418            }
1419            if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
1420                push_home(PathBuf::from(mnt));
1421            }
1422            // Already a WSL path.
1423            if trimmed.starts_with("/mnt/") {
1424                push_home(PathBuf::from(trimmed));
1425            }
1426        }
1427    }
1428
1429    // `cmd.exe /c echo %USERPROFILE%` is the most reliable user mapping on WSL.
1430    if let Some(profile) = query_windows_userprofile_via_cmd() {
1431        if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
1432            push_home(PathBuf::from(mnt));
1433        }
1434    }
1435
1436    // Heuristic: /mnt/c/Users/<name> matching Linux username or common aliases.
1437    if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
1438        for drive in b'c'..=b'z' {
1439            let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
1440            if !users_dir.is_dir() {
1441                continue;
1442            }
1443            let direct = users_dir.join(&linux_user);
1444            if direct.is_dir() {
1445                push_home(direct);
1446            }
1447            // Case-insensitive scan for a single matching profile when names differ
1448            // only by case (Windows vs Linux username).
1449            if let Ok(entries) = fs::read_dir(&users_dir) {
1450                for entry in entries.flatten() {
1451                    let name = entry.file_name().to_string_lossy().to_string();
1452                    if name.eq_ignore_ascii_case(&linux_user) {
1453                        push_home(entry.path());
1454                    }
1455                }
1456            }
1457            // Only probe C: thoroughly; other drives are rare for profiles.
1458            if drive == b'c' {
1459                break;
1460            }
1461        }
1462    }
1463
1464    homes
1465}
1466
1467#[cfg(not(target_os = "windows"))]
1468fn query_windows_userprofile_via_cmd() -> Option<String> {
1469    // Only attempt on WSL-like environments to avoid hanging on pure Linux.
1470    let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
1471        || env::var_os("WSL_INTEROP").is_some()
1472        || fs::read_to_string("/proc/version")
1473            .map(|content| {
1474                let lower = content.to_ascii_lowercase();
1475                lower.contains("microsoft") || lower.contains("wsl")
1476            })
1477            .unwrap_or(false);
1478    if !is_wsl {
1479        return None;
1480    }
1481
1482    let output = std::process::Command::new("cmd.exe")
1483        .args(["/C", "echo %USERPROFILE%"])
1484        .output()
1485        .ok()?;
1486    if !output.status.success() {
1487        return None;
1488    }
1489    let stdout = String::from_utf8_lossy(&output.stdout);
1490    let profile = stdout
1491        .lines()
1492        .map(str::trim)
1493        .find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
1494        .to_string();
1495    if profile.is_empty() {
1496        None
1497    } else {
1498        Some(profile)
1499    }
1500}
1501
1502#[cfg(target_os = "windows")]
1503fn resolve_windows_home_dir() -> Option<PathBuf> {
1504    dirs::home_dir()
1505        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
1506        .or_else(|| {
1507            let drive = env::var_os("HOMEDRIVE")?;
1508            let path = env::var_os("HOMEPATH")?;
1509            Some(PathBuf::from(drive).join(path))
1510        })
1511}
1512
1513fn windows_drive_letter(path: &Path) -> Option<char> {
1514    let normalized = path.to_string_lossy().replace('/', "\\");
1515    let bytes = normalized.as_bytes();
1516    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1517        Some((bytes[0] as char).to_ascii_uppercase())
1518    } else {
1519        None
1520    }
1521}
1522
1523#[cfg(target_os = "windows")]
1524fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
1525    let normalized = path.to_string_lossy().replace('/', "\\");
1526    let bytes = normalized.as_bytes();
1527    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
1528        let tail = &normalized[3..];
1529        if tail.is_empty() {
1530            Some(PathBuf::new())
1531        } else {
1532            Some(PathBuf::from(tail))
1533        }
1534    } else {
1535        None
1536    }
1537}
1538
1539#[cfg(target_os = "windows")]
1540fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
1541    let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
1542        return Ok(());
1543    };
1544
1545    migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
1546    migrate_legacy_windows_file_if_missing(
1547        &legacy_root.join("versioning-files.yaml"),
1548        &paths.versioning_files_file,
1549    )?;
1550    migrate_legacy_windows_file_if_missing(
1551        &legacy_root.join("package-name-files.yaml"),
1552        &paths.package_name_files_file,
1553    )?;
1554
1555    Ok(())
1556}
1557
1558#[cfg(not(target_os = "windows"))]
1559fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
1560    Ok(())
1561}
1562
1563#[cfg(target_os = "windows")]
1564fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
1565    if to.exists() || !from.exists() {
1566        return Ok(());
1567    }
1568
1569    if let Some(parent) = to.parent() {
1570        fs::create_dir_all(parent).map_err(|e| {
1571            format!(
1572                "Failed to create directory for migrated file {}: {}",
1573                parent.display(),
1574                e
1575            )
1576        })?;
1577    }
1578
1579    fs::copy(from, to).map_err(|e| {
1580        format!(
1581            "Failed to migrate legacy config file {} -> {}: {}",
1582            from.display(),
1583            to.display(),
1584            e
1585        )
1586    })?;
1587
1588    Ok(())
1589}
1590
1591pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
1592    let paths = global_xbp_paths()?;
1593    Ok(vec![
1594        ("root".to_string(), paths.root_dir),
1595        ("config".to_string(), paths.config_file),
1596        ("ssh".to_string(), paths.ssh_dir),
1597        ("cache".to_string(), paths.cache_dir),
1598        ("logs".to_string(), paths.logs_dir),
1599        ("versioning".to_string(), paths.versioning_files_file),
1600        ("package-names".to_string(), paths.package_name_files_file),
1601    ])
1602}
1603
1604pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
1605    let paths = ensure_global_xbp_paths()?;
1606    Ok(paths.versioning_files_file)
1607}
1608
1609pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
1610    let registry_path = sync_versioning_files_registry()?;
1611    let content = fs::read_to_string(&registry_path).map_err(|e| {
1612        format!(
1613            "Failed to read versioning registry {}: {}",
1614            registry_path.display(),
1615            e
1616        )
1617    })?;
1618
1619    let config: VersioningFilesConfig = serde_yaml::from_str(&content)
1620        .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
1621
1622    Ok(config.files)
1623}
1624
1625pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
1626    let paths = ensure_global_xbp_paths()?;
1627    Ok(paths.package_name_files_file)
1628}
1629
1630pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
1631    let registry_path = sync_package_name_files_registry()?;
1632    let content = fs::read_to_string(&registry_path).map_err(|e| {
1633        format!(
1634            "Failed to read package-name registry {}: {}",
1635            registry_path.display(),
1636            e
1637        )
1638    })?;
1639
1640    let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
1641        .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
1642
1643    Ok(config.lookups)
1644}
1645
1646fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
1647    let mut config = if path.exists() {
1648        let content = fs::read_to_string(path).map_err(|e| {
1649            format!(
1650                "Failed to read versioning registry {}: {}",
1651                path.display(),
1652                e
1653            )
1654        })?;
1655        serde_yaml::from_str::<VersioningFilesConfig>(&content)
1656            .unwrap_or_else(|_| VersioningFilesConfig::default())
1657    } else {
1658        VersioningFilesConfig::default()
1659    };
1660
1661    let mut changed = false;
1662    for default_file in default_versioning_files() {
1663        if !config
1664            .files
1665            .iter()
1666            .any(|existing| existing == &default_file)
1667        {
1668            config.files.push(default_file);
1669            changed = true;
1670        }
1671    }
1672
1673    if changed || !path.exists() {
1674        let content = serde_yaml::to_string(&config)
1675            .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
1676        fs::write(path, content).map_err(|e| {
1677            format!(
1678                "Failed to write versioning registry {}: {}",
1679                path.display(),
1680                e
1681            )
1682        })?;
1683    }
1684
1685    Ok(())
1686}
1687
1688fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
1689    let mut config = if path.exists() {
1690        let content = fs::read_to_string(path).map_err(|e| {
1691            format!(
1692                "Failed to read package-name registry {}: {}",
1693                path.display(),
1694                e
1695            )
1696        })?;
1697        serde_yaml::from_str::<PackageNameFilesConfig>(&content)
1698            .unwrap_or_else(|_| PackageNameFilesConfig::default())
1699    } else {
1700        PackageNameFilesConfig::default()
1701    };
1702
1703    let mut changed = false;
1704    for default_lookup in default_package_name_lookups() {
1705        if !config
1706            .lookups
1707            .iter()
1708            .any(|existing| existing == &default_lookup)
1709        {
1710            config.lookups.push(default_lookup);
1711            changed = true;
1712        }
1713    }
1714
1715    if changed || !path.exists() {
1716        let content = serde_yaml::to_string(&config)
1717            .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
1718        fs::write(path, content).map_err(|e| {
1719            format!(
1720                "Failed to write package-name registry {}: {}",
1721                path.display(),
1722                e
1723            )
1724        })?;
1725    }
1726
1727    Ok(())
1728}
1729
1730fn default_versioning_files() -> Vec<String> {
1731    vec![
1732        "README.md".to_string(),
1733        "openapi.yaml".to_string(),
1734        "openapi.yml".to_string(),
1735        "openapi.json".to_string(),
1736        "package.json".to_string(),
1737        "package-lock.json".to_string(),
1738        "Cargo.toml".to_string(),
1739        "Cargo.lock".to_string(),
1740        "pyproject.toml".to_string(),
1741        "composer.json".to_string(),
1742        "deno.json".to_string(),
1743        "deno.jsonc".to_string(),
1744        "Chart.yaml".to_string(),
1745        "app.json".to_string(),
1746        "manifest.json".to_string(),
1747        "pom.xml".to_string(),
1748        "build.gradle".to_string(),
1749        "build.gradle.kts".to_string(),
1750        "mix.exs".to_string(),
1751        "xbp.yaml".to_string(),
1752        "xbp.yml".to_string(),
1753        "xbp.json".to_string(),
1754        ".xbp/xbp.json".to_string(),
1755        ".xbp/xbp.yaml".to_string(),
1756        ".xbp/xbp.yml".to_string(),
1757    ]
1758}
1759
1760fn default_package_name_lookups() -> Vec<PackageNameLookup> {
1761    vec![
1762        PackageNameLookup {
1763            file: "package.json".to_string(),
1764            format: "json".to_string(),
1765            key: "name".to_string(),
1766            registry: "npm".to_string(),
1767        },
1768        PackageNameLookup {
1769            file: "Cargo.toml".to_string(),
1770            format: "toml".to_string(),
1771            key: "package.name".to_string(),
1772            registry: "crates.io".to_string(),
1773        },
1774    ]
1775}
1776
1777const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
1778
1779/// Simple API configuration for the XBP version endpoints.
1780#[derive(Debug, Clone)]
1781pub struct ApiConfig {
1782    base_url: String,
1783}
1784
1785impl ApiConfig {
1786    /// Load the API configuration from API_XBP_URL, falling back to the default.
1787    pub fn load() -> Self {
1788        let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
1789        Self::from_base_url(&raw_url)
1790    }
1791
1792    pub fn from_env() -> Self {
1793        Self::load()
1794    }
1795
1796    pub fn from_base_url(raw_url: &str) -> Self {
1797        let base_url = Self::normalize_base_url(raw_url);
1798        ApiConfig { base_url }
1799    }
1800
1801    /// Return the normalized base URL that downstream callers should use.
1802    pub fn base_url(&self) -> &str {
1803        &self.base_url
1804    }
1805
1806    /// Build the version query endpoint.
1807    pub fn version_endpoint(&self, project_name: &str) -> String {
1808        format!("{}/version?project_name={}", self.base_url, project_name)
1809    }
1810
1811    /// Build the endpoint that increments a version.
1812    pub fn increment_endpoint(&self) -> String {
1813        format!("{}/version/increment", self.base_url)
1814    }
1815
1816    pub fn web_base_url(&self) -> String {
1817        Self::derive_web_base_url(&self.base_url)
1818    }
1819
1820    pub fn cli_auth_request_endpoint(&self) -> String {
1821        format!("{}/api/cli/auth/request", self.web_base_url())
1822    }
1823
1824    pub fn cli_auth_poll_endpoint(&self) -> String {
1825        format!("{}/api/cli/auth/poll", self.web_base_url())
1826    }
1827
1828    pub fn cli_auth_session_endpoint(&self) -> String {
1829        format!("{}/api/cli/auth/session", self.web_base_url())
1830    }
1831
1832    pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
1833        format!("{}/cli/login/{}", self.web_base_url(), flow_id)
1834    }
1835
1836    pub fn cli_linear_key_endpoint(&self) -> String {
1837        format!("{}/api/cli/linear/key", self.web_base_url())
1838    }
1839
1840    pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
1841        format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
1842    }
1843
1844    pub fn cloudflare_settings_url(&self) -> String {
1845        format!("{}/settings/cloudflare", self.web_base_url())
1846    }
1847
1848    pub fn cli_version_activity_endpoint(&self) -> String {
1849        format!("{}/api/cli/version/activity", self.web_base_url())
1850    }
1851
1852    pub fn cli_error_report_endpoint(&self) -> String {
1853        format!("{}/api/cli/errors/report", self.web_base_url())
1854    }
1855
1856    pub fn cli_cursor_ingest_endpoint(&self) -> String {
1857        format!("{}/api/cli/cursor/ingest", self.web_base_url())
1858    }
1859
1860    pub fn cli_projects_register_endpoint(&self) -> String {
1861        format!("{}/api/cli/projects/register", self.web_base_url())
1862    }
1863
1864    pub fn cli_worktree_mutations_endpoint(&self) -> String {
1865        format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
1866    }
1867
1868    pub fn cli_secrets_sync_endpoint(&self) -> String {
1869        format!("{}/api/cli/secrets/sync", self.web_base_url())
1870    }
1871
1872    pub fn cli_secrets_list_endpoint(&self) -> String {
1873        format!("{}/api/cli/secrets", self.web_base_url())
1874    }
1875
1876    pub fn cli_openapi_upload_endpoint(&self) -> String {
1877        format!("{}/api/cli/openapi/upload", self.web_base_url())
1878    }
1879
1880    pub fn cli_openapi_list_endpoint(&self) -> String {
1881        format!("{}/api/cli/openapi", self.web_base_url())
1882    }
1883
1884    fn normalize_base_url(raw: &str) -> String {
1885        let trimmed = raw.trim();
1886        if trimmed.is_empty() {
1887            return DEFAULT_API_XBP_URL.to_string();
1888        }
1889
1890        let trimmed = trimmed.trim_end_matches('/');
1891        if trimmed.is_empty() {
1892            return DEFAULT_API_XBP_URL.to_string();
1893        }
1894
1895        trimmed.to_string()
1896    }
1897
1898    fn derive_web_base_url(base_url: &str) -> String {
1899        let Ok(mut url) = Url::parse(base_url) else {
1900            return base_url.to_string();
1901        };
1902
1903        if let Some(host) = url.host_str().map(str::to_string) {
1904            if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
1905                let _ = url.set_host(Some("xbp.app"));
1906            } else if let Some(stripped) = host.strip_prefix("api.") {
1907                let _ = url.set_host(Some(stripped));
1908            }
1909        }
1910
1911        url.set_path("");
1912        url.set_query(None);
1913        url.set_fragment(None);
1914
1915        url.to_string().trim_end_matches('/').to_string()
1916    }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::{
1922        cloudflare_account_id_from_project_env, default_package_name_lookups,
1923        default_versioning_files, resolve_cloudflare_account_id, resolve_github_oauth2_key,
1924        resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
1925        sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
1926        OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig, VersioningFilesConfig,
1927    };
1928    use crate::openrouter::{
1929        DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1930    };
1931    use std::env;
1932    use std::fs;
1933    use std::path::PathBuf;
1934    use std::time::{SystemTime, UNIX_EPOCH};
1935
1936    fn temp_path(label: &str) -> PathBuf {
1937        let nanos = SystemTime::now()
1938            .duration_since(UNIX_EPOCH)
1939            .expect("time")
1940            .as_nanos();
1941        std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
1942    }
1943
1944    #[test]
1945    fn versioning_registry_defaults_include_core_files() {
1946        let defaults = default_versioning_files();
1947        assert!(defaults.contains(&"README.md".to_string()));
1948        assert!(defaults.contains(&"Cargo.toml".to_string()));
1949        assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
1950    }
1951
1952    #[test]
1953    fn versioning_registry_default_config_populates_files() {
1954        let config = VersioningFilesConfig::default();
1955        assert!(!config.files.is_empty());
1956    }
1957
1958    #[test]
1959    fn versioning_registry_defaults_do_not_contain_duplicates() {
1960        let defaults = default_versioning_files();
1961        let mut deduped = defaults.clone();
1962        deduped.sort();
1963        deduped.dedup();
1964        assert_eq!(defaults.len(), deduped.len());
1965    }
1966
1967    #[test]
1968    fn syncing_registry_creates_file_with_defaults() {
1969        let path = temp_path("defaults");
1970        sync_versioning_files_registry_at(&path).expect("sync");
1971
1972        let content = fs::read_to_string(&path).expect("read");
1973        assert!(content.contains("README.md"));
1974        assert!(content.contains("Cargo.toml"));
1975
1976        let _ = fs::remove_file(path);
1977    }
1978
1979    #[test]
1980    fn syncing_registry_preserves_user_added_entries() {
1981        let path = temp_path("preserve");
1982        fs::write(&path, "files:\n  - custom.file\n").expect("write registry");
1983
1984        sync_versioning_files_registry_at(&path).expect("sync");
1985
1986        let content = fs::read_to_string(&path).expect("read");
1987        assert!(content.contains("custom.file"));
1988        assert!(content.contains("README.md"));
1989
1990        let _ = fs::remove_file(path);
1991    }
1992
1993    #[test]
1994    fn api_config_normalizes_trailing_slashes() {
1995        assert_eq!(
1996            ApiConfig::normalize_base_url("https://api.xbp.app///"),
1997            "https://api.xbp.app".to_string()
1998        );
1999    }
2000
2001    #[test]
2002    fn api_config_uses_default_for_blank_values() {
2003        assert_eq!(
2004            ApiConfig::normalize_base_url("   "),
2005            "https://api.xbp.app".to_string()
2006        );
2007    }
2008
2009    #[test]
2010    fn api_config_builds_version_endpoints() {
2011        let config = ApiConfig {
2012            base_url: "https://api.test.xbp".to_string(),
2013        };
2014        let endpoint = config.version_endpoint("demo");
2015        let increment = config.increment_endpoint();
2016
2017        assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
2018        assert_eq!(increment, "https://api.test.xbp/version/increment");
2019    }
2020
2021    #[test]
2022    fn api_config_exposes_cli_projects_register_endpoint() {
2023        let config = ApiConfig {
2024            base_url: "https://api.xbp.app".to_string(),
2025        };
2026        assert_eq!(
2027            config.cli_projects_register_endpoint(),
2028            "https://xbp.app/api/cli/projects/register"
2029        );
2030    }
2031
2032    #[test]
2033    fn api_config_derives_browser_base_url_from_api_subdomain() {
2034        assert_eq!(
2035            ApiConfig::derive_web_base_url("https://api.xbp.app"),
2036            "https://xbp.app".to_string()
2037        );
2038        assert_eq!(
2039            ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
2040            "https://xbp.app".to_string()
2041        );
2042        assert_eq!(
2043            ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
2044            "https://xbp.app".to_string()
2045        );
2046        assert_eq!(
2047            ApiConfig::derive_web_base_url("https://api.internal.example.com"),
2048            "https://internal.example.com".to_string()
2049        );
2050        assert_eq!(
2051            ApiConfig::derive_web_base_url("http://localhost:3000"),
2052            "http://localhost:3000".to_string()
2053        );
2054    }
2055
2056    #[test]
2057    fn package_lookup_defaults_include_npm_and_crates() {
2058        let defaults = default_package_name_lookups();
2059        assert!(defaults.iter().any(|entry| {
2060            entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
2061        }));
2062        assert!(defaults.iter().any(|entry| {
2063            entry.file == "Cargo.toml"
2064                && entry.registry == "crates.io"
2065                && entry.key == "package.name"
2066        }));
2067    }
2068
2069    #[test]
2070    fn package_lookup_default_config_populates_entries() {
2071        let config = PackageNameFilesConfig::default();
2072        assert!(!config.lookups.is_empty());
2073    }
2074
2075    #[test]
2076    fn syncing_package_lookup_registry_creates_defaults() {
2077        let path = temp_path("package-lookup-defaults");
2078        sync_package_name_files_registry_at(&path).expect("sync");
2079
2080        let content = fs::read_to_string(&path).expect("read");
2081        assert!(content.contains("package.json"));
2082        assert!(content.contains("Cargo.toml"));
2083
2084        let _ = fs::remove_file(path);
2085    }
2086
2087    #[test]
2088    fn syncing_package_lookup_registry_preserves_custom_entries() {
2089        let path = temp_path("package-lookup-custom");
2090        fs::write(
2091            &path,
2092            "lookups:\n  - file: custom.yaml\n    format: yaml\n    key: app.name\n    registry: npm\n",
2093        )
2094        .expect("write package lookup registry");
2095
2096        sync_package_name_files_registry_at(&path).expect("sync");
2097        let content = fs::read_to_string(&path).expect("read");
2098        assert!(content.contains("custom.yaml"));
2099        assert!(content.contains("package.json"));
2100
2101        let _ = fs::remove_file(path);
2102    }
2103
2104    #[test]
2105    fn secret_provider_parses_supported_keys() {
2106        assert_eq!(
2107            SecretProvider::from_key("openrouter"),
2108            Some(SecretProvider::OpenRouter)
2109        );
2110        assert_eq!(
2111            SecretProvider::from_key("github"),
2112            Some(SecretProvider::Github)
2113        );
2114        assert_eq!(
2115            SecretProvider::from_key("linear"),
2116            Some(SecretProvider::Linear)
2117        );
2118        assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
2119        assert_eq!(
2120            SecretProvider::from_key("crates"),
2121            Some(SecretProvider::Crates)
2122        );
2123        assert_eq!(SecretProvider::from_key("unknown"), None);
2124    }
2125
2126    #[test]
2127    fn ssh_config_secret_get_set_roundtrip() {
2128        let mut cfg = SshConfig::new();
2129        cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
2130        cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
2131        cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
2132        cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
2133        cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
2134
2135        assert_eq!(
2136            cfg.get_secret(SecretProvider::OpenRouter),
2137            Some("or-test-123")
2138        );
2139        assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
2140        assert_eq!(
2141            cfg.get_secret(SecretProvider::Linear),
2142            Some("lin_api_test_789")
2143        );
2144        assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
2145        assert_eq!(
2146            cfg.get_secret(SecretProvider::Crates),
2147            Some("crate_test_token")
2148        );
2149        assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
2150    }
2151
2152    #[test]
2153    fn default_openrouter_config_populates_models_and_prompts() {
2154        let config = OpenRouterConfig::default();
2155
2156        assert_eq!(config.commit_model, DEFAULT_MODEL);
2157        assert_eq!(config.release_notes_model, DEFAULT_MODEL);
2158        assert_eq!(
2159            config.commit_system_prompt,
2160            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2161        );
2162        assert_eq!(
2163            config.release_notes_system_prompt,
2164            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2165        );
2166    }
2167
2168    #[test]
2169    fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
2170        let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
2171
2172        assert!(yaml.contains("openrouter:"));
2173        assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
2174        assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
2175        assert!(yaml.contains("commit_system_prompt"));
2176        assert!(yaml.contains("release_notes_system_prompt"));
2177    }
2178
2179    #[test]
2180    fn ssh_config_new_uses_openrouter_defaults() {
2181        let config = SshConfig::new();
2182
2183        assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
2184        assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
2185        assert_eq!(
2186            config.openrouter.commit_system_prompt,
2187            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2188        );
2189        assert_eq!(
2190            config.openrouter.release_notes_system_prompt,
2191            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2192        );
2193    }
2194
2195    #[test]
2196    fn syncing_global_config_backfills_openrouter_generation_defaults() {
2197        let path = temp_path("global-config");
2198        fs::write(
2199            &path,
2200            "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",
2201        )
2202        .expect("write config");
2203
2204        sync_global_config_defaults_at(&path).expect("sync config defaults");
2205
2206        let content = fs::read_to_string(&path).expect("read config");
2207        assert!(content.contains("openrouter:"));
2208        assert!(content.contains("commit_model: openai/gpt-4o-mini"));
2209        assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
2210        assert!(content.contains("commit_system_prompt"));
2211        assert!(content.contains("release_notes_system_prompt"));
2212
2213        let _ = fs::remove_file(path);
2214    }
2215
2216    #[test]
2217    fn resolve_secret_prefers_environment_value() {
2218        std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
2219        std::env::set_var("GITHUB_TOKEN", "env-github");
2220        std::env::set_var("XBP_API_TOKEN", "env-xbp");
2221
2222        assert_eq!(
2223            resolve_openrouter_api_key(),
2224            Some("env-openrouter".to_string())
2225        );
2226        assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
2227        assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
2228
2229        std::env::remove_var("OPENROUTER_API_KEY");
2230        std::env::remove_var("GITHUB_TOKEN");
2231        std::env::remove_var("XBP_API_TOKEN");
2232    }
2233
2234    #[test]
2235    fn resolve_cloudflare_account_id_reads_project_env_files() {
2236        use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
2237
2238        let dir = temp_path("cf-account-env");
2239        fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
2240        fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
2241        fs::write(
2242            dir.join(".env"),
2243            "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
2244        )
2245        .expect("write env");
2246
2247        let previous = env::current_dir().expect("cwd");
2248        env::set_current_dir(&dir).expect("chdir");
2249        for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
2250            env::remove_var(key);
2251        }
2252
2253        assert_eq!(
2254            cloudflare_account_id_from_project_env().as_deref(),
2255            Some("acc-from-project-env")
2256        );
2257        assert_eq!(
2258            resolve_cloudflare_account_id().as_deref(),
2259            Some("acc-from-project-env")
2260        );
2261
2262        env::set_current_dir(previous).expect("restore cwd");
2263        let _ = fs::remove_dir_all(dir);
2264    }
2265}