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}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum SecretProvider {
556    OpenRouter,
557    Github,
558    Cloudflare,
559    Linear,
560    Npm,
561    Crates,
562}
563
564impl SecretProvider {
565    pub fn from_key(key: &str) -> Option<Self> {
566        match key.trim().to_ascii_lowercase().as_str() {
567            "openrouter" => Some(Self::OpenRouter),
568            "github" => Some(Self::Github),
569            "cloudflare" => Some(Self::Cloudflare),
570            "linear" => Some(Self::Linear),
571            "npm" | "npmjs" => Some(Self::Npm),
572            "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
573            _ => None,
574        }
575    }
576
577    pub fn as_key(&self) -> &'static str {
578        match self {
579            SecretProvider::OpenRouter => "openrouter",
580            SecretProvider::Github => "github",
581            SecretProvider::Cloudflare => "cloudflare",
582            SecretProvider::Linear => "linear",
583            SecretProvider::Npm => "npm",
584            SecretProvider::Crates => "crates",
585        }
586    }
587
588    pub fn config_field(&self) -> &'static str {
589        match self {
590            SecretProvider::OpenRouter => "openrouter_api_key",
591            SecretProvider::Github => "github_oauth2_key",
592            SecretProvider::Cloudflare => "cloudflare_api_token",
593            SecretProvider::Linear => "linear_api_key",
594            SecretProvider::Npm => "npm_token",
595            SecretProvider::Crates => "crates_token",
596        }
597    }
598}
599
600impl Default for SshConfig {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606impl SshConfig {
607    pub fn new() -> Self {
608        SshConfig {
609            password: None,
610            username: None,
611            host: None,
612            project_dir: None,
613            xbp_api_token: None,
614            openrouter_api_key: None,
615            github_oauth2_key: None,
616            cloudflare_api_token: None,
617            cloudflare_account_id: None,
618            linear_api_key: None,
619            npm_token: None,
620            crates_token: None,
621            openrouter: OpenRouterConfig::default(),
622            linear: None,
623            cli_auth: None,
624            device: None,
625            secret_metadata: BTreeMap::new(),
626            system_inventory: None,
627            cursor_ingest: None,
628            system_inventory_refresh: None,
629            dns_inventory: None,
630            runners: None,
631            worktree_watch: None,
632            issues: None,
633            todos: None,
634            discord: None,
635        }
636    }
637
638    /// Load worktree-watch ignore rules from the global config (client-side only).
639    pub fn worktree_watch_config(&self) -> WorktreeWatchConfig {
640        self.worktree_watch.clone().unwrap_or_default()
641    }
642
643    pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
644        match provider {
645            SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
646            SecretProvider::Github => self.github_oauth2_key.as_deref(),
647            SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
648            SecretProvider::Linear => self.linear_api_key.as_deref(),
649            SecretProvider::Npm => self.npm_token.as_deref(),
650            SecretProvider::Crates => self.crates_token.as_deref(),
651        }
652    }
653
654    pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
655        match provider {
656            SecretProvider::OpenRouter => self.openrouter_api_key = value,
657            SecretProvider::Github => self.github_oauth2_key = value,
658            SecretProvider::Cloudflare => self.cloudflare_api_token = value,
659            SecretProvider::Linear => self.linear_api_key = value,
660            SecretProvider::Npm => self.npm_token = value,
661            SecretProvider::Crates => self.crates_token = value,
662        }
663
664        match self.get_secret(provider) {
665            Some(_) => {
666                self.secret_metadata.insert(
667                    provider.as_key().to_string(),
668                    SecretMetadata {
669                        added_at: Some(Utc::now()),
670                    },
671                );
672            }
673            None => {
674                self.secret_metadata.remove(provider.as_key());
675            }
676        }
677    }
678
679    pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
680        self.secret_metadata.get(provider.as_key())
681    }
682
683    pub fn load() -> Result<Self, String> {
684        let config_path = get_config_path();
685        let legacy_path = legacy_config_path();
686        let path_to_read = if config_path.exists() {
687            config_path
688        } else if legacy_path.exists() {
689            legacy_path
690        } else {
691            return Ok(SshConfig::new());
692        };
693
694        let content = fs::read_to_string(&path_to_read)
695            .map_err(|e| format!("Failed to read config file: {}", e))?;
696        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
697    }
698
699    pub fn save(&self) -> Result<(), String> {
700        let config_path = get_config_path();
701        let config_dir = config_path.parent().ok_or("Invalid config path")?;
702        fs::create_dir_all(config_dir)
703            .map_err(|e| format!("Failed to create config directory: {}", e))?;
704
705        let content = serde_yaml::to_string(self)
706            .map_err(|e| format!("Failed to serialize config: {}", e))?;
707        fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
708    }
709}
710
711/// Resolve worktree-watch filters from the global XBP config file (client-side only).
712pub fn resolve_worktree_watch_config() -> WorktreeWatchConfig {
713    SshConfig::load()
714        .ok()
715        .map(|cfg| cfg.worktree_watch_config())
716        .unwrap_or_default()
717}
718
719pub fn global_runner_root_dir() -> Result<PathBuf, String> {
720    let paths = ensure_global_xbp_paths()?;
721    let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
722    if let Some(path) = config
723        .runners
724        .as_ref()
725        .and_then(|runners| runners.runtime_root.as_deref())
726        .map(str::trim)
727        .filter(|value| !value.is_empty())
728    {
729        let candidate = PathBuf::from(path);
730        fs::create_dir_all(&candidate).map_err(|error| {
731            format!(
732                "Failed to create runner runtime root {}: {}",
733                candidate.display(),
734                error
735            )
736        })?;
737        return Ok(candidate);
738    }
739
740    let default_root = paths.root_dir.join("github").join("runners");
741    fs::create_dir_all(&default_root).map_err(|error| {
742        format!(
743            "Failed to create runner runtime root {}: {}",
744            default_root.display(),
745            error
746        )
747    })?;
748    Ok(default_root)
749}
750
751#[derive(Debug, Serialize, Deserialize, Clone)]
752pub struct VersioningFilesConfig {
753    #[serde(default = "default_versioning_files")]
754    pub files: Vec<String>,
755}
756
757impl Default for VersioningFilesConfig {
758    fn default() -> Self {
759        Self {
760            files: default_versioning_files(),
761        }
762    }
763}
764
765#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
766pub struct PackageNameLookup {
767    pub file: String,
768    pub format: String,
769    pub key: String,
770    pub registry: String,
771}
772
773#[derive(Debug, Serialize, Deserialize, Clone)]
774pub struct PackageNameFilesConfig {
775    #[serde(default = "default_package_name_lookups")]
776    pub lookups: Vec<PackageNameLookup>,
777}
778
779impl Default for PackageNameFilesConfig {
780    fn default() -> Self {
781        Self {
782            lookups: default_package_name_lookups(),
783        }
784    }
785}
786
787pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
788    let root_dir = preferred_global_root_dir();
789
790    let paths = GlobalXbpPaths {
791        config_file: root_dir.join("config.yaml"),
792        ssh_dir: root_dir.join("ssh"),
793        cache_dir: root_dir.join("cache"),
794        logs_dir: root_dir.join("logs"),
795        versioning_files_file: root_dir.join("versioning-files.yaml"),
796        package_name_files_file: root_dir.join("package-name-files.yaml"),
797        worktree_ignore_file: root_dir.join(GLOBAL_WORKTREE_IGNORE_FILENAME),
798        root_dir,
799    };
800
801    for dir in [
802        &paths.root_dir,
803        &paths.ssh_dir,
804        &paths.cache_dir,
805        &paths.logs_dir,
806    ] {
807        fs::create_dir_all(dir)
808            .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
809    }
810
811    maybe_migrate_legacy_windows_files(&paths)?;
812
813    if !paths.config_file.exists() {
814        let default_config = serde_yaml::to_string(&SshConfig::new())
815            .map_err(|e| format!("Failed to serialize default config: {}", e))?;
816        fs::write(&paths.config_file, default_config).map_err(|e| {
817            format!(
818                "Failed to initialize config file {}: {}",
819                paths.config_file.display(),
820                e
821            )
822        })?;
823    }
824    sync_global_config_defaults_at(&paths.config_file)?;
825
826    sync_versioning_files_registry_at(&paths.versioning_files_file)?;
827    sync_package_name_files_registry_at(&paths.package_name_files_file)?;
828    sync_global_worktree_ignore_file(&paths.worktree_ignore_file)?;
829
830    Ok(paths)
831}
832
833/// Load machine-wide worktree-watch ignore rules from `~/.xbp/.xbp-worktree-ignore`.
834pub fn load_global_worktree_ignore() -> XbpIgnoreSet {
835    match ensure_global_xbp_paths() {
836        Ok(paths) => load_global_worktree_ignore_from_path(&paths.worktree_ignore_file),
837        Err(_) => XbpIgnoreSet::empty(),
838    }
839}
840
841fn default_openrouter_commit_model() -> String {
842    DEFAULT_MODEL.to_string()
843}
844
845fn default_openrouter_release_notes_model() -> String {
846    DEFAULT_MODEL.to_string()
847}
848
849fn default_openrouter_commit_system_prompt() -> String {
850    DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
851}
852
853fn default_openrouter_release_notes_system_prompt() -> String {
854    DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
855}
856
857const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
858
859fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
860where
861    F: FnOnce(&OpenRouterConfig) -> &str,
862{
863    SshConfig::load()
864        .ok()
865        .map(|cfg| selector(&cfg.openrouter).trim().to_string())
866        .filter(|value| !value.is_empty())
867        .unwrap_or_else(|| fallback.to_string())
868}
869
870fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
871    let content = fs::read_to_string(config_path).map_err(|e| {
872        format!(
873            "Failed to read config file {}: {}",
874            config_path.display(),
875            e
876        )
877    })?;
878
879    if content.contains("openrouter:")
880        && content.contains("commit_model:")
881        && content.contains("release_notes_model:")
882        && content.contains("commit_system_prompt:")
883        && content.contains("release_notes_system_prompt:")
884    {
885        return Ok(());
886    }
887
888    let config: SshConfig = serde_yaml::from_str(&content)
889        .map_err(|e| format!("Failed to parse config file: {}", e))?;
890    let normalized =
891        serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
892
893    if normalized != content {
894        fs::write(config_path, normalized).map_err(|e| {
895            format!(
896                "Failed to write config file {}: {}",
897                config_path.display(),
898                e
899            )
900        })?;
901    }
902
903    Ok(())
904}
905
906pub fn resolve_openrouter_api_key() -> Option<String> {
907    env::var("OPENROUTER_API_KEY")
908        .ok()
909        .map(|value| value.trim().to_string())
910        .filter(|value| !value.is_empty())
911        .or_else(|| {
912            SshConfig::load()
913                .ok()
914                .and_then(|cfg| {
915                    cfg.get_secret(SecretProvider::OpenRouter)
916                        .map(str::to_string)
917                })
918                .map(|value| value.trim().to_string())
919                .filter(|value| !value.is_empty())
920        })
921}
922
923pub fn resolve_openrouter_commit_model() -> String {
924    resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
925}
926
927pub fn resolve_openrouter_release_notes_model() -> String {
928    resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
929}
930
931pub fn resolve_openrouter_commit_system_prompt() -> String {
932    resolve_openrouter_config_value(
933        |cfg| cfg.commit_system_prompt.as_str(),
934        DEFAULT_COMMIT_SYSTEM_PROMPT,
935    )
936}
937
938pub fn resolve_openrouter_release_notes_system_prompt() -> String {
939    resolve_openrouter_config_value(
940        |cfg| cfg.release_notes_system_prompt.as_str(),
941        DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
942    )
943}
944
945pub fn resolve_xbp_api_token() -> Option<String> {
946    env::var("XBP_API_TOKEN")
947        .ok()
948        .map(|value| value.trim().to_string())
949        .filter(|value| !value.is_empty())
950        .or_else(|| {
951            SshConfig::load()
952                .ok()
953                .and_then(|cfg| cfg.xbp_api_token)
954                .map(|value| value.trim().to_string())
955                .filter(|value| !value.is_empty())
956        })
957}
958
959pub fn resolve_github_oauth2_key() -> Option<String> {
960    for env_var in [
961        "GITHUB_TOKEN",
962        "GITHUB_OAUTH2_KEY",
963        "GITHUB_OAUTH2_TOKEN",
964        "GITHUB_OAUTH_TOKEN",
965    ] {
966        if let Ok(value) = env::var(env_var) {
967            let token = value.trim();
968            if !token.is_empty() {
969                return Some(token.to_string());
970            }
971        }
972    }
973
974    SshConfig::load()
975        .ok()
976        .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
977        .map(|value| value.trim().to_string())
978        .filter(|value| !value.is_empty())
979}
980
981pub fn resolve_cloudflare_api_token() -> Option<String> {
982    env::var("CLOUDFLARE_API_TOKEN")
983        .ok()
984        .map(|value| value.trim().to_string())
985        .filter(|value| !value.is_empty())
986        .or_else(|| {
987            SshConfig::load()
988                .ok()
989                .and_then(|cfg| {
990                    cfg.get_secret(SecretProvider::Cloudflare)
991                        .map(str::to_string)
992                })
993                .map(|value| value.trim().to_string())
994                .filter(|value| !value.is_empty())
995        })
996}
997
998pub fn cloudflare_account_id_from_process_env() -> Option<String> {
999    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
1000        env::var(key)
1001            .ok()
1002            .map(|value| value.trim().to_string())
1003            .filter(|value| !value.is_empty())
1004    })
1005}
1006
1007pub fn cloudflare_account_id_from_project_env() -> Option<String> {
1008    let current_dir = env::current_dir().ok()?;
1009    let found = find_xbp_config_upwards(&current_dir)?;
1010    let lookup = load_env_lookup(&found.project_root);
1011    first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
1012}
1013
1014pub fn cloudflare_account_id_from_global_config() -> Option<String> {
1015    SshConfig::load()
1016        .ok()
1017        .and_then(|cfg| cfg.cloudflare_account_id)
1018        .map(|value| value.trim().to_string())
1019        .filter(|value| !value.is_empty())
1020}
1021
1022pub fn resolve_cloudflare_account_id() -> Option<String> {
1023    cloudflare_account_id_from_process_env()
1024        .or_else(cloudflare_account_id_from_project_env)
1025        .or_else(cloudflare_account_id_from_global_config)
1026}
1027
1028pub fn resolve_linear_api_key() -> Option<String> {
1029    SshConfig::load()
1030        .ok()
1031        .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
1032        .map(|value| value.trim().to_string())
1033        .filter(|value| !value.is_empty())
1034}
1035
1036pub fn resolve_npm_token() -> Option<String> {
1037    for env_var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] {
1038        if let Ok(value) = env::var(env_var) {
1039            let token = value.trim();
1040            if !token.is_empty() {
1041                return Some(token.to_string());
1042            }
1043        }
1044    }
1045
1046    SshConfig::load()
1047        .ok()
1048        .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
1049        .map(|value| value.trim().to_string())
1050        .filter(|value| !value.is_empty())
1051}
1052
1053pub fn resolve_crates_token() -> Option<String> {
1054    for env_var in ["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"] {
1055        if let Ok(value) = env::var(env_var) {
1056            let token = value.trim();
1057            if !token.is_empty() {
1058                return Some(token.to_string());
1059            }
1060        }
1061    }
1062
1063    SshConfig::load()
1064        .ok()
1065        .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
1066        .map(|value| value.trim().to_string())
1067        .filter(|value| !value.is_empty())
1068}
1069
1070pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
1071    let mut config = SshConfig::load()?;
1072    config.cloudflare_account_id = value;
1073    config.save()
1074}
1075
1076pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
1077    Ok(SshConfig::load()?.cloudflare_account_id)
1078}
1079
1080pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
1081    SshConfig::load()
1082        .ok()
1083        .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
1084}
1085
1086pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
1087    ensure_global_xbp_paths()?;
1088    let mut config = SshConfig::load()?;
1089    if let Some(device) = config.device.clone() {
1090        if !device.hardware_id.trim().is_empty() {
1091            return Ok(device);
1092        }
1093    }
1094
1095    let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
1096    let device = DeviceIdentity {
1097        hardware_id,
1098        created_at: Some(Utc::now()),
1099    };
1100    config.device = Some(device.clone());
1101    config.save()?;
1102    Ok(device)
1103}
1104
1105pub fn reserve_cursor_ingest_slot(
1106    trigger: &str,
1107    mode: &str,
1108    min_interval: ChronoDuration,
1109) -> Result<bool, String> {
1110    ensure_global_xbp_paths()?;
1111    let mut config = SshConfig::load()?;
1112    let now = Utc::now();
1113
1114    if let Some(state) = config.cursor_ingest.as_ref() {
1115        if let Some(last_attempted_at) = state.last_attempted_at {
1116            if now - last_attempted_at < min_interval {
1117                return Ok(false);
1118            }
1119        }
1120    }
1121
1122    let mut state = config.cursor_ingest.unwrap_or_default();
1123    state.last_status = Some("scheduled".to_string());
1124    state.last_trigger = Some(trigger.to_string());
1125    state.last_mode = Some(mode.to_string());
1126    state.last_attempted_at = Some(now);
1127    state.last_error = None;
1128    config.cursor_ingest = Some(state);
1129    config.save()?;
1130    Ok(true)
1131}
1132
1133pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
1134    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1135    let mut state = config.cursor_ingest.unwrap_or_default();
1136    state.last_status = Some("running".to_string());
1137    state.last_trigger = Some(trigger.to_string());
1138    state.last_mode = Some(mode.to_string());
1139    state.last_attempted_at = Some(Utc::now());
1140    state.last_error = None;
1141    config.cursor_ingest = Some(state);
1142    config.save()
1143}
1144
1145pub fn record_cursor_ingest_success(
1146    trigger: &str,
1147    mode: &str,
1148    workspace_count: usize,
1149    entry_count: usize,
1150    entries_skipped: usize,
1151) -> Result<(), String> {
1152    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1153    let mut state = config.cursor_ingest.unwrap_or_default();
1154    let completed_at = Utc::now();
1155    state.last_status = Some("success".to_string());
1156    state.last_trigger = Some(trigger.to_string());
1157    state.last_mode = Some(mode.to_string());
1158    state.last_attempted_at = Some(completed_at);
1159    state.last_succeeded_at = Some(completed_at);
1160    state.last_error = None;
1161    state.last_workspace_count = Some(workspace_count);
1162    state.last_entry_count = Some(entry_count);
1163    state.last_entries_skipped = Some(entries_skipped);
1164    config.cursor_ingest = Some(state);
1165    config.save()
1166}
1167
1168pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
1169    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1170    let mut state = config.cursor_ingest.unwrap_or_default();
1171    state.last_status = Some("failed".to_string());
1172    state.last_trigger = Some(trigger.to_string());
1173    state.last_mode = Some(mode.to_string());
1174    state.last_attempted_at = Some(Utc::now());
1175    state.last_error = Some(error.chars().take(400).collect());
1176    config.cursor_ingest = Some(state);
1177    config.save()
1178}
1179
1180pub fn reserve_system_inventory_refresh_slot(
1181    trigger: &str,
1182    mode: &str,
1183    include_cursor: bool,
1184    min_interval: ChronoDuration,
1185) -> Result<bool, String> {
1186    ensure_global_xbp_paths()?;
1187    let mut config = SshConfig::load()?;
1188    let now = Utc::now();
1189
1190    if let Some(existing) = config.system_inventory.as_ref() {
1191        if !system_inventory_needs_refresh(existing, include_cursor) {
1192            return Ok(false);
1193        }
1194    }
1195
1196    if let Some(state) = config.system_inventory_refresh.as_ref() {
1197        if let Some(last_attempted_at) = state.last_attempted_at {
1198            if now - last_attempted_at < min_interval {
1199                return Ok(false);
1200            }
1201        }
1202    }
1203
1204    let mut state = config.system_inventory_refresh.unwrap_or_default();
1205    state.last_status = Some("scheduled".to_string());
1206    state.last_trigger = Some(trigger.to_string());
1207    state.last_mode = Some(mode.to_string());
1208    state.last_attempted_at = Some(now);
1209    state.last_error = None;
1210    state.include_cursor = Some(include_cursor);
1211    config.system_inventory_refresh = Some(state);
1212    config.save()?;
1213    Ok(true)
1214}
1215
1216pub fn record_system_inventory_refresh_started(
1217    trigger: &str,
1218    mode: &str,
1219    include_cursor: bool,
1220) -> Result<(), String> {
1221    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1222    let mut state = config.system_inventory_refresh.unwrap_or_default();
1223    state.last_status = Some("running".to_string());
1224    state.last_trigger = Some(trigger.to_string());
1225    state.last_mode = Some(mode.to_string());
1226    state.last_attempted_at = Some(Utc::now());
1227    state.last_error = None;
1228    state.include_cursor = Some(include_cursor);
1229    config.system_inventory_refresh = Some(state);
1230    config.save()
1231}
1232
1233pub fn record_system_inventory_refresh_success(
1234    trigger: &str,
1235    mode: &str,
1236    include_cursor: bool,
1237    refreshed: bool,
1238) -> Result<(), String> {
1239    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1240    let mut state = config.system_inventory_refresh.unwrap_or_default();
1241    let completed_at = Utc::now();
1242    state.last_status = Some("success".to_string());
1243    state.last_trigger = Some(trigger.to_string());
1244    state.last_mode = Some(mode.to_string());
1245    state.last_attempted_at = Some(completed_at);
1246    state.last_succeeded_at = Some(completed_at);
1247    state.last_error = None;
1248    state.include_cursor = Some(include_cursor);
1249    state.refreshed = Some(refreshed);
1250    config.system_inventory_refresh = Some(state);
1251    config.save()
1252}
1253
1254pub fn record_system_inventory_refresh_failure(
1255    trigger: &str,
1256    mode: &str,
1257    include_cursor: bool,
1258    error: &str,
1259) -> Result<(), String> {
1260    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1261    let mut state = config.system_inventory_refresh.unwrap_or_default();
1262    state.last_status = Some("failed".to_string());
1263    state.last_trigger = Some(trigger.to_string());
1264    state.last_mode = Some(mode.to_string());
1265    state.last_attempted_at = Some(Utc::now());
1266    state.last_error = Some(error.chars().take(400).collect());
1267    state.include_cursor = Some(include_cursor);
1268    config.system_inventory_refresh = Some(state);
1269    config.save()
1270}
1271
1272pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
1273where
1274    F: FnOnce(&mut DnsInventoryCache),
1275{
1276    ensure_global_xbp_paths()?;
1277    let mut config = SshConfig::load()?;
1278    let mut cache = config.dns_inventory.unwrap_or_default();
1279    updater(&mut cache);
1280    config.dns_inventory = Some(cache);
1281    config.save()
1282}
1283
1284pub fn sync_system_inventory(
1285    force: bool,
1286    include_cursor: bool,
1287    current_dir: Option<&Path>,
1288) -> Result<SystemInventorySyncResult, String> {
1289    let paths = ensure_global_xbp_paths()?;
1290    let mut config = SshConfig::load()?;
1291
1292    if !force {
1293        if let Some(existing) = config.system_inventory.clone() {
1294            if !system_inventory_needs_refresh(&existing, include_cursor) {
1295                return Ok(SystemInventorySyncResult {
1296                    inventory: existing,
1297                    config_path: paths.config_file,
1298                    refreshed: false,
1299                });
1300            }
1301        }
1302    }
1303
1304    let mut options = SystemInventoryOptions {
1305        include_cursor,
1306        current_dir: current_dir.map(Path::to_path_buf),
1307        xbp_global_root: Some(paths.root_dir.clone()),
1308    };
1309    if options.current_dir.is_none() {
1310        options.current_dir = env::current_dir().ok();
1311    }
1312
1313    let mut inventory = collect_codetime_system_inventory(&options);
1314    if !include_cursor {
1315        if let Some(existing_cursor) = config
1316            .system_inventory
1317            .as_ref()
1318            .and_then(|existing| existing.cursor.clone())
1319        {
1320            inventory.cursor = Some(existing_cursor);
1321        }
1322    }
1323
1324    config.system_inventory = Some(inventory.clone());
1325    config.save()?;
1326
1327    Ok(SystemInventorySyncResult {
1328        inventory,
1329        config_path: paths.config_file,
1330        refreshed: true,
1331    })
1332}
1333
1334fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
1335    if include_cursor && inventory.cursor.is_none() {
1336        return true;
1337    }
1338
1339    match inventory.collected_at {
1340        Some(collected_at) => {
1341            Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
1342        }
1343        None => true,
1344    }
1345}
1346
1347pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
1348    ensure_global_xbp_paths()
1349}
1350
1351pub fn get_config_path() -> PathBuf {
1352    ensure_global_xbp_paths()
1353        .map(|paths| paths.config_file)
1354        .unwrap_or_else(|_| legacy_config_path())
1355}
1356
1357#[cfg(target_os = "windows")]
1358fn legacy_config_path() -> PathBuf {
1359    dirs::config_dir()
1360        .unwrap_or_else(|| PathBuf::from("."))
1361        .join("xbp")
1362        .join("config.yaml")
1363}
1364
1365#[cfg(not(target_os = "windows"))]
1366fn legacy_config_path() -> PathBuf {
1367    dirs::home_dir()
1368        .unwrap_or_else(|| PathBuf::from("."))
1369        .join(".xbp")
1370        .join("config.yaml")
1371}
1372
1373/// Resolve the global `~/.xbp` (or platform equivalent) root.
1374///
1375/// On WSL this prefers an already-established Windows profile root when it is
1376/// reachable via `/mnt/<drive>/Users/...`, so worktree-watch spoils and config
1377/// stay reconcilable with native Windows installs of the same machine.
1378pub fn resolve_global_xbp_root_dir() -> PathBuf {
1379    preferred_global_root_dir()
1380}
1381
1382#[cfg(test)]
1383mod global_root_tests {
1384    use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
1385    use std::path::Path;
1386
1387    #[test]
1388    fn maps_windows_and_wsl_paths_both_ways() {
1389        assert_eq!(
1390            map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
1391            Some("/mnt/c/Users/szymon/.xbp".to_string())
1392        );
1393        assert_eq!(
1394            map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
1395            Some("C:/Users/szymon/.xbp".to_string())
1396        );
1397    }
1398
1399    #[test]
1400    fn scores_missing_root_as_zero() {
1401        assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
1402    }
1403}
1404
1405fn preferred_global_root_dir() -> PathBuf {
1406    if let Some(explicit) = explicit_xbp_home_from_env() {
1407        return explicit;
1408    }
1409
1410    let candidates = collect_global_xbp_root_candidates();
1411    if let Some(best) = pick_established_xbp_root(&candidates) {
1412        return best;
1413    }
1414
1415    // Fresh install defaults.
1416    #[cfg(target_os = "windows")]
1417    {
1418        if let Some(home_dir) = resolve_windows_home_dir() {
1419            let c_drive = Path::new(r"C:\");
1420            if c_drive.exists() {
1421                if windows_drive_letter(&home_dir) == Some('C') {
1422                    return home_dir.join(".xbp");
1423                }
1424                if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
1425                    let c_profile_candidate = c_drive.join(relative_profile_path);
1426                    if c_profile_candidate.exists() {
1427                        return c_profile_candidate.join(".xbp");
1428                    }
1429                }
1430            }
1431            return home_dir.join(".xbp");
1432        }
1433        return dirs::config_dir()
1434            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
1435            .unwrap_or_else(|| PathBuf::from("."))
1436            .join("xbp");
1437    }
1438
1439    #[cfg(not(target_os = "windows"))]
1440    {
1441        // Prefer classic ~/.xbp over ~/.config/xbp so WSL/Linux matches Windows
1442        // layout and worktree-watch spoils stay discoverable.
1443        if let Some(home) = dirs::home_dir() {
1444            return home.join(".xbp");
1445        }
1446        dirs::config_dir()
1447            .unwrap_or_else(|| PathBuf::from("."))
1448            .join("xbp")
1449    }
1450}
1451
1452fn explicit_xbp_home_from_env() -> Option<PathBuf> {
1453    for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
1454        if let Ok(value) = env::var(key) {
1455            let trimmed = value.trim();
1456            if !trimmed.is_empty() {
1457                return Some(PathBuf::from(trimmed));
1458            }
1459        }
1460    }
1461    None
1462}
1463
1464fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
1465    let mut candidates: Vec<PathBuf> = Vec::new();
1466    let mut push = |path: PathBuf| {
1467        if candidates
1468            .iter()
1469            .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1470        {
1471            return;
1472        }
1473        candidates.push(path);
1474    };
1475
1476    if let Some(explicit) = explicit_xbp_home_from_env() {
1477        push(explicit);
1478    }
1479
1480    #[cfg(target_os = "windows")]
1481    {
1482        if let Some(home_dir) = resolve_windows_home_dir() {
1483            push(home_dir.join(".xbp"));
1484            if let Some(relative) = windows_path_without_drive(&home_dir) {
1485                push(Path::new(r"C:\").join(relative).join(".xbp"));
1486            }
1487        }
1488        if let Some(config_dir) = dirs::config_dir() {
1489            push(config_dir.join("xbp"));
1490        }
1491    }
1492
1493    #[cfg(not(target_os = "windows"))]
1494    {
1495        if let Some(home) = dirs::home_dir() {
1496            push(home.join(".xbp"));
1497            push(home.join(".config").join("xbp"));
1498        }
1499        if let Some(config_dir) = dirs::config_dir() {
1500            push(config_dir.join("xbp"));
1501        }
1502
1503        // WSL: discover the Windows user profile .xbp that native Windows uses.
1504        for windows_home in discover_windows_profile_homes_from_unix() {
1505            push(windows_home.join(".xbp"));
1506        }
1507    }
1508
1509    candidates
1510}
1511
1512fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
1513    candidates
1514        .iter()
1515        .map(|path| (score_xbp_root(path), path.clone()))
1516        .filter(|(score, _)| *score > 0)
1517        .max_by(|left, right| {
1518            left.0.cmp(&right.0).then_with(|| {
1519                // Prefer Windows-drive-backed roots when scores tie (shared WSL/Windows).
1520                let left_windows = is_windows_drive_backed_path(&left.1);
1521                let right_windows = is_windows_drive_backed_path(&right.1);
1522                right_windows.cmp(&left_windows)
1523            })
1524        })
1525        .map(|(_, path)| path)
1526}
1527
1528fn score_xbp_root(root: &Path) -> u64 {
1529    if !root.exists() {
1530        return 0;
1531    }
1532
1533    let mut score = 1u64;
1534    if root.join("config.yaml").is_file() {
1535        score += 100;
1536    }
1537    if root.join("config.yml").is_file() {
1538        score += 80;
1539    }
1540    let mutations = root.join("mutations");
1541    if mutations.is_dir() {
1542        score += 50;
1543        score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
1544    }
1545    if root.join("ssh").is_dir() {
1546        score += 10;
1547    }
1548    if root.join("cache").is_dir() {
1549        score += 5;
1550    }
1551    // Strong signal that this is the shared Windows profile used from WSL.
1552    if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
1553        score += 40;
1554    }
1555    score
1556}
1557
1558fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
1559    let Ok(entries) = fs::read_dir(path) else {
1560        return 0;
1561    };
1562    let mut count = 0u64;
1563    for _entry in entries.flatten() {
1564        count += 1;
1565        if count >= cap {
1566            break;
1567        }
1568    }
1569    count
1570}
1571
1572fn is_windows_drive_backed_path(path: &Path) -> bool {
1573    let normalized = path.to_string_lossy().replace('\\', "/");
1574    if cfg!(windows) {
1575        return windows_drive_letter(path).is_some();
1576    }
1577    // WSL mounts of Windows drives: /mnt/c/Users/...
1578    let lower = normalized.to_ascii_lowercase();
1579    lower.starts_with("/mnt/")
1580        && lower.len() >= 6
1581        && lower
1582            .as_bytes()
1583            .get(5)
1584            .is_some_and(|b| b.is_ascii_alphabetic())
1585}
1586
1587fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
1588    normalize_path_key(left) == normalize_path_key(right)
1589}
1590
1591fn normalize_path_key(path: &Path) -> String {
1592    let mut value = path.to_string_lossy().replace('\\', "/");
1593    // Collapse Windows drive letters to a comparable form with WSL /mnt/<drive>/.
1594    if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
1595        value = mapped;
1596    } else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
1597        // Normalize both directions to the /mnt form for comparison on Unix.
1598        if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
1599            value = mnt;
1600        } else {
1601            value = mapped;
1602        }
1603    }
1604    if cfg!(windows) {
1605        value.to_ascii_lowercase()
1606    } else {
1607        value
1608    }
1609}
1610
1611/// `C:\Users\foo` → `/mnt/c/Users/foo` (for comparison / WSL access).
1612pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
1613    let normalized = path.trim().replace('\\', "/");
1614    let bytes = normalized.as_bytes();
1615    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1616        let drive = (bytes[0] as char).to_ascii_lowercase();
1617        let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
1618        if rest.is_empty() {
1619            return Some(format!("/mnt/{drive}"));
1620        }
1621        return Some(format!("/mnt/{drive}/{rest}"));
1622    }
1623    None
1624}
1625
1626/// `/mnt/c/Users/foo` → `C:/Users/foo`.
1627pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
1628    let normalized = path.trim().replace('\\', "/");
1629    let rest = normalized.strip_prefix("/mnt/")?;
1630    let mut parts = rest.splitn(2, '/');
1631    let drive = parts.next()?.chars().next()?;
1632    if !drive.is_ascii_alphabetic() {
1633        return None;
1634    }
1635    let tail = parts.next().unwrap_or("");
1636    if tail.is_empty() {
1637        Some(format!("{}:", drive.to_ascii_uppercase()))
1638    } else {
1639        Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
1640    }
1641}
1642
1643#[cfg(not(target_os = "windows"))]
1644fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
1645    let mut homes: Vec<PathBuf> = Vec::new();
1646    let mut push_home = |path: PathBuf| {
1647        if path.is_dir()
1648            && !homes
1649                .iter()
1650                .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
1651        {
1652            homes.push(path);
1653        }
1654    };
1655
1656    // Explicit Windows profile exported into the WSL environment.
1657    for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
1658        if let Ok(value) = env::var(key) {
1659            let trimmed = value.trim();
1660            if trimmed.is_empty() {
1661                continue;
1662            }
1663            if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
1664                push_home(PathBuf::from(mnt));
1665            }
1666            // Already a WSL path.
1667            if trimmed.starts_with("/mnt/") {
1668                push_home(PathBuf::from(trimmed));
1669            }
1670        }
1671    }
1672
1673    // `cmd.exe /c echo %USERPROFILE%` is the most reliable user mapping on WSL.
1674    if let Some(profile) = query_windows_userprofile_via_cmd() {
1675        if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
1676            push_home(PathBuf::from(mnt));
1677        }
1678    }
1679
1680    // Heuristic: /mnt/c/Users/<name> matching Linux username or common aliases.
1681    if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
1682        for drive in b'c'..=b'z' {
1683            let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
1684            if !users_dir.is_dir() {
1685                continue;
1686            }
1687            let direct = users_dir.join(&linux_user);
1688            if direct.is_dir() {
1689                push_home(direct);
1690            }
1691            // Case-insensitive scan for a single matching profile when names differ
1692            // only by case (Windows vs Linux username).
1693            if let Ok(entries) = fs::read_dir(&users_dir) {
1694                for entry in entries.flatten() {
1695                    let name = entry.file_name().to_string_lossy().to_string();
1696                    if name.eq_ignore_ascii_case(&linux_user) {
1697                        push_home(entry.path());
1698                    }
1699                }
1700            }
1701            // Only probe C: thoroughly; other drives are rare for profiles.
1702            if drive == b'c' {
1703                break;
1704            }
1705        }
1706    }
1707
1708    homes
1709}
1710
1711#[cfg(not(target_os = "windows"))]
1712fn query_windows_userprofile_via_cmd() -> Option<String> {
1713    // Only attempt on WSL-like environments to avoid hanging on pure Linux.
1714    let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
1715        || env::var_os("WSL_INTEROP").is_some()
1716        || fs::read_to_string("/proc/version")
1717            .map(|content| {
1718                let lower = content.to_ascii_lowercase();
1719                lower.contains("microsoft") || lower.contains("wsl")
1720            })
1721            .unwrap_or(false);
1722    if !is_wsl {
1723        return None;
1724    }
1725
1726    let output = std::process::Command::new("cmd.exe")
1727        .args(["/C", "echo %USERPROFILE%"])
1728        .output()
1729        .ok()?;
1730    if !output.status.success() {
1731        return None;
1732    }
1733    let stdout = String::from_utf8_lossy(&output.stdout);
1734    let profile = stdout
1735        .lines()
1736        .map(str::trim)
1737        .find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
1738        .to_string();
1739    if profile.is_empty() {
1740        None
1741    } else {
1742        Some(profile)
1743    }
1744}
1745
1746#[cfg(target_os = "windows")]
1747fn resolve_windows_home_dir() -> Option<PathBuf> {
1748    dirs::home_dir()
1749        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
1750        .or_else(|| {
1751            let drive = env::var_os("HOMEDRIVE")?;
1752            let path = env::var_os("HOMEPATH")?;
1753            Some(PathBuf::from(drive).join(path))
1754        })
1755}
1756
1757fn windows_drive_letter(path: &Path) -> Option<char> {
1758    let normalized = path.to_string_lossy().replace('/', "\\");
1759    let bytes = normalized.as_bytes();
1760    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1761        Some((bytes[0] as char).to_ascii_uppercase())
1762    } else {
1763        None
1764    }
1765}
1766
1767#[cfg(target_os = "windows")]
1768fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
1769    let normalized = path.to_string_lossy().replace('/', "\\");
1770    let bytes = normalized.as_bytes();
1771    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
1772        let tail = &normalized[3..];
1773        if tail.is_empty() {
1774            Some(PathBuf::new())
1775        } else {
1776            Some(PathBuf::from(tail))
1777        }
1778    } else {
1779        None
1780    }
1781}
1782
1783#[cfg(target_os = "windows")]
1784fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
1785    let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
1786        return Ok(());
1787    };
1788
1789    migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
1790    migrate_legacy_windows_file_if_missing(
1791        &legacy_root.join("versioning-files.yaml"),
1792        &paths.versioning_files_file,
1793    )?;
1794    migrate_legacy_windows_file_if_missing(
1795        &legacy_root.join("package-name-files.yaml"),
1796        &paths.package_name_files_file,
1797    )?;
1798
1799    Ok(())
1800}
1801
1802#[cfg(not(target_os = "windows"))]
1803fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
1804    Ok(())
1805}
1806
1807#[cfg(target_os = "windows")]
1808fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
1809    if to.exists() || !from.exists() {
1810        return Ok(());
1811    }
1812
1813    if let Some(parent) = to.parent() {
1814        fs::create_dir_all(parent).map_err(|e| {
1815            format!(
1816                "Failed to create directory for migrated file {}: {}",
1817                parent.display(),
1818                e
1819            )
1820        })?;
1821    }
1822
1823    fs::copy(from, to).map_err(|e| {
1824        format!(
1825            "Failed to migrate legacy config file {} -> {}: {}",
1826            from.display(),
1827            to.display(),
1828            e
1829        )
1830    })?;
1831
1832    Ok(())
1833}
1834
1835pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
1836    let paths = global_xbp_paths()?;
1837    Ok(vec![
1838        ("root".to_string(), paths.root_dir),
1839        ("config".to_string(), paths.config_file),
1840        ("ssh".to_string(), paths.ssh_dir),
1841        ("cache".to_string(), paths.cache_dir),
1842        ("logs".to_string(), paths.logs_dir),
1843        ("versioning".to_string(), paths.versioning_files_file),
1844        ("package-names".to_string(), paths.package_name_files_file),
1845        ("worktree-ignore".to_string(), paths.worktree_ignore_file),
1846    ])
1847}
1848
1849pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
1850    let paths = ensure_global_xbp_paths()?;
1851    Ok(paths.versioning_files_file)
1852}
1853
1854pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
1855    let registry_path = sync_versioning_files_registry()?;
1856    let content = fs::read_to_string(&registry_path).map_err(|e| {
1857        format!(
1858            "Failed to read versioning registry {}: {}",
1859            registry_path.display(),
1860            e
1861        )
1862    })?;
1863
1864    let config: VersioningFilesConfig = serde_yaml::from_str(&content)
1865        .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
1866
1867    Ok(config.files)
1868}
1869
1870pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
1871    let paths = ensure_global_xbp_paths()?;
1872    Ok(paths.package_name_files_file)
1873}
1874
1875pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
1876    let registry_path = sync_package_name_files_registry()?;
1877    let content = fs::read_to_string(&registry_path).map_err(|e| {
1878        format!(
1879            "Failed to read package-name registry {}: {}",
1880            registry_path.display(),
1881            e
1882        )
1883    })?;
1884
1885    let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
1886        .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
1887
1888    Ok(config.lookups)
1889}
1890
1891fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
1892    let mut config = if path.exists() {
1893        let content = fs::read_to_string(path).map_err(|e| {
1894            format!(
1895                "Failed to read versioning registry {}: {}",
1896                path.display(),
1897                e
1898            )
1899        })?;
1900        serde_yaml::from_str::<VersioningFilesConfig>(&content)
1901            .unwrap_or_else(|_| VersioningFilesConfig::default())
1902    } else {
1903        VersioningFilesConfig::default()
1904    };
1905
1906    let mut changed = false;
1907    for default_file in default_versioning_files() {
1908        if !config
1909            .files
1910            .iter()
1911            .any(|existing| existing == &default_file)
1912        {
1913            config.files.push(default_file);
1914            changed = true;
1915        }
1916    }
1917
1918    if changed || !path.exists() {
1919        let content = serde_yaml::to_string(&config)
1920            .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
1921        fs::write(path, content).map_err(|e| {
1922            format!(
1923                "Failed to write versioning registry {}: {}",
1924                path.display(),
1925                e
1926            )
1927        })?;
1928    }
1929
1930    Ok(())
1931}
1932
1933fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
1934    let mut config = if path.exists() {
1935        let content = fs::read_to_string(path).map_err(|e| {
1936            format!(
1937                "Failed to read package-name registry {}: {}",
1938                path.display(),
1939                e
1940            )
1941        })?;
1942        serde_yaml::from_str::<PackageNameFilesConfig>(&content)
1943            .unwrap_or_else(|_| PackageNameFilesConfig::default())
1944    } else {
1945        PackageNameFilesConfig::default()
1946    };
1947
1948    let mut changed = false;
1949    for default_lookup in default_package_name_lookups() {
1950        if !config
1951            .lookups
1952            .iter()
1953            .any(|existing| existing == &default_lookup)
1954        {
1955            config.lookups.push(default_lookup);
1956            changed = true;
1957        }
1958    }
1959
1960    if changed || !path.exists() {
1961        let content = serde_yaml::to_string(&config)
1962            .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
1963        fs::write(path, content).map_err(|e| {
1964            format!(
1965                "Failed to write package-name registry {}: {}",
1966                path.display(),
1967                e
1968            )
1969        })?;
1970    }
1971
1972    Ok(())
1973}
1974
1975fn default_versioning_files() -> Vec<String> {
1976    vec![
1977        "README.md".to_string(),
1978        "openapi.yaml".to_string(),
1979        "openapi.yml".to_string(),
1980        "openapi.json".to_string(),
1981        "package.json".to_string(),
1982        "package-lock.json".to_string(),
1983        "Cargo.toml".to_string(),
1984        "Cargo.lock".to_string(),
1985        "pyproject.toml".to_string(),
1986        "composer.json".to_string(),
1987        "deno.json".to_string(),
1988        "deno.jsonc".to_string(),
1989        "Chart.yaml".to_string(),
1990        "app.json".to_string(),
1991        "manifest.json".to_string(),
1992        "pom.xml".to_string(),
1993        "build.gradle".to_string(),
1994        "build.gradle.kts".to_string(),
1995        "mix.exs".to_string(),
1996        "xbp.yaml".to_string(),
1997        "xbp.yml".to_string(),
1998        "xbp.json".to_string(),
1999        ".xbp/xbp.json".to_string(),
2000        ".xbp/xbp.yaml".to_string(),
2001        ".xbp/xbp.yml".to_string(),
2002    ]
2003}
2004
2005fn default_package_name_lookups() -> Vec<PackageNameLookup> {
2006    vec![
2007        PackageNameLookup {
2008            file: "package.json".to_string(),
2009            format: "json".to_string(),
2010            key: "name".to_string(),
2011            registry: "npm".to_string(),
2012        },
2013        PackageNameLookup {
2014            file: "Cargo.toml".to_string(),
2015            format: "toml".to_string(),
2016            key: "package.name".to_string(),
2017            registry: "crates.io".to_string(),
2018        },
2019    ]
2020}
2021
2022const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
2023
2024/// Simple API configuration for the XBP version endpoints.
2025#[derive(Debug, Clone)]
2026pub struct ApiConfig {
2027    base_url: String,
2028}
2029
2030impl ApiConfig {
2031    /// Load the API configuration from API_XBP_URL, falling back to the default.
2032    pub fn load() -> Self {
2033        let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
2034        Self::from_base_url(&raw_url)
2035    }
2036
2037    pub fn from_env() -> Self {
2038        Self::load()
2039    }
2040
2041    pub fn from_base_url(raw_url: &str) -> Self {
2042        let base_url = Self::normalize_base_url(raw_url);
2043        ApiConfig { base_url }
2044    }
2045
2046    /// Return the normalized base URL that downstream callers should use.
2047    pub fn base_url(&self) -> &str {
2048        &self.base_url
2049    }
2050
2051    /// Build the version query endpoint.
2052    pub fn version_endpoint(&self, project_name: &str) -> String {
2053        format!("{}/version?project_name={}", self.base_url, project_name)
2054    }
2055
2056    /// Build the endpoint that increments a version.
2057    pub fn increment_endpoint(&self) -> String {
2058        format!("{}/version/increment", self.base_url)
2059    }
2060
2061    pub fn web_base_url(&self) -> String {
2062        Self::derive_web_base_url(&self.base_url)
2063    }
2064
2065    pub fn cli_auth_request_endpoint(&self) -> String {
2066        format!("{}/api/cli/auth/request", self.web_base_url())
2067    }
2068
2069    pub fn cli_auth_poll_endpoint(&self) -> String {
2070        format!("{}/api/cli/auth/poll", self.web_base_url())
2071    }
2072
2073    pub fn cli_auth_session_endpoint(&self) -> String {
2074        format!("{}/api/cli/auth/session", self.web_base_url())
2075    }
2076
2077    pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
2078        format!("{}/cli/login/{}", self.web_base_url(), flow_id)
2079    }
2080
2081    pub fn cli_linear_key_endpoint(&self) -> String {
2082        format!("{}/api/cli/linear/key", self.web_base_url())
2083    }
2084
2085    pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
2086        format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
2087    }
2088
2089    pub fn cloudflare_settings_url(&self) -> String {
2090        format!("{}/settings/cloudflare", self.web_base_url())
2091    }
2092
2093    pub fn cli_version_activity_endpoint(&self) -> String {
2094        format!("{}/api/cli/version/activity", self.web_base_url())
2095    }
2096
2097    pub fn cli_error_report_endpoint(&self) -> String {
2098        format!("{}/api/cli/errors/report", self.web_base_url())
2099    }
2100
2101    pub fn cli_cursor_ingest_endpoint(&self) -> String {
2102        format!("{}/api/cli/cursor/ingest", self.web_base_url())
2103    }
2104
2105    pub fn cli_projects_register_endpoint(&self) -> String {
2106        format!("{}/api/cli/projects/register", self.web_base_url())
2107    }
2108
2109    pub fn cli_worktree_mutations_endpoint(&self) -> String {
2110        format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
2111    }
2112
2113    pub fn cli_secrets_sync_endpoint(&self) -> String {
2114        format!("{}/api/cli/secrets/sync", self.web_base_url())
2115    }
2116
2117    pub fn cli_secrets_list_endpoint(&self) -> String {
2118        format!("{}/api/cli/secrets", self.web_base_url())
2119    }
2120
2121    pub fn cli_openapi_upload_endpoint(&self) -> String {
2122        format!("{}/api/cli/openapi/upload", self.web_base_url())
2123    }
2124
2125    pub fn cli_openapi_list_endpoint(&self) -> String {
2126        format!("{}/api/cli/openapi", self.web_base_url())
2127    }
2128
2129    fn normalize_base_url(raw: &str) -> String {
2130        let trimmed = raw.trim();
2131        if trimmed.is_empty() {
2132            return DEFAULT_API_XBP_URL.to_string();
2133        }
2134
2135        let trimmed = trimmed.trim_end_matches('/');
2136        if trimmed.is_empty() {
2137            return DEFAULT_API_XBP_URL.to_string();
2138        }
2139
2140        trimmed.to_string()
2141    }
2142
2143    fn derive_web_base_url(base_url: &str) -> String {
2144        let Ok(mut url) = Url::parse(base_url) else {
2145            return base_url.to_string();
2146        };
2147
2148        if let Some(host) = url.host_str().map(str::to_string) {
2149            if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
2150                let _ = url.set_host(Some("xbp.app"));
2151            } else if let Some(stripped) = host.strip_prefix("api.") {
2152                let _ = url.set_host(Some(stripped));
2153            }
2154        }
2155
2156        url.set_path("");
2157        url.set_query(None);
2158        url.set_fragment(None);
2159
2160        url.to_string().trim_end_matches('/').to_string()
2161    }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166    use super::{
2167        cloudflare_account_id_from_project_env, default_package_name_lookups,
2168        default_versioning_files, resolve_cloudflare_account_id, resolve_github_oauth2_key,
2169        resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
2170        sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
2171        OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig, VersioningFilesConfig,
2172    };
2173    use crate::openrouter::{
2174        DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
2175    };
2176    use std::env;
2177    use std::fs;
2178    use std::path::PathBuf;
2179    use std::time::{SystemTime, UNIX_EPOCH};
2180
2181    fn temp_path(label: &str) -> PathBuf {
2182        let nanos = SystemTime::now()
2183            .duration_since(UNIX_EPOCH)
2184            .expect("time")
2185            .as_nanos();
2186        std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
2187    }
2188
2189    #[test]
2190    fn versioning_registry_defaults_include_core_files() {
2191        let defaults = default_versioning_files();
2192        assert!(defaults.contains(&"README.md".to_string()));
2193        assert!(defaults.contains(&"Cargo.toml".to_string()));
2194        assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
2195    }
2196
2197    #[test]
2198    fn versioning_registry_default_config_populates_files() {
2199        let config = VersioningFilesConfig::default();
2200        assert!(!config.files.is_empty());
2201    }
2202
2203    #[test]
2204    fn versioning_registry_defaults_do_not_contain_duplicates() {
2205        let defaults = default_versioning_files();
2206        let mut deduped = defaults.clone();
2207        deduped.sort();
2208        deduped.dedup();
2209        assert_eq!(defaults.len(), deduped.len());
2210    }
2211
2212    #[test]
2213    fn syncing_registry_creates_file_with_defaults() {
2214        let path = temp_path("defaults");
2215        sync_versioning_files_registry_at(&path).expect("sync");
2216
2217        let content = fs::read_to_string(&path).expect("read");
2218        assert!(content.contains("README.md"));
2219        assert!(content.contains("Cargo.toml"));
2220
2221        let _ = fs::remove_file(path);
2222    }
2223
2224    #[test]
2225    fn syncing_registry_preserves_user_added_entries() {
2226        let path = temp_path("preserve");
2227        fs::write(&path, "files:\n  - custom.file\n").expect("write registry");
2228
2229        sync_versioning_files_registry_at(&path).expect("sync");
2230
2231        let content = fs::read_to_string(&path).expect("read");
2232        assert!(content.contains("custom.file"));
2233        assert!(content.contains("README.md"));
2234
2235        let _ = fs::remove_file(path);
2236    }
2237
2238    #[test]
2239    fn api_config_normalizes_trailing_slashes() {
2240        assert_eq!(
2241            ApiConfig::normalize_base_url("https://api.xbp.app///"),
2242            "https://api.xbp.app".to_string()
2243        );
2244    }
2245
2246    #[test]
2247    fn api_config_uses_default_for_blank_values() {
2248        assert_eq!(
2249            ApiConfig::normalize_base_url("   "),
2250            "https://api.xbp.app".to_string()
2251        );
2252    }
2253
2254    #[test]
2255    fn api_config_builds_version_endpoints() {
2256        let config = ApiConfig {
2257            base_url: "https://api.test.xbp".to_string(),
2258        };
2259        let endpoint = config.version_endpoint("demo");
2260        let increment = config.increment_endpoint();
2261
2262        assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
2263        assert_eq!(increment, "https://api.test.xbp/version/increment");
2264    }
2265
2266    #[test]
2267    fn api_config_exposes_cli_projects_register_endpoint() {
2268        let config = ApiConfig {
2269            base_url: "https://api.xbp.app".to_string(),
2270        };
2271        assert_eq!(
2272            config.cli_projects_register_endpoint(),
2273            "https://xbp.app/api/cli/projects/register"
2274        );
2275    }
2276
2277    #[test]
2278    fn api_config_derives_browser_base_url_from_api_subdomain() {
2279        assert_eq!(
2280            ApiConfig::derive_web_base_url("https://api.xbp.app"),
2281            "https://xbp.app".to_string()
2282        );
2283        assert_eq!(
2284            ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
2285            "https://xbp.app".to_string()
2286        );
2287        assert_eq!(
2288            ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
2289            "https://xbp.app".to_string()
2290        );
2291        assert_eq!(
2292            ApiConfig::derive_web_base_url("https://api.internal.example.com"),
2293            "https://internal.example.com".to_string()
2294        );
2295        assert_eq!(
2296            ApiConfig::derive_web_base_url("http://localhost:3000"),
2297            "http://localhost:3000".to_string()
2298        );
2299    }
2300
2301    #[test]
2302    fn package_lookup_defaults_include_npm_and_crates() {
2303        let defaults = default_package_name_lookups();
2304        assert!(defaults.iter().any(|entry| {
2305            entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
2306        }));
2307        assert!(defaults.iter().any(|entry| {
2308            entry.file == "Cargo.toml"
2309                && entry.registry == "crates.io"
2310                && entry.key == "package.name"
2311        }));
2312    }
2313
2314    #[test]
2315    fn package_lookup_default_config_populates_entries() {
2316        let config = PackageNameFilesConfig::default();
2317        assert!(!config.lookups.is_empty());
2318    }
2319
2320    #[test]
2321    fn syncing_package_lookup_registry_creates_defaults() {
2322        let path = temp_path("package-lookup-defaults");
2323        sync_package_name_files_registry_at(&path).expect("sync");
2324
2325        let content = fs::read_to_string(&path).expect("read");
2326        assert!(content.contains("package.json"));
2327        assert!(content.contains("Cargo.toml"));
2328
2329        let _ = fs::remove_file(path);
2330    }
2331
2332    #[test]
2333    fn syncing_package_lookup_registry_preserves_custom_entries() {
2334        let path = temp_path("package-lookup-custom");
2335        fs::write(
2336            &path,
2337            "lookups:\n  - file: custom.yaml\n    format: yaml\n    key: app.name\n    registry: npm\n",
2338        )
2339        .expect("write package lookup registry");
2340
2341        sync_package_name_files_registry_at(&path).expect("sync");
2342        let content = fs::read_to_string(&path).expect("read");
2343        assert!(content.contains("custom.yaml"));
2344        assert!(content.contains("package.json"));
2345
2346        let _ = fs::remove_file(path);
2347    }
2348
2349    #[test]
2350    fn secret_provider_parses_supported_keys() {
2351        assert_eq!(
2352            SecretProvider::from_key("openrouter"),
2353            Some(SecretProvider::OpenRouter)
2354        );
2355        assert_eq!(
2356            SecretProvider::from_key("github"),
2357            Some(SecretProvider::Github)
2358        );
2359        assert_eq!(
2360            SecretProvider::from_key("linear"),
2361            Some(SecretProvider::Linear)
2362        );
2363        assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
2364        assert_eq!(
2365            SecretProvider::from_key("crates"),
2366            Some(SecretProvider::Crates)
2367        );
2368        assert_eq!(SecretProvider::from_key("unknown"), None);
2369    }
2370
2371    #[test]
2372    fn ssh_config_secret_get_set_roundtrip() {
2373        let mut cfg = SshConfig::new();
2374        cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
2375        cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
2376        cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
2377        cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
2378        cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
2379
2380        assert_eq!(
2381            cfg.get_secret(SecretProvider::OpenRouter),
2382            Some("or-test-123")
2383        );
2384        assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
2385        assert_eq!(
2386            cfg.get_secret(SecretProvider::Linear),
2387            Some("lin_api_test_789")
2388        );
2389        assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
2390        assert_eq!(
2391            cfg.get_secret(SecretProvider::Crates),
2392            Some("crate_test_token")
2393        );
2394        assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
2395    }
2396
2397    #[test]
2398    fn default_openrouter_config_populates_models_and_prompts() {
2399        let config = OpenRouterConfig::default();
2400
2401        assert_eq!(config.commit_model, DEFAULT_MODEL);
2402        assert_eq!(config.release_notes_model, DEFAULT_MODEL);
2403        assert_eq!(
2404            config.commit_system_prompt,
2405            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2406        );
2407        assert_eq!(
2408            config.release_notes_system_prompt,
2409            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2410        );
2411    }
2412
2413    #[test]
2414    fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
2415        let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
2416
2417        assert!(yaml.contains("openrouter:"));
2418        assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
2419        assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
2420        assert!(yaml.contains("commit_system_prompt"));
2421        assert!(yaml.contains("release_notes_system_prompt"));
2422    }
2423
2424    #[test]
2425    fn ssh_config_new_uses_openrouter_defaults() {
2426        let config = SshConfig::new();
2427
2428        assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
2429        assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
2430        assert_eq!(
2431            config.openrouter.commit_system_prompt,
2432            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
2433        );
2434        assert_eq!(
2435            config.openrouter.release_notes_system_prompt,
2436            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
2437        );
2438    }
2439
2440    #[test]
2441    fn syncing_global_config_backfills_openrouter_generation_defaults() {
2442        let path = temp_path("global-config");
2443        fs::write(
2444            &path,
2445            "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",
2446        )
2447        .expect("write config");
2448
2449        sync_global_config_defaults_at(&path).expect("sync config defaults");
2450
2451        let content = fs::read_to_string(&path).expect("read config");
2452        assert!(content.contains("openrouter:"));
2453        assert!(content.contains("commit_model: openai/gpt-4o-mini"));
2454        assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
2455        assert!(content.contains("commit_system_prompt"));
2456        assert!(content.contains("release_notes_system_prompt"));
2457
2458        let _ = fs::remove_file(path);
2459    }
2460
2461    #[test]
2462    fn resolve_secret_prefers_environment_value() {
2463        std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
2464        std::env::set_var("GITHUB_TOKEN", "env-github");
2465        std::env::set_var("XBP_API_TOKEN", "env-xbp");
2466
2467        assert_eq!(
2468            resolve_openrouter_api_key(),
2469            Some("env-openrouter".to_string())
2470        );
2471        assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
2472        assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
2473
2474        std::env::remove_var("OPENROUTER_API_KEY");
2475        std::env::remove_var("GITHUB_TOKEN");
2476        std::env::remove_var("XBP_API_TOKEN");
2477    }
2478
2479    #[test]
2480    fn resolve_cloudflare_account_id_reads_project_env_files() {
2481        use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
2482
2483        let dir = temp_path("cf-account-env");
2484        fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
2485        fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
2486        fs::write(
2487            dir.join(".env"),
2488            "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
2489        )
2490        .expect("write env");
2491
2492        let previous = env::current_dir().expect("cwd");
2493        env::set_current_dir(&dir).expect("chdir");
2494        for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
2495            env::remove_var(key);
2496        }
2497
2498        assert_eq!(
2499            cloudflare_account_id_from_project_env().as_deref(),
2500            Some("acc-from-project-env")
2501        );
2502        assert_eq!(
2503            resolve_cloudflare_account_id().as_deref(),
2504            Some("acc-from-project-env")
2505        );
2506
2507        env::set_current_dir(previous).expect("restore cwd");
2508        let _ = fs::remove_dir_all(dir);
2509    }
2510}