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