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