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