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, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
17};
18use chrono::{DateTime, Duration as ChronoDuration, Utc};
19use reqwest::Url;
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22use std::env;
23use std::fs;
24use std::path::Path;
25use std::path::PathBuf;
26use xbp_core::{RunnerExecutionRuntime, RunnerPlatform};
27
28#[derive(Debug, Clone)]
29pub struct GlobalXbpPaths {
30 pub root_dir: PathBuf,
31 pub config_file: PathBuf,
32 pub ssh_dir: PathBuf,
33 pub cache_dir: PathBuf,
34 pub logs_dir: PathBuf,
35 pub versioning_files_file: PathBuf,
36 pub package_name_files_file: PathBuf,
37}
38
39#[derive(Debug, Clone)]
40pub struct SystemInventorySyncResult {
41 pub inventory: SystemInventory,
42 pub config_path: PathBuf,
43 pub refreshed: bool,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
47pub struct DeviceIdentity {
48 #[serde(default)]
49 pub hardware_id: String,
50 #[serde(default)]
51 pub created_at: Option<DateTime<Utc>>,
52}
53
54#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
55pub struct LinearReleaseConfig {
56 #[serde(default)]
57 pub enabled: Option<bool>,
58 #[serde(default)]
59 pub initiative_ids: Option<Vec<String>>,
60 #[serde(default, alias = "org_name")]
61 pub organization_name: Option<String>,
62 #[serde(default)]
63 pub health: Option<String>,
64}
65
66#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
67pub struct LinearConfig {
68 #[serde(default)]
69 pub release: Option<LinearReleaseConfig>,
70}
71
72#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
73pub struct SecretMetadata {
74 #[serde(default)]
75 pub added_at: Option<DateTime<Utc>>,
76}
77
78#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
79pub struct CliAuthState {
80 #[serde(default)]
81 pub user_id: Option<String>,
82 #[serde(default)]
83 pub user_name: Option<String>,
84 #[serde(default)]
85 pub user_email: Option<String>,
86 #[serde(default)]
87 pub token_label: Option<String>,
88 #[serde(default)]
89 pub token_prefix: Option<String>,
90 #[serde(default)]
91 pub token_created_at: Option<DateTime<Utc>>,
92 #[serde(default)]
93 pub token_expires_at: Option<DateTime<Utc>>,
94 #[serde(default)]
95 pub last_verified_at: Option<DateTime<Utc>>,
96}
97
98#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
99pub struct CursorIngestState {
100 #[serde(default)]
101 pub last_status: Option<String>,
102 #[serde(default)]
103 pub last_trigger: Option<String>,
104 #[serde(default)]
105 pub last_mode: Option<String>,
106 #[serde(default)]
107 pub last_attempted_at: Option<DateTime<Utc>>,
108 #[serde(default)]
109 pub last_succeeded_at: Option<DateTime<Utc>>,
110 #[serde(default)]
111 pub last_error: Option<String>,
112 #[serde(default)]
113 pub last_workspace_count: Option<usize>,
114 #[serde(default)]
115 pub last_entry_count: Option<usize>,
116 #[serde(default)]
117 pub last_entries_skipped: Option<usize>,
118}
119
120#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
121pub struct SystemInventoryRefreshState {
122 #[serde(default)]
123 pub last_status: Option<String>,
124 #[serde(default)]
125 pub last_trigger: Option<String>,
126 #[serde(default)]
127 pub last_mode: Option<String>,
128 #[serde(default)]
129 pub last_attempted_at: Option<DateTime<Utc>>,
130 #[serde(default)]
131 pub last_succeeded_at: Option<DateTime<Utc>>,
132 #[serde(default)]
133 pub last_error: Option<String>,
134 #[serde(default)]
135 pub include_cursor: Option<bool>,
136 #[serde(default)]
137 pub refreshed: Option<bool>,
138}
139
140#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
141pub struct ConfiguredRunnerHost {
142 pub runner_host_id: String,
143 #[serde(default)]
144 pub organization_id: Option<String>,
145 #[serde(default)]
146 pub hostname: Option<String>,
147 #[serde(default)]
148 pub platform: Option<RunnerPlatform>,
149 #[serde(default)]
150 pub runtime: Option<RunnerExecutionRuntime>,
151 #[serde(default)]
152 pub labels: Vec<String>,
153 #[serde(default)]
154 pub updated_at: Option<DateTime<Utc>>,
155}
156
157#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
158pub struct RunnerMachineConfig {
159 #[serde(default)]
160 pub runtime_root: Option<String>,
161 #[serde(default)]
162 pub preferred_runtime: Option<RunnerExecutionRuntime>,
163 #[serde(default)]
164 pub hosts: Vec<ConfiguredRunnerHost>,
165}
166
167#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
168pub struct OpenRouterConfig {
169 #[serde(default = "default_openrouter_commit_model")]
170 pub commit_model: String,
171 #[serde(default = "default_openrouter_release_notes_model")]
172 pub release_notes_model: String,
173 #[serde(default = "default_openrouter_commit_system_prompt")]
174 pub commit_system_prompt: String,
175 #[serde(default = "default_openrouter_release_notes_system_prompt")]
176 pub release_notes_system_prompt: String,
177}
178
179impl Default for OpenRouterConfig {
180 fn default() -> Self {
181 Self {
182 commit_model: default_openrouter_commit_model(),
183 release_notes_model: default_openrouter_release_notes_model(),
184 commit_system_prompt: default_openrouter_commit_system_prompt(),
185 release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
186 }
187 }
188}
189
190#[derive(Debug, Serialize, Deserialize, Clone)]
191pub struct SshConfig {
192 pub password: Option<String>,
193 pub username: Option<String>,
194 pub host: Option<String>,
195 pub project_dir: Option<String>,
196 #[serde(default)]
197 pub xbp_api_token: Option<String>,
198 pub openrouter_api_key: Option<String>,
199 pub github_oauth2_key: Option<String>,
200 #[serde(default)]
201 pub cloudflare_api_token: Option<String>,
202 #[serde(default)]
203 pub cloudflare_account_id: Option<String>,
204 pub linear_api_key: Option<String>,
205 #[serde(default)]
206 pub npm_token: Option<String>,
207 #[serde(default)]
208 pub crates_token: Option<String>,
209 #[serde(default)]
210 pub openrouter: OpenRouterConfig,
211 #[serde(default)]
212 pub linear: Option<LinearConfig>,
213 #[serde(default)]
214 pub cli_auth: Option<CliAuthState>,
215 #[serde(default)]
216 pub device: Option<DeviceIdentity>,
217 #[serde(default)]
218 pub secret_metadata: BTreeMap<String, SecretMetadata>,
219 #[serde(default)]
220 pub system_inventory: Option<SystemInventory>,
221 #[serde(default)]
222 pub cursor_ingest: Option<CursorIngestState>,
223 #[serde(default)]
224 pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
225 #[serde(default)]
226 pub dns_inventory: Option<DnsInventoryCache>,
227 #[serde(default)]
228 pub runners: Option<RunnerMachineConfig>,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum SecretProvider {
233 OpenRouter,
234 Github,
235 Cloudflare,
236 Linear,
237 Npm,
238 Crates,
239}
240
241impl SecretProvider {
242 pub fn from_key(key: &str) -> Option<Self> {
243 match key.trim().to_ascii_lowercase().as_str() {
244 "openrouter" => Some(Self::OpenRouter),
245 "github" => Some(Self::Github),
246 "cloudflare" => Some(Self::Cloudflare),
247 "linear" => Some(Self::Linear),
248 "npm" | "npmjs" => Some(Self::Npm),
249 "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
250 _ => None,
251 }
252 }
253
254 pub fn as_key(&self) -> &'static str {
255 match self {
256 SecretProvider::OpenRouter => "openrouter",
257 SecretProvider::Github => "github",
258 SecretProvider::Cloudflare => "cloudflare",
259 SecretProvider::Linear => "linear",
260 SecretProvider::Npm => "npm",
261 SecretProvider::Crates => "crates",
262 }
263 }
264
265 pub fn config_field(&self) -> &'static str {
266 match self {
267 SecretProvider::OpenRouter => "openrouter_api_key",
268 SecretProvider::Github => "github_oauth2_key",
269 SecretProvider::Cloudflare => "cloudflare_api_token",
270 SecretProvider::Linear => "linear_api_key",
271 SecretProvider::Npm => "npm_token",
272 SecretProvider::Crates => "crates_token",
273 }
274 }
275}
276
277impl Default for SshConfig {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283impl SshConfig {
284 pub fn new() -> Self {
285 SshConfig {
286 password: None,
287 username: None,
288 host: None,
289 project_dir: None,
290 xbp_api_token: None,
291 openrouter_api_key: None,
292 github_oauth2_key: None,
293 cloudflare_api_token: None,
294 cloudflare_account_id: None,
295 linear_api_key: None,
296 npm_token: None,
297 crates_token: None,
298 openrouter: OpenRouterConfig::default(),
299 linear: None,
300 cli_auth: None,
301 device: None,
302 secret_metadata: BTreeMap::new(),
303 system_inventory: None,
304 cursor_ingest: None,
305 system_inventory_refresh: None,
306 dns_inventory: None,
307 runners: None,
308 }
309 }
310
311 pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
312 match provider {
313 SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
314 SecretProvider::Github => self.github_oauth2_key.as_deref(),
315 SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
316 SecretProvider::Linear => self.linear_api_key.as_deref(),
317 SecretProvider::Npm => self.npm_token.as_deref(),
318 SecretProvider::Crates => self.crates_token.as_deref(),
319 }
320 }
321
322 pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
323 match provider {
324 SecretProvider::OpenRouter => self.openrouter_api_key = value,
325 SecretProvider::Github => self.github_oauth2_key = value,
326 SecretProvider::Cloudflare => self.cloudflare_api_token = value,
327 SecretProvider::Linear => self.linear_api_key = value,
328 SecretProvider::Npm => self.npm_token = value,
329 SecretProvider::Crates => self.crates_token = value,
330 }
331
332 match self.get_secret(provider) {
333 Some(_) => {
334 self.secret_metadata.insert(
335 provider.as_key().to_string(),
336 SecretMetadata {
337 added_at: Some(Utc::now()),
338 },
339 );
340 }
341 None => {
342 self.secret_metadata.remove(provider.as_key());
343 }
344 }
345 }
346
347 pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
348 self.secret_metadata.get(provider.as_key())
349 }
350
351 pub fn load() -> Result<Self, String> {
352 let config_path = get_config_path();
353 let legacy_path = legacy_config_path();
354 let path_to_read = if config_path.exists() {
355 config_path
356 } else if legacy_path.exists() {
357 legacy_path
358 } else {
359 return Ok(SshConfig::new());
360 };
361
362 let content = fs::read_to_string(&path_to_read)
363 .map_err(|e| format!("Failed to read config file: {}", e))?;
364 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
365 }
366
367 pub fn save(&self) -> Result<(), String> {
368 let config_path = get_config_path();
369 let config_dir = config_path.parent().ok_or("Invalid config path")?;
370 fs::create_dir_all(config_dir)
371 .map_err(|e| format!("Failed to create config directory: {}", e))?;
372
373 let content = serde_yaml::to_string(self)
374 .map_err(|e| format!("Failed to serialize config: {}", e))?;
375 fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
376 }
377}
378
379pub fn global_runner_root_dir() -> Result<PathBuf, String> {
380 let paths = ensure_global_xbp_paths()?;
381 let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
382 if let Some(path) = config
383 .runners
384 .as_ref()
385 .and_then(|runners| runners.runtime_root.as_deref())
386 .map(str::trim)
387 .filter(|value| !value.is_empty())
388 {
389 let candidate = PathBuf::from(path);
390 fs::create_dir_all(&candidate).map_err(|error| {
391 format!(
392 "Failed to create runner runtime root {}: {}",
393 candidate.display(),
394 error
395 )
396 })?;
397 return Ok(candidate);
398 }
399
400 let default_root = paths.root_dir.join("github").join("runners");
401 fs::create_dir_all(&default_root).map_err(|error| {
402 format!(
403 "Failed to create runner runtime root {}: {}",
404 default_root.display(),
405 error
406 )
407 })?;
408 Ok(default_root)
409}
410
411#[derive(Debug, Serialize, Deserialize, Clone)]
412pub struct VersioningFilesConfig {
413 #[serde(default = "default_versioning_files")]
414 pub files: Vec<String>,
415}
416
417impl Default for VersioningFilesConfig {
418 fn default() -> Self {
419 Self {
420 files: default_versioning_files(),
421 }
422 }
423}
424
425#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
426pub struct PackageNameLookup {
427 pub file: String,
428 pub format: String,
429 pub key: String,
430 pub registry: String,
431}
432
433#[derive(Debug, Serialize, Deserialize, Clone)]
434pub struct PackageNameFilesConfig {
435 #[serde(default = "default_package_name_lookups")]
436 pub lookups: Vec<PackageNameLookup>,
437}
438
439impl Default for PackageNameFilesConfig {
440 fn default() -> Self {
441 Self {
442 lookups: default_package_name_lookups(),
443 }
444 }
445}
446
447pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
448 let root_dir = preferred_global_root_dir();
449
450 let paths = GlobalXbpPaths {
451 config_file: root_dir.join("config.yaml"),
452 ssh_dir: root_dir.join("ssh"),
453 cache_dir: root_dir.join("cache"),
454 logs_dir: root_dir.join("logs"),
455 versioning_files_file: root_dir.join("versioning-files.yaml"),
456 package_name_files_file: root_dir.join("package-name-files.yaml"),
457 root_dir,
458 };
459
460 for dir in [
461 &paths.root_dir,
462 &paths.ssh_dir,
463 &paths.cache_dir,
464 &paths.logs_dir,
465 ] {
466 fs::create_dir_all(dir)
467 .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
468 }
469
470 maybe_migrate_legacy_windows_files(&paths)?;
471
472 if !paths.config_file.exists() {
473 let default_config = serde_yaml::to_string(&SshConfig::new())
474 .map_err(|e| format!("Failed to serialize default config: {}", e))?;
475 fs::write(&paths.config_file, default_config).map_err(|e| {
476 format!(
477 "Failed to initialize config file {}: {}",
478 paths.config_file.display(),
479 e
480 )
481 })?;
482 }
483 sync_global_config_defaults_at(&paths.config_file)?;
484
485 sync_versioning_files_registry_at(&paths.versioning_files_file)?;
486 sync_package_name_files_registry_at(&paths.package_name_files_file)?;
487
488 Ok(paths)
489}
490
491fn default_openrouter_commit_model() -> String {
492 DEFAULT_MODEL.to_string()
493}
494
495fn default_openrouter_release_notes_model() -> String {
496 DEFAULT_MODEL.to_string()
497}
498
499fn default_openrouter_commit_system_prompt() -> String {
500 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
501}
502
503fn default_openrouter_release_notes_system_prompt() -> String {
504 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
505}
506
507const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
508
509fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
510where
511 F: FnOnce(&OpenRouterConfig) -> &str,
512{
513 SshConfig::load()
514 .ok()
515 .map(|cfg| selector(&cfg.openrouter).trim().to_string())
516 .filter(|value| !value.is_empty())
517 .unwrap_or_else(|| fallback.to_string())
518}
519
520fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
521 let content = fs::read_to_string(config_path).map_err(|e| {
522 format!(
523 "Failed to read config file {}: {}",
524 config_path.display(),
525 e
526 )
527 })?;
528
529 if content.contains("openrouter:")
530 && content.contains("commit_model:")
531 && content.contains("release_notes_model:")
532 && content.contains("commit_system_prompt:")
533 && content.contains("release_notes_system_prompt:")
534 {
535 return Ok(());
536 }
537
538 let config: SshConfig = serde_yaml::from_str(&content)
539 .map_err(|e| format!("Failed to parse config file: {}", e))?;
540 let normalized =
541 serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
542
543 if normalized != content {
544 fs::write(config_path, normalized).map_err(|e| {
545 format!(
546 "Failed to write config file {}: {}",
547 config_path.display(),
548 e
549 )
550 })?;
551 }
552
553 Ok(())
554}
555
556pub fn resolve_openrouter_api_key() -> Option<String> {
557 env::var("OPENROUTER_API_KEY")
558 .ok()
559 .map(|value| value.trim().to_string())
560 .filter(|value| !value.is_empty())
561 .or_else(|| {
562 SshConfig::load()
563 .ok()
564 .and_then(|cfg| {
565 cfg.get_secret(SecretProvider::OpenRouter)
566 .map(str::to_string)
567 })
568 .map(|value| value.trim().to_string())
569 .filter(|value| !value.is_empty())
570 })
571}
572
573pub fn resolve_openrouter_commit_model() -> String {
574 resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
575}
576
577pub fn resolve_openrouter_release_notes_model() -> String {
578 resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
579}
580
581pub fn resolve_openrouter_commit_system_prompt() -> String {
582 resolve_openrouter_config_value(
583 |cfg| cfg.commit_system_prompt.as_str(),
584 DEFAULT_COMMIT_SYSTEM_PROMPT,
585 )
586}
587
588pub fn resolve_openrouter_release_notes_system_prompt() -> String {
589 resolve_openrouter_config_value(
590 |cfg| cfg.release_notes_system_prompt.as_str(),
591 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
592 )
593}
594
595pub fn resolve_xbp_api_token() -> Option<String> {
596 env::var("XBP_API_TOKEN")
597 .ok()
598 .map(|value| value.trim().to_string())
599 .filter(|value| !value.is_empty())
600 .or_else(|| {
601 SshConfig::load()
602 .ok()
603 .and_then(|cfg| cfg.xbp_api_token)
604 .map(|value| value.trim().to_string())
605 .filter(|value| !value.is_empty())
606 })
607}
608
609pub fn resolve_github_oauth2_key() -> Option<String> {
610 for env_var in [
611 "GITHUB_TOKEN",
612 "GITHUB_OAUTH2_KEY",
613 "GITHUB_OAUTH2_TOKEN",
614 "GITHUB_OAUTH_TOKEN",
615 ] {
616 if let Ok(value) = env::var(env_var) {
617 let token = value.trim();
618 if !token.is_empty() {
619 return Some(token.to_string());
620 }
621 }
622 }
623
624 SshConfig::load()
625 .ok()
626 .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
627 .map(|value| value.trim().to_string())
628 .filter(|value| !value.is_empty())
629}
630
631pub fn resolve_cloudflare_api_token() -> Option<String> {
632 env::var("CLOUDFLARE_API_TOKEN")
633 .ok()
634 .map(|value| value.trim().to_string())
635 .filter(|value| !value.is_empty())
636 .or_else(|| {
637 SshConfig::load()
638 .ok()
639 .and_then(|cfg| {
640 cfg.get_secret(SecretProvider::Cloudflare)
641 .map(str::to_string)
642 })
643 .map(|value| value.trim().to_string())
644 .filter(|value| !value.is_empty())
645 })
646}
647
648pub fn cloudflare_account_id_from_process_env() -> Option<String> {
649 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
650 env::var(key)
651 .ok()
652 .map(|value| value.trim().to_string())
653 .filter(|value| !value.is_empty())
654 })
655}
656
657pub fn cloudflare_account_id_from_project_env() -> Option<String> {
658 let current_dir = env::current_dir().ok()?;
659 let found = find_xbp_config_upwards(¤t_dir)?;
660 let lookup = load_env_lookup(&found.project_root);
661 first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
662}
663
664pub fn cloudflare_account_id_from_global_config() -> Option<String> {
665 SshConfig::load()
666 .ok()
667 .and_then(|cfg| cfg.cloudflare_account_id)
668 .map(|value| value.trim().to_string())
669 .filter(|value| !value.is_empty())
670}
671
672pub fn resolve_cloudflare_account_id() -> Option<String> {
673 cloudflare_account_id_from_process_env()
674 .or_else(cloudflare_account_id_from_project_env)
675 .or_else(cloudflare_account_id_from_global_config)
676}
677
678pub fn resolve_linear_api_key() -> Option<String> {
679 SshConfig::load()
680 .ok()
681 .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
682 .map(|value| value.trim().to_string())
683 .filter(|value| !value.is_empty())
684}
685
686pub fn resolve_npm_token() -> Option<String> {
687 for env_var in ["NPM_TOKEN", "NODE_AUTH_TOKEN"] {
688 if let Ok(value) = env::var(env_var) {
689 let token = value.trim();
690 if !token.is_empty() {
691 return Some(token.to_string());
692 }
693 }
694 }
695
696 SshConfig::load()
697 .ok()
698 .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
699 .map(|value| value.trim().to_string())
700 .filter(|value| !value.is_empty())
701}
702
703pub fn resolve_crates_token() -> Option<String> {
704 for env_var in ["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"] {
705 if let Ok(value) = env::var(env_var) {
706 let token = value.trim();
707 if !token.is_empty() {
708 return Some(token.to_string());
709 }
710 }
711 }
712
713 SshConfig::load()
714 .ok()
715 .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
716 .map(|value| value.trim().to_string())
717 .filter(|value| !value.is_empty())
718}
719
720pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
721 let mut config = SshConfig::load()?;
722 config.cloudflare_account_id = value;
723 config.save()
724}
725
726pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
727 Ok(SshConfig::load()?.cloudflare_account_id)
728}
729
730pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
731 SshConfig::load()
732 .ok()
733 .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
734}
735
736pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
737 ensure_global_xbp_paths()?;
738 let mut config = SshConfig::load()?;
739 if let Some(device) = config.device.clone() {
740 if !device.hardware_id.trim().is_empty() {
741 return Ok(device);
742 }
743 }
744
745 let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
746 let device = DeviceIdentity {
747 hardware_id,
748 created_at: Some(Utc::now()),
749 };
750 config.device = Some(device.clone());
751 config.save()?;
752 Ok(device)
753}
754
755pub fn reserve_cursor_ingest_slot(
756 trigger: &str,
757 mode: &str,
758 min_interval: ChronoDuration,
759) -> Result<bool, String> {
760 ensure_global_xbp_paths()?;
761 let mut config = SshConfig::load()?;
762 let now = Utc::now();
763
764 if let Some(state) = config.cursor_ingest.as_ref() {
765 if let Some(last_attempted_at) = state.last_attempted_at {
766 if now - last_attempted_at < min_interval {
767 return Ok(false);
768 }
769 }
770 }
771
772 let mut state = config.cursor_ingest.unwrap_or_default();
773 state.last_status = Some("scheduled".to_string());
774 state.last_trigger = Some(trigger.to_string());
775 state.last_mode = Some(mode.to_string());
776 state.last_attempted_at = Some(now);
777 state.last_error = None;
778 config.cursor_ingest = Some(state);
779 config.save()?;
780 Ok(true)
781}
782
783pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
784 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
785 let mut state = config.cursor_ingest.unwrap_or_default();
786 state.last_status = Some("running".to_string());
787 state.last_trigger = Some(trigger.to_string());
788 state.last_mode = Some(mode.to_string());
789 state.last_attempted_at = Some(Utc::now());
790 state.last_error = None;
791 config.cursor_ingest = Some(state);
792 config.save()
793}
794
795pub fn record_cursor_ingest_success(
796 trigger: &str,
797 mode: &str,
798 workspace_count: usize,
799 entry_count: usize,
800 entries_skipped: usize,
801) -> Result<(), String> {
802 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
803 let mut state = config.cursor_ingest.unwrap_or_default();
804 let completed_at = Utc::now();
805 state.last_status = Some("success".to_string());
806 state.last_trigger = Some(trigger.to_string());
807 state.last_mode = Some(mode.to_string());
808 state.last_attempted_at = Some(completed_at);
809 state.last_succeeded_at = Some(completed_at);
810 state.last_error = None;
811 state.last_workspace_count = Some(workspace_count);
812 state.last_entry_count = Some(entry_count);
813 state.last_entries_skipped = Some(entries_skipped);
814 config.cursor_ingest = Some(state);
815 config.save()
816}
817
818pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
819 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
820 let mut state = config.cursor_ingest.unwrap_or_default();
821 state.last_status = Some("failed".to_string());
822 state.last_trigger = Some(trigger.to_string());
823 state.last_mode = Some(mode.to_string());
824 state.last_attempted_at = Some(Utc::now());
825 state.last_error = Some(error.chars().take(400).collect());
826 config.cursor_ingest = Some(state);
827 config.save()
828}
829
830pub fn reserve_system_inventory_refresh_slot(
831 trigger: &str,
832 mode: &str,
833 include_cursor: bool,
834 min_interval: ChronoDuration,
835) -> Result<bool, String> {
836 ensure_global_xbp_paths()?;
837 let mut config = SshConfig::load()?;
838 let now = Utc::now();
839
840 if let Some(existing) = config.system_inventory.as_ref() {
841 if !system_inventory_needs_refresh(existing, include_cursor) {
842 return Ok(false);
843 }
844 }
845
846 if let Some(state) = config.system_inventory_refresh.as_ref() {
847 if let Some(last_attempted_at) = state.last_attempted_at {
848 if now - last_attempted_at < min_interval {
849 return Ok(false);
850 }
851 }
852 }
853
854 let mut state = config.system_inventory_refresh.unwrap_or_default();
855 state.last_status = Some("scheduled".to_string());
856 state.last_trigger = Some(trigger.to_string());
857 state.last_mode = Some(mode.to_string());
858 state.last_attempted_at = Some(now);
859 state.last_error = None;
860 state.include_cursor = Some(include_cursor);
861 config.system_inventory_refresh = Some(state);
862 config.save()?;
863 Ok(true)
864}
865
866pub fn record_system_inventory_refresh_started(
867 trigger: &str,
868 mode: &str,
869 include_cursor: bool,
870) -> Result<(), String> {
871 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
872 let mut state = config.system_inventory_refresh.unwrap_or_default();
873 state.last_status = Some("running".to_string());
874 state.last_trigger = Some(trigger.to_string());
875 state.last_mode = Some(mode.to_string());
876 state.last_attempted_at = Some(Utc::now());
877 state.last_error = None;
878 state.include_cursor = Some(include_cursor);
879 config.system_inventory_refresh = Some(state);
880 config.save()
881}
882
883pub fn record_system_inventory_refresh_success(
884 trigger: &str,
885 mode: &str,
886 include_cursor: bool,
887 refreshed: bool,
888) -> Result<(), String> {
889 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
890 let mut state = config.system_inventory_refresh.unwrap_or_default();
891 let completed_at = Utc::now();
892 state.last_status = Some("success".to_string());
893 state.last_trigger = Some(trigger.to_string());
894 state.last_mode = Some(mode.to_string());
895 state.last_attempted_at = Some(completed_at);
896 state.last_succeeded_at = Some(completed_at);
897 state.last_error = None;
898 state.include_cursor = Some(include_cursor);
899 state.refreshed = Some(refreshed);
900 config.system_inventory_refresh = Some(state);
901 config.save()
902}
903
904pub fn record_system_inventory_refresh_failure(
905 trigger: &str,
906 mode: &str,
907 include_cursor: bool,
908 error: &str,
909) -> Result<(), String> {
910 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
911 let mut state = config.system_inventory_refresh.unwrap_or_default();
912 state.last_status = Some("failed".to_string());
913 state.last_trigger = Some(trigger.to_string());
914 state.last_mode = Some(mode.to_string());
915 state.last_attempted_at = Some(Utc::now());
916 state.last_error = Some(error.chars().take(400).collect());
917 state.include_cursor = Some(include_cursor);
918 config.system_inventory_refresh = Some(state);
919 config.save()
920}
921
922pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
923where
924 F: FnOnce(&mut DnsInventoryCache),
925{
926 ensure_global_xbp_paths()?;
927 let mut config = SshConfig::load()?;
928 let mut cache = config.dns_inventory.unwrap_or_default();
929 updater(&mut cache);
930 config.dns_inventory = Some(cache);
931 config.save()
932}
933
934pub fn sync_system_inventory(
935 force: bool,
936 include_cursor: bool,
937 current_dir: Option<&Path>,
938) -> Result<SystemInventorySyncResult, String> {
939 let paths = ensure_global_xbp_paths()?;
940 let mut config = SshConfig::load()?;
941
942 if !force {
943 if let Some(existing) = config.system_inventory.clone() {
944 if !system_inventory_needs_refresh(&existing, include_cursor) {
945 return Ok(SystemInventorySyncResult {
946 inventory: existing,
947 config_path: paths.config_file,
948 refreshed: false,
949 });
950 }
951 }
952 }
953
954 let mut options = SystemInventoryOptions {
955 include_cursor,
956 current_dir: current_dir.map(Path::to_path_buf),
957 xbp_global_root: Some(paths.root_dir.clone()),
958 };
959 if options.current_dir.is_none() {
960 options.current_dir = env::current_dir().ok();
961 }
962
963 let mut inventory = collect_codetime_system_inventory(&options);
964 if !include_cursor {
965 if let Some(existing_cursor) = config
966 .system_inventory
967 .as_ref()
968 .and_then(|existing| existing.cursor.clone())
969 {
970 inventory.cursor = Some(existing_cursor);
971 }
972 }
973
974 config.system_inventory = Some(inventory.clone());
975 config.save()?;
976
977 Ok(SystemInventorySyncResult {
978 inventory,
979 config_path: paths.config_file,
980 refreshed: true,
981 })
982}
983
984fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
985 if include_cursor && inventory.cursor.is_none() {
986 return true;
987 }
988
989 match inventory.collected_at {
990 Some(collected_at) => {
991 Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
992 }
993 None => true,
994 }
995}
996
997pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
998 ensure_global_xbp_paths()
999}
1000
1001pub fn get_config_path() -> PathBuf {
1002 ensure_global_xbp_paths()
1003 .map(|paths| paths.config_file)
1004 .unwrap_or_else(|_| legacy_config_path())
1005}
1006
1007#[cfg(target_os = "windows")]
1008fn legacy_config_path() -> PathBuf {
1009 dirs::config_dir()
1010 .unwrap_or_else(|| PathBuf::from("."))
1011 .join("xbp")
1012 .join("config.yaml")
1013}
1014
1015#[cfg(not(target_os = "windows"))]
1016fn legacy_config_path() -> PathBuf {
1017 dirs::home_dir()
1018 .unwrap_or_else(|| PathBuf::from("."))
1019 .join(".xbp")
1020 .join("config.yaml")
1021}
1022
1023#[cfg(target_os = "windows")]
1024fn preferred_global_root_dir() -> PathBuf {
1025 let fallback = dirs::config_dir()
1026 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
1027 .unwrap_or_else(|| PathBuf::from("."))
1028 .join("xbp");
1029
1030 let Some(home_dir) = resolve_windows_home_dir() else {
1031 return fallback;
1032 };
1033 let c_drive = Path::new(r"C:\");
1034
1035 if c_drive.exists() {
1037 if windows_drive_letter(&home_dir) == Some('C') {
1038 return home_dir.join(".xbp");
1039 }
1040
1041 if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
1042 let c_profile_candidate = c_drive.join(relative_profile_path);
1043 if c_profile_candidate.exists() {
1044 return c_profile_candidate.join(".xbp");
1045 }
1046 }
1047 }
1048
1049 home_dir.join(".xbp")
1050}
1051
1052#[cfg(not(target_os = "windows"))]
1053fn preferred_global_root_dir() -> PathBuf {
1054 dirs::config_dir()
1055 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
1056 .unwrap_or_else(|| PathBuf::from("."))
1057 .join("xbp")
1058}
1059
1060#[cfg(target_os = "windows")]
1061fn resolve_windows_home_dir() -> Option<PathBuf> {
1062 dirs::home_dir()
1063 .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
1064 .or_else(|| {
1065 let drive = env::var_os("HOMEDRIVE")?;
1066 let path = env::var_os("HOMEPATH")?;
1067 Some(PathBuf::from(drive).join(path))
1068 })
1069}
1070
1071#[cfg(target_os = "windows")]
1072fn windows_drive_letter(path: &Path) -> Option<char> {
1073 let normalized = path.to_string_lossy().replace('/', "\\");
1074 let bytes = normalized.as_bytes();
1075 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1076 Some((bytes[0] as char).to_ascii_uppercase())
1077 } else {
1078 None
1079 }
1080}
1081
1082#[cfg(target_os = "windows")]
1083fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
1084 let normalized = path.to_string_lossy().replace('/', "\\");
1085 let bytes = normalized.as_bytes();
1086 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
1087 let tail = &normalized[3..];
1088 if tail.is_empty() {
1089 Some(PathBuf::new())
1090 } else {
1091 Some(PathBuf::from(tail))
1092 }
1093 } else {
1094 None
1095 }
1096}
1097
1098#[cfg(target_os = "windows")]
1099fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
1100 let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
1101 return Ok(());
1102 };
1103
1104 migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
1105 migrate_legacy_windows_file_if_missing(
1106 &legacy_root.join("versioning-files.yaml"),
1107 &paths.versioning_files_file,
1108 )?;
1109 migrate_legacy_windows_file_if_missing(
1110 &legacy_root.join("package-name-files.yaml"),
1111 &paths.package_name_files_file,
1112 )?;
1113
1114 Ok(())
1115}
1116
1117#[cfg(not(target_os = "windows"))]
1118fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
1119 Ok(())
1120}
1121
1122#[cfg(target_os = "windows")]
1123fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
1124 if to.exists() || !from.exists() {
1125 return Ok(());
1126 }
1127
1128 if let Some(parent) = to.parent() {
1129 fs::create_dir_all(parent).map_err(|e| {
1130 format!(
1131 "Failed to create directory for migrated file {}: {}",
1132 parent.display(),
1133 e
1134 )
1135 })?;
1136 }
1137
1138 fs::copy(from, to).map_err(|e| {
1139 format!(
1140 "Failed to migrate legacy config file {} -> {}: {}",
1141 from.display(),
1142 to.display(),
1143 e
1144 )
1145 })?;
1146
1147 Ok(())
1148}
1149
1150pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
1151 let paths = global_xbp_paths()?;
1152 Ok(vec![
1153 ("root".to_string(), paths.root_dir),
1154 ("config".to_string(), paths.config_file),
1155 ("ssh".to_string(), paths.ssh_dir),
1156 ("cache".to_string(), paths.cache_dir),
1157 ("logs".to_string(), paths.logs_dir),
1158 ("versioning".to_string(), paths.versioning_files_file),
1159 ("package-names".to_string(), paths.package_name_files_file),
1160 ])
1161}
1162
1163pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
1164 let paths = ensure_global_xbp_paths()?;
1165 Ok(paths.versioning_files_file)
1166}
1167
1168pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
1169 let registry_path = sync_versioning_files_registry()?;
1170 let content = fs::read_to_string(®istry_path).map_err(|e| {
1171 format!(
1172 "Failed to read versioning registry {}: {}",
1173 registry_path.display(),
1174 e
1175 )
1176 })?;
1177
1178 let config: VersioningFilesConfig = serde_yaml::from_str(&content)
1179 .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
1180
1181 Ok(config.files)
1182}
1183
1184pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
1185 let paths = ensure_global_xbp_paths()?;
1186 Ok(paths.package_name_files_file)
1187}
1188
1189pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
1190 let registry_path = sync_package_name_files_registry()?;
1191 let content = fs::read_to_string(®istry_path).map_err(|e| {
1192 format!(
1193 "Failed to read package-name registry {}: {}",
1194 registry_path.display(),
1195 e
1196 )
1197 })?;
1198
1199 let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
1200 .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
1201
1202 Ok(config.lookups)
1203}
1204
1205fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
1206 let mut config = if path.exists() {
1207 let content = fs::read_to_string(path).map_err(|e| {
1208 format!(
1209 "Failed to read versioning registry {}: {}",
1210 path.display(),
1211 e
1212 )
1213 })?;
1214 serde_yaml::from_str::<VersioningFilesConfig>(&content)
1215 .unwrap_or_else(|_| VersioningFilesConfig::default())
1216 } else {
1217 VersioningFilesConfig::default()
1218 };
1219
1220 let mut changed = false;
1221 for default_file in default_versioning_files() {
1222 if !config
1223 .files
1224 .iter()
1225 .any(|existing| existing == &default_file)
1226 {
1227 config.files.push(default_file);
1228 changed = true;
1229 }
1230 }
1231
1232 if changed || !path.exists() {
1233 let content = serde_yaml::to_string(&config)
1234 .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
1235 fs::write(path, content).map_err(|e| {
1236 format!(
1237 "Failed to write versioning registry {}: {}",
1238 path.display(),
1239 e
1240 )
1241 })?;
1242 }
1243
1244 Ok(())
1245}
1246
1247fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
1248 let mut config = if path.exists() {
1249 let content = fs::read_to_string(path).map_err(|e| {
1250 format!(
1251 "Failed to read package-name registry {}: {}",
1252 path.display(),
1253 e
1254 )
1255 })?;
1256 serde_yaml::from_str::<PackageNameFilesConfig>(&content)
1257 .unwrap_or_else(|_| PackageNameFilesConfig::default())
1258 } else {
1259 PackageNameFilesConfig::default()
1260 };
1261
1262 let mut changed = false;
1263 for default_lookup in default_package_name_lookups() {
1264 if !config
1265 .lookups
1266 .iter()
1267 .any(|existing| existing == &default_lookup)
1268 {
1269 config.lookups.push(default_lookup);
1270 changed = true;
1271 }
1272 }
1273
1274 if changed || !path.exists() {
1275 let content = serde_yaml::to_string(&config)
1276 .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
1277 fs::write(path, content).map_err(|e| {
1278 format!(
1279 "Failed to write package-name registry {}: {}",
1280 path.display(),
1281 e
1282 )
1283 })?;
1284 }
1285
1286 Ok(())
1287}
1288
1289fn default_versioning_files() -> Vec<String> {
1290 vec![
1291 "README.md".to_string(),
1292 "openapi.yaml".to_string(),
1293 "openapi.yml".to_string(),
1294 "openapi.json".to_string(),
1295 "package.json".to_string(),
1296 "package-lock.json".to_string(),
1297 "Cargo.toml".to_string(),
1298 "Cargo.lock".to_string(),
1299 "pyproject.toml".to_string(),
1300 "composer.json".to_string(),
1301 "deno.json".to_string(),
1302 "deno.jsonc".to_string(),
1303 "Chart.yaml".to_string(),
1304 "app.json".to_string(),
1305 "manifest.json".to_string(),
1306 "pom.xml".to_string(),
1307 "build.gradle".to_string(),
1308 "build.gradle.kts".to_string(),
1309 "mix.exs".to_string(),
1310 "xbp.yaml".to_string(),
1311 "xbp.yml".to_string(),
1312 "xbp.json".to_string(),
1313 ".xbp/xbp.json".to_string(),
1314 ".xbp/xbp.yaml".to_string(),
1315 ".xbp/xbp.yml".to_string(),
1316 ]
1317}
1318
1319fn default_package_name_lookups() -> Vec<PackageNameLookup> {
1320 vec![
1321 PackageNameLookup {
1322 file: "package.json".to_string(),
1323 format: "json".to_string(),
1324 key: "name".to_string(),
1325 registry: "npm".to_string(),
1326 },
1327 PackageNameLookup {
1328 file: "Cargo.toml".to_string(),
1329 format: "toml".to_string(),
1330 key: "package.name".to_string(),
1331 registry: "crates.io".to_string(),
1332 },
1333 ]
1334}
1335
1336const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
1337
1338#[derive(Debug, Clone)]
1340pub struct ApiConfig {
1341 base_url: String,
1342}
1343
1344impl ApiConfig {
1345 pub fn load() -> Self {
1347 let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
1348 Self::from_base_url(&raw_url)
1349 }
1350
1351 pub fn from_env() -> Self {
1352 Self::load()
1353 }
1354
1355 pub fn from_base_url(raw_url: &str) -> Self {
1356 let base_url = Self::normalize_base_url(raw_url);
1357 ApiConfig { base_url }
1358 }
1359
1360 pub fn base_url(&self) -> &str {
1362 &self.base_url
1363 }
1364
1365 pub fn version_endpoint(&self, project_name: &str) -> String {
1367 format!("{}/version?project_name={}", self.base_url, project_name)
1368 }
1369
1370 pub fn increment_endpoint(&self) -> String {
1372 format!("{}/version/increment", self.base_url)
1373 }
1374
1375 pub fn web_base_url(&self) -> String {
1376 Self::derive_web_base_url(&self.base_url)
1377 }
1378
1379 pub fn cli_auth_request_endpoint(&self) -> String {
1380 format!("{}/api/cli/auth/request", self.web_base_url())
1381 }
1382
1383 pub fn cli_auth_poll_endpoint(&self) -> String {
1384 format!("{}/api/cli/auth/poll", self.web_base_url())
1385 }
1386
1387 pub fn cli_auth_session_endpoint(&self) -> String {
1388 format!("{}/api/cli/auth/session", self.web_base_url())
1389 }
1390
1391 pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
1392 format!("{}/cli/login/{}", self.web_base_url(), flow_id)
1393 }
1394
1395 pub fn cli_linear_key_endpoint(&self) -> String {
1396 format!("{}/api/cli/linear/key", self.web_base_url())
1397 }
1398
1399 pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
1400 format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
1401 }
1402
1403 pub fn cloudflare_settings_url(&self) -> String {
1404 format!("{}/settings/cloudflare", self.web_base_url())
1405 }
1406
1407 pub fn cli_version_activity_endpoint(&self) -> String {
1408 format!("{}/api/cli/version/activity", self.web_base_url())
1409 }
1410
1411 pub fn cli_cursor_ingest_endpoint(&self) -> String {
1412 format!("{}/api/cli/cursor/ingest", self.web_base_url())
1413 }
1414
1415 pub fn cli_projects_register_endpoint(&self) -> String {
1416 format!("{}/api/cli/projects/register", self.web_base_url())
1417 }
1418
1419 pub fn cli_worktree_mutations_endpoint(&self) -> String {
1420 format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
1421 }
1422
1423 fn normalize_base_url(raw: &str) -> String {
1424 let trimmed = raw.trim();
1425 if trimmed.is_empty() {
1426 return DEFAULT_API_XBP_URL.to_string();
1427 }
1428
1429 let trimmed = trimmed.trim_end_matches('/');
1430 if trimmed.is_empty() {
1431 return DEFAULT_API_XBP_URL.to_string();
1432 }
1433
1434 trimmed.to_string()
1435 }
1436
1437 fn derive_web_base_url(base_url: &str) -> String {
1438 let Ok(mut url) = Url::parse(base_url) else {
1439 return base_url.to_string();
1440 };
1441
1442 if let Some(host) = url.host_str().map(str::to_string) {
1443 if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
1444 let _ = url.set_host(Some("xbp.app"));
1445 } else if let Some(stripped) = host.strip_prefix("api.") {
1446 let _ = url.set_host(Some(stripped));
1447 }
1448 }
1449
1450 url.set_path("");
1451 url.set_query(None);
1452 url.set_fragment(None);
1453
1454 url.to_string().trim_end_matches('/').to_string()
1455 }
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460 use super::{
1461 cloudflare_account_id_from_project_env, default_package_name_lookups,
1462 default_versioning_files, resolve_cloudflare_account_id, resolve_github_oauth2_key,
1463 resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
1464 sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
1465 OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig, VersioningFilesConfig,
1466 };
1467 use crate::openrouter::{
1468 DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1469 };
1470 use std::env;
1471 use std::fs;
1472 use std::path::PathBuf;
1473 use std::time::{SystemTime, UNIX_EPOCH};
1474
1475 fn temp_path(label: &str) -> PathBuf {
1476 let nanos = SystemTime::now()
1477 .duration_since(UNIX_EPOCH)
1478 .expect("time")
1479 .as_nanos();
1480 std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
1481 }
1482
1483 #[test]
1484 fn versioning_registry_defaults_include_core_files() {
1485 let defaults = default_versioning_files();
1486 assert!(defaults.contains(&"README.md".to_string()));
1487 assert!(defaults.contains(&"Cargo.toml".to_string()));
1488 assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
1489 }
1490
1491 #[test]
1492 fn versioning_registry_default_config_populates_files() {
1493 let config = VersioningFilesConfig::default();
1494 assert!(!config.files.is_empty());
1495 }
1496
1497 #[test]
1498 fn versioning_registry_defaults_do_not_contain_duplicates() {
1499 let defaults = default_versioning_files();
1500 let mut deduped = defaults.clone();
1501 deduped.sort();
1502 deduped.dedup();
1503 assert_eq!(defaults.len(), deduped.len());
1504 }
1505
1506 #[test]
1507 fn syncing_registry_creates_file_with_defaults() {
1508 let path = temp_path("defaults");
1509 sync_versioning_files_registry_at(&path).expect("sync");
1510
1511 let content = fs::read_to_string(&path).expect("read");
1512 assert!(content.contains("README.md"));
1513 assert!(content.contains("Cargo.toml"));
1514
1515 let _ = fs::remove_file(path);
1516 }
1517
1518 #[test]
1519 fn syncing_registry_preserves_user_added_entries() {
1520 let path = temp_path("preserve");
1521 fs::write(&path, "files:\n - custom.file\n").expect("write registry");
1522
1523 sync_versioning_files_registry_at(&path).expect("sync");
1524
1525 let content = fs::read_to_string(&path).expect("read");
1526 assert!(content.contains("custom.file"));
1527 assert!(content.contains("README.md"));
1528
1529 let _ = fs::remove_file(path);
1530 }
1531
1532 #[test]
1533 fn api_config_normalizes_trailing_slashes() {
1534 assert_eq!(
1535 ApiConfig::normalize_base_url("https://api.xbp.app///"),
1536 "https://api.xbp.app".to_string()
1537 );
1538 }
1539
1540 #[test]
1541 fn api_config_uses_default_for_blank_values() {
1542 assert_eq!(
1543 ApiConfig::normalize_base_url(" "),
1544 "https://api.xbp.app".to_string()
1545 );
1546 }
1547
1548 #[test]
1549 fn api_config_builds_version_endpoints() {
1550 let config = ApiConfig {
1551 base_url: "https://api.test.xbp".to_string(),
1552 };
1553 let endpoint = config.version_endpoint("demo");
1554 let increment = config.increment_endpoint();
1555
1556 assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
1557 assert_eq!(increment, "https://api.test.xbp/version/increment");
1558 }
1559
1560 #[test]
1561 fn api_config_exposes_cli_projects_register_endpoint() {
1562 let config = ApiConfig {
1563 base_url: "https://api.xbp.app".to_string(),
1564 };
1565 assert_eq!(
1566 config.cli_projects_register_endpoint(),
1567 "https://xbp.app/api/cli/projects/register"
1568 );
1569 }
1570
1571 #[test]
1572 fn api_config_derives_browser_base_url_from_api_subdomain() {
1573 assert_eq!(
1574 ApiConfig::derive_web_base_url("https://api.xbp.app"),
1575 "https://xbp.app".to_string()
1576 );
1577 assert_eq!(
1578 ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
1579 "https://xbp.app".to_string()
1580 );
1581 assert_eq!(
1582 ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
1583 "https://xbp.app".to_string()
1584 );
1585 assert_eq!(
1586 ApiConfig::derive_web_base_url("https://api.internal.example.com"),
1587 "https://internal.example.com".to_string()
1588 );
1589 assert_eq!(
1590 ApiConfig::derive_web_base_url("http://localhost:3000"),
1591 "http://localhost:3000".to_string()
1592 );
1593 }
1594
1595 #[test]
1596 fn package_lookup_defaults_include_npm_and_crates() {
1597 let defaults = default_package_name_lookups();
1598 assert!(defaults.iter().any(|entry| {
1599 entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
1600 }));
1601 assert!(defaults.iter().any(|entry| {
1602 entry.file == "Cargo.toml"
1603 && entry.registry == "crates.io"
1604 && entry.key == "package.name"
1605 }));
1606 }
1607
1608 #[test]
1609 fn package_lookup_default_config_populates_entries() {
1610 let config = PackageNameFilesConfig::default();
1611 assert!(!config.lookups.is_empty());
1612 }
1613
1614 #[test]
1615 fn syncing_package_lookup_registry_creates_defaults() {
1616 let path = temp_path("package-lookup-defaults");
1617 sync_package_name_files_registry_at(&path).expect("sync");
1618
1619 let content = fs::read_to_string(&path).expect("read");
1620 assert!(content.contains("package.json"));
1621 assert!(content.contains("Cargo.toml"));
1622
1623 let _ = fs::remove_file(path);
1624 }
1625
1626 #[test]
1627 fn syncing_package_lookup_registry_preserves_custom_entries() {
1628 let path = temp_path("package-lookup-custom");
1629 fs::write(
1630 &path,
1631 "lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
1632 )
1633 .expect("write package lookup registry");
1634
1635 sync_package_name_files_registry_at(&path).expect("sync");
1636 let content = fs::read_to_string(&path).expect("read");
1637 assert!(content.contains("custom.yaml"));
1638 assert!(content.contains("package.json"));
1639
1640 let _ = fs::remove_file(path);
1641 }
1642
1643 #[test]
1644 fn secret_provider_parses_supported_keys() {
1645 assert_eq!(
1646 SecretProvider::from_key("openrouter"),
1647 Some(SecretProvider::OpenRouter)
1648 );
1649 assert_eq!(
1650 SecretProvider::from_key("github"),
1651 Some(SecretProvider::Github)
1652 );
1653 assert_eq!(
1654 SecretProvider::from_key("linear"),
1655 Some(SecretProvider::Linear)
1656 );
1657 assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
1658 assert_eq!(
1659 SecretProvider::from_key("crates"),
1660 Some(SecretProvider::Crates)
1661 );
1662 assert_eq!(SecretProvider::from_key("unknown"), None);
1663 }
1664
1665 #[test]
1666 fn ssh_config_secret_get_set_roundtrip() {
1667 let mut cfg = SshConfig::new();
1668 cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
1669 cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
1670 cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
1671 cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
1672 cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
1673
1674 assert_eq!(
1675 cfg.get_secret(SecretProvider::OpenRouter),
1676 Some("or-test-123")
1677 );
1678 assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
1679 assert_eq!(
1680 cfg.get_secret(SecretProvider::Linear),
1681 Some("lin_api_test_789")
1682 );
1683 assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
1684 assert_eq!(
1685 cfg.get_secret(SecretProvider::Crates),
1686 Some("crate_test_token")
1687 );
1688 assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
1689 }
1690
1691 #[test]
1692 fn default_openrouter_config_populates_models_and_prompts() {
1693 let config = OpenRouterConfig::default();
1694
1695 assert_eq!(config.commit_model, DEFAULT_MODEL);
1696 assert_eq!(config.release_notes_model, DEFAULT_MODEL);
1697 assert_eq!(
1698 config.commit_system_prompt,
1699 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1700 );
1701 assert_eq!(
1702 config.release_notes_system_prompt,
1703 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1704 );
1705 }
1706
1707 #[test]
1708 fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
1709 let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
1710
1711 assert!(yaml.contains("openrouter:"));
1712 assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
1713 assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
1714 assert!(yaml.contains("commit_system_prompt"));
1715 assert!(yaml.contains("release_notes_system_prompt"));
1716 }
1717
1718 #[test]
1719 fn ssh_config_new_uses_openrouter_defaults() {
1720 let config = SshConfig::new();
1721
1722 assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
1723 assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
1724 assert_eq!(
1725 config.openrouter.commit_system_prompt,
1726 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1727 );
1728 assert_eq!(
1729 config.openrouter.release_notes_system_prompt,
1730 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1731 );
1732 }
1733
1734 #[test]
1735 fn syncing_global_config_backfills_openrouter_generation_defaults() {
1736 let path = temp_path("global-config");
1737 fs::write(
1738 &path,
1739 "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",
1740 )
1741 .expect("write config");
1742
1743 sync_global_config_defaults_at(&path).expect("sync config defaults");
1744
1745 let content = fs::read_to_string(&path).expect("read config");
1746 assert!(content.contains("openrouter:"));
1747 assert!(content.contains("commit_model: openai/gpt-4o-mini"));
1748 assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
1749 assert!(content.contains("commit_system_prompt"));
1750 assert!(content.contains("release_notes_system_prompt"));
1751
1752 let _ = fs::remove_file(path);
1753 }
1754
1755 #[test]
1756 fn resolve_secret_prefers_environment_value() {
1757 std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
1758 std::env::set_var("GITHUB_TOKEN", "env-github");
1759 std::env::set_var("XBP_API_TOKEN", "env-xbp");
1760
1761 assert_eq!(
1762 resolve_openrouter_api_key(),
1763 Some("env-openrouter".to_string())
1764 );
1765 assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
1766 assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
1767
1768 std::env::remove_var("OPENROUTER_API_KEY");
1769 std::env::remove_var("GITHUB_TOKEN");
1770 std::env::remove_var("XBP_API_TOKEN");
1771 }
1772
1773 #[test]
1774 fn resolve_cloudflare_account_id_reads_project_env_files() {
1775 use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
1776
1777 let dir = temp_path("cf-account-env");
1778 fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
1779 fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
1780 fs::write(
1781 dir.join(".env"),
1782 "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
1783 )
1784 .expect("write env");
1785
1786 let previous = env::current_dir().expect("cwd");
1787 env::set_current_dir(&dir).expect("chdir");
1788 for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
1789 env::remove_var(key);
1790 }
1791
1792 assert_eq!(
1793 cloudflare_account_id_from_project_env().as_deref(),
1794 Some("acc-from-project-env")
1795 );
1796 assert_eq!(
1797 resolve_cloudflare_account_id().as_deref(),
1798 Some("acc-from-project-env")
1799 );
1800
1801 env::set_current_dir(previous).expect("restore cwd");
1802 let _ = fs::remove_dir_all(dir);
1803 }
1804}