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