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