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