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