Skip to main content

hashtree_config/
lib.rs

1//! Shared configuration for hashtree tools
2//!
3//! Reads from ~/.hashtree/config.toml
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10/// Default read-only file servers
11pub const DEFAULT_READ_SERVERS: &[&str] = &["https://cdn.iris.to", "https://hashtree.iris.to"];
12
13/// Default write-enabled file servers
14pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
15
16/// Default nostr relays
17pub const DEFAULT_RELAYS: &[&str] = &[
18    "wss://temp.iris.to",
19    "wss://relay.damus.io",
20    "wss://nos.lol",
21    "wss://relay.primal.net",
22    "wss://offchain.pub",
23];
24
25/// Top-level config structure
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct Config {
28    #[serde(default)]
29    pub server: ServerConfig,
30    #[serde(default)]
31    pub storage: StorageConfig,
32    #[serde(default)]
33    pub nostr: NostrConfig,
34    #[serde(default)]
35    pub blossom: BlossomConfig,
36    #[serde(default)]
37    pub sync: SyncConfig,
38}
39
40/// Server configuration
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ServerConfig {
43    #[serde(default = "default_bind_address")]
44    pub bind_address: String,
45    #[serde(default = "default_true")]
46    pub enable_auth: bool,
47    #[serde(default)]
48    pub public_writes: bool,
49    #[serde(default)]
50    pub enable_webrtc: bool,
51    #[serde(default)]
52    pub stun_port: u16,
53}
54
55impl Default for ServerConfig {
56    fn default() -> Self {
57        Self {
58            bind_address: default_bind_address(),
59            enable_auth: true,
60            public_writes: false,
61            enable_webrtc: false,
62            stun_port: 0,
63        }
64    }
65}
66
67fn default_bind_address() -> String {
68    "127.0.0.1:8080".to_string()
69}
70
71fn default_true() -> bool {
72    true
73}
74
75/// Storage backend type
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum StorageBackend {
79    /// LMDB storage - requires lmdb feature
80    #[default]
81    Lmdb,
82    /// Filesystem storage - stores in ~/.hashtree/blobs/{prefix}/{hash}
83    Fs,
84}
85
86/// Storage configuration
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StorageConfig {
89    /// Storage backend: "lmdb" (default) or "fs"
90    #[serde(default)]
91    pub backend: StorageBackend,
92    #[serde(default = "default_data_dir")]
93    pub data_dir: String,
94    #[serde(default = "default_max_size_gb")]
95    pub max_size_gb: u64,
96    #[serde(default)]
97    pub s3: Option<S3Config>,
98}
99
100impl Default for StorageConfig {
101    fn default() -> Self {
102        Self {
103            backend: StorageBackend::default(),
104            data_dir: default_data_dir(),
105            max_size_gb: default_max_size_gb(),
106            s3: None,
107        }
108    }
109}
110
111fn default_data_dir() -> String {
112    get_hashtree_dir()
113        .join("data")
114        .to_string_lossy()
115        .to_string()
116}
117
118fn default_max_size_gb() -> u64 {
119    10
120}
121
122/// S3-compatible storage configuration
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct S3Config {
125    pub endpoint: String,
126    pub bucket: String,
127    pub region: String,
128    #[serde(default)]
129    pub prefix: Option<String>,
130}
131
132/// Nostr relay configuration
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct NostrConfig {
135    #[serde(default = "default_relays")]
136    pub relays: Vec<String>,
137    #[serde(default)]
138    pub allowed_npubs: Vec<String>,
139    /// Max size for the trusted social graph store in GB (default: 10)
140    #[serde(default = "default_nostr_db_max_size_gb")]
141    pub db_max_size_gb: u64,
142    /// Max size for the social graph spambox store in GB (default: 1)
143    /// Set to 0 for memory-only spambox (no on-disk DB)
144    #[serde(default = "default_nostr_spambox_max_size_gb")]
145    pub spambox_max_size_gb: u64,
146}
147
148impl Default for NostrConfig {
149    fn default() -> Self {
150        Self {
151            relays: default_relays(),
152            allowed_npubs: vec![],
153            db_max_size_gb: default_nostr_db_max_size_gb(),
154            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
155        }
156    }
157}
158
159fn default_nostr_db_max_size_gb() -> u64 {
160    10
161}
162
163fn default_nostr_spambox_max_size_gb() -> u64 {
164    1
165}
166
167fn default_relays() -> Vec<String> {
168    DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
169}
170
171/// File server (blossom) configuration
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct BlossomConfig {
174    /// Legacy servers field (both read and write)
175    #[serde(default)]
176    pub servers: Vec<String>,
177    /// Read-only file servers
178    #[serde(default = "default_read_servers")]
179    pub read_servers: Vec<String>,
180    /// Write-enabled file servers
181    #[serde(default = "default_write_servers")]
182    pub write_servers: Vec<String>,
183    /// Max upload size in MB
184    #[serde(default = "default_max_upload_mb")]
185    pub max_upload_mb: u64,
186    /// Force upload all blobs, skipping "server already has" check
187    #[serde(default)]
188    pub force_upload: bool,
189}
190
191impl Default for BlossomConfig {
192    fn default() -> Self {
193        Self {
194            servers: vec![],
195            read_servers: default_read_servers(),
196            write_servers: default_write_servers(),
197            max_upload_mb: default_max_upload_mb(),
198            force_upload: false,
199        }
200    }
201}
202
203fn default_read_servers() -> Vec<String> {
204    DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
205}
206
207fn default_write_servers() -> Vec<String> {
208    DEFAULT_WRITE_SERVERS
209        .iter()
210        .map(|s| s.to_string())
211        .collect()
212}
213
214fn default_max_upload_mb() -> u64 {
215    100
216}
217
218impl BlossomConfig {
219    /// Get all read servers (legacy + read_servers)
220    pub fn all_read_servers(&self) -> Vec<String> {
221        let mut servers = self.servers.clone();
222        servers.extend(self.read_servers.clone());
223        servers.sort();
224        servers.dedup();
225        servers
226    }
227
228    /// Get all write servers (legacy + write_servers)
229    pub fn all_write_servers(&self) -> Vec<String> {
230        let mut servers = self.servers.clone();
231        servers.extend(self.write_servers.clone());
232        servers.sort();
233        servers.dedup();
234        servers
235    }
236}
237
238/// Background sync configuration
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SyncConfig {
241    #[serde(default)]
242    pub enabled: bool,
243    #[serde(default = "default_true")]
244    pub sync_own: bool,
245    #[serde(default)]
246    pub sync_followed: bool,
247    #[serde(default = "default_max_concurrent")]
248    pub max_concurrent: usize,
249    #[serde(default = "default_webrtc_timeout_ms")]
250    pub webrtc_timeout_ms: u64,
251    #[serde(default = "default_blossom_timeout_ms")]
252    pub blossom_timeout_ms: u64,
253}
254
255impl Default for SyncConfig {
256    fn default() -> Self {
257        Self {
258            enabled: false,
259            sync_own: true,
260            sync_followed: false,
261            max_concurrent: default_max_concurrent(),
262            webrtc_timeout_ms: default_webrtc_timeout_ms(),
263            blossom_timeout_ms: default_blossom_timeout_ms(),
264        }
265    }
266}
267
268fn default_max_concurrent() -> usize {
269    4
270}
271
272fn default_webrtc_timeout_ms() -> u64 {
273    5000
274}
275
276fn default_blossom_timeout_ms() -> u64 {
277    10000
278}
279
280impl Config {
281    /// Load config from file, or create default if doesn't exist
282    pub fn load() -> Result<Self> {
283        let config_path = get_config_path();
284
285        if config_path.exists() {
286            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
287            toml::from_str(&content).context("Failed to parse config file")
288        } else {
289            let config = Config::default();
290            config.save()?;
291            Ok(config)
292        }
293    }
294
295    /// Load config, returning default on any error (no panic)
296    pub fn load_or_default() -> Self {
297        Self::load().unwrap_or_default()
298    }
299
300    /// Save config to file
301    pub fn save(&self) -> Result<()> {
302        let config_path = get_config_path();
303
304        if let Some(parent) = config_path.parent() {
305            fs::create_dir_all(parent)?;
306        }
307
308        let content = toml::to_string_pretty(self)?;
309        fs::write(&config_path, content)?;
310
311        Ok(())
312    }
313}
314
315/// Get the hashtree directory (~/.hashtree)
316pub fn get_hashtree_dir() -> PathBuf {
317    if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
318        return PathBuf::from(dir);
319    }
320    dirs::home_dir()
321        .unwrap_or_else(|| PathBuf::from("."))
322        .join(".hashtree")
323}
324
325/// Get the config file path (~/.hashtree/config.toml)
326pub fn get_config_path() -> PathBuf {
327    get_hashtree_dir().join("config.toml")
328}
329
330/// Get the keys file path (~/.hashtree/keys)
331pub fn get_keys_path() -> PathBuf {
332    get_hashtree_dir().join("keys")
333}
334
335/// Get the public alias file path (~/.hashtree/aliases)
336pub fn get_aliases_path() -> PathBuf {
337    get_hashtree_dir().join("aliases")
338}
339
340/// A stored key entry from the keys file
341#[derive(Debug, Clone)]
342pub struct KeyEntry {
343    /// The raw identity token (for example nsec, npub, or hex)
344    pub secret: String,
345    /// Optional alias/petname
346    pub alias: Option<String>,
347}
348
349/// Parse the keys file content into key entries
350/// Format: `<identity> [alias]` per line
351/// Lines starting with # are comments
352pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
353    let mut entries = Vec::new();
354    for line in content.lines() {
355        let line = line.trim();
356        if line.is_empty() || line.starts_with('#') {
357            continue;
358        }
359        let parts: Vec<&str> = line.splitn(2, ' ').collect();
360        let secret = parts[0].to_string();
361        let alias = parts.get(1).map(|s| s.trim().to_string());
362        entries.push(KeyEntry { secret, alias });
363    }
364    entries
365}
366
367/// Read and parse keys file, returning the first key's secret
368/// Returns None if file doesn't exist or is empty
369pub fn read_first_key() -> Option<String> {
370    let keys_path = get_keys_path();
371    let content = std::fs::read_to_string(&keys_path).ok()?;
372    let entries = parse_keys_file(&content);
373    entries.into_iter().next().map(|e| e.secret)
374}
375
376/// Get the auth cookie path (~/.hashtree/auth.cookie)
377pub fn get_auth_cookie_path() -> PathBuf {
378    get_hashtree_dir().join("auth.cookie")
379}
380
381/// Get the data directory from config (defaults to ~/.hashtree/data)
382/// Can be overridden with HTREE_DATA_DIR environment variable
383pub fn get_data_dir() -> PathBuf {
384    if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
385        return PathBuf::from(dir);
386    }
387    let config = Config::load_or_default();
388    PathBuf::from(&config.storage.data_dir)
389}
390
391/// Detect a local hashtree daemon on localhost and return its Blossom base URL.
392pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
393    use std::net::{SocketAddr, TcpStream};
394    use std::time::Duration;
395
396    if !prefer_local_daemon() {
397        return None;
398    }
399
400    let port = local_daemon_port(bind_address);
401    if port == 0 {
402        return None;
403    }
404
405    let addr = SocketAddr::from(([127, 0, 0, 1], port));
406    let timeout = Duration::from_millis(100);
407    TcpStream::connect_timeout(&addr, timeout).ok()?;
408    Some(format!("http://127.0.0.1:{}", port))
409}
410
411/// Detect local Nostr relay URLs (e.g., hashtree daemon or local relay on common ports).
412pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
413    let mut relays = Vec::new();
414
415    if let Some(list) =
416        parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
417    {
418        for raw in list {
419            if let Some(url) = normalize_relay_url(&raw) {
420                relays.push(url);
421            }
422        }
423    }
424
425    if let Some(base) = detect_local_daemon_url(bind_address) {
426        if let Some(ws) = normalize_relay_url(&base) {
427            let ws = ws.trim_end_matches('/');
428            let ws = if ws.contains("/ws") {
429                ws.to_string()
430            } else {
431                format!("{}/ws", ws)
432            };
433            relays.push(ws);
434        }
435    }
436
437    let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
438    if ports.is_empty() {
439        ports.push(4869);
440    }
441
442    let daemon_port = local_daemon_port(bind_address);
443    for port in ports {
444        if port == 0 || port == daemon_port {
445            continue;
446        }
447        if local_port_open(port) {
448            relays.push(format!("ws://127.0.0.1:{port}"));
449        }
450    }
451
452    dedupe_relays(relays)
453}
454
455/// Resolve relays using environment overrides and optional local relay discovery.
456pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
457    let mut base = match parse_env_list("NOSTR_RELAYS") {
458        Some(list) => list,
459        None => config_relays.to_vec(),
460    };
461
462    base = base
463        .into_iter()
464        .filter_map(|r| normalize_relay_url(&r))
465        .collect();
466
467    if !prefer_local_relay() {
468        return dedupe_relays(base);
469    }
470
471    let mut combined = detect_local_relay_urls(bind_address);
472    combined.extend(base);
473    dedupe_relays(combined)
474}
475
476fn local_daemon_port(bind_address: Option<&str>) -> u16 {
477    let default_port = 8080;
478    let Some(addr) = bind_address else {
479        return default_port;
480    };
481    if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
482        return sock.port();
483    }
484    if let Some((_, port_str)) = addr.rsplit_once(':') {
485        if let Ok(port) = port_str.parse::<u16>() {
486            return port;
487        }
488    }
489    default_port
490}
491
492fn prefer_local_relay() -> bool {
493    for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
494        if let Ok(val) = std::env::var(key) {
495            let val = val.trim().to_lowercase();
496            return !matches!(val.as_str(), "0" | "false" | "no" | "off");
497        }
498    }
499    true
500}
501
502fn prefer_local_daemon() -> bool {
503    for key in [
504        "HTREE_PREFER_LOCAL_DAEMON",
505        "NOSTR_PREFER_LOCAL",
506        "HTREE_PREFER_LOCAL_RELAY",
507    ] {
508        if let Ok(val) = std::env::var(key) {
509            let val = val.trim().to_lowercase();
510            return !matches!(val.as_str(), "0" | "false" | "no" | "off");
511        }
512    }
513    false
514}
515
516fn parse_env_list(var: &str) -> Option<Vec<String>> {
517    let value = std::env::var(var).ok()?;
518    let mut items = Vec::new();
519    for part in value.split([',', ';', '\n', '\t', ' ']) {
520        let trimmed = part.trim();
521        if !trimmed.is_empty() {
522            items.push(trimmed.to_string());
523        }
524    }
525    if items.is_empty() {
526        None
527    } else {
528        Some(items)
529    }
530}
531
532fn parse_env_ports(var: &str) -> Vec<u16> {
533    let Some(list) = parse_env_list(var) else {
534        return Vec::new();
535    };
536    list.into_iter()
537        .filter_map(|item| item.parse::<u16>().ok())
538        .collect()
539}
540
541fn normalize_relay_url(raw: &str) -> Option<String> {
542    let trimmed = raw.trim();
543    if trimmed.is_empty() {
544        return None;
545    }
546    let trimmed = trimmed.trim_end_matches('/');
547    let lower = trimmed.to_lowercase();
548    if lower.starts_with("ws://") || lower.starts_with("wss://") {
549        return Some(trimmed.to_string());
550    }
551    if lower.starts_with("http://") {
552        return Some(format!("ws://{}", &trimmed[7..]));
553    }
554    if lower.starts_with("https://") {
555        return Some(format!("wss://{}", &trimmed[8..]));
556    }
557    Some(format!("ws://{}", trimmed))
558}
559
560fn local_port_open(port: u16) -> bool {
561    use std::net::{SocketAddr, TcpStream};
562    use std::time::Duration;
563
564    let addr = SocketAddr::from(([127, 0, 0, 1], port));
565    let timeout = Duration::from_millis(100);
566    TcpStream::connect_timeout(&addr, timeout).is_ok()
567}
568
569fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
570    use std::collections::HashSet;
571    let mut seen = HashSet::new();
572    let mut out = Vec::new();
573    for relay in relays {
574        let key = relay.trim_end_matches('/').to_lowercase();
575        if seen.insert(key) {
576            out.push(relay);
577        }
578    }
579    out
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::net::TcpListener;
586    use std::sync::Mutex;
587
588    static ENV_LOCK: Mutex<()> = Mutex::new(());
589
590    struct EnvGuard {
591        key: &'static str,
592        prev: Option<String>,
593    }
594
595    impl EnvGuard {
596        fn set(key: &'static str, value: &str) -> Self {
597            let prev = std::env::var(key).ok();
598            std::env::set_var(key, value);
599            Self { key, prev }
600        }
601
602        fn clear(key: &'static str) -> Self {
603            let prev = std::env::var(key).ok();
604            std::env::remove_var(key);
605            Self { key, prev }
606        }
607    }
608
609    impl Drop for EnvGuard {
610        fn drop(&mut self) {
611            if let Some(prev) = &self.prev {
612                std::env::set_var(self.key, prev);
613            } else {
614                std::env::remove_var(self.key);
615            }
616        }
617    }
618
619    #[test]
620    fn test_default_config() {
621        let config = Config::default();
622        assert!(!config.blossom.read_servers.is_empty());
623        assert!(!config.blossom.write_servers.is_empty());
624        assert!(!config.nostr.relays.is_empty());
625    }
626
627    #[test]
628    fn test_parse_empty_config() {
629        let config: Config = toml::from_str("").unwrap();
630        assert!(!config.blossom.read_servers.is_empty());
631    }
632
633    #[test]
634    fn test_parse_partial_config() {
635        let toml = r#"
636[blossom]
637write_servers = ["https://custom.server"]
638"#;
639        let config: Config = toml::from_str(toml).unwrap();
640        assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
641        assert!(!config.blossom.read_servers.is_empty());
642    }
643
644    #[test]
645    fn test_all_servers() {
646        let mut config = BlossomConfig::default();
647        config.servers = vec!["https://legacy.server".to_string()];
648
649        let read = config.all_read_servers();
650        assert!(read.contains(&"https://legacy.server".to_string()));
651        assert!(read.contains(&"https://cdn.iris.to".to_string()));
652
653        let write = config.all_write_servers();
654        assert!(write.contains(&"https://legacy.server".to_string()));
655        assert!(write.contains(&"https://upload.iris.to".to_string()));
656    }
657
658    #[test]
659    fn test_storage_backend_default() {
660        let config = Config::default();
661        assert_eq!(config.storage.backend, StorageBackend::Lmdb);
662    }
663
664    #[test]
665    fn test_storage_backend_lmdb() {
666        let toml = r#"
667[storage]
668backend = "lmdb"
669"#;
670        let config: Config = toml::from_str(toml).unwrap();
671        assert_eq!(config.storage.backend, StorageBackend::Lmdb);
672    }
673
674    #[test]
675    fn test_storage_backend_fs_explicit() {
676        let toml = r#"
677[storage]
678backend = "fs"
679"#;
680        let config: Config = toml::from_str(toml).unwrap();
681        assert_eq!(config.storage.backend, StorageBackend::Fs);
682    }
683
684    #[test]
685    fn test_parse_keys_file() {
686        let content = r#"
687nsec1abc123 self
688# comment line
689nsec1def456 work
690
691nsec1ghi789
692"#;
693        let entries = parse_keys_file(content);
694        assert_eq!(entries.len(), 3);
695        assert_eq!(entries[0].secret, "nsec1abc123");
696        assert_eq!(entries[0].alias, Some("self".to_string()));
697        assert_eq!(entries[1].secret, "nsec1def456");
698        assert_eq!(entries[1].alias, Some("work".to_string()));
699        assert_eq!(entries[2].secret, "nsec1ghi789");
700        assert_eq!(entries[2].alias, None);
701    }
702
703    #[test]
704    fn test_local_daemon_port_default() {
705        assert_eq!(local_daemon_port(None), 8080);
706    }
707
708    #[test]
709    fn test_local_daemon_port_parses_ipv4() {
710        assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
711    }
712
713    #[test]
714    fn test_local_daemon_port_parses_anyhost() {
715        assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
716    }
717
718    #[test]
719    fn test_local_daemon_port_parses_ipv6() {
720        assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
721    }
722
723    #[test]
724    fn test_local_daemon_port_parses_hostname() {
725        assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
726    }
727
728    #[test]
729    fn test_local_daemon_port_invalid() {
730        assert_eq!(local_daemon_port(Some("localhost")), 8080);
731    }
732
733    #[test]
734    fn test_detect_local_daemon_url_respects_prefer_local_flag() {
735        let _lock = ENV_LOCK.lock().unwrap();
736        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
737        let port = listener.local_addr().unwrap().port();
738        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
739
740        assert_eq!(
741            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
742            None
743        );
744    }
745
746    #[test]
747    fn test_detect_local_daemon_url_requires_opt_in() {
748        let _lock = ENV_LOCK.lock().unwrap();
749        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
750        let port = listener.local_addr().unwrap().port();
751        let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
752        let _prefer_nostr = EnvGuard::clear("NOSTR_PREFER_LOCAL");
753        let _prefer_relay = EnvGuard::clear("HTREE_PREFER_LOCAL_RELAY");
754
755        assert_eq!(
756            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
757            None
758        );
759    }
760
761    #[test]
762    fn test_detect_local_daemon_url_uses_opt_in_flag() {
763        let _lock = ENV_LOCK.lock().unwrap();
764        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
765        let port = listener.local_addr().unwrap().port();
766        let _prefer = EnvGuard::set("HTREE_PREFER_LOCAL_DAEMON", "1");
767
768        assert_eq!(
769            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
770            Some(format!("http://127.0.0.1:{port}"))
771        );
772    }
773
774    #[test]
775    fn test_resolve_relays_prefers_local() {
776        let _lock = ENV_LOCK.lock().unwrap();
777        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
778        let port = listener.local_addr().unwrap().port();
779
780        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
781        let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
782        let _relays = EnvGuard::clear("NOSTR_RELAYS");
783
784        let base = vec!["wss://relay.example".to_string()];
785        let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
786
787        assert!(!resolved.is_empty());
788        assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
789        assert!(resolved.contains(&"wss://relay.example".to_string()));
790    }
791
792    #[test]
793    fn test_resolve_relays_env_override() {
794        let _lock = ENV_LOCK.lock().unwrap();
795        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
796        let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
797
798        let base = vec!["wss://relay.example".to_string()];
799        let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
800
801        assert_eq!(
802            resolved,
803            vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
804        );
805    }
806}