1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub const DEFAULT_READ_SERVERS: &[&str] = &[
12 "https://cdn.iris.to",
13 "https://hashtree.iris.to",
14 "https://blossom.primal.net",
15];
16
17pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
19
20pub 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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum StorageBackend {
84 #[default]
86 Lmdb,
87 Fs,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct StorageConfig {
94 #[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#[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#[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 #[serde(default)]
145 pub socialgraph_root: Option<String>,
146 #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
147 pub social_graph_crawl_depth: u32,
148 #[serde(default = "default_max_write_distance")]
149 pub max_write_distance: u32,
150 #[serde(default = "default_nostr_db_max_size_gb")]
152 pub db_max_size_gb: u64,
153 #[serde(default = "default_nostr_spambox_max_size_gb")]
156 pub spambox_max_size_gb: u64,
157 #[serde(default)]
159 pub negentropy_only: bool,
160 #[serde(default = "default_nostr_mirror_kinds")]
162 pub mirror_kinds: Vec<u16>,
163 #[serde(default = "default_nostr_history_sync_author_chunk_size")]
165 pub history_sync_author_chunk_size: usize,
166 #[serde(default = "default_nostr_history_sync_on_reconnect")]
168 pub history_sync_on_reconnect: bool,
169}
170
171impl Default for NostrConfig {
172 fn default() -> Self {
173 Self {
174 relays: default_relays(),
175 allowed_npubs: vec![],
176 socialgraph_root: None,
177 social_graph_crawl_depth: default_social_graph_crawl_depth(),
178 max_write_distance: default_max_write_distance(),
179 db_max_size_gb: default_nostr_db_max_size_gb(),
180 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
181 negentropy_only: false,
182 mirror_kinds: default_nostr_mirror_kinds(),
183 history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
184 history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
185 }
186 }
187}
188
189fn default_social_graph_crawl_depth() -> u32 {
190 2
191}
192
193fn default_max_write_distance() -> u32 {
194 3
195}
196
197fn default_nostr_db_max_size_gb() -> u64 {
198 10
199}
200
201fn default_nostr_spambox_max_size_gb() -> u64 {
202 1
203}
204
205fn default_nostr_history_sync_on_reconnect() -> bool {
206 true
207}
208
209fn default_nostr_mirror_kinds() -> Vec<u16> {
210 vec![0]
211}
212
213fn default_nostr_history_sync_author_chunk_size() -> usize {
214 5_000
215}
216
217fn default_relays() -> Vec<String> {
218 DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct BlossomConfig {
224 #[serde(default)]
226 pub servers: Vec<String>,
227 #[serde(default = "default_read_servers")]
229 pub read_servers: Vec<String>,
230 #[serde(default = "default_write_servers")]
232 pub write_servers: Vec<String>,
233 #[serde(default = "default_max_upload_mb")]
235 pub max_upload_mb: u64,
236 #[serde(default)]
238 pub force_upload: bool,
239}
240
241impl Default for BlossomConfig {
242 fn default() -> Self {
243 Self {
244 servers: vec![],
245 read_servers: default_read_servers(),
246 write_servers: default_write_servers(),
247 max_upload_mb: default_max_upload_mb(),
248 force_upload: false,
249 }
250 }
251}
252
253fn default_read_servers() -> Vec<String> {
254 let mut servers: Vec<String> = DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect();
255 servers.sort();
256 servers
257}
258
259fn default_write_servers() -> Vec<String> {
260 DEFAULT_WRITE_SERVERS
261 .iter()
262 .map(|s| s.to_string())
263 .collect()
264}
265
266fn default_max_upload_mb() -> u64 {
267 100
268}
269
270impl BlossomConfig {
271 pub fn all_read_servers(&self) -> Vec<String> {
273 let mut servers = self.servers.clone();
274 servers.extend(self.read_servers.clone());
275 if servers.is_empty() {
276 servers = default_read_servers();
277 }
278 servers.sort();
279 servers.dedup();
280 servers
281 }
282
283 pub fn all_write_servers(&self) -> Vec<String> {
285 let mut servers = self.servers.clone();
286 servers.extend(self.write_servers.clone());
287 if servers.is_empty() {
288 servers = default_write_servers();
289 }
290 servers.sort();
291 servers.dedup();
292 servers
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct SyncConfig {
299 #[serde(default)]
300 pub enabled: bool,
301 #[serde(default = "default_true")]
302 pub sync_own: bool,
303 #[serde(default)]
304 pub sync_followed: bool,
305 #[serde(default = "default_max_concurrent")]
306 pub max_concurrent: usize,
307 #[serde(default = "default_webrtc_timeout_ms")]
308 pub webrtc_timeout_ms: u64,
309 #[serde(default = "default_blossom_timeout_ms")]
310 pub blossom_timeout_ms: u64,
311}
312
313impl Default for SyncConfig {
314 fn default() -> Self {
315 Self {
316 enabled: false,
317 sync_own: true,
318 sync_followed: false,
319 max_concurrent: default_max_concurrent(),
320 webrtc_timeout_ms: default_webrtc_timeout_ms(),
321 blossom_timeout_ms: default_blossom_timeout_ms(),
322 }
323 }
324}
325
326fn default_max_concurrent() -> usize {
327 4
328}
329
330fn default_webrtc_timeout_ms() -> u64 {
331 5000
332}
333
334fn default_blossom_timeout_ms() -> u64 {
335 10000
336}
337
338impl Config {
339 pub fn load() -> Result<Self> {
341 let config_path = get_config_path();
342
343 if config_path.exists() {
344 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
345 toml::from_str(&content).context("Failed to parse config file")
346 } else {
347 let config = Config::default();
348 config.save()?;
349 Ok(config)
350 }
351 }
352
353 pub fn load_or_default() -> Self {
355 Self::load().unwrap_or_default()
356 }
357
358 pub fn save(&self) -> Result<()> {
360 let config_path = get_config_path();
361
362 if let Some(parent) = config_path.parent() {
363 fs::create_dir_all(parent)?;
364 }
365
366 let content = toml::to_string_pretty(self)?;
367 fs::write(&config_path, content)?;
368
369 Ok(())
370 }
371}
372
373pub fn get_hashtree_dir() -> PathBuf {
375 if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
376 return PathBuf::from(dir);
377 }
378 dirs::home_dir()
379 .unwrap_or_else(|| PathBuf::from("."))
380 .join(".hashtree")
381}
382
383pub fn get_config_path() -> PathBuf {
385 get_hashtree_dir().join("config.toml")
386}
387
388pub fn get_keys_path() -> PathBuf {
390 get_hashtree_dir().join("keys")
391}
392
393pub fn get_aliases_path() -> PathBuf {
395 get_hashtree_dir().join("aliases")
396}
397
398#[derive(Debug, Clone)]
400pub struct KeyEntry {
401 pub secret: String,
403 pub alias: Option<String>,
405}
406
407pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
411 let mut entries = Vec::new();
412 for line in content.lines() {
413 let line = line.trim();
414 if line.is_empty() || line.starts_with('#') {
415 continue;
416 }
417 let parts: Vec<&str> = line.splitn(2, ' ').collect();
418 let secret = parts[0].to_string();
419 let alias = parts.get(1).map(|s| s.trim().to_string());
420 entries.push(KeyEntry { secret, alias });
421 }
422 entries
423}
424
425pub fn read_first_key() -> Option<String> {
428 let keys_path = get_keys_path();
429 let content = std::fs::read_to_string(&keys_path).ok()?;
430 let entries = parse_keys_file(&content);
431 entries.into_iter().next().map(|e| e.secret)
432}
433
434pub fn get_auth_cookie_path() -> PathBuf {
436 get_hashtree_dir().join("auth.cookie")
437}
438
439pub fn get_data_dir() -> PathBuf {
442 if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
443 return PathBuf::from(dir);
444 }
445 let config = Config::load_or_default();
446 PathBuf::from(&config.storage.data_dir)
447}
448
449pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
451 use std::net::{SocketAddr, TcpStream};
452 use std::time::Duration;
453
454 if !prefer_local_daemon() {
455 return None;
456 }
457
458 let port = local_daemon_port(bind_address);
459 if port == 0 {
460 return None;
461 }
462
463 let addr = SocketAddr::from(([127, 0, 0, 1], port));
464 let timeout = Duration::from_millis(100);
465 TcpStream::connect_timeout(&addr, timeout).ok()?;
466 Some(format!("http://127.0.0.1:{}", port))
467}
468
469pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
471 let mut relays = Vec::new();
472
473 if let Some(list) =
474 parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
475 {
476 for raw in list {
477 if let Some(url) = normalize_relay_url(&raw) {
478 relays.push(url);
479 }
480 }
481 }
482
483 if let Some(base) = detect_local_daemon_url(bind_address) {
484 if let Some(ws) = normalize_relay_url(&base) {
485 let ws = ws.trim_end_matches('/');
486 let ws = if ws.contains("/ws") {
487 ws.to_string()
488 } else {
489 format!("{}/ws", ws)
490 };
491 relays.push(ws);
492 }
493 }
494
495 let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
496 if ports.is_empty() {
497 ports.push(4869);
498 }
499
500 let daemon_port = local_daemon_port(bind_address);
501 for port in ports {
502 if port == 0 || port == daemon_port {
503 continue;
504 }
505 if local_port_open(port) {
506 relays.push(format!("ws://127.0.0.1:{port}"));
507 }
508 }
509
510 dedupe_relays(relays)
511}
512
513pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
515 let mut base = match parse_env_list("NOSTR_RELAYS") {
516 Some(list) => list,
517 None => config_relays.to_vec(),
518 };
519
520 base = base
521 .into_iter()
522 .filter_map(|r| normalize_relay_url(&r))
523 .collect();
524
525 if !prefer_local_relay() {
526 return dedupe_relays(base);
527 }
528
529 let mut combined = detect_local_relay_urls(bind_address);
530 combined.extend(base);
531 dedupe_relays(combined)
532}
533
534fn local_daemon_port(bind_address: Option<&str>) -> u16 {
535 let default_port = 8080;
536 let Some(addr) = bind_address else {
537 return default_port;
538 };
539 if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
540 return sock.port();
541 }
542 if let Some((_, port_str)) = addr.rsplit_once(':') {
543 if let Ok(port) = port_str.parse::<u16>() {
544 return port;
545 }
546 }
547 default_port
548}
549
550fn prefer_local_relay() -> bool {
551 for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
552 if let Ok(val) = std::env::var(key) {
553 let val = val.trim().to_lowercase();
554 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
555 }
556 }
557 true
558}
559
560fn prefer_local_daemon() -> bool {
561 for key in [
562 "HTREE_PREFER_LOCAL_DAEMON",
563 "NOSTR_PREFER_LOCAL",
564 "HTREE_PREFER_LOCAL_RELAY",
565 ] {
566 if let Ok(val) = std::env::var(key) {
567 let val = val.trim().to_lowercase();
568 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
569 }
570 }
571 false
572}
573
574fn parse_env_list(var: &str) -> Option<Vec<String>> {
575 let value = std::env::var(var).ok()?;
576 let mut items = Vec::new();
577 for part in value.split([',', ';', '\n', '\t', ' ']) {
578 let trimmed = part.trim();
579 if !trimmed.is_empty() {
580 items.push(trimmed.to_string());
581 }
582 }
583 if items.is_empty() {
584 None
585 } else {
586 Some(items)
587 }
588}
589
590fn parse_env_ports(var: &str) -> Vec<u16> {
591 let Some(list) = parse_env_list(var) else {
592 return Vec::new();
593 };
594 list.into_iter()
595 .filter_map(|item| item.parse::<u16>().ok())
596 .collect()
597}
598
599fn normalize_relay_url(raw: &str) -> Option<String> {
600 let trimmed = raw.trim();
601 if trimmed.is_empty() {
602 return None;
603 }
604 let trimmed = trimmed.trim_end_matches('/');
605 let lower = trimmed.to_lowercase();
606 if lower.starts_with("ws://") || lower.starts_with("wss://") {
607 return Some(trimmed.to_string());
608 }
609 if lower.starts_with("http://") {
610 return Some(format!("ws://{}", &trimmed[7..]));
611 }
612 if lower.starts_with("https://") {
613 return Some(format!("wss://{}", &trimmed[8..]));
614 }
615 Some(format!("ws://{}", trimmed))
616}
617
618fn local_port_open(port: u16) -> bool {
619 use std::net::{SocketAddr, TcpStream};
620 use std::time::Duration;
621
622 let addr = SocketAddr::from(([127, 0, 0, 1], port));
623 let timeout = Duration::from_millis(100);
624 TcpStream::connect_timeout(&addr, timeout).is_ok()
625}
626
627fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
628 use std::collections::HashSet;
629 let mut seen = HashSet::new();
630 let mut out = Vec::new();
631 for relay in relays {
632 let key = relay.trim_end_matches('/').to_lowercase();
633 if seen.insert(key) {
634 out.push(relay);
635 }
636 }
637 out
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use std::net::TcpListener;
644 use std::sync::Mutex;
645
646 static ENV_LOCK: Mutex<()> = Mutex::new(());
647
648 struct EnvGuard {
649 key: &'static str,
650 prev: Option<String>,
651 }
652
653 impl EnvGuard {
654 fn set(key: &'static str, value: &str) -> Self {
655 let prev = std::env::var(key).ok();
656 std::env::set_var(key, value);
657 Self { key, prev }
658 }
659
660 fn clear(key: &'static str) -> Self {
661 let prev = std::env::var(key).ok();
662 std::env::remove_var(key);
663 Self { key, prev }
664 }
665 }
666
667 impl Drop for EnvGuard {
668 fn drop(&mut self) {
669 if let Some(prev) = &self.prev {
670 std::env::set_var(self.key, prev);
671 } else {
672 std::env::remove_var(self.key);
673 }
674 }
675 }
676
677 #[test]
678 fn test_default_config() {
679 let config = Config::default();
680 assert!(!config.blossom.read_servers.is_empty());
681 assert!(!config.blossom.write_servers.is_empty());
682 assert!(!config.nostr.relays.is_empty());
683 assert!(config
684 .nostr
685 .relays
686 .contains(&"wss://upload.iris.to/nostr".to_string()));
687 }
688
689 #[test]
690 fn test_parse_empty_config() {
691 let config: Config = toml::from_str("").unwrap();
692 assert!(!config.blossom.read_servers.is_empty());
693 }
694
695 #[test]
696 fn test_parse_partial_config() {
697 let toml = r#"
698[blossom]
699write_servers = ["https://custom.server"]
700"#;
701 let config: Config = toml::from_str(toml).unwrap();
702 assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
703 assert!(!config.blossom.read_servers.is_empty());
704 }
705
706 #[test]
707 fn test_all_servers() {
708 let mut config = BlossomConfig::default();
709 config.servers = vec!["https://legacy.server".to_string()];
710
711 let read = config.all_read_servers();
712 assert!(read.contains(&"https://legacy.server".to_string()));
713 assert!(read.contains(&"https://cdn.iris.to".to_string()));
714 assert!(read.contains(&"https://blossom.primal.net".to_string()));
715
716 let write = config.all_write_servers();
717 assert!(write.contains(&"https://legacy.server".to_string()));
718 assert!(write.contains(&"https://upload.iris.to".to_string()));
719 }
720
721 #[test]
722 fn test_all_servers_fall_back_to_defaults_when_explicitly_empty() {
723 let config = BlossomConfig {
724 servers: vec![],
725 read_servers: vec![],
726 write_servers: vec![],
727 max_upload_mb: default_max_upload_mb(),
728 force_upload: false,
729 };
730
731 assert_eq!(config.all_read_servers(), default_read_servers());
732 assert_eq!(config.all_write_servers(), default_write_servers());
733 }
734
735 #[test]
736 fn test_storage_backend_default() {
737 let config = Config::default();
738 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
739 }
740
741 #[test]
742 fn test_storage_backend_lmdb() {
743 let toml = r#"
744[storage]
745backend = "lmdb"
746"#;
747 let config: Config = toml::from_str(toml).unwrap();
748 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
749 }
750
751 #[test]
752 fn test_storage_backend_fs_explicit() {
753 let toml = r#"
754[storage]
755backend = "fs"
756"#;
757 let config: Config = toml::from_str(toml).unwrap();
758 assert_eq!(config.storage.backend, StorageBackend::Fs);
759 }
760
761 #[test]
762 fn test_parse_keys_file() {
763 let content = r#"
764nsec1abc123 self
765# comment line
766nsec1def456 work
767
768nsec1ghi789
769"#;
770 let entries = parse_keys_file(content);
771 assert_eq!(entries.len(), 3);
772 assert_eq!(entries[0].secret, "nsec1abc123");
773 assert_eq!(entries[0].alias, Some("self".to_string()));
774 assert_eq!(entries[1].secret, "nsec1def456");
775 assert_eq!(entries[1].alias, Some("work".to_string()));
776 assert_eq!(entries[2].secret, "nsec1ghi789");
777 assert_eq!(entries[2].alias, None);
778 }
779
780 #[test]
781 fn test_local_daemon_port_default() {
782 assert_eq!(local_daemon_port(None), 8080);
783 }
784
785 #[test]
786 fn test_local_daemon_port_parses_ipv4() {
787 assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
788 }
789
790 #[test]
791 fn test_local_daemon_port_parses_anyhost() {
792 assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
793 }
794
795 #[test]
796 fn test_local_daemon_port_parses_ipv6() {
797 assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
798 }
799
800 #[test]
801 fn test_local_daemon_port_parses_hostname() {
802 assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
803 }
804
805 #[test]
806 fn test_local_daemon_port_invalid() {
807 assert_eq!(local_daemon_port(Some("localhost")), 8080);
808 }
809
810 #[test]
811 fn test_detect_local_daemon_url_respects_prefer_local_flag() {
812 let _lock = ENV_LOCK.lock().unwrap();
813 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
814 let port = listener.local_addr().unwrap().port();
815 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
816
817 assert_eq!(
818 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
819 None
820 );
821 }
822
823 #[test]
824 fn test_detect_local_daemon_url_requires_opt_in() {
825 let _lock = ENV_LOCK.lock().unwrap();
826 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
827 let port = listener.local_addr().unwrap().port();
828 let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
829 let _prefer_nostr = EnvGuard::clear("NOSTR_PREFER_LOCAL");
830 let _prefer_relay = EnvGuard::clear("HTREE_PREFER_LOCAL_RELAY");
831
832 assert_eq!(
833 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
834 None
835 );
836 }
837
838 #[test]
839 fn test_detect_local_daemon_url_uses_opt_in_flag() {
840 let _lock = ENV_LOCK.lock().unwrap();
841 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
842 let port = listener.local_addr().unwrap().port();
843 let _prefer = EnvGuard::set("HTREE_PREFER_LOCAL_DAEMON", "1");
844
845 assert_eq!(
846 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
847 Some(format!("http://127.0.0.1:{port}"))
848 );
849 }
850
851 #[test]
852 fn test_resolve_relays_prefers_local() {
853 let _lock = ENV_LOCK.lock().unwrap();
854 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
855 let port = listener.local_addr().unwrap().port();
856
857 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
858 let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
859 let _relays = EnvGuard::clear("NOSTR_RELAYS");
860
861 let base = vec!["wss://relay.example".to_string()];
862 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
863
864 assert!(!resolved.is_empty());
865 assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
866 assert!(resolved.contains(&"wss://relay.example".to_string()));
867 }
868
869 #[test]
870 fn test_resolve_relays_env_override() {
871 let _lock = ENV_LOCK.lock().unwrap();
872 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
873 let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
874
875 let base = vec!["wss://relay.example".to_string()];
876 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
877
878 assert_eq!(
879 resolved,
880 vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
881 );
882 }
883
884 #[test]
885 fn test_nostr_config_defaults_include_mirror_settings() {
886 let config = NostrConfig::default();
887
888 assert_eq!(config.social_graph_crawl_depth, 2);
889 assert_eq!(config.max_write_distance, 3);
890 assert!(config.socialgraph_root.is_none());
891 assert!(!config.negentropy_only);
892 assert_eq!(config.mirror_kinds, vec![0]);
893 assert_eq!(config.history_sync_author_chunk_size, 5_000);
894 assert!(config.history_sync_on_reconnect);
895 }
896
897 #[test]
898 fn test_nostr_config_deserializes_mirror_settings() {
899 let config: NostrConfig = toml::from_str(
900 r#"
901relays = ["wss://relay.example"]
902socialgraph_root = "npub1test"
903social_graph_crawl_depth = 6
904max_write_distance = 7
905negentropy_only = true
906mirror_kinds = [0, 3]
907history_sync_author_chunk_size = 512
908history_sync_on_reconnect = false
909"#,
910 )
911 .expect("deserialize nostr config");
912
913 assert_eq!(config.relays, vec!["wss://relay.example".to_string()]);
914 assert_eq!(config.socialgraph_root.as_deref(), Some("npub1test"));
915 assert_eq!(config.social_graph_crawl_depth, 6);
916 assert_eq!(config.max_write_distance, 7);
917 assert!(config.negentropy_only);
918 assert_eq!(config.mirror_kinds, vec![0, 3]);
919 assert_eq!(config.history_sync_author_chunk_size, 512);
920 assert!(!config.history_sync_on_reconnect);
921 }
922
923 #[test]
924 fn test_nostr_config_deserializes_legacy_crawl_depth_alias() {
925 let config: NostrConfig = toml::from_str(
926 r#"
927relays = ["wss://relay.example"]
928crawl_depth = 5
929"#,
930 )
931 .expect("deserialize nostr config");
932
933 assert_eq!(config.social_graph_crawl_depth, 5);
934 }
935}