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