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