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