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_dotenv_value_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 => {
263                "Cloudflare Workers / Containers deploy (batched; includes wrangler + containers)"
264            }
265            Self::KubernetesDeploy => "Kubernetes deploy (batched)",
266            Self::Deploy => "Other deploy outcomes (mixed providers)",
267            Self::OpenApi => "OpenAPI generation",
268            Self::Issues => "Issues created (Linear/GitHub, grouped)",
269            Self::Commit => "xbp commit / short commit messages",
270        }
271    }
272
273    pub fn group(self) -> DiscordEventGroup {
274        match self {
275            Self::VersionRelease
276            | Self::GitHubTag
277            | Self::GitHubRelease
278            | Self::CratesIo
279            | Self::Npm
280            | Self::Linear
281            | Self::CargoDist => DiscordEventGroup::ReleasePublish,
282            Self::Oci | Self::WorkersDeploy | Self::KubernetesDeploy | Self::Deploy => {
283                DiscordEventGroup::Deploy
284            }
285            Self::OpenApi | Self::Issues | Self::Commit => DiscordEventGroup::Automation,
286        }
287    }
288
289    pub fn all() -> &'static [DiscordNotifyEvent] {
290        &[
291            Self::VersionRelease,
292            Self::GitHubTag,
293            Self::GitHubRelease,
294            Self::CratesIo,
295            Self::Npm,
296            Self::Linear,
297            Self::CargoDist,
298            Self::Oci,
299            Self::WorkersDeploy,
300            Self::KubernetesDeploy,
301            Self::Deploy,
302            Self::OpenApi,
303            Self::Issues,
304            Self::Commit,
305        ]
306    }
307
308    pub fn parse(raw: &str) -> Option<Self> {
309        match raw.trim().to_ascii_lowercase().as_str() {
310            "version_release" | "version" | "release" | "release_summary" => {
311                Some(Self::VersionRelease)
312            }
313            "github_tag" | "tag" | "git_tag" => Some(Self::GitHubTag),
314            "github_release" | "gh_release" | "release_created" => Some(Self::GitHubRelease),
315            "crates_io" | "crates" | "crates-io" | "cratesio" => Some(Self::CratesIo),
316            "npm" | "npmjs" => Some(Self::Npm),
317            "linear" | "linear_post" => Some(Self::Linear),
318            "cargo_dist" | "dist" | "cargo-dist" => Some(Self::CargoDist),
319            "oci" | "oci_registry" | "docker" | "ghcr" => Some(Self::Oci),
320            "workers_deploy"
321            | "workers"
322            | "worker_deploy"
323            | "worker"
324            | "cloudflare"
325            | "cloudflare_deploy"
326            | "containers"
327            | "cf_containers" => Some(Self::WorkersDeploy),
328            "kubernetes_deploy" | "k8s" | "kubernetes" => Some(Self::KubernetesDeploy),
329            "deploy" | "deployment" => Some(Self::Deploy),
330            "openapi" | "openapi_gen" | "openapi_generate" => Some(Self::OpenApi),
331            "issues" | "issue" | "todos" | "todo_issues" => Some(Self::Issues),
332            "commit" | "commits" | "git_commit" => Some(Self::Commit),
333            _ => None,
334        }
335    }
336}
337
338/// Project or global Discord webhook settings.
339///
340/// Project `.xbp/xbp.toml` (or other project config) `discord:` overrides global `~/.xbp/config.yaml` `discord:`.
341/// Webhook URL may use `${DISCORD_WEBHOOK_URL}` placeholders.
342#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
343pub struct DiscordConfig {
344    /// Master switch. Defaults to true when a webhook URL is present.
345    #[serde(default)]
346    pub enabled: Option<bool>,
347    /// Discord incoming webhook URL (or `${ENV}` placeholder).
348    #[serde(default, alias = "url", alias = "webhook")]
349    pub webhook_url: Option<String>,
350    /// Optional webhook display name (defaults to `XBP`).
351    #[serde(default)]
352    pub username: Option<String>,
353    /// Optional avatar URL for the webhook.
354    #[serde(default)]
355    pub avatar_url: Option<String>,
356    /// Per-event enable flags (default all on when webhook is set).
357    #[serde(default)]
358    pub events: Option<DiscordEventsConfig>,
359}
360
361impl DiscordConfig {
362    pub fn is_master_enabled(&self) -> bool {
363        self.enabled.unwrap_or(true)
364    }
365
366    pub fn event_enabled(&self, event: DiscordNotifyEvent) -> bool {
367        if !self.is_master_enabled() {
368            return false;
369        }
370        self.events
371            .as_ref()
372            .map(|events| events.is_enabled(event))
373            .unwrap_or(true)
374    }
375}
376
377/// Optional per-service Discord event overrides (`services[].discord`).
378///
379/// When set, event flags here override project/global flags **for that service**
380/// (useful for OCI notifications on selected services only).
381#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
382pub struct ServiceDiscordConfig {
383    #[serde(default)]
384    pub enabled: Option<bool>,
385    #[serde(default)]
386    pub events: Option<DiscordEventsConfig>,
387}
388
389impl ServiceDiscordConfig {
390    pub fn event_enabled(&self, event: DiscordNotifyEvent, project_default: bool) -> bool {
391        if self.enabled == Some(false) {
392            return false;
393        }
394        self.events
395            .as_ref()
396            .map(|events| events.is_enabled(event))
397            .unwrap_or(project_default)
398    }
399}
400
401/// Project/global automation settings for `xbp issues scan|sync`.
402///
403/// Project (`.xbp/xbp.toml` / project config) overrides global (`~/.xbp/config.yaml`).
404///
405/// ```yaml
406/// issues:
407///   default_to: both          # linear | github | both
408///   auto_yes: true            # skip multi-select when syncing
409///   prompt_sync_after_scan: true
410///   linear_labels: [xbp-todo, tech-debt]
411///   github_labels: [xbp-todo]
412///   linear_assignee: me
413///   linear_project: My Project   # name, id, or slug
414///   watch_paths:                 # only scan these repo-relative prefixes (empty = whole tree)
415///     - crates/cli
416///   linear_link_wait_secs: 20    # after GitHub creates, wait for Linear auto-link
417///   linear_link_poll_ms: 2000
418///   purge_duplicate_linear: true # archive xbp-created Linear dups when integration already linked
419///   kinds: [TODO, FIXME, HACK]
420///   priority_by_kind:
421///     FIXME: 1
422///     HACK: 2
423///     TODO: 3
424///     XXX: 4
425///   annotate_source: false    # stamp `// XLX-99 (#42)` onto source TODO lines after create
426///   openrouter_enrich: false  # OPT-IN: expand terse TODOs via OpenRouter before create
427///   openrouter_enrich_model: openai/gpt-4o-mini
428/// ```
429#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
430pub struct TodosConfig {
431    /// Default sync target: `linear`, `github`, or `both`.
432    #[serde(default)]
433    pub default_to: Option<String>,
434    /// When true, `xbp issues sync` files all candidates without multi-select.
435    #[serde(default)]
436    pub auto_yes: Option<bool>,
437    /// After `xbp issues scan`, interactively offer to run sync (default true).
438    #[serde(default)]
439    pub prompt_sync_after_scan: Option<bool>,
440    /// Linear label names applied to created issues (default: `xbp-todo`).
441    #[serde(default)]
442    pub linear_labels: Option<Vec<String>>,
443    /// GitHub label names applied to created issues (default: `xbp-todo`).
444    #[serde(default)]
445    pub github_labels: Option<Vec<String>>,
446    /// Linear assignee for created issues (`me`, name, email, or id).
447    #[serde(default)]
448    pub linear_assignee: Option<String>,
449    /// Linear project for created issues (name, id, or slug).
450    #[serde(default)]
451    pub linear_project: Option<String>,
452    /// Only scan TODOs under these repo-relative path prefixes (empty/omit = whole tree).
453    #[serde(default)]
454    pub watch_paths: Option<Vec<String>>,
455    /// After creating GitHub issues (`--to both`), wait this many seconds for Linear auto-link.
456    #[serde(default)]
457    pub linear_link_wait_secs: Option<u64>,
458    /// Poll interval (ms) while waiting for Linear GitHub integration comments/attachments.
459    #[serde(default)]
460    pub linear_link_poll_ms: Option<u64>,
461    /// When true, archive xbp-created Linear issues that duplicate Linear↔GitHub auto-links.
462    #[serde(default)]
463    pub purge_duplicate_linear: Option<bool>,
464    /// Only sync these marker kinds (default: all of TODO/FIXME/XXX/HACK).
465    #[serde(default)]
466    pub kinds: Option<Vec<String>>,
467    /// Map marker kind → Linear priority (0–4).
468    #[serde(default)]
469    pub priority_by_kind: Option<BTreeMap<String, i32>>,
470    /// When true, append created issue identifiers onto the source TODO line.
471    #[serde(default)]
472    pub annotate_source: Option<bool>,
473    /// **Opt-in.** When true, enrich issue titles/bodies with OpenRouter before create.
474    /// Default is always off — must be set explicitly (or pass `--enrich` on sync).
475    #[serde(default)]
476    pub openrouter_enrich: Option<bool>,
477    /// Optional OpenRouter model for TODO enrichment (falls back to commit model).
478    #[serde(default)]
479    pub openrouter_enrich_model: Option<String>,
480}
481
482/// Client-side filters + adaptive scheduling for `xbp worktree-watch` (never sent to the server).
483///
484/// Configured in the global `~/.xbp/config.yaml` (or platform equivalent) and
485/// in `~/.xbp/.xbp-worktree-ignore` (gitignore-style patterns; `mcps/` is seeded by default):
486///
487/// ```yaml
488/// worktree_watch:
489///   forbidden_paths:
490///     - secrets/
491///     - apps/web/.env.local
492///   forbidden_folders:
493///     - .cache
494///     - coverage
495///   banned_words:
496///     - credential
497///     - private-key
498///   # Adaptive resource control (defaults shown)
499///   idle_after_seconds: 180        # no FS activity → warm
500///   cold_after_seconds: 600        # no activity → cold (unwatch FS; release Windows locks)
501///   hot_commit_check_seconds: 12
502///   warm_commit_check_seconds: 120
503///   cold_commit_check_seconds: 300
504///   parent_rescan_seconds: 300
505///   rank_persist_seconds: 120
506///   max_hot_repos: 4
507///   max_fs_watches: 6              # concurrent recursive FS watches (hot+warm budget)
508///   max_commit_checks_per_tick: 6  # cap git HEAD probes per loop (CPU)
509///   spool_retention_days: 7        # prune synced jsonl + old .seen markers
510///   repo_ranks:                    # maintained by the watcher (activity score)
511///     - root: C:/Users/you/Documents/GitHub/xbp
512///       score: 12.5
513///       last_event_at: "2026-07-22T12:00:00Z"
514///       rank: 1
515/// ```
516#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
517pub struct WorktreeWatchConfig {
518    /// Relative path prefixes (repo-root relative). Nested under a watched tree is still blocked.
519    /// Examples: `secrets`, `secrets/`, `apps/web/.turbo`, `**/dist` is NOT glob — use folder names.
520    #[serde(default, alias = "exclude_paths", alias = "forbidden_path")]
521    pub forbidden_paths: Vec<String>,
522    /// Directory / path-segment names blocked anywhere in the relative path.
523    #[serde(default, alias = "exclude_folders", alias = "forbidden_folder")]
524    pub forbidden_folders: Vec<String>,
525    /// Case-insensitive substrings; if any appear in the relative path, the path is ignored.
526    #[serde(default, alias = "forbidden_words", alias = "banned_word")]
527    pub banned_words: Vec<String>,
528
529    /// Seconds without FS mutations before a repo cools from hot → warm (default 180).
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub idle_after_seconds: Option<u64>,
532    /// Seconds without activity before warm → cold (unwatch FS handles; default 600).
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub cold_after_seconds: Option<u64>,
535    /// How often to poll `git rev-parse HEAD` for hot repos (default 12).
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub hot_commit_check_seconds: Option<u64>,
538    /// Commit check interval for warm repos (default 120).
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub warm_commit_check_seconds: Option<u64>,
541    /// Commit check interval for cold repos (default 300).
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub cold_commit_check_seconds: Option<u64>,
544    /// Max `git` HEAD probes per watch loop tick under a parent cover (default 6).
545    /// Caps CPU when dozens of warm repos would otherwise all fire at once.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub max_commit_checks_per_tick: Option<usize>,
548    /// Discover new/vanished repos under a parent cover (default 180).
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub parent_rescan_seconds: Option<u64>,
551    /// How often to write `repo_ranks` back into global config (default 60).
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub rank_persist_seconds: Option<u64>,
554    /// Cap how many repos stay in the hot tier at once (default 6).
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub max_hot_repos: Option<usize>,
557    /// Max concurrent recursive filesystem watches under a parent cover (default 8).
558    /// Hot repos are preferred; remaining budget goes to highest-score warm repos.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub max_fs_watches: Option<usize>,
561    /// Days to keep `.synced.*.jsonl` and old dedupe `.seen` markers (default 7).
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub spool_retention_days: Option<u64>,
564    /// Activity ranking (highest score first). Updated by the watcher.
565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
566    pub repo_ranks: Vec<WorktreeWatchRepoRank>,
567}
568
569/// One repository's activity rank in the global worktree-watch config.
570#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
571pub struct WorktreeWatchRepoRank {
572    /// Absolute repo root (as known on this machine).
573    pub root: String,
574    /// Optional owner/name labels for readability.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub owner: Option<String>,
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub name: Option<String>,
579    /// Exponentially decayed activity score (higher = more active).
580    #[serde(default)]
581    pub score: f64,
582    /// ISO-8601 timestamp of last FS/commit activity observed by the watcher.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub last_event_at: Option<String>,
585    /// 1-based rank after sort by score (1 = busiest).
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub rank: Option<u32>,
588    /// Current scheduling tier: `hot` | `warm` | `cold`.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub tier: Option<String>,
591}
592
593impl WorktreeWatchConfig {
594    pub fn idle_after_seconds(&self) -> u64 {
595        self.idle_after_seconds.unwrap_or(180).max(30)
596    }
597    pub fn cold_after_seconds(&self) -> u64 {
598        self.cold_after_seconds
599            .unwrap_or(600)
600            .max(self.idle_after_seconds())
601    }
602    pub fn hot_commit_check_seconds(&self) -> u64 {
603        self.hot_commit_check_seconds.unwrap_or(12).max(5)
604    }
605    pub fn warm_commit_check_seconds(&self) -> u64 {
606        self.warm_commit_check_seconds.unwrap_or(120).max(30)
607    }
608    pub fn cold_commit_check_seconds(&self) -> u64 {
609        self.cold_commit_check_seconds.unwrap_or(300).max(60)
610    }
611    pub fn parent_rescan_seconds(&self) -> u64 {
612        self.parent_rescan_seconds.unwrap_or(300).max(60)
613    }
614    pub fn rank_persist_seconds(&self) -> u64 {
615        self.rank_persist_seconds.unwrap_or(120).max(30)
616    }
617    pub fn max_hot_repos(&self) -> usize {
618        self.max_hot_repos.unwrap_or(4).max(1)
619    }
620    /// Concurrent recursive FS watches under a parent cover (default 6).
621    pub fn max_fs_watches(&self) -> usize {
622        self.max_fs_watches
623            .unwrap_or_else(|| self.max_hot_repos().saturating_add(2).max(6))
624            .max(1)
625    }
626    /// Max git HEAD probes per parent-watch loop iteration (default 6).
627    pub fn max_commit_checks_per_tick(&self) -> usize {
628        self.max_commit_checks_per_tick.unwrap_or(6).max(1)
629    }
630    pub fn spool_retention_days(&self) -> u64 {
631        self.spool_retention_days.unwrap_or(7).max(1)
632    }
633}
634
635#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
636pub struct SecretMetadata {
637    #[serde(default)]
638    pub added_at: Option<DateTime<Utc>>,
639}
640
641#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
642pub struct CliAuthState {
643    #[serde(default)]
644    pub user_id: Option<String>,
645    #[serde(default)]
646    pub user_name: Option<String>,
647    #[serde(default)]
648    pub user_email: Option<String>,
649    #[serde(default)]
650    pub token_label: Option<String>,
651    #[serde(default)]
652    pub token_prefix: Option<String>,
653    #[serde(default)]
654    pub token_created_at: Option<DateTime<Utc>>,
655    #[serde(default)]
656    pub token_expires_at: Option<DateTime<Utc>>,
657    #[serde(default)]
658    pub last_verified_at: Option<DateTime<Utc>>,
659}
660
661#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
662pub struct CursorIngestState {
663    #[serde(default)]
664    pub last_status: Option<String>,
665    #[serde(default)]
666    pub last_trigger: Option<String>,
667    #[serde(default)]
668    pub last_mode: Option<String>,
669    #[serde(default)]
670    pub last_attempted_at: Option<DateTime<Utc>>,
671    #[serde(default)]
672    pub last_succeeded_at: Option<DateTime<Utc>>,
673    #[serde(default)]
674    pub last_error: Option<String>,
675    #[serde(default)]
676    pub last_workspace_count: Option<usize>,
677    #[serde(default)]
678    pub last_entry_count: Option<usize>,
679    #[serde(default)]
680    pub last_entries_skipped: Option<usize>,
681}
682
683#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
684pub struct SystemInventoryRefreshState {
685    #[serde(default)]
686    pub last_status: Option<String>,
687    #[serde(default)]
688    pub last_trigger: Option<String>,
689    #[serde(default)]
690    pub last_mode: Option<String>,
691    #[serde(default)]
692    pub last_attempted_at: Option<DateTime<Utc>>,
693    #[serde(default)]
694    pub last_succeeded_at: Option<DateTime<Utc>>,
695    #[serde(default)]
696    pub last_error: Option<String>,
697    #[serde(default)]
698    pub include_cursor: Option<bool>,
699    #[serde(default)]
700    pub refreshed: Option<bool>,
701}
702
703#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
704pub struct ConfiguredRunnerHost {
705    pub runner_host_id: String,
706    #[serde(default)]
707    pub organization_id: Option<String>,
708    #[serde(default)]
709    pub hostname: Option<String>,
710    #[serde(default)]
711    pub platform: Option<RunnerPlatform>,
712    #[serde(default)]
713    pub runtime: Option<RunnerExecutionRuntime>,
714    #[serde(default)]
715    pub labels: Vec<String>,
716    #[serde(default)]
717    pub updated_at: Option<DateTime<Utc>>,
718}
719
720#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
721pub struct RunnerMachineConfig {
722    #[serde(default)]
723    pub runtime_root: Option<String>,
724    #[serde(default)]
725    pub preferred_runtime: Option<RunnerExecutionRuntime>,
726    #[serde(default)]
727    pub hosts: Vec<ConfiguredRunnerHost>,
728}
729
730#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
731pub struct OpenRouterConfig {
732    #[serde(default = "default_openrouter_commit_model")]
733    pub commit_model: String,
734    #[serde(default = "default_openrouter_release_notes_model")]
735    pub release_notes_model: String,
736    #[serde(default = "default_openrouter_commit_system_prompt")]
737    pub commit_system_prompt: String,
738    #[serde(default = "default_openrouter_release_notes_system_prompt")]
739    pub release_notes_system_prompt: String,
740}
741
742impl Default for OpenRouterConfig {
743    fn default() -> Self {
744        Self {
745            commit_model: default_openrouter_commit_model(),
746            release_notes_model: default_openrouter_release_notes_model(),
747            commit_system_prompt: default_openrouter_commit_system_prompt(),
748            release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
749        }
750    }
751}
752
753#[derive(Debug, Serialize, Deserialize, Clone)]
754pub struct SshConfig {
755    pub password: Option<String>,
756    pub username: Option<String>,
757    pub host: Option<String>,
758    pub project_dir: Option<String>,
759    #[serde(default)]
760    pub xbp_api_token: Option<String>,
761    pub openrouter_api_key: Option<String>,
762    pub github_oauth2_key: Option<String>,
763    /// Primary / default Cloudflare API token (legacy single slot; still used by Wrangler).
764    #[serde(default)]
765    pub cloudflare_api_token: Option<String>,
766    /// User API token (`cfut_…`). Required for Workers Builds; also fine for Wrangler.
767    #[serde(default)]
768    pub cloudflare_user_api_token: Option<String>,
769    /// Account-owned API token (`cfat_…`). OK for Wrangler/deploy; not accepted by Workers Builds yet.
770    #[serde(default)]
771    pub cloudflare_account_api_token: Option<String>,
772    #[serde(default)]
773    pub cloudflare_account_id: Option<String>,
774    pub linear_api_key: Option<String>,
775    #[serde(default)]
776    pub npm_token: Option<String>,
777    #[serde(default)]
778    pub crates_token: Option<String>,
779    #[serde(default)]
780    pub openrouter: OpenRouterConfig,
781    #[serde(default)]
782    pub linear: Option<LinearConfig>,
783    #[serde(default)]
784    pub cli_auth: Option<CliAuthState>,
785    #[serde(default)]
786    pub device: Option<DeviceIdentity>,
787    #[serde(default)]
788    pub secret_metadata: BTreeMap<String, SecretMetadata>,
789    #[serde(default)]
790    pub system_inventory: Option<SystemInventory>,
791    #[serde(default)]
792    pub cursor_ingest: Option<CursorIngestState>,
793    #[serde(default)]
794    pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
795    #[serde(default)]
796    pub dns_inventory: Option<DnsInventoryCache>,
797    #[serde(default)]
798    pub runners: Option<RunnerMachineConfig>,
799    /// Client-side worktree-watch path filters (paths, folders, banned words).
800    #[serde(default)]
801    pub worktree_watch: Option<WorktreeWatchConfig>,
802    /// Defaults for `xbp issues` automation (overridden by project config `issues:`).
803    #[serde(default)]
804    pub issues: Option<TodosConfig>,
805    /// Deprecated defaults for `xbp todos` automation. Read as fallback for `issues:`.
806    #[serde(default)]
807    pub todos: Option<TodosConfig>,
808    /// Global Discord webhook defaults (overridden by project `.xbp/xbp.toml` / project config `discord:`).
809    #[serde(default)]
810    pub discord: Option<DiscordConfig>,
811    /// Global OCI registry credentials (tokens never printed by default).
812    #[serde(default)]
813    pub oci: Option<GlobalOciConfig>,
814}
815
816/// Global OCI registry credential store (`~/.xbp/config.yaml` → `oci.registries`).
817#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
818pub struct GlobalOciConfig {
819    #[serde(default)]
820    pub registries: BTreeMap<String, GlobalOciRegistryCred>,
821}
822
823#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
824pub struct GlobalOciRegistryCred {
825    #[serde(default)]
826    pub username: Option<String>,
827    #[serde(default)]
828    pub token: Option<String>,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832pub enum SecretProvider {
833    OpenRouter,
834    Github,
835    Cloudflare,
836    Linear,
837    Npm,
838    Crates,
839}
840
841impl SecretProvider {
842    pub fn from_key(key: &str) -> Option<Self> {
843        match key.trim().to_ascii_lowercase().as_str() {
844            "openrouter" => Some(Self::OpenRouter),
845            "github" => Some(Self::Github),
846            "cloudflare" => Some(Self::Cloudflare),
847            "linear" => Some(Self::Linear),
848            "npm" | "npmjs" => Some(Self::Npm),
849            "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
850            _ => None,
851        }
852    }
853
854    pub fn as_key(&self) -> &'static str {
855        match self {
856            SecretProvider::OpenRouter => "openrouter",
857            SecretProvider::Github => "github",
858            SecretProvider::Cloudflare => "cloudflare",
859            SecretProvider::Linear => "linear",
860            SecretProvider::Npm => "npm",
861            SecretProvider::Crates => "crates",
862        }
863    }
864
865    pub fn config_field(&self) -> &'static str {
866        match self {
867            SecretProvider::OpenRouter => "openrouter_api_key",
868            SecretProvider::Github => "github_oauth2_key",
869            SecretProvider::Cloudflare => "cloudflare_api_token",
870            SecretProvider::Linear => "linear_api_key",
871            SecretProvider::Npm => "npm_token",
872            SecretProvider::Crates => "crates_token",
873        }
874    }
875}
876
877impl Default for SshConfig {
878    fn default() -> Self {
879        Self::new()
880    }
881}
882
883impl SshConfig {
884    pub fn new() -> Self {
885        SshConfig {
886            password: None,
887            username: None,
888            host: None,
889            project_dir: None,
890            xbp_api_token: None,
891            openrouter_api_key: None,
892            github_oauth2_key: None,
893            cloudflare_api_token: None,
894            cloudflare_user_api_token: None,
895            cloudflare_account_api_token: None,
896            cloudflare_account_id: None,
897            linear_api_key: None,
898            npm_token: None,
899            crates_token: None,
900            openrouter: OpenRouterConfig::default(),
901            linear: None,
902            cli_auth: None,
903            device: None,
904            secret_metadata: BTreeMap::new(),
905            system_inventory: None,
906            cursor_ingest: None,
907            system_inventory_refresh: None,
908            dns_inventory: None,
909            runners: None,
910            worktree_watch: None,
911            issues: None,
912            todos: None,
913            discord: None,
914            oci: None,
915        }
916    }
917
918    /// Load worktree-watch ignore rules from the global config (client-side only).
919    pub fn worktree_watch_config(&self) -> WorktreeWatchConfig {
920        self.worktree_watch.clone().unwrap_or_default()
921    }
922
923    pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
924        match provider {
925            SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
926            SecretProvider::Github => self.github_oauth2_key.as_deref(),
927            SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
928            SecretProvider::Linear => self.linear_api_key.as_deref(),
929            SecretProvider::Npm => self.npm_token.as_deref(),
930            SecretProvider::Crates => self.crates_token.as_deref(),
931        }
932    }
933
934    pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
935        match provider {
936            SecretProvider::OpenRouter => self.openrouter_api_key = value,
937            SecretProvider::Github => self.github_oauth2_key = value,
938            SecretProvider::Cloudflare => self.cloudflare_api_token = value,
939            SecretProvider::Linear => self.linear_api_key = value,
940            SecretProvider::Npm => self.npm_token = value,
941            SecretProvider::Crates => self.crates_token = value,
942        }
943
944        match self.get_secret(provider) {
945            Some(_) => {
946                self.secret_metadata.insert(
947                    provider.as_key().to_string(),
948                    SecretMetadata {
949                        added_at: Some(Utc::now()),
950                    },
951                );
952            }
953            None => {
954                self.secret_metadata.remove(provider.as_key());
955            }
956        }
957    }
958
959    pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
960        self.secret_metadata.get(provider.as_key())
961    }
962
963    pub fn load() -> Result<Self, String> {
964        let config_path = get_config_path();
965        let legacy_path = legacy_config_path();
966        let path_to_read = if config_path.exists() {
967            config_path
968        } else if legacy_path.exists() {
969            legacy_path
970        } else {
971            return Ok(SshConfig::new());
972        };
973
974        let content = fs::read_to_string(&path_to_read)
975            .map_err(|e| format!("Failed to read config file: {}", e))?;
976        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
977    }
978
979    pub fn save(&self) -> Result<(), String> {
980        let config_path = get_config_path();
981        let config_dir = config_path.parent().ok_or("Invalid config path")?;
982        fs::create_dir_all(config_dir)
983            .map_err(|e| format!("Failed to create config directory: {}", e))?;
984
985        let content = serde_yaml::to_string(self)
986            .map_err(|e| format!("Failed to serialize config: {}", e))?;
987        fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
988    }
989}
990
991/// Resolve worktree-watch filters from the global XBP config file (client-side only).
992pub fn resolve_worktree_watch_config() -> WorktreeWatchConfig {
993    SshConfig::load()
994        .ok()
995        .map(|cfg| cfg.worktree_watch_config())
996        .unwrap_or_default()
997}
998
999pub fn global_runner_root_dir() -> Result<PathBuf, String> {
1000    let paths = ensure_global_xbp_paths()?;
1001    let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1002    if let Some(path) = config
1003        .runners
1004        .as_ref()
1005        .and_then(|runners| runners.runtime_root.as_deref())
1006        .map(str::trim)
1007        .filter(|value| !value.is_empty())
1008    {
1009        let candidate = PathBuf::from(path);
1010        fs::create_dir_all(&candidate).map_err(|error| {
1011            format!(
1012                "Failed to create runner runtime root {}: {}",
1013                candidate.display(),
1014                error
1015            )
1016        })?;
1017        return Ok(candidate);
1018    }
1019
1020    let default_root = paths.root_dir.join("github").join("runners");
1021    fs::create_dir_all(&default_root).map_err(|error| {
1022        format!(
1023            "Failed to create runner runtime root {}: {}",
1024            default_root.display(),
1025            error
1026        )
1027    })?;
1028    Ok(default_root)
1029}
1030
1031#[derive(Debug, Serialize, Deserialize, Clone)]
1032pub struct VersioningFilesConfig {
1033    #[serde(default = "default_versioning_files")]
1034    pub files: Vec<String>,
1035}
1036
1037impl Default for VersioningFilesConfig {
1038    fn default() -> Self {
1039        Self {
1040            files: default_versioning_files(),
1041        }
1042    }
1043}
1044
1045#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
1046pub struct PackageNameLookup {
1047    pub file: String,
1048    pub format: String,
1049    pub key: String,
1050    pub registry: String,
1051}
1052
1053#[derive(Debug, Serialize, Deserialize, Clone)]
1054pub struct PackageNameFilesConfig {
1055    #[serde(default = "default_package_name_lookups")]
1056    pub lookups: Vec<PackageNameLookup>,
1057}
1058
1059impl Default for PackageNameFilesConfig {
1060    fn default() -> Self {
1061        Self {
1062            lookups: default_package_name_lookups(),
1063        }
1064    }
1065}
1066
1067pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
1068    let root_dir = preferred_global_root_dir();
1069
1070    let paths = GlobalXbpPaths {
1071        config_file: root_dir.join("config.yaml"),
1072        ssh_dir: root_dir.join("ssh"),
1073        cache_dir: root_dir.join("cache"),
1074        logs_dir: root_dir.join("logs"),
1075        versioning_files_file: root_dir.join("versioning-files.yaml"),
1076        package_name_files_file: root_dir.join("package-name-files.yaml"),
1077        worktree_ignore_file: root_dir.join(GLOBAL_WORKTREE_IGNORE_FILENAME),
1078        root_dir,
1079    };
1080
1081    for dir in [
1082        &paths.root_dir,
1083        &paths.ssh_dir,
1084        &paths.cache_dir,
1085        &paths.logs_dir,
1086    ] {
1087        fs::create_dir_all(dir)
1088            .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
1089    }
1090
1091    maybe_migrate_legacy_windows_files(&paths)?;
1092
1093    if !paths.config_file.exists() {
1094        let default_config = serde_yaml::to_string(&SshConfig::new())
1095            .map_err(|e| format!("Failed to serialize default config: {}", e))?;
1096        fs::write(&paths.config_file, default_config).map_err(|e| {
1097            format!(
1098                "Failed to initialize config file {}: {}",
1099                paths.config_file.display(),
1100                e
1101            )
1102        })?;
1103    }
1104    sync_global_config_defaults_at(&paths.config_file)?;
1105
1106    sync_versioning_files_registry_at(&paths.versioning_files_file)?;
1107    sync_package_name_files_registry_at(&paths.package_name_files_file)?;
1108    sync_global_worktree_ignore_file(&paths.worktree_ignore_file)?;
1109
1110    Ok(paths)
1111}
1112
1113/// Load machine-wide worktree-watch ignore rules from `~/.xbp/.xbp-worktree-ignore`.
1114pub fn load_global_worktree_ignore() -> XbpIgnoreSet {
1115    match ensure_global_xbp_paths() {
1116        Ok(paths) => load_global_worktree_ignore_from_path(&paths.worktree_ignore_file),
1117        Err(_) => XbpIgnoreSet::empty(),
1118    }
1119}
1120
1121fn default_openrouter_commit_model() -> String {
1122    DEFAULT_MODEL.to_string()
1123}
1124
1125fn default_openrouter_release_notes_model() -> String {
1126    DEFAULT_MODEL.to_string()
1127}
1128
1129fn default_openrouter_commit_system_prompt() -> String {
1130    DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1131}
1132
1133fn default_openrouter_release_notes_system_prompt() -> String {
1134    DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1135}
1136
1137const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
1138
1139fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
1140where
1141    F: FnOnce(&OpenRouterConfig) -> &str,
1142{
1143    SshConfig::load()
1144        .ok()
1145        .map(|cfg| selector(&cfg.openrouter).trim().to_string())
1146        .filter(|value| !value.is_empty())
1147        .unwrap_or_else(|| fallback.to_string())
1148}
1149
1150fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
1151    let content = fs::read_to_string(config_path).map_err(|e| {
1152        format!(
1153            "Failed to read config file {}: {}",
1154            config_path.display(),
1155            e
1156        )
1157    })?;
1158
1159    if content.contains("openrouter:")
1160        && content.contains("commit_model:")
1161        && content.contains("release_notes_model:")
1162        && content.contains("commit_system_prompt:")
1163        && content.contains("release_notes_system_prompt:")
1164    {
1165        return Ok(());
1166    }
1167
1168    let config: SshConfig = serde_yaml::from_str(&content)
1169        .map_err(|e| format!("Failed to parse config file: {}", e))?;
1170    let normalized =
1171        serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
1172
1173    if normalized != content {
1174        fs::write(config_path, normalized).map_err(|e| {
1175            format!(
1176                "Failed to write config file {}: {}",
1177                config_path.display(),
1178                e
1179            )
1180        })?;
1181    }
1182
1183    Ok(())
1184}
1185
1186pub fn resolve_openrouter_api_key() -> Option<String> {
1187    env::var("OPENROUTER_API_KEY")
1188        .ok()
1189        .map(|value| value.trim().to_string())
1190        .filter(|value| !value.is_empty())
1191        .or_else(|| {
1192            SshConfig::load()
1193                .ok()
1194                .and_then(|cfg| {
1195                    cfg.get_secret(SecretProvider::OpenRouter)
1196                        .map(str::to_string)
1197                })
1198                .map(|value| value.trim().to_string())
1199                .filter(|value| !value.is_empty())
1200        })
1201}
1202
1203pub fn resolve_openrouter_commit_model() -> String {
1204    resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
1205}
1206
1207pub fn resolve_openrouter_release_notes_model() -> String {
1208    resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
1209}
1210
1211pub fn resolve_openrouter_commit_system_prompt() -> String {
1212    resolve_openrouter_config_value(
1213        |cfg| cfg.commit_system_prompt.as_str(),
1214        DEFAULT_COMMIT_SYSTEM_PROMPT,
1215    )
1216}
1217
1218pub fn resolve_openrouter_release_notes_system_prompt() -> String {
1219    resolve_openrouter_config_value(
1220        |cfg| cfg.release_notes_system_prompt.as_str(),
1221        DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1222    )
1223}
1224
1225pub fn resolve_xbp_api_token() -> Option<String> {
1226    env::var("XBP_API_TOKEN")
1227        .ok()
1228        .map(|value| value.trim().to_string())
1229        .filter(|value| !value.is_empty())
1230        .or_else(|| {
1231            SshConfig::load()
1232                .ok()
1233                .and_then(|cfg| cfg.xbp_api_token)
1234                .map(|value| value.trim().to_string())
1235                .filter(|value| !value.is_empty())
1236        })
1237}
1238
1239pub fn resolve_github_oauth2_key() -> Option<String> {
1240    for env_var in [
1241        "GITHUB_TOKEN",
1242        "GITHUB_OAUTH2_KEY",
1243        "GITHUB_OAUTH2_TOKEN",
1244        "GITHUB_OAUTH_TOKEN",
1245    ] {
1246        if let Ok(value) = env::var(env_var) {
1247            let token = value.trim();
1248            if !token.is_empty() {
1249                return Some(token.to_string());
1250            }
1251        }
1252    }
1253
1254    SshConfig::load()
1255        .ok()
1256        .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
1257        .map(|value| value.trim().to_string())
1258        .filter(|value| !value.is_empty())
1259}
1260
1261pub fn resolve_cloudflare_api_token() -> Option<String> {
1262    cloudflare_api_token_candidates()
1263        .into_iter()
1264        .next()
1265        .map(|(_, token)| token)
1266}
1267
1268/// Cloudflare scannable credential kind from prefix
1269/// (`cfut_` user, `cfat_` account, `cfk_` global key).
1270///
1271/// See <https://developers.cloudflare.com/fundamentals/api/get-started/token-formats/>.
1272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273pub enum CloudflareTokenKind {
1274    /// User API token (`cfut_…`). Preferred for Workers Builds.
1275    User,
1276    /// Account-owned API token (`cfat_…`). Wrangler OK; Builds not supported yet.
1277    Account,
1278    /// Global API key (`cfk_…`).
1279    GlobalKey,
1280    /// Pre-2026 unprefixed token/key — kind unknown from string alone.
1281    Legacy,
1282    /// Empty after trim.
1283    Empty,
1284}
1285
1286/// Classify a Cloudflare API credential by scannable prefix.
1287pub fn classify_cloudflare_token(token: &str) -> CloudflareTokenKind {
1288    let token = token.trim();
1289    if token.is_empty() {
1290        return CloudflareTokenKind::Empty;
1291    }
1292    if token.starts_with("cfut_") {
1293        CloudflareTokenKind::User
1294    } else if token.starts_with("cfat_") {
1295        CloudflareTokenKind::Account
1296    } else if token.starts_with("cfk_") {
1297        CloudflareTokenKind::GlobalKey
1298    } else {
1299        CloudflareTokenKind::Legacy
1300    }
1301}
1302
1303/// Short operator-facing label for doctor / error copy.
1304pub fn cloudflare_token_kind_label(kind: CloudflareTokenKind) -> &'static str {
1305    match kind {
1306        CloudflareTokenKind::User => "user token (cfut_)",
1307        CloudflareTokenKind::Account => "account-owned token (cfat_)",
1308        CloudflareTokenKind::GlobalKey => "global API key (cfk_)",
1309        CloudflareTokenKind::Legacy => "legacy unprefixed token",
1310        CloudflareTokenKind::Empty => "missing",
1311    }
1312}
1313
1314/// Safe token preview for logs: `cfat…ca47 (len=53, account-owned)`.
1315pub fn format_cloudflare_token_preview(token: &str) -> String {
1316    let t = token.trim();
1317    if t.is_empty() {
1318        return "missing".to_string();
1319    }
1320    let kind = classify_cloudflare_token(t);
1321    let kind_hint = match kind {
1322        CloudflareTokenKind::User => "user",
1323        CloudflareTokenKind::Account => "account-owned",
1324        CloudflareTokenKind::GlobalKey => "global-key",
1325        CloudflareTokenKind::Legacy => "legacy",
1326        CloudflareTokenKind::Empty => "missing",
1327    };
1328    if t.len() <= 8 {
1329        return format!("******** ({kind_hint})");
1330    }
1331    format!(
1332        "{}…{} (len={}, {kind_hint})",
1333        &t[..4.min(t.len())],
1334        &t[t.len().saturating_sub(4)..],
1335        t.len()
1336    )
1337}
1338
1339/// Which config slot to write when storing a Cloudflare token.
1340#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1341pub enum CloudflareTokenSlot {
1342    /// Route by scannable prefix (`cfut_` → user, `cfat_` → account, else primary).
1343    #[default]
1344    Auto,
1345    /// Explicit user slot (`cloudflare_user_api_token`).
1346    User,
1347    /// Explicit account-owned slot (`cloudflare_account_api_token`).
1348    Account,
1349    /// Legacy primary slot only (`cloudflare_api_token`).
1350    Primary,
1351    /// Clear/select every slot (delete only).
1352    All,
1353}
1354
1355impl CloudflareTokenSlot {
1356    pub fn parse(raw: &str) -> Result<Self, String> {
1357        match raw.trim().to_ascii_lowercase().as_str() {
1358            "auto" => Ok(Self::Auto),
1359            "user" | "cfut" | "cfut_" => Ok(Self::User),
1360            "account" | "account-owned" | "cfat" | "cfat_" => Ok(Self::Account),
1361            "primary" | "default" | "legacy" => Ok(Self::Primary),
1362            "all" => Ok(Self::All),
1363            other => Err(format!(
1364                "Unknown Cloudflare token kind `{other}`. Use auto, user, account, primary, or all."
1365            )),
1366        }
1367    }
1368
1369    pub fn as_str(self) -> &'static str {
1370        match self {
1371            Self::Auto => "auto",
1372            Self::User => "user",
1373            Self::Account => "account",
1374            Self::Primary => "primary",
1375            Self::All => "all",
1376        }
1377    }
1378}
1379
1380/// Result of storing a Cloudflare token into one or more config slots.
1381#[derive(Debug, Clone)]
1382pub struct CloudflareTokenStoreReport {
1383    pub kind: CloudflareTokenKind,
1384    pub slots_written: Vec<&'static str>,
1385    pub notes: Vec<String>,
1386}
1387
1388impl SshConfig {
1389    pub fn cloudflare_user_api_token(&self) -> Option<&str> {
1390        self.cloudflare_user_api_token
1391            .as_deref()
1392            .map(str::trim)
1393            .filter(|v| !v.is_empty())
1394    }
1395
1396    pub fn cloudflare_account_api_token(&self) -> Option<&str> {
1397        self.cloudflare_account_api_token
1398            .as_deref()
1399            .map(str::trim)
1400            .filter(|v| !v.is_empty())
1401    }
1402
1403    pub fn cloudflare_primary_api_token(&self) -> Option<&str> {
1404        self.cloudflare_api_token
1405            .as_deref()
1406            .map(str::trim)
1407            .filter(|v| !v.is_empty())
1408    }
1409
1410    /// Store a token in the right slot(s). Primary is updated when it is empty or matches
1411    /// the same kind being written, so Wrangler keeps a sensible default.
1412    pub fn store_cloudflare_token(
1413        &mut self,
1414        token: impl Into<String>,
1415        slot: CloudflareTokenSlot,
1416    ) -> Result<CloudflareTokenStoreReport, String> {
1417        let token = token.into().trim().to_string();
1418        if token.is_empty() {
1419            return Err("Refusing to store an empty Cloudflare API token.".into());
1420        }
1421        if matches!(slot, CloudflareTokenSlot::All) {
1422            return Err(
1423                "Cannot store into kind `all` — use auto, user, account, or primary.".into(),
1424            );
1425        }
1426
1427        let detected = classify_cloudflare_token(&token);
1428        let mut slots_written = Vec::new();
1429        let mut notes = Vec::new();
1430
1431        let target = match slot {
1432            CloudflareTokenSlot::Auto => match detected {
1433                CloudflareTokenKind::User => CloudflareTokenSlot::User,
1434                CloudflareTokenKind::Account => CloudflareTokenSlot::Account,
1435                CloudflareTokenKind::GlobalKey => {
1436                    notes.push(
1437                        "Global API keys (`cfk_`) are not recommended; stored in primary only."
1438                            .into(),
1439                    );
1440                    CloudflareTokenSlot::Primary
1441                }
1442                CloudflareTokenKind::Legacy | CloudflareTokenKind::Empty => {
1443                    notes.push(
1444                        "Unprefixed/legacy token — stored in primary only. Prefer scannable \
1445                         `cfut_` (user) or `cfat_` (account) tokens."
1446                            .into(),
1447                    );
1448                    CloudflareTokenSlot::Primary
1449                }
1450            },
1451            other => other,
1452        };
1453
1454        if slot != CloudflareTokenSlot::Auto {
1455            match (slot, detected) {
1456                (CloudflareTokenSlot::User, CloudflareTokenKind::Account) => notes.push(
1457                    "Warning: value looks like an account-owned token (`cfat_`) but was forced \
1458                     into the user slot."
1459                        .into(),
1460                ),
1461                (CloudflareTokenSlot::Account, CloudflareTokenKind::User) => notes.push(
1462                    "Warning: value looks like a user token (`cfut_`) but was forced into the \
1463                     account slot."
1464                        .into(),
1465                ),
1466                _ => {}
1467            }
1468        }
1469
1470        match target {
1471            CloudflareTokenSlot::User => {
1472                self.cloudflare_user_api_token = Some(token.clone());
1473                slots_written.push("cloudflare_user_api_token");
1474                self.touch_secret_metadata("cloudflare_user");
1475                if self.should_mirror_cloudflare_primary(CloudflareTokenKind::User) {
1476                    self.cloudflare_api_token = Some(token.clone());
1477                    slots_written.push("cloudflare_api_token (primary)");
1478                    self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1479                    notes.push(
1480                        "Also set primary `cloudflare_api_token` for Wrangler/default resolve."
1481                            .into(),
1482                    );
1483                }
1484                if detected == CloudflareTokenKind::User {
1485                    notes.push(
1486                        "User token (`cfut_`) — suitable for Workers Builds and Wrangler.".into(),
1487                    );
1488                }
1489            }
1490            CloudflareTokenSlot::Account => {
1491                self.cloudflare_account_api_token = Some(token.clone());
1492                slots_written.push("cloudflare_account_api_token");
1493                self.touch_secret_metadata("cloudflare_account");
1494                if self.should_mirror_cloudflare_primary(CloudflareTokenKind::Account) {
1495                    self.cloudflare_api_token = Some(token.clone());
1496                    slots_written.push("cloudflare_api_token (primary)");
1497                    self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1498                    notes.push(
1499                        "Also set primary `cloudflare_api_token` for Wrangler/default resolve."
1500                            .into(),
1501                    );
1502                }
1503                if detected == CloudflareTokenKind::Account {
1504                    notes.push(
1505                        "Account-owned token (`cfat_`) — Wrangler OK; Workers Builds needs a \
1506                         separate user token (`cfut_`) in `cloudflare_user_api_token`."
1507                            .into(),
1508                    );
1509                }
1510            }
1511            CloudflareTokenSlot::Primary => {
1512                self.cloudflare_api_token = Some(token);
1513                slots_written.push("cloudflare_api_token (primary)");
1514                self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1515            }
1516            CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => unreachable!(),
1517        }
1518
1519        Ok(CloudflareTokenStoreReport {
1520            kind: detected,
1521            slots_written,
1522            notes,
1523        })
1524    }
1525
1526    /// Delete one or all Cloudflare token slots.
1527    pub fn clear_cloudflare_token_slot(&mut self, slot: CloudflareTokenSlot) -> Vec<&'static str> {
1528        let mut cleared = Vec::new();
1529        let clear_primary = || {};
1530        let _ = clear_primary;
1531        match slot {
1532            CloudflareTokenSlot::User => {
1533                if self.cloudflare_user_api_token.take().is_some() {
1534                    cleared.push("cloudflare_user_api_token");
1535                    self.secret_metadata.remove("cloudflare_user");
1536                }
1537            }
1538            CloudflareTokenSlot::Account => {
1539                if self.cloudflare_account_api_token.take().is_some() {
1540                    cleared.push("cloudflare_account_api_token");
1541                    self.secret_metadata.remove("cloudflare_account");
1542                }
1543            }
1544            CloudflareTokenSlot::Primary => {
1545                if self.cloudflare_api_token.take().is_some() {
1546                    cleared.push("cloudflare_api_token");
1547                    self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
1548                }
1549            }
1550            CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => {
1551                if self.cloudflare_user_api_token.take().is_some() {
1552                    cleared.push("cloudflare_user_api_token");
1553                    self.secret_metadata.remove("cloudflare_user");
1554                }
1555                if self.cloudflare_account_api_token.take().is_some() {
1556                    cleared.push("cloudflare_account_api_token");
1557                    self.secret_metadata.remove("cloudflare_account");
1558                }
1559                if self.cloudflare_api_token.take().is_some() {
1560                    cleared.push("cloudflare_api_token");
1561                    self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
1562                }
1563            }
1564        }
1565        cleared
1566    }
1567
1568    fn should_mirror_cloudflare_primary(&self, writing: CloudflareTokenKind) -> bool {
1569        match self.cloudflare_primary_api_token() {
1570            None => true,
1571            Some(primary) => match writing {
1572                // Prefer user tokens as Wrangler/default primary.
1573                CloudflareTokenKind::User => true,
1574                CloudflareTokenKind::Account => matches!(
1575                    classify_cloudflare_token(primary),
1576                    CloudflareTokenKind::Account | CloudflareTokenKind::Empty
1577                ),
1578                _ => classify_cloudflare_token(primary) == writing,
1579            },
1580        }
1581    }
1582
1583    fn touch_secret_metadata(&mut self, key: &str) {
1584        self.secret_metadata.insert(
1585            key.to_string(),
1586            SecretMetadata {
1587                added_at: Some(Utc::now()),
1588            },
1589        );
1590    }
1591
1592    /// Configured Cloudflare tokens as (source label, token), kind-specific first.
1593    pub fn cloudflare_config_token_entries(&self) -> Vec<(String, String)> {
1594        let mut out = Vec::new();
1595        if let Some(t) = self.cloudflare_user_api_token() {
1596            out.push((
1597                "global cloudflare_user_api_token (user)".into(),
1598                t.to_string(),
1599            ));
1600        }
1601        if let Some(t) = self.cloudflare_primary_api_token() {
1602            let kind = cloudflare_token_kind_label(classify_cloudflare_token(t));
1603            out.push((
1604                format!("global cloudflare_api_token (primary, {kind})"),
1605                t.to_string(),
1606            ));
1607        }
1608        if let Some(t) = self.cloudflare_account_api_token() {
1609            out.push((
1610                "global cloudflare_account_api_token (account-owned)".into(),
1611                t.to_string(),
1612            ));
1613        }
1614        out
1615    }
1616}
1617
1618/// Ordered Cloudflare API token candidates: process env first, then global config slots.
1619///
1620/// Deduplicates identical secrets. Prefer user tokens earlier when `prefer_user_for_builds`
1621/// is true (Workers Builds rejects account-owned tokens).
1622pub fn cloudflare_api_token_candidates() -> Vec<(String, String)> {
1623    cloudflare_api_token_candidates_ordered(false)
1624}
1625
1626/// Same as [`cloudflare_api_token_candidates`], but user-class tokens are tried before
1627/// account-owned ones (env + config). Use for Workers Builds API calls.
1628pub fn cloudflare_api_token_candidates_builds_first() -> Vec<(String, String)> {
1629    cloudflare_api_token_candidates_ordered(true)
1630}
1631
1632fn cloudflare_api_token_candidates_ordered(prefer_user_for_builds: bool) -> Vec<(String, String)> {
1633    let mut out = Vec::new();
1634    let mut seen = std::collections::BTreeSet::new();
1635
1636    let push = |out: &mut Vec<(String, String)>,
1637                seen: &mut std::collections::BTreeSet<String>,
1638                source: &str,
1639                token: String| {
1640        let token = token.trim().to_string();
1641        if token.is_empty() || !seen.insert(token.clone()) {
1642            return;
1643        }
1644        out.push((source.to_string(), token));
1645    };
1646
1647    // Env: generic first (historical), then explicit kind envs.
1648    if let Ok(value) = env::var("CLOUDFLARE_API_TOKEN") {
1649        push(
1650            &mut out,
1651            &mut seen,
1652            "process env CLOUDFLARE_API_TOKEN",
1653            value,
1654        );
1655    }
1656    if let Ok(value) = env::var("CLOUDFLARE_USER_API_TOKEN") {
1657        push(
1658            &mut out,
1659            &mut seen,
1660            "process env CLOUDFLARE_USER_API_TOKEN",
1661            value,
1662        );
1663    }
1664    if let Ok(value) = env::var("CLOUDFLARE_ACCOUNT_API_TOKEN") {
1665        push(
1666            &mut out,
1667            &mut seen,
1668            "process env CLOUDFLARE_ACCOUNT_API_TOKEN",
1669            value,
1670        );
1671    }
1672
1673    if let Ok(cfg) = SshConfig::load() {
1674        for (source, token) in cfg.cloudflare_config_token_entries() {
1675            push(&mut out, &mut seen, &source, token);
1676        }
1677    }
1678
1679    if prefer_user_for_builds {
1680        out.sort_by_key(|(_, token)| {
1681            // Lower sorts first: user=0, legacy=1, account=2, global=3
1682            match classify_cloudflare_token(token) {
1683                CloudflareTokenKind::User => 0u8,
1684                CloudflareTokenKind::Legacy => 1,
1685                CloudflareTokenKind::Empty => 2,
1686                CloudflareTokenKind::Account => 3,
1687                CloudflareTokenKind::GlobalKey => 4,
1688            }
1689        });
1690    }
1691
1692    out
1693}
1694
1695/// True when process env `CLOUDFLARE_API_TOKEN` differs from the resolved primary config token.
1696pub fn cloudflare_env_token_shadows_config() -> bool {
1697    let env_tok = env::var("CLOUDFLARE_API_TOKEN")
1698        .ok()
1699        .map(|v| v.trim().to_string())
1700        .filter(|v| !v.is_empty());
1701    let cfg_tok = SshConfig::load().ok().and_then(|cfg| {
1702        cfg.cloudflare_primary_api_token()
1703            .or_else(|| cfg.cloudflare_user_api_token())
1704            .or_else(|| cfg.cloudflare_account_api_token())
1705            .map(str::to_string)
1706    });
1707    match (env_tok, cfg_tok) {
1708        (Some(a), Some(b)) => a != b,
1709        _ => false,
1710    }
1711}
1712
1713pub fn cloudflare_account_id_from_process_env() -> Option<String> {
1714    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
1715        env::var(key)
1716            .ok()
1717            .map(|value| value.trim().to_string())
1718            .filter(|value| !value.is_empty())
1719    })
1720}
1721
1722pub fn cloudflare_account_id_from_project_env() -> Option<String> {
1723    let current_dir = env::current_dir().ok()?;
1724    let found = find_xbp_config_upwards(&current_dir)?;
1725    let lookup = load_env_lookup(&found.project_root);
1726    first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
1727}
1728
1729pub fn cloudflare_account_id_from_global_config() -> Option<String> {
1730    SshConfig::load()
1731        .ok()
1732        .and_then(|cfg| cfg.cloudflare_account_id)
1733        .map(|value| value.trim().to_string())
1734        .filter(|value| !value.is_empty())
1735}
1736
1737pub fn resolve_cloudflare_account_id() -> Option<String> {
1738    cloudflare_account_id_from_process_env()
1739        .or_else(cloudflare_account_id_from_project_env)
1740        .or_else(cloudflare_account_id_from_global_config)
1741}
1742
1743pub fn resolve_linear_api_key() -> Option<String> {
1744    SshConfig::load()
1745        .ok()
1746        .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
1747        .map(|value| value.trim().to_string())
1748        .filter(|value| !value.is_empty())
1749}
1750
1751/// Process / dotenv / global keys accepted for npm publish auth.
1752pub const NPM_TOKEN_ENV_KEYS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "NPM_CONFIG_TOKEN"];
1753
1754/// Process / dotenv / global keys accepted for crates.io publish auth.
1755pub const CRATES_TOKEN_ENV_KEYS: &[&str] = &["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"];
1756
1757/// How far to walk parent directories when resolving monorepo dotenv tokens.
1758const REGISTRY_TOKEN_DOTENV_MAX_DEPTH: usize = 12;
1759
1760/// Resolve an npm registry token for publishing.
1761///
1762/// Search order (first hit wins):
1763/// 1. Process env (`NPM_TOKEN`, `NODE_AUTH_TOKEN`, `NPM_CONFIG_TOKEN`)
1764/// 2. Dotenv files from the current XBP project root / cwd upwards to `.git`
1765///    (`.env.local` before `.env` in each directory)
1766/// 3. Global XBP config (`xbp config npm set-key` → `npm_token`)
1767///
1768/// Nested package roots (e.g. `packages/athena-auth-ui`) therefore inherit a
1769/// monorepo-root `NPM_TOKEN` without requiring a stale global token.
1770pub fn resolve_npm_token() -> Option<String> {
1771    let start = registry_token_search_start();
1772    resolve_npm_token_from(start.as_deref())
1773}
1774
1775/// Like [`resolve_npm_token`], but search dotenv files starting at `start_dir`
1776/// (typically the XBP project root that owns `publish.npm`).
1777pub fn resolve_npm_token_from(start_dir: Option<&Path>) -> Option<String> {
1778    token_from_process_env(NPM_TOKEN_ENV_KEYS)
1779        .or_else(|| {
1780            start_dir.and_then(|dir| {
1781                first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
1782                    .map(|(value, _)| value)
1783            })
1784        })
1785        .or_else(npm_token_from_global_config)
1786}
1787
1788pub fn resolve_crates_token() -> Option<String> {
1789    let start = registry_token_search_start();
1790    resolve_crates_token_from(start.as_deref())
1791}
1792
1793pub fn resolve_crates_token_from(start_dir: Option<&Path>) -> Option<String> {
1794    token_from_process_env(CRATES_TOKEN_ENV_KEYS)
1795        .or_else(|| {
1796            start_dir.and_then(|dir| {
1797                first_dotenv_value_upwards(
1798                    dir,
1799                    CRATES_TOKEN_ENV_KEYS,
1800                    REGISTRY_TOKEN_DOTENV_MAX_DEPTH,
1801                )
1802                .map(|(value, _)| value)
1803            })
1804        })
1805        .or_else(crates_token_from_global_config)
1806}
1807
1808fn registry_token_search_start() -> Option<PathBuf> {
1809    let cwd = env::current_dir().ok()?;
1810    find_xbp_config_upwards(&cwd)
1811        .map(|found| found.project_root)
1812        .or(Some(cwd))
1813}
1814
1815fn token_from_process_env(keys: &[&str]) -> Option<String> {
1816    keys.iter().find_map(|key| {
1817        env::var(key)
1818            .ok()
1819            .map(|value| value.trim().to_string())
1820            .filter(|value| !value.is_empty())
1821    })
1822}
1823
1824fn npm_token_from_global_config() -> Option<String> {
1825    SshConfig::load()
1826        .ok()
1827        .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
1828        .map(|value| value.trim().to_string())
1829        .filter(|value| !value.is_empty())
1830}
1831
1832fn crates_token_from_global_config() -> Option<String> {
1833    SshConfig::load()
1834        .ok()
1835        .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
1836        .map(|value| value.trim().to_string())
1837        .filter(|value| !value.is_empty())
1838}
1839
1840/// Mask a registry token for diagnostics (`npm_…NSMy` / `cio…abcd`).
1841pub fn mask_registry_token(token: &str) -> String {
1842    let trimmed = token.trim();
1843    if trimmed.len() <= 8 {
1844        return "***".to_string();
1845    }
1846    let prefix_len = trimmed
1847        .find('_')
1848        .map(|idx| (idx + 1).min(6))
1849        .unwrap_or(4)
1850        .min(trimmed.len() / 2);
1851    let prefix = &trimmed[..prefix_len];
1852    let suffix = &trimmed[trimmed.len().saturating_sub(4)..];
1853    format!("{prefix}…{suffix}")
1854}
1855
1856/// Human-readable description of where an npm token was resolved from (no secret).
1857pub fn describe_npm_token_sources(start_dir: Option<&Path>) -> Vec<String> {
1858    let mut lines = Vec::new();
1859    for key in NPM_TOKEN_ENV_KEYS {
1860        match env::var(key) {
1861            Ok(value) if !value.trim().is_empty() => {
1862                lines.push(format!(
1863                    "process env {key}: set ({})",
1864                    mask_registry_token(value.trim())
1865                ));
1866            }
1867            _ => lines.push(format!("process env {key}: unset")),
1868        }
1869    }
1870    if let Some(dir) = start_dir {
1871        match first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
1872        {
1873            Some((value, path)) => lines.push(format!(
1874                "dotenv {}: set ({})",
1875                path.display(),
1876                mask_registry_token(&value)
1877            )),
1878            None => lines.push(format!(
1879                "dotenv under {}: no NPM_TOKEN / NODE_AUTH_TOKEN / NPM_CONFIG_TOKEN",
1880                dir.display()
1881            )),
1882        }
1883    }
1884    match npm_token_from_global_config() {
1885        Some(value) => lines.push(format!(
1886            "global xbp config (npm_token): set ({})",
1887            mask_registry_token(&value)
1888        )),
1889        None => lines.push("global xbp config (npm_token): unset".to_string()),
1890    }
1891    lines
1892}
1893
1894pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
1895    let mut config = SshConfig::load()?;
1896    config.cloudflare_account_id = value;
1897    config.save()
1898}
1899
1900pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
1901    Ok(SshConfig::load()?.cloudflare_account_id)
1902}
1903
1904pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
1905    SshConfig::load()
1906        .ok()
1907        .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
1908}
1909
1910pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
1911    ensure_global_xbp_paths()?;
1912    let mut config = SshConfig::load()?;
1913    if let Some(device) = config.device.clone() {
1914        if !device.hardware_id.trim().is_empty() {
1915            return Ok(device);
1916        }
1917    }
1918
1919    let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
1920    let device = DeviceIdentity {
1921        hardware_id,
1922        created_at: Some(Utc::now()),
1923    };
1924    config.device = Some(device.clone());
1925    config.save()?;
1926    Ok(device)
1927}
1928
1929pub fn reserve_cursor_ingest_slot(
1930    trigger: &str,
1931    mode: &str,
1932    min_interval: ChronoDuration,
1933) -> Result<bool, String> {
1934    ensure_global_xbp_paths()?;
1935    let mut config = SshConfig::load()?;
1936    let now = Utc::now();
1937
1938    if let Some(state) = config.cursor_ingest.as_ref() {
1939        if let Some(last_attempted_at) = state.last_attempted_at {
1940            if now - last_attempted_at < min_interval {
1941                return Ok(false);
1942            }
1943        }
1944    }
1945
1946    let mut state = config.cursor_ingest.unwrap_or_default();
1947    state.last_status = Some("scheduled".to_string());
1948    state.last_trigger = Some(trigger.to_string());
1949    state.last_mode = Some(mode.to_string());
1950    state.last_attempted_at = Some(now);
1951    state.last_error = None;
1952    config.cursor_ingest = Some(state);
1953    config.save()?;
1954    Ok(true)
1955}
1956
1957pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
1958    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1959    let mut state = config.cursor_ingest.unwrap_or_default();
1960    state.last_status = Some("running".to_string());
1961    state.last_trigger = Some(trigger.to_string());
1962    state.last_mode = Some(mode.to_string());
1963    state.last_attempted_at = Some(Utc::now());
1964    state.last_error = None;
1965    config.cursor_ingest = Some(state);
1966    config.save()
1967}
1968
1969pub fn record_cursor_ingest_success(
1970    trigger: &str,
1971    mode: &str,
1972    workspace_count: usize,
1973    entry_count: usize,
1974    entries_skipped: usize,
1975) -> Result<(), String> {
1976    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1977    let mut state = config.cursor_ingest.unwrap_or_default();
1978    let completed_at = Utc::now();
1979    state.last_status = Some("success".to_string());
1980    state.last_trigger = Some(trigger.to_string());
1981    state.last_mode = Some(mode.to_string());
1982    state.last_attempted_at = Some(completed_at);
1983    state.last_succeeded_at = Some(completed_at);
1984    state.last_error = None;
1985    state.last_workspace_count = Some(workspace_count);
1986    state.last_entry_count = Some(entry_count);
1987    state.last_entries_skipped = Some(entries_skipped);
1988    config.cursor_ingest = Some(state);
1989    config.save()
1990}
1991
1992pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
1993    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1994    let mut state = config.cursor_ingest.unwrap_or_default();
1995    state.last_status = Some("failed".to_string());
1996    state.last_trigger = Some(trigger.to_string());
1997    state.last_mode = Some(mode.to_string());
1998    state.last_attempted_at = Some(Utc::now());
1999    state.last_error = Some(error.chars().take(400).collect());
2000    config.cursor_ingest = Some(state);
2001    config.save()
2002}
2003
2004pub fn reserve_system_inventory_refresh_slot(
2005    trigger: &str,
2006    mode: &str,
2007    include_cursor: bool,
2008    min_interval: ChronoDuration,
2009) -> Result<bool, String> {
2010    ensure_global_xbp_paths()?;
2011    let mut config = SshConfig::load()?;
2012    let now = Utc::now();
2013
2014    if let Some(existing) = config.system_inventory.as_ref() {
2015        if !system_inventory_needs_refresh(existing, include_cursor) {
2016            return Ok(false);
2017        }
2018    }
2019
2020    if let Some(state) = config.system_inventory_refresh.as_ref() {
2021        if let Some(last_attempted_at) = state.last_attempted_at {
2022            if now - last_attempted_at < min_interval {
2023                return Ok(false);
2024            }
2025        }
2026    }
2027
2028    let mut state = config.system_inventory_refresh.unwrap_or_default();
2029    state.last_status = Some("scheduled".to_string());
2030    state.last_trigger = Some(trigger.to_string());
2031    state.last_mode = Some(mode.to_string());
2032    state.last_attempted_at = Some(now);
2033    state.last_error = None;
2034    state.include_cursor = Some(include_cursor);
2035    config.system_inventory_refresh = Some(state);
2036    config.save()?;
2037    Ok(true)
2038}
2039
2040pub fn record_system_inventory_refresh_started(
2041    trigger: &str,
2042    mode: &str,
2043    include_cursor: bool,
2044) -> Result<(), String> {
2045    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2046    let mut state = config.system_inventory_refresh.unwrap_or_default();
2047    state.last_status = Some("running".to_string());
2048    state.last_trigger = Some(trigger.to_string());
2049    state.last_mode = Some(mode.to_string());
2050    state.last_attempted_at = Some(Utc::now());
2051    state.last_error = None;
2052    state.include_cursor = Some(include_cursor);
2053    config.system_inventory_refresh = Some(state);
2054    config.save()
2055}
2056
2057pub fn record_system_inventory_refresh_success(
2058    trigger: &str,
2059    mode: &str,
2060    include_cursor: bool,
2061    refreshed: bool,
2062) -> Result<(), String> {
2063    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2064    let mut state = config.system_inventory_refresh.unwrap_or_default();
2065    let completed_at = Utc::now();
2066    state.last_status = Some("success".to_string());
2067    state.last_trigger = Some(trigger.to_string());
2068    state.last_mode = Some(mode.to_string());
2069    state.last_attempted_at = Some(completed_at);
2070    state.last_succeeded_at = Some(completed_at);
2071    state.last_error = None;
2072    state.include_cursor = Some(include_cursor);
2073    state.refreshed = Some(refreshed);
2074    config.system_inventory_refresh = Some(state);
2075    config.save()
2076}
2077
2078pub fn record_system_inventory_refresh_failure(
2079    trigger: &str,
2080    mode: &str,
2081    include_cursor: bool,
2082    error: &str,
2083) -> Result<(), String> {
2084    let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2085    let mut state = config.system_inventory_refresh.unwrap_or_default();
2086    state.last_status = Some("failed".to_string());
2087    state.last_trigger = Some(trigger.to_string());
2088    state.last_mode = Some(mode.to_string());
2089    state.last_attempted_at = Some(Utc::now());
2090    state.last_error = Some(error.chars().take(400).collect());
2091    state.include_cursor = Some(include_cursor);
2092    config.system_inventory_refresh = Some(state);
2093    config.save()
2094}
2095
2096pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
2097where
2098    F: FnOnce(&mut DnsInventoryCache),
2099{
2100    ensure_global_xbp_paths()?;
2101    let mut config = SshConfig::load()?;
2102    let mut cache = config.dns_inventory.unwrap_or_default();
2103    updater(&mut cache);
2104    config.dns_inventory = Some(cache);
2105    config.save()
2106}
2107
2108pub fn sync_system_inventory(
2109    force: bool,
2110    include_cursor: bool,
2111    current_dir: Option<&Path>,
2112) -> Result<SystemInventorySyncResult, String> {
2113    let paths = ensure_global_xbp_paths()?;
2114    let mut config = SshConfig::load()?;
2115
2116    if !force {
2117        if let Some(existing) = config.system_inventory.clone() {
2118            if !system_inventory_needs_refresh(&existing, include_cursor) {
2119                return Ok(SystemInventorySyncResult {
2120                    inventory: existing,
2121                    config_path: paths.config_file,
2122                    refreshed: false,
2123                });
2124            }
2125        }
2126    }
2127
2128    let mut options = SystemInventoryOptions {
2129        include_cursor,
2130        current_dir: current_dir.map(Path::to_path_buf),
2131        xbp_global_root: Some(paths.root_dir.clone()),
2132    };
2133    if options.current_dir.is_none() {
2134        options.current_dir = env::current_dir().ok();
2135    }
2136
2137    let mut inventory = collect_codetime_system_inventory(&options);
2138    if !include_cursor {
2139        if let Some(existing_cursor) = config
2140            .system_inventory
2141            .as_ref()
2142            .and_then(|existing| existing.cursor.clone())
2143        {
2144            inventory.cursor = Some(existing_cursor);
2145        }
2146    }
2147
2148    config.system_inventory = Some(inventory.clone());
2149    config.save()?;
2150
2151    Ok(SystemInventorySyncResult {
2152        inventory,
2153        config_path: paths.config_file,
2154        refreshed: true,
2155    })
2156}
2157
2158fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
2159    if include_cursor && inventory.cursor.is_none() {
2160        return true;
2161    }
2162
2163    match inventory.collected_at {
2164        Some(collected_at) => {
2165            Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
2166        }
2167        None => true,
2168    }
2169}
2170
2171pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
2172    ensure_global_xbp_paths()
2173}
2174
2175pub fn get_config_path() -> PathBuf {
2176    ensure_global_xbp_paths()
2177        .map(|paths| paths.config_file)
2178        .unwrap_or_else(|_| legacy_config_path())
2179}
2180
2181#[cfg(target_os = "windows")]
2182fn legacy_config_path() -> PathBuf {
2183    dirs::config_dir()
2184        .unwrap_or_else(|| PathBuf::from("."))
2185        .join("xbp")
2186        .join("config.yaml")
2187}
2188
2189#[cfg(not(target_os = "windows"))]
2190fn legacy_config_path() -> PathBuf {
2191    dirs::home_dir()
2192        .unwrap_or_else(|| PathBuf::from("."))
2193        .join(".xbp")
2194        .join("config.yaml")
2195}
2196
2197/// Resolve the global `~/.xbp` (or platform equivalent) root.
2198///
2199/// On WSL this prefers an already-established Windows profile root when it is
2200/// reachable via `/mnt/<drive>/Users/...`, so worktree-watch spoils and config
2201/// stay reconcilable with native Windows installs of the same machine.
2202pub fn resolve_global_xbp_root_dir() -> PathBuf {
2203    preferred_global_root_dir()
2204}
2205
2206#[cfg(test)]
2207mod global_root_tests {
2208    use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
2209    use std::path::Path;
2210
2211    #[test]
2212    fn maps_windows_and_wsl_paths_both_ways() {
2213        assert_eq!(
2214            map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
2215            Some("/mnt/c/Users/szymon/.xbp".to_string())
2216        );
2217        assert_eq!(
2218            map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
2219            Some("C:/Users/szymon/.xbp".to_string())
2220        );
2221    }
2222
2223    #[test]
2224    fn scores_missing_root_as_zero() {
2225        assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
2226    }
2227}
2228
2229fn preferred_global_root_dir() -> PathBuf {
2230    if let Some(explicit) = explicit_xbp_home_from_env() {
2231        return explicit;
2232    }
2233
2234    let candidates = collect_global_xbp_root_candidates();
2235    if let Some(best) = pick_established_xbp_root(&candidates) {
2236        return best;
2237    }
2238
2239    // Fresh install defaults.
2240    #[cfg(target_os = "windows")]
2241    {
2242        if let Some(home_dir) = resolve_windows_home_dir() {
2243            let c_drive = Path::new(r"C:\");
2244            if c_drive.exists() {
2245                if windows_drive_letter(&home_dir) == Some('C') {
2246                    return home_dir.join(".xbp");
2247                }
2248                if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
2249                    let c_profile_candidate = c_drive.join(relative_profile_path);
2250                    if c_profile_candidate.exists() {
2251                        return c_profile_candidate.join(".xbp");
2252                    }
2253                }
2254            }
2255            return home_dir.join(".xbp");
2256        }
2257        return dirs::config_dir()
2258            .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
2259            .unwrap_or_else(|| PathBuf::from("."))
2260            .join("xbp");
2261    }
2262
2263    #[cfg(not(target_os = "windows"))]
2264    {
2265        // Prefer classic ~/.xbp over ~/.config/xbp so WSL/Linux matches Windows
2266        // layout and worktree-watch spoils stay discoverable.
2267        if let Some(home) = dirs::home_dir() {
2268            return home.join(".xbp");
2269        }
2270        dirs::config_dir()
2271            .unwrap_or_else(|| PathBuf::from("."))
2272            .join("xbp")
2273    }
2274}
2275
2276fn explicit_xbp_home_from_env() -> Option<PathBuf> {
2277    for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
2278        if let Ok(value) = env::var(key) {
2279            let trimmed = value.trim();
2280            if !trimmed.is_empty() {
2281                return Some(PathBuf::from(trimmed));
2282            }
2283        }
2284    }
2285    None
2286}
2287
2288fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
2289    let mut candidates: Vec<PathBuf> = Vec::new();
2290    let mut push = |path: PathBuf| {
2291        if candidates
2292            .iter()
2293            .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
2294        {
2295            return;
2296        }
2297        candidates.push(path);
2298    };
2299
2300    if let Some(explicit) = explicit_xbp_home_from_env() {
2301        push(explicit);
2302    }
2303
2304    #[cfg(target_os = "windows")]
2305    {
2306        if let Some(home_dir) = resolve_windows_home_dir() {
2307            push(home_dir.join(".xbp"));
2308            if let Some(relative) = windows_path_without_drive(&home_dir) {
2309                push(Path::new(r"C:\").join(relative).join(".xbp"));
2310            }
2311        }
2312        if let Some(config_dir) = dirs::config_dir() {
2313            push(config_dir.join("xbp"));
2314        }
2315    }
2316
2317    #[cfg(not(target_os = "windows"))]
2318    {
2319        if let Some(home) = dirs::home_dir() {
2320            push(home.join(".xbp"));
2321            push(home.join(".config").join("xbp"));
2322        }
2323        if let Some(config_dir) = dirs::config_dir() {
2324            push(config_dir.join("xbp"));
2325        }
2326
2327        // WSL: discover the Windows user profile .xbp that native Windows uses.
2328        for windows_home in discover_windows_profile_homes_from_unix() {
2329            push(windows_home.join(".xbp"));
2330        }
2331    }
2332
2333    candidates
2334}
2335
2336fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
2337    candidates
2338        .iter()
2339        .map(|path| (score_xbp_root(path), path.clone()))
2340        .filter(|(score, _)| *score > 0)
2341        .max_by(|left, right| {
2342            left.0.cmp(&right.0).then_with(|| {
2343                // Prefer Windows-drive-backed roots when scores tie (shared WSL/Windows).
2344                let left_windows = is_windows_drive_backed_path(&left.1);
2345                let right_windows = is_windows_drive_backed_path(&right.1);
2346                right_windows.cmp(&left_windows)
2347            })
2348        })
2349        .map(|(_, path)| path)
2350}
2351
2352fn score_xbp_root(root: &Path) -> u64 {
2353    if !root.exists() {
2354        return 0;
2355    }
2356
2357    let mut score = 1u64;
2358    if root.join("config.yaml").is_file() {
2359        score += 100;
2360    }
2361    if root.join("config.yml").is_file() {
2362        score += 80;
2363    }
2364    let mutations = root.join("mutations");
2365    if mutations.is_dir() {
2366        score += 50;
2367        score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
2368    }
2369    if root.join("ssh").is_dir() {
2370        score += 10;
2371    }
2372    if root.join("cache").is_dir() {
2373        score += 5;
2374    }
2375    // Strong signal that this is the shared Windows profile used from WSL.
2376    if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
2377        score += 40;
2378    }
2379    score
2380}
2381
2382fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
2383    let Ok(entries) = fs::read_dir(path) else {
2384        return 0;
2385    };
2386    let mut count = 0u64;
2387    for _entry in entries.flatten() {
2388        count += 1;
2389        if count >= cap {
2390            break;
2391        }
2392    }
2393    count
2394}
2395
2396fn is_windows_drive_backed_path(path: &Path) -> bool {
2397    let normalized = path.to_string_lossy().replace('\\', "/");
2398    if cfg!(windows) {
2399        return windows_drive_letter(path).is_some();
2400    }
2401    // WSL mounts of Windows drives: /mnt/c/Users/...
2402    let lower = normalized.to_ascii_lowercase();
2403    lower.starts_with("/mnt/")
2404        && lower.len() >= 6
2405        && lower
2406            .as_bytes()
2407            .get(5)
2408            .is_some_and(|b| b.is_ascii_alphabetic())
2409}
2410
2411fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
2412    normalize_path_key(left) == normalize_path_key(right)
2413}
2414
2415fn normalize_path_key(path: &Path) -> String {
2416    let mut value = path.to_string_lossy().replace('\\', "/");
2417    // Collapse Windows drive letters to a comparable form with WSL /mnt/<drive>/.
2418    if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
2419        value = mapped;
2420    } else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
2421        // Normalize both directions to the /mnt form for comparison on Unix.
2422        if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
2423            value = mnt;
2424        } else {
2425            value = mapped;
2426        }
2427    }
2428    if cfg!(windows) {
2429        value.to_ascii_lowercase()
2430    } else {
2431        value
2432    }
2433}
2434
2435/// `C:\Users\foo` → `/mnt/c/Users/foo` (for comparison / WSL access).
2436pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
2437    let normalized = path.trim().replace('\\', "/");
2438    let bytes = normalized.as_bytes();
2439    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
2440        let drive = (bytes[0] as char).to_ascii_lowercase();
2441        let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
2442        if rest.is_empty() {
2443            return Some(format!("/mnt/{drive}"));
2444        }
2445        return Some(format!("/mnt/{drive}/{rest}"));
2446    }
2447    None
2448}
2449
2450/// `/mnt/c/Users/foo` → `C:/Users/foo`.
2451pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
2452    let normalized = path.trim().replace('\\', "/");
2453    let rest = normalized.strip_prefix("/mnt/")?;
2454    let mut parts = rest.splitn(2, '/');
2455    let drive = parts.next()?.chars().next()?;
2456    if !drive.is_ascii_alphabetic() {
2457        return None;
2458    }
2459    let tail = parts.next().unwrap_or("");
2460    if tail.is_empty() {
2461        Some(format!("{}:", drive.to_ascii_uppercase()))
2462    } else {
2463        Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
2464    }
2465}
2466
2467#[cfg(not(target_os = "windows"))]
2468fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
2469    let mut homes: Vec<PathBuf> = Vec::new();
2470    let mut push_home = |path: PathBuf| {
2471        if path.is_dir()
2472            && !homes
2473                .iter()
2474                .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
2475        {
2476            homes.push(path);
2477        }
2478    };
2479
2480    // Explicit Windows profile exported into the WSL environment.
2481    for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
2482        if let Ok(value) = env::var(key) {
2483            let trimmed = value.trim();
2484            if trimmed.is_empty() {
2485                continue;
2486            }
2487            if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
2488                push_home(PathBuf::from(mnt));
2489            }
2490            // Already a WSL path.
2491            if trimmed.starts_with("/mnt/") {
2492                push_home(PathBuf::from(trimmed));
2493            }
2494        }
2495    }
2496
2497    // `cmd.exe /c echo %USERPROFILE%` is the most reliable user mapping on WSL.
2498    if let Some(profile) = query_windows_userprofile_via_cmd() {
2499        if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
2500            push_home(PathBuf::from(mnt));
2501        }
2502    }
2503
2504    // Heuristic: /mnt/c/Users/<name> matching Linux username or common aliases.
2505    if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
2506        for drive in b'c'..=b'z' {
2507            let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
2508            if !users_dir.is_dir() {
2509                continue;
2510            }
2511            let direct = users_dir.join(&linux_user);
2512            if direct.is_dir() {
2513                push_home(direct);
2514            }
2515            // Case-insensitive scan for a single matching profile when names differ
2516            // only by case (Windows vs Linux username).
2517            if let Ok(entries) = fs::read_dir(&users_dir) {
2518                for entry in entries.flatten() {
2519                    let name = entry.file_name().to_string_lossy().to_string();
2520                    if name.eq_ignore_ascii_case(&linux_user) {
2521                        push_home(entry.path());
2522                    }
2523                }
2524            }
2525            // Only probe C: thoroughly; other drives are rare for profiles.
2526            if drive == b'c' {
2527                break;
2528            }
2529        }
2530    }
2531
2532    homes
2533}
2534
2535#[cfg(not(target_os = "windows"))]
2536fn query_windows_userprofile_via_cmd() -> Option<String> {
2537    // Only attempt on WSL-like environments to avoid hanging on pure Linux.
2538    let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
2539        || env::var_os("WSL_INTEROP").is_some()
2540        || fs::read_to_string("/proc/version")
2541            .map(|content| {
2542                let lower = content.to_ascii_lowercase();
2543                lower.contains("microsoft") || lower.contains("wsl")
2544            })
2545            .unwrap_or(false);
2546    if !is_wsl {
2547        return None;
2548    }
2549
2550    let output = std::process::Command::new("cmd.exe")
2551        .args(["/C", "echo %USERPROFILE%"])
2552        .output()
2553        .ok()?;
2554    if !output.status.success() {
2555        return None;
2556    }
2557    let stdout = String::from_utf8_lossy(&output.stdout);
2558    let profile = stdout
2559        .lines()
2560        .map(str::trim)
2561        .find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
2562        .to_string();
2563    if profile.is_empty() {
2564        None
2565    } else {
2566        Some(profile)
2567    }
2568}
2569
2570#[cfg(target_os = "windows")]
2571fn resolve_windows_home_dir() -> Option<PathBuf> {
2572    dirs::home_dir()
2573        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
2574        .or_else(|| {
2575            let drive = env::var_os("HOMEDRIVE")?;
2576            let path = env::var_os("HOMEPATH")?;
2577            Some(PathBuf::from(drive).join(path))
2578        })
2579}
2580
2581fn windows_drive_letter(path: &Path) -> Option<char> {
2582    let normalized = path.to_string_lossy().replace('/', "\\");
2583    let bytes = normalized.as_bytes();
2584    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
2585        Some((bytes[0] as char).to_ascii_uppercase())
2586    } else {
2587        None
2588    }
2589}
2590
2591#[cfg(target_os = "windows")]
2592fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
2593    let normalized = path.to_string_lossy().replace('/', "\\");
2594    let bytes = normalized.as_bytes();
2595    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
2596        let tail = &normalized[3..];
2597        if tail.is_empty() {
2598            Some(PathBuf::new())
2599        } else {
2600            Some(PathBuf::from(tail))
2601        }
2602    } else {
2603        None
2604    }
2605}
2606
2607#[cfg(target_os = "windows")]
2608fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
2609    let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
2610        return Ok(());
2611    };
2612
2613    migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
2614    migrate_legacy_windows_file_if_missing(
2615        &legacy_root.join("versioning-files.yaml"),
2616        &paths.versioning_files_file,
2617    )?;
2618    migrate_legacy_windows_file_if_missing(
2619        &legacy_root.join("package-name-files.yaml"),
2620        &paths.package_name_files_file,
2621    )?;
2622
2623    Ok(())
2624}
2625
2626#[cfg(not(target_os = "windows"))]
2627fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
2628    Ok(())
2629}
2630
2631#[cfg(target_os = "windows")]
2632fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
2633    if to.exists() || !from.exists() {
2634        return Ok(());
2635    }
2636
2637    if let Some(parent) = to.parent() {
2638        fs::create_dir_all(parent).map_err(|e| {
2639            format!(
2640                "Failed to create directory for migrated file {}: {}",
2641                parent.display(),
2642                e
2643            )
2644        })?;
2645    }
2646
2647    fs::copy(from, to).map_err(|e| {
2648        format!(
2649            "Failed to migrate legacy config file {} -> {}: {}",
2650            from.display(),
2651            to.display(),
2652            e
2653        )
2654    })?;
2655
2656    Ok(())
2657}
2658
2659pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
2660    let paths = global_xbp_paths()?;
2661    Ok(vec![
2662        ("root".to_string(), paths.root_dir),
2663        ("config".to_string(), paths.config_file),
2664        ("ssh".to_string(), paths.ssh_dir),
2665        ("cache".to_string(), paths.cache_dir),
2666        ("logs".to_string(), paths.logs_dir),
2667        ("versioning".to_string(), paths.versioning_files_file),
2668        ("package-names".to_string(), paths.package_name_files_file),
2669        ("worktree-ignore".to_string(), paths.worktree_ignore_file),
2670    ])
2671}
2672
2673pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
2674    let paths = ensure_global_xbp_paths()?;
2675    Ok(paths.versioning_files_file)
2676}
2677
2678pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
2679    let registry_path = sync_versioning_files_registry()?;
2680    let content = fs::read_to_string(&registry_path).map_err(|e| {
2681        format!(
2682            "Failed to read versioning registry {}: {}",
2683            registry_path.display(),
2684            e
2685        )
2686    })?;
2687
2688    let config: VersioningFilesConfig = serde_yaml::from_str(&content)
2689        .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
2690
2691    Ok(config.files)
2692}
2693
2694pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
2695    let paths = ensure_global_xbp_paths()?;
2696    Ok(paths.package_name_files_file)
2697}
2698
2699pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
2700    let registry_path = sync_package_name_files_registry()?;
2701    let content = fs::read_to_string(&registry_path).map_err(|e| {
2702        format!(
2703            "Failed to read package-name registry {}: {}",
2704            registry_path.display(),
2705            e
2706        )
2707    })?;
2708
2709    let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
2710        .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
2711
2712    Ok(config.lookups)
2713}
2714
2715fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
2716    let mut config = if path.exists() {
2717        let content = fs::read_to_string(path).map_err(|e| {
2718            format!(
2719                "Failed to read versioning registry {}: {}",
2720                path.display(),
2721                e
2722            )
2723        })?;
2724        serde_yaml::from_str::<VersioningFilesConfig>(&content)
2725            .unwrap_or_else(|_| VersioningFilesConfig::default())
2726    } else {
2727        VersioningFilesConfig::default()
2728    };
2729
2730    let mut changed = false;
2731    for default_file in default_versioning_files() {
2732        if !config
2733            .files
2734            .iter()
2735            .any(|existing| existing == &default_file)
2736        {
2737            config.files.push(default_file);
2738            changed = true;
2739        }
2740    }
2741
2742    if changed || !path.exists() {
2743        let content = serde_yaml::to_string(&config)
2744            .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
2745        fs::write(path, content).map_err(|e| {
2746            format!(
2747                "Failed to write versioning registry {}: {}",
2748                path.display(),
2749                e
2750            )
2751        })?;
2752    }
2753
2754    Ok(())
2755}
2756
2757fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
2758    let mut config = if path.exists() {
2759        let content = fs::read_to_string(path).map_err(|e| {
2760            format!(
2761                "Failed to read package-name registry {}: {}",
2762                path.display(),
2763                e
2764            )
2765        })?;
2766        serde_yaml::from_str::<PackageNameFilesConfig>(&content)
2767            .unwrap_or_else(|_| PackageNameFilesConfig::default())
2768    } else {
2769        PackageNameFilesConfig::default()
2770    };
2771
2772    let mut changed = false;
2773    for default_lookup in default_package_name_lookups() {
2774        if !config
2775            .lookups
2776            .iter()
2777            .any(|existing| existing == &default_lookup)
2778        {
2779            config.lookups.push(default_lookup);
2780            changed = true;
2781        }
2782    }
2783
2784    if changed || !path.exists() {
2785        let content = serde_yaml::to_string(&config)
2786            .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
2787        fs::write(path, content).map_err(|e| {
2788            format!(
2789                "Failed to write package-name registry {}: {}",
2790                path.display(),
2791                e
2792            )
2793        })?;
2794    }
2795
2796    Ok(())
2797}
2798
2799fn default_versioning_files() -> Vec<String> {
2800    vec![
2801        "README.md".to_string(),
2802        "openapi.yaml".to_string(),
2803        "openapi.yml".to_string(),
2804        "openapi.json".to_string(),
2805        "package.json".to_string(),
2806        "package-lock.json".to_string(),
2807        "Cargo.toml".to_string(),
2808        "Cargo.lock".to_string(),
2809        "pyproject.toml".to_string(),
2810        "composer.json".to_string(),
2811        "deno.json".to_string(),
2812        "deno.jsonc".to_string(),
2813        "Chart.yaml".to_string(),
2814        "app.json".to_string(),
2815        "manifest.json".to_string(),
2816        "pom.xml".to_string(),
2817        "build.gradle".to_string(),
2818        "build.gradle.kts".to_string(),
2819        "mix.exs".to_string(),
2820        "xbp.toml".to_string(),
2821        "xbp.jsonc".to_string(),
2822        "xbp.yaml".to_string(),
2823        "xbp.yml".to_string(),
2824        "xbp.json".to_string(),
2825        ".xbp/xbp.toml".to_string(),
2826        ".xbp/xbp.jsonc".to_string(),
2827        ".xbp/xbp.json".to_string(),
2828        ".xbp/xbp.yaml".to_string(),
2829        ".xbp/xbp.yml".to_string(),
2830    ]
2831}
2832
2833fn default_package_name_lookups() -> Vec<PackageNameLookup> {
2834    vec![
2835        PackageNameLookup {
2836            file: "package.json".to_string(),
2837            format: "json".to_string(),
2838            key: "name".to_string(),
2839            registry: "npm".to_string(),
2840        },
2841        PackageNameLookup {
2842            file: "Cargo.toml".to_string(),
2843            format: "toml".to_string(),
2844            key: "package.name".to_string(),
2845            registry: "crates.io".to_string(),
2846        },
2847    ]
2848}
2849
2850const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
2851
2852/// Simple API configuration for the XBP version endpoints.
2853#[derive(Debug, Clone)]
2854pub struct ApiConfig {
2855    base_url: String,
2856}
2857
2858impl ApiConfig {
2859    /// Load the API configuration from API_XBP_URL, falling back to the default.
2860    pub fn load() -> Self {
2861        let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
2862        Self::from_base_url(&raw_url)
2863    }
2864
2865    pub fn from_env() -> Self {
2866        Self::load()
2867    }
2868
2869    pub fn from_base_url(raw_url: &str) -> Self {
2870        let base_url = Self::normalize_base_url(raw_url);
2871        ApiConfig { base_url }
2872    }
2873
2874    /// Return the normalized base URL that downstream callers should use.
2875    pub fn base_url(&self) -> &str {
2876        &self.base_url
2877    }
2878
2879    /// Build the version query endpoint.
2880    pub fn version_endpoint(&self, project_name: &str) -> String {
2881        format!("{}/version?project_name={}", self.base_url, project_name)
2882    }
2883
2884    /// Build the endpoint that increments a version.
2885    pub fn increment_endpoint(&self) -> String {
2886        format!("{}/version/increment", self.base_url)
2887    }
2888
2889    pub fn web_base_url(&self) -> String {
2890        Self::derive_web_base_url(&self.base_url)
2891    }
2892
2893    pub fn cli_auth_request_endpoint(&self) -> String {
2894        format!("{}/api/cli/auth/request", self.web_base_url())
2895    }
2896
2897    pub fn cli_auth_poll_endpoint(&self) -> String {
2898        format!("{}/api/cli/auth/poll", self.web_base_url())
2899    }
2900
2901    pub fn cli_auth_session_endpoint(&self) -> String {
2902        format!("{}/api/cli/auth/session", self.web_base_url())
2903    }
2904
2905    pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
2906        format!("{}/cli/login/{}", self.web_base_url(), flow_id)
2907    }
2908
2909    pub fn cli_linear_key_endpoint(&self) -> String {
2910        format!("{}/api/cli/linear/key", self.web_base_url())
2911    }
2912
2913    pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
2914        format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
2915    }
2916
2917    pub fn cloudflare_settings_url(&self) -> String {
2918        format!("{}/settings/cloudflare", self.web_base_url())
2919    }
2920
2921    pub fn cli_version_activity_endpoint(&self) -> String {
2922        format!("{}/api/cli/version/activity", self.web_base_url())
2923    }
2924
2925    pub fn cli_error_report_endpoint(&self) -> String {
2926        format!("{}/api/cli/errors/report", self.web_base_url())
2927    }
2928
2929    pub fn cli_deploy_activity_endpoint(&self) -> String {
2930        format!("{}/api/cli/deploy/activity", self.web_base_url())
2931    }
2932
2933    pub fn cli_deploy_secrets_endpoint(&self) -> String {
2934        format!("{}/api/cli/deploy/secrets", self.web_base_url())
2935    }
2936
2937    pub fn cli_command_activity_endpoint(&self) -> String {
2938        format!("{}/api/cli/command/activity", self.web_base_url())
2939    }
2940
2941    pub fn cli_cursor_ingest_endpoint(&self) -> String {
2942        format!("{}/api/cli/cursor/ingest", self.web_base_url())
2943    }
2944
2945    pub fn cli_projects_register_endpoint(&self) -> String {
2946        format!("{}/api/cli/projects/register", self.web_base_url())
2947    }
2948
2949    pub fn cli_worktree_mutations_endpoint(&self) -> String {
2950        format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
2951    }
2952
2953    pub fn cli_secrets_sync_endpoint(&self) -> String {
2954        format!("{}/api/cli/secrets/sync", self.web_base_url())
2955    }
2956
2957    pub fn cli_secrets_list_endpoint(&self) -> String {
2958        format!("{}/api/cli/secrets", self.web_base_url())
2959    }
2960
2961    pub fn cli_openapi_upload_endpoint(&self) -> String {
2962        format!("{}/api/cli/openapi/upload", self.web_base_url())
2963    }
2964
2965    pub fn cli_openapi_list_endpoint(&self) -> String {
2966        format!("{}/api/cli/openapi", self.web_base_url())
2967    }
2968
2969    fn normalize_base_url(raw: &str) -> String {
2970        let trimmed = raw.trim();
2971        if trimmed.is_empty() {
2972            return DEFAULT_API_XBP_URL.to_string();
2973        }
2974
2975        let trimmed = trimmed.trim_end_matches('/');
2976        if trimmed.is_empty() {
2977            return DEFAULT_API_XBP_URL.to_string();
2978        }
2979
2980        trimmed.to_string()
2981    }
2982
2983    fn derive_web_base_url(base_url: &str) -> String {
2984        let Ok(mut url) = Url::parse(base_url) else {
2985            return base_url.to_string();
2986        };
2987
2988        if let Some(host) = url.host_str().map(str::to_string) {
2989            if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
2990                let _ = url.set_host(Some("xbp.app"));
2991            } else if let Some(stripped) = host.strip_prefix("api.") {
2992                let _ = url.set_host(Some(stripped));
2993            }
2994        }
2995
2996        url.set_path("");
2997        url.set_query(None);
2998        url.set_fragment(None);
2999
3000        url.to_string().trim_end_matches('/').to_string()
3001    }
3002}
3003
3004#[cfg(test)]
3005mod tests {
3006    use super::{
3007        classify_cloudflare_token, cloudflare_account_id_from_project_env,
3008        cloudflare_token_kind_label, default_package_name_lookups, default_versioning_files,
3009        format_cloudflare_token_preview, resolve_cloudflare_account_id, resolve_github_oauth2_key,
3010        resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
3011        sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
3012        CloudflareTokenKind, OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig,
3013        VersioningFilesConfig,
3014    };
3015    use crate::openrouter::{
3016        DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
3017    };
3018    use std::env;
3019    use std::fs;
3020    use std::path::PathBuf;
3021    use std::time::{SystemTime, UNIX_EPOCH};
3022
3023    fn temp_path(label: &str) -> PathBuf {
3024        let nanos = SystemTime::now()
3025            .duration_since(UNIX_EPOCH)
3026            .expect("time")
3027            .as_nanos();
3028        std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
3029    }
3030
3031    #[test]
3032    fn versioning_registry_defaults_include_core_files() {
3033        let defaults = default_versioning_files();
3034        assert!(defaults.contains(&"README.md".to_string()));
3035        assert!(defaults.contains(&"Cargo.toml".to_string()));
3036        assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
3037    }
3038
3039    #[test]
3040    fn versioning_registry_default_config_populates_files() {
3041        let config = VersioningFilesConfig::default();
3042        assert!(!config.files.is_empty());
3043    }
3044
3045    #[test]
3046    fn versioning_registry_defaults_do_not_contain_duplicates() {
3047        let defaults = default_versioning_files();
3048        let mut deduped = defaults.clone();
3049        deduped.sort();
3050        deduped.dedup();
3051        assert_eq!(defaults.len(), deduped.len());
3052    }
3053
3054    #[test]
3055    fn syncing_registry_creates_file_with_defaults() {
3056        let path = temp_path("defaults");
3057        sync_versioning_files_registry_at(&path).expect("sync");
3058
3059        let content = fs::read_to_string(&path).expect("read");
3060        assert!(content.contains("README.md"));
3061        assert!(content.contains("Cargo.toml"));
3062
3063        let _ = fs::remove_file(path);
3064    }
3065
3066    #[test]
3067    fn syncing_registry_preserves_user_added_entries() {
3068        let path = temp_path("preserve");
3069        fs::write(&path, "files:\n  - custom.file\n").expect("write registry");
3070
3071        sync_versioning_files_registry_at(&path).expect("sync");
3072
3073        let content = fs::read_to_string(&path).expect("read");
3074        assert!(content.contains("custom.file"));
3075        assert!(content.contains("README.md"));
3076
3077        let _ = fs::remove_file(path);
3078    }
3079
3080    #[test]
3081    fn classifies_cloudflare_scannable_token_prefixes() {
3082        assert_eq!(
3083            classify_cloudflare_token("cfut_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3084            CloudflareTokenKind::User
3085        );
3086        assert_eq!(
3087            classify_cloudflare_token("cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3088            CloudflareTokenKind::Account
3089        );
3090        assert_eq!(
3091            classify_cloudflare_token("cfk_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3092            CloudflareTokenKind::GlobalKey
3093        );
3094        assert_eq!(
3095            classify_cloudflare_token("a".repeat(40).as_str()),
3096            CloudflareTokenKind::Legacy
3097        );
3098        assert_eq!(classify_cloudflare_token("  "), CloudflareTokenKind::Empty);
3099        assert_eq!(
3100            cloudflare_token_kind_label(CloudflareTokenKind::Account),
3101            "account-owned token (cfat_)"
3102        );
3103        let preview = format_cloudflare_token_preview(
3104            "cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
3105        );
3106        assert!(preview.contains("account-owned"), "{preview}");
3107        assert!(preview.starts_with("cfat"), "{preview}");
3108    }
3109
3110    #[test]
3111    fn stores_user_and_account_cloudflare_tokens_in_separate_slots() {
3112        use super::CloudflareTokenSlot;
3113
3114        let user = "cfut_abcdefghijklmnopqrstuvwxyz0123456789USER";
3115        let account = "cfat_abcdefghijklmnopqrstuvwxyz0123456789ACCT";
3116        let mut cfg = SshConfig::new();
3117
3118        let r1 = cfg
3119            .store_cloudflare_token(user, CloudflareTokenSlot::Auto)
3120            .expect("store user");
3121        assert_eq!(r1.kind, CloudflareTokenKind::User);
3122        assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3123        assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
3124
3125        let r2 = cfg
3126            .store_cloudflare_token(account, CloudflareTokenSlot::Auto)
3127            .expect("store account");
3128        assert_eq!(r2.kind, CloudflareTokenKind::Account);
3129        assert_eq!(cfg.cloudflare_account_api_token(), Some(account));
3130        // Primary stays user because it is a different kind — both remain stored.
3131        assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3132        assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
3133
3134        let entries = cfg.cloudflare_config_token_entries();
3135        assert!(
3136            entries.iter().any(|(s, t)| s.contains("user") && t == user),
3137            "{entries:?}"
3138        );
3139        assert!(
3140            entries
3141                .iter()
3142                .any(|(s, t)| s.contains("account") && t == account),
3143            "{entries:?}"
3144        );
3145
3146        let cleared = cfg.clear_cloudflare_token_slot(CloudflareTokenSlot::Account);
3147        assert!(cleared.contains(&"cloudflare_account_api_token"));
3148        assert!(cfg.cloudflare_account_api_token().is_none());
3149        assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3150    }
3151
3152    #[test]
3153    fn api_config_normalizes_trailing_slashes() {
3154        assert_eq!(
3155            ApiConfig::normalize_base_url("https://api.xbp.app///"),
3156            "https://api.xbp.app".to_string()
3157        );
3158    }
3159
3160    #[test]
3161    fn api_config_uses_default_for_blank_values() {
3162        assert_eq!(
3163            ApiConfig::normalize_base_url("   "),
3164            "https://api.xbp.app".to_string()
3165        );
3166    }
3167
3168    #[test]
3169    fn api_config_builds_version_endpoints() {
3170        let config = ApiConfig {
3171            base_url: "https://api.test.xbp".to_string(),
3172        };
3173        let endpoint = config.version_endpoint("demo");
3174        let increment = config.increment_endpoint();
3175
3176        assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
3177        assert_eq!(increment, "https://api.test.xbp/version/increment");
3178    }
3179
3180    #[test]
3181    fn api_config_exposes_cli_projects_register_endpoint() {
3182        let config = ApiConfig {
3183            base_url: "https://api.xbp.app".to_string(),
3184        };
3185        assert_eq!(
3186            config.cli_projects_register_endpoint(),
3187            "https://xbp.app/api/cli/projects/register"
3188        );
3189    }
3190
3191    #[test]
3192    fn api_config_derives_browser_base_url_from_api_subdomain() {
3193        assert_eq!(
3194            ApiConfig::derive_web_base_url("https://api.xbp.app"),
3195            "https://xbp.app".to_string()
3196        );
3197        assert_eq!(
3198            ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
3199            "https://xbp.app".to_string()
3200        );
3201        assert_eq!(
3202            ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
3203            "https://xbp.app".to_string()
3204        );
3205        assert_eq!(
3206            ApiConfig::derive_web_base_url("https://api.internal.example.com"),
3207            "https://internal.example.com".to_string()
3208        );
3209        assert_eq!(
3210            ApiConfig::derive_web_base_url("http://localhost:3000"),
3211            "http://localhost:3000".to_string()
3212        );
3213    }
3214
3215    #[test]
3216    fn package_lookup_defaults_include_npm_and_crates() {
3217        let defaults = default_package_name_lookups();
3218        assert!(defaults.iter().any(|entry| {
3219            entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
3220        }));
3221        assert!(defaults.iter().any(|entry| {
3222            entry.file == "Cargo.toml"
3223                && entry.registry == "crates.io"
3224                && entry.key == "package.name"
3225        }));
3226    }
3227
3228    #[test]
3229    fn package_lookup_default_config_populates_entries() {
3230        let config = PackageNameFilesConfig::default();
3231        assert!(!config.lookups.is_empty());
3232    }
3233
3234    #[test]
3235    fn syncing_package_lookup_registry_creates_defaults() {
3236        let path = temp_path("package-lookup-defaults");
3237        sync_package_name_files_registry_at(&path).expect("sync");
3238
3239        let content = fs::read_to_string(&path).expect("read");
3240        assert!(content.contains("package.json"));
3241        assert!(content.contains("Cargo.toml"));
3242
3243        let _ = fs::remove_file(path);
3244    }
3245
3246    #[test]
3247    fn syncing_package_lookup_registry_preserves_custom_entries() {
3248        let path = temp_path("package-lookup-custom");
3249        fs::write(
3250            &path,
3251            "lookups:\n  - file: custom.yaml\n    format: yaml\n    key: app.name\n    registry: npm\n",
3252        )
3253        .expect("write package lookup registry");
3254
3255        sync_package_name_files_registry_at(&path).expect("sync");
3256        let content = fs::read_to_string(&path).expect("read");
3257        assert!(content.contains("custom.yaml"));
3258        assert!(content.contains("package.json"));
3259
3260        let _ = fs::remove_file(path);
3261    }
3262
3263    #[test]
3264    fn secret_provider_parses_supported_keys() {
3265        assert_eq!(
3266            SecretProvider::from_key("openrouter"),
3267            Some(SecretProvider::OpenRouter)
3268        );
3269        assert_eq!(
3270            SecretProvider::from_key("github"),
3271            Some(SecretProvider::Github)
3272        );
3273        assert_eq!(
3274            SecretProvider::from_key("linear"),
3275            Some(SecretProvider::Linear)
3276        );
3277        assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
3278        assert_eq!(
3279            SecretProvider::from_key("crates"),
3280            Some(SecretProvider::Crates)
3281        );
3282        assert_eq!(SecretProvider::from_key("unknown"), None);
3283    }
3284
3285    #[test]
3286    fn ssh_config_secret_get_set_roundtrip() {
3287        let mut cfg = SshConfig::new();
3288        cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
3289        cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
3290        cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
3291        cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
3292        cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
3293
3294        assert_eq!(
3295            cfg.get_secret(SecretProvider::OpenRouter),
3296            Some("or-test-123")
3297        );
3298        assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
3299        assert_eq!(
3300            cfg.get_secret(SecretProvider::Linear),
3301            Some("lin_api_test_789")
3302        );
3303        assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
3304        assert_eq!(
3305            cfg.get_secret(SecretProvider::Crates),
3306            Some("crate_test_token")
3307        );
3308        assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
3309    }
3310
3311    #[test]
3312    fn default_openrouter_config_populates_models_and_prompts() {
3313        let config = OpenRouterConfig::default();
3314
3315        assert_eq!(config.commit_model, DEFAULT_MODEL);
3316        assert_eq!(config.release_notes_model, DEFAULT_MODEL);
3317        assert_eq!(
3318            config.commit_system_prompt,
3319            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
3320        );
3321        assert_eq!(
3322            config.release_notes_system_prompt,
3323            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
3324        );
3325    }
3326
3327    #[test]
3328    fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
3329        let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
3330
3331        assert!(yaml.contains("openrouter:"));
3332        assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
3333        assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
3334        assert!(yaml.contains("commit_system_prompt"));
3335        assert!(yaml.contains("release_notes_system_prompt"));
3336    }
3337
3338    #[test]
3339    fn ssh_config_new_uses_openrouter_defaults() {
3340        let config = SshConfig::new();
3341
3342        assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
3343        assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
3344        assert_eq!(
3345            config.openrouter.commit_system_prompt,
3346            DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
3347        );
3348        assert_eq!(
3349            config.openrouter.release_notes_system_prompt,
3350            DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
3351        );
3352    }
3353
3354    #[test]
3355    fn syncing_global_config_backfills_openrouter_generation_defaults() {
3356        let path = temp_path("global-config");
3357        fs::write(
3358            &path,
3359            "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",
3360        )
3361        .expect("write config");
3362
3363        sync_global_config_defaults_at(&path).expect("sync config defaults");
3364
3365        let content = fs::read_to_string(&path).expect("read config");
3366        assert!(content.contains("openrouter:"));
3367        assert!(content.contains("commit_model: openai/gpt-4o-mini"));
3368        assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
3369        assert!(content.contains("commit_system_prompt"));
3370        assert!(content.contains("release_notes_system_prompt"));
3371
3372        let _ = fs::remove_file(path);
3373    }
3374
3375    #[test]
3376    fn resolve_secret_prefers_environment_value() {
3377        std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
3378        std::env::set_var("GITHUB_TOKEN", "env-github");
3379        std::env::set_var("XBP_API_TOKEN", "env-xbp");
3380
3381        assert_eq!(
3382            resolve_openrouter_api_key(),
3383            Some("env-openrouter".to_string())
3384        );
3385        assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
3386        assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
3387
3388        std::env::remove_var("OPENROUTER_API_KEY");
3389        std::env::remove_var("GITHUB_TOKEN");
3390        std::env::remove_var("XBP_API_TOKEN");
3391    }
3392
3393    #[test]
3394    fn resolve_cloudflare_account_id_reads_project_env_files() {
3395        use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
3396
3397        let dir = temp_path("cf-account-env");
3398        fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
3399        fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
3400        fs::write(
3401            dir.join(".env"),
3402            "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
3403        )
3404        .expect("write env");
3405
3406        let previous = env::current_dir().expect("cwd");
3407        env::set_current_dir(&dir).expect("chdir");
3408        for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
3409            env::remove_var(key);
3410        }
3411
3412        assert_eq!(
3413            cloudflare_account_id_from_project_env().as_deref(),
3414            Some("acc-from-project-env")
3415        );
3416        assert_eq!(
3417            resolve_cloudflare_account_id().as_deref(),
3418            Some("acc-from-project-env")
3419        );
3420
3421        env::set_current_dir(previous).expect("restore cwd");
3422        let _ = fs::remove_dir_all(dir);
3423    }
3424}