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