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