runtime.rs (22132B)
1 use std::{ 2 path::PathBuf, 3 time::{SystemTime, UNIX_EPOCH}, 4 }; 5 6 use radroots_local_events::normalize_relay_url; 7 use serde::Serialize; 8 use thiserror::Error; 9 10 use crate::{AppRuntimePathsError, AppRuntimeRoots}; 11 12 pub const APP_ID: &str = "org.radroots.app"; 13 pub const APP_NAME: &str = "Radroots"; 14 pub const APP_PLATFORM_RUNTIME: &str = "app-macos-native"; 15 pub const APP_PROJECTION_SOURCE: &str = "gpui-native"; 16 pub const APP_RUNTIME_ORIGIN: &str = "gpui://localhost"; 17 pub const APP_HOST_PLATFORM: &str = "desktop"; 18 pub const APP_RUNTIME_MODE_ENV: &str = "RADROOTS_APP_RUNTIME_MODE"; 19 pub const APP_NOSTR_RELAY_URLS_ENV: &str = "RADROOTS_APP_NOSTR_RELAY_URLS"; 20 pub const APP_LOCAL_LOG_ROOT_ENV: &str = "RADROOTS_APP_LOCAL_LOG_ROOT"; 21 22 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 23 pub enum AppRuntimeMode { 24 LocalhostDev, 25 Development, 26 Production, 27 } 28 29 #[derive(Clone, Debug, Eq, PartialEq)] 30 pub struct AppRuntimeConfig { 31 pub runtime_mode: AppRuntimeMode, 32 pub nostr_relay_urls: Vec<String>, 33 pub local_log_root: PathBuf, 34 } 35 36 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] 37 pub struct AppBuildIdentity { 38 pub package_name: String, 39 pub package_version: String, 40 pub build_profile: String, 41 pub target_triple: String, 42 pub projection_source: String, 43 pub git_commit: Option<String>, 44 } 45 46 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] 47 pub struct AppCoreRuntimeMetadata { 48 pub package_name: String, 49 pub package_version: String, 50 pub package_authors: String, 51 pub rust_edition: String, 52 pub rust_toolchain: String, 53 } 54 55 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] 56 pub struct AppHostRuntimeMetadata { 57 pub app_identifier: String, 58 pub app_name: String, 59 pub app_version: String, 60 pub app_build: String, 61 pub platform_name: String, 62 pub operating_system: String, 63 pub host_locale: String, 64 pub runtime_origin: String, 65 } 66 67 #[derive(Clone, Debug, Eq, PartialEq)] 68 pub struct AppRuntimeCapture { 69 pub host_locale: String, 70 pub operating_system: String, 71 pub run_id: String, 72 } 73 74 #[derive(Clone, Debug, Eq, PartialEq)] 75 pub struct AppRuntimeSnapshot { 76 pub title: String, 77 pub runtime_mode: AppRuntimeMode, 78 pub run_id: String, 79 pub core: AppCoreRuntimeMetadata, 80 pub build: AppBuildIdentity, 81 pub host: AppHostRuntimeMetadata, 82 } 83 84 #[derive(Debug, Error)] 85 pub enum AppRuntimeConfigError { 86 #[error(transparent)] 87 RuntimePaths(#[from] AppRuntimePathsError), 88 #[error("missing required runtime env: {0}")] 89 MissingEnv(&'static str), 90 #[error("unsupported runtime mode: {0}")] 91 UnsupportedRuntimeMode(String), 92 #[error("missing required runtime config field: {0}")] 93 MissingField(&'static str), 94 #[error("invalid runtime relay url in {field}: {value}")] 95 InvalidRelayUrl { field: &'static str, value: String }, 96 } 97 98 impl AppRuntimeConfig { 99 pub fn from_env() -> Result<Self, AppRuntimeConfigError> { 100 Self::from_env_with(|name| std::env::var(name).ok(), None) 101 } 102 103 fn from_env_with<F>( 104 mut read_env: F, 105 default_log_root: Option<PathBuf>, 106 ) -> Result<Self, AppRuntimeConfigError> 107 where 108 F: FnMut(&str) -> Option<String>, 109 { 110 let runtime_mode = 111 parse_config_runtime_mode(&require_env_value(&mut read_env, APP_RUNTIME_MODE_ENV)?)?; 112 let nostr_relay_urls = parse_relay_url_set( 113 APP_NOSTR_RELAY_URLS_ENV, 114 require_env_value(&mut read_env, APP_NOSTR_RELAY_URLS_ENV)?, 115 )?; 116 let local_log_root = read_env(APP_LOCAL_LOG_ROOT_ENV) 117 .map(|value| require_path_value(APP_LOCAL_LOG_ROOT_ENV, value)) 118 .transpose()?; 119 let local_log_root = match local_log_root { 120 Some(local_log_root) => local_log_root, 121 None => match default_log_root { 122 Some(default_log_root) => default_log_root, 123 None => AppRuntimeRoots::current_desktop()?.logs, 124 }, 125 }; 126 127 Ok(Self { 128 runtime_mode, 129 nostr_relay_urls, 130 local_log_root, 131 }) 132 } 133 } 134 135 impl AppRuntimeCapture { 136 pub fn current(mode: &AppRuntimeMode) -> Self { 137 Self { 138 host_locale: detect_host_locale(), 139 operating_system: std::env::consts::OS.to_owned(), 140 run_id: build_run_id(mode), 141 } 142 } 143 } 144 145 impl AppRuntimeSnapshot { 146 pub fn capture(build: AppBuildIdentity) -> Self { 147 let mode = parse_build_runtime_mode(&build.build_profile); 148 Self::capture_for_mode(build, mode) 149 } 150 151 pub fn capture_for_mode(build: AppBuildIdentity, runtime_mode: AppRuntimeMode) -> Self { 152 Self::from_capture( 153 build, 154 runtime_mode, 155 AppRuntimeCapture::current(&runtime_mode), 156 ) 157 } 158 159 pub fn from_capture( 160 build: AppBuildIdentity, 161 runtime_mode: AppRuntimeMode, 162 capture: AppRuntimeCapture, 163 ) -> Self { 164 let app_version = build.package_version.clone(); 165 let app_build = build 166 .git_commit 167 .clone() 168 .unwrap_or_else(|| build.build_profile.clone()); 169 170 Self { 171 title: APP_NAME.to_owned(), 172 runtime_mode, 173 run_id: capture.run_id, 174 core: AppCoreRuntimeMetadata { 175 package_name: env!("CARGO_PKG_NAME").to_owned(), 176 package_version: env!("CARGO_PKG_VERSION").to_owned(), 177 package_authors: env!("CARGO_PKG_AUTHORS").to_owned(), 178 rust_edition: "2024".to_owned(), 179 rust_toolchain: env!("CARGO_PKG_RUST_VERSION").to_owned(), 180 }, 181 build, 182 host: AppHostRuntimeMetadata { 183 app_identifier: APP_ID.to_owned(), 184 app_name: APP_NAME.to_owned(), 185 app_version, 186 app_build, 187 platform_name: APP_HOST_PLATFORM.to_owned(), 188 operating_system: capture.operating_system, 189 host_locale: capture.host_locale, 190 runtime_origin: APP_RUNTIME_ORIGIN.to_owned(), 191 }, 192 } 193 } 194 } 195 196 pub fn runtime_mode_label(mode: &AppRuntimeMode) -> &'static str { 197 match mode { 198 AppRuntimeMode::LocalhostDev => "localhost-dev", 199 AppRuntimeMode::Development => "development", 200 AppRuntimeMode::Production => "production", 201 } 202 } 203 204 fn parse_build_runtime_mode(build_profile: &str) -> AppRuntimeMode { 205 match build_profile.trim() { 206 "release" => AppRuntimeMode::Production, 207 _ => AppRuntimeMode::Development, 208 } 209 } 210 211 fn parse_config_runtime_mode(value: &str) -> Result<AppRuntimeMode, AppRuntimeConfigError> { 212 match value.trim() { 213 "localhost-dev" => Ok(AppRuntimeMode::LocalhostDev), 214 "development" => Ok(AppRuntimeMode::Development), 215 "production" => Ok(AppRuntimeMode::Production), 216 other => Err(AppRuntimeConfigError::UnsupportedRuntimeMode( 217 other.to_owned(), 218 )), 219 } 220 } 221 222 fn parse_relay_url_set( 223 field: &'static str, 224 value: String, 225 ) -> Result<Vec<String>, AppRuntimeConfigError> { 226 let mut relays = Vec::new(); 227 for relay in value.split(',') { 228 let relay = relay.trim(); 229 if relay.is_empty() { 230 return Err(AppRuntimeConfigError::InvalidRelayUrl { 231 field, 232 value: relay.to_owned(), 233 }); 234 } 235 let normalized = normalize_app_relay_url(field, relay).map_err(|_| { 236 AppRuntimeConfigError::InvalidRelayUrl { 237 field, 238 value: relay.to_owned(), 239 } 240 })?; 241 if !relays.iter().any(|existing| existing == &normalized) { 242 relays.push(normalized); 243 } 244 } 245 246 if relays.is_empty() { 247 return Err(AppRuntimeConfigError::MissingField(field)); 248 } 249 250 Ok(relays) 251 } 252 253 fn normalize_app_relay_url( 254 field: &'static str, 255 relay: &str, 256 ) -> Result<String, AppRuntimeConfigError> { 257 normalize_relay_url(relay).map_err(|_| AppRuntimeConfigError::InvalidRelayUrl { 258 field, 259 value: relay.to_owned(), 260 }) 261 } 262 263 fn require_path_value( 264 field: &'static str, 265 value: String, 266 ) -> Result<PathBuf, AppRuntimeConfigError> { 267 let trimmed = value.trim(); 268 if trimmed.is_empty() { 269 return Err(AppRuntimeConfigError::MissingField(field)); 270 } 271 272 Ok(PathBuf::from(trimmed)) 273 } 274 275 fn require_value(field: &'static str, value: String) -> Result<String, AppRuntimeConfigError> { 276 let trimmed = value.trim(); 277 if trimmed.is_empty() { 278 return Err(AppRuntimeConfigError::MissingField(field)); 279 } 280 281 Ok(trimmed.to_owned()) 282 } 283 284 fn require_env_value<F>( 285 read_env: &mut F, 286 field: &'static str, 287 ) -> Result<String, AppRuntimeConfigError> 288 where 289 F: FnMut(&str) -> Option<String>, 290 { 291 let value = read_env(field).ok_or(AppRuntimeConfigError::MissingEnv(field))?; 292 require_value(field, value) 293 } 294 295 fn detect_host_locale() -> String { 296 [ 297 std::env::var("LC_ALL").ok(), 298 std::env::var("LC_MESSAGES").ok(), 299 std::env::var("LANGUAGE").ok(), 300 std::env::var("LANG").ok(), 301 ] 302 .into_iter() 303 .flatten() 304 .find_map(|value| { 305 let trimmed = value.trim(); 306 if trimmed.is_empty() { 307 None 308 } else { 309 Some(trimmed.to_owned()) 310 } 311 }) 312 .unwrap_or_else(|| "en".to_owned()) 313 } 314 315 fn build_run_id(mode: &AppRuntimeMode) -> String { 316 let started_at_ms = SystemTime::now() 317 .duration_since(UNIX_EPOCH) 318 .unwrap_or_default() 319 .as_millis(); 320 format!( 321 "run-{}-{started_at_ms}-pid{}", 322 runtime_mode_label(mode), 323 std::process::id() 324 ) 325 } 326 327 #[cfg(test)] 328 mod tests { 329 use std::{collections::BTreeMap, path::PathBuf}; 330 331 use super::{ 332 APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, APP_NAME, APP_NOSTR_RELAY_URLS_ENV, 333 APP_PROJECTION_SOURCE, APP_RUNTIME_MODE_ENV, APP_RUNTIME_ORIGIN, AppBuildIdentity, 334 AppRuntimeCapture, AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode, 335 AppRuntimeSnapshot, runtime_mode_label, 336 }; 337 338 fn test_build_identity() -> AppBuildIdentity { 339 AppBuildIdentity { 340 package_name: "radroots_app".to_owned(), 341 package_version: "0.1.0".to_owned(), 342 build_profile: "debug".to_owned(), 343 target_triple: "aarch64-apple-darwin".to_owned(), 344 projection_source: APP_PROJECTION_SOURCE.to_owned(), 345 git_commit: Some("deadbeefcafefeed".to_owned()), 346 } 347 } 348 349 fn test_runtime_env() -> BTreeMap<&'static str, String> { 350 BTreeMap::from([ 351 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 352 ( 353 APP_NOSTR_RELAY_URLS_ENV, 354 " ws://127.0.0.1:8080 , ws://127.0.0.1:8081 , ws://127.0.0.1:8080 ".to_owned(), 355 ), 356 ]) 357 } 358 359 #[test] 360 fn runtime_snapshot_surfaces_core_and_host_metadata() { 361 let snapshot = AppRuntimeSnapshot::from_capture( 362 test_build_identity(), 363 AppRuntimeMode::Development, 364 AppRuntimeCapture { 365 host_locale: "en_US.UTF-8".to_owned(), 366 operating_system: "macos".to_owned(), 367 run_id: "run-development-123-pid456".to_owned(), 368 }, 369 ); 370 371 assert_eq!(snapshot.title, APP_NAME); 372 assert_eq!(snapshot.run_id, "run-development-123-pid456"); 373 assert_eq!(snapshot.core.package_name, "radroots_app_core"); 374 assert_eq!(snapshot.core.package_version, env!("CARGO_PKG_VERSION")); 375 assert_eq!(snapshot.core.package_authors, env!("CARGO_PKG_AUTHORS")); 376 assert_eq!(snapshot.core.rust_edition, "2024"); 377 assert_eq!(snapshot.core.rust_toolchain, env!("CARGO_PKG_RUST_VERSION")); 378 assert_eq!(snapshot.build.package_name, "radroots_app"); 379 assert_eq!(snapshot.build.target_triple, "aarch64-apple-darwin"); 380 assert_eq!(snapshot.host.app_identifier, APP_ID); 381 assert_eq!(snapshot.host.app_name, APP_NAME); 382 assert_eq!(snapshot.host.app_version, env!("CARGO_PKG_VERSION")); 383 assert_eq!(snapshot.host.app_build, "deadbeefcafefeed"); 384 assert_eq!(snapshot.host.platform_name, APP_HOST_PLATFORM); 385 assert_eq!(snapshot.host.operating_system, "macos"); 386 assert_eq!(snapshot.host.host_locale, "en_US.UTF-8"); 387 assert_eq!(snapshot.host.runtime_origin, APP_RUNTIME_ORIGIN); 388 } 389 390 #[test] 391 fn runtime_config_requires_explicit_runtime_mode_env() { 392 let env = BTreeMap::from([(APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned())]); 393 let error = AppRuntimeConfig::from_env_with( 394 |name| env.get(name).cloned(), 395 Some(PathBuf::from("/tmp/default-logs")), 396 ) 397 .expect_err("missing runtime mode env should fail"); 398 399 assert!(matches!( 400 error, 401 AppRuntimeConfigError::MissingEnv(APP_RUNTIME_MODE_ENV) 402 )); 403 } 404 405 #[test] 406 fn runtime_config_surfaces_explicit_local_log_root() { 407 let env = BTreeMap::from([ 408 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 409 (APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned()), 410 (APP_LOCAL_LOG_ROOT_ENV, "/tmp/radroots/logs".to_owned()), 411 ]); 412 let config = AppRuntimeConfig::from_env_with( 413 |name| env.get(name).cloned(), 414 Some(PathBuf::from("/tmp/default-logs")), 415 ) 416 .expect("valid env config"); 417 418 assert_eq!(config.runtime_mode, AppRuntimeMode::LocalhostDev); 419 assert_eq!(config.nostr_relay_urls, vec!["ws://127.0.0.1:8080"]); 420 assert_eq!(config.local_log_root, PathBuf::from("/tmp/radroots/logs")); 421 } 422 423 #[test] 424 fn runtime_config_normalizes_configured_nostr_relay_urls() { 425 let env = test_runtime_env(); 426 let config = AppRuntimeConfig::from_env_with( 427 |name| env.get(name).cloned(), 428 Some(PathBuf::from("/tmp/default-logs")), 429 ) 430 .expect("valid env config"); 431 432 assert_eq!( 433 config.nostr_relay_urls, 434 vec!["ws://127.0.0.1:8080", "ws://127.0.0.1:8081"] 435 ); 436 } 437 438 #[test] 439 fn runtime_config_rejects_malformed_nostr_relay_urls() { 440 let env = BTreeMap::from([ 441 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 442 (APP_NOSTR_RELAY_URLS_ENV, "not-a-url".to_owned()), 443 ]); 444 let error = AppRuntimeConfig::from_env_with( 445 |name| env.get(name).cloned(), 446 Some(PathBuf::from("/tmp/default-logs")), 447 ) 448 .expect_err("malformed relay url should fail"); 449 450 assert!( 451 matches!( 452 error, 453 AppRuntimeConfigError::InvalidRelayUrl { 454 field: APP_NOSTR_RELAY_URLS_ENV, 455 ref value 456 } if value == "not-a-url" 457 ), 458 "unexpected error: {error}" 459 ); 460 } 461 462 #[test] 463 fn runtime_config_rejects_non_websocket_nostr_relay_urls() { 464 let env = BTreeMap::from([ 465 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 466 (APP_NOSTR_RELAY_URLS_ENV, "https://relay.example".to_owned()), 467 ]); 468 let error = AppRuntimeConfig::from_env_with( 469 |name| env.get(name).cloned(), 470 Some(PathBuf::from("/tmp/default-logs")), 471 ) 472 .expect_err("non-websocket relay url should fail"); 473 474 assert!( 475 matches!( 476 error, 477 AppRuntimeConfigError::InvalidRelayUrl { 478 field: APP_NOSTR_RELAY_URLS_ENV, 479 ref value 480 } if value == "https://relay.example" 481 ), 482 "unexpected error: {error}" 483 ); 484 } 485 486 #[test] 487 fn runtime_config_rejects_hostless_nostr_relay_urls() { 488 let env = BTreeMap::from([ 489 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 490 (APP_NOSTR_RELAY_URLS_ENV, "wss://".to_owned()), 491 ]); 492 let error = AppRuntimeConfig::from_env_with( 493 |name| env.get(name).cloned(), 494 Some(PathBuf::from("/tmp/default-logs")), 495 ) 496 .expect_err("hostless relay url should fail"); 497 498 assert!( 499 matches!( 500 error, 501 AppRuntimeConfigError::InvalidRelayUrl { 502 field: APP_NOSTR_RELAY_URLS_ENV, 503 ref value 504 } if value == "wss://" 505 ), 506 "unexpected error: {error}" 507 ); 508 } 509 510 #[test] 511 fn runtime_config_rejects_malformed_nostr_relay_authority() { 512 for relay_url in [ 513 "wss://user@relay.example", 514 "wss://relay.example:abc", 515 "wss://2001:db8::1", 516 "wss://relay.example,,wss://relay-two.example", 517 ] { 518 let env = BTreeMap::from([ 519 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 520 (APP_NOSTR_RELAY_URLS_ENV, relay_url.to_owned()), 521 ]); 522 let error = AppRuntimeConfig::from_env_with( 523 |name| env.get(name).cloned(), 524 Some(PathBuf::from("/tmp/default-logs")), 525 ) 526 .expect_err("malformed relay authority should fail"); 527 528 assert!( 529 matches!( 530 error, 531 AppRuntimeConfigError::InvalidRelayUrl { 532 field: APP_NOSTR_RELAY_URLS_ENV, 533 .. 534 } 535 ), 536 "unexpected error for {relay_url}: {error}" 537 ); 538 } 539 } 540 541 #[test] 542 fn runtime_config_accepts_bracketed_ipv6_nostr_relay_urls() { 543 let env = BTreeMap::from([ 544 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 545 ( 546 APP_NOSTR_RELAY_URLS_ENV, 547 " wss://[2001:db8::1]:443/relay ".to_owned(), 548 ), 549 ]); 550 let config = AppRuntimeConfig::from_env_with( 551 |name| env.get(name).cloned(), 552 Some(PathBuf::from("/tmp/default-logs")), 553 ) 554 .expect("ipv6 relay url should resolve"); 555 556 assert_eq!( 557 config.nostr_relay_urls, 558 vec!["wss://[2001:db8::1]:443/relay"] 559 ); 560 } 561 562 #[test] 563 fn runtime_config_defaults_local_log_root_from_runtime_paths() { 564 let env = test_runtime_env(); 565 let config = AppRuntimeConfig::from_env_with( 566 |name| env.get(name).cloned(), 567 Some(PathBuf::from("/tmp/default-logs")), 568 ) 569 .expect("default log root should apply"); 570 571 assert_eq!(config.local_log_root, PathBuf::from("/tmp/default-logs")); 572 } 573 574 #[test] 575 fn runtime_config_accepts_explicit_log_root_without_default_runtime_paths() { 576 let env = BTreeMap::from([ 577 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 578 (APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned()), 579 (APP_LOCAL_LOG_ROOT_ENV, "/tmp/explicit-logs".to_owned()), 580 ]); 581 let config = AppRuntimeConfig::from_env_with(|name| env.get(name).cloned(), None) 582 .expect("explicit local log root should bypass runtime root discovery"); 583 584 assert_eq!(config.local_log_root, PathBuf::from("/tmp/explicit-logs")); 585 } 586 587 #[test] 588 fn runtime_snapshot_falls_back_to_build_profile_when_git_commit_is_missing() { 589 let mut build = test_build_identity(); 590 build.git_commit = None; 591 build.build_profile = "release".to_owned(); 592 593 let snapshot = AppRuntimeSnapshot::from_capture( 594 build, 595 AppRuntimeMode::Production, 596 AppRuntimeCapture { 597 host_locale: "en".to_owned(), 598 operating_system: "linux".to_owned(), 599 run_id: "run-production-123-pid456".to_owned(), 600 }, 601 ); 602 603 assert_eq!(snapshot.host.app_build, "release"); 604 assert_eq!(runtime_mode_label(&snapshot.runtime_mode), "production"); 605 } 606 607 #[test] 608 fn runtime_snapshot_capture_for_mode_uses_rust_owned_host_identity() { 609 let snapshot = AppRuntimeSnapshot::capture_for_mode( 610 test_build_identity(), 611 AppRuntimeMode::LocalhostDev, 612 ); 613 614 assert_eq!(snapshot.title, APP_NAME); 615 assert!(snapshot.run_id.starts_with("run-localhost-dev-")); 616 assert!(snapshot.run_id.contains("-pid")); 617 assert_eq!(snapshot.host.app_identifier, APP_ID); 618 assert_eq!(snapshot.host.app_name, APP_NAME); 619 assert_eq!(snapshot.host.app_version, env!("CARGO_PKG_VERSION")); 620 assert_eq!(snapshot.host.platform_name, APP_HOST_PLATFORM); 621 assert_eq!(snapshot.host.operating_system, std::env::consts::OS); 622 assert_eq!(snapshot.host.runtime_origin, APP_RUNTIME_ORIGIN); 623 assert!(!snapshot.host.host_locale.trim().is_empty()); 624 } 625 626 #[test] 627 fn runtime_config_rejects_empty_required_fields() { 628 let env = BTreeMap::from([ 629 (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), 630 (APP_NOSTR_RELAY_URLS_ENV, "".to_owned()), 631 ]); 632 let error = AppRuntimeConfig::from_env_with( 633 |name| env.get(name).cloned(), 634 Some(PathBuf::from("/tmp/default-logs")), 635 ) 636 .expect_err("missing relay env should fail"); 637 638 assert!( 639 matches!( 640 error, 641 AppRuntimeConfigError::MissingField(APP_NOSTR_RELAY_URLS_ENV) 642 ), 643 "unexpected error: {error}" 644 ); 645 } 646 647 #[test] 648 fn runtime_config_rejects_missing_nostr_relay_urls() { 649 let env = BTreeMap::from([(APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned())]); 650 let error = AppRuntimeConfig::from_env_with( 651 |name| env.get(name).cloned(), 652 Some(PathBuf::from("/tmp/default-logs")), 653 ) 654 .expect_err("missing relay urls should fail"); 655 656 assert!( 657 matches!( 658 error, 659 AppRuntimeConfigError::MissingEnv(APP_NOSTR_RELAY_URLS_ENV) 660 ), 661 "unexpected error: {error}" 662 ); 663 } 664 }