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