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