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