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