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