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