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