Skip to main content

hashtree_config/
lib.rs

1//! Shared configuration for hashtree tools
2//!
3//! Reads from ~/.hashtree/config.toml
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10/// Default read-only file servers
11pub const DEFAULT_READ_SERVERS: &[&str] = &[
12    "https://cdn.iris.to",
13    "https://hashtree.iris.to",
14    "https://blossom.primal.net",
15];
16
17/// Default write-enabled file servers
18pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
19
20/// Default nostr relays
21pub const DEFAULT_RELAYS: &[&str] = &[
22    "wss://temp.iris.to",
23    "wss://relay.damus.io",
24    "wss://nos.lol",
25    "wss://relay.primal.net",
26    "wss://offchain.pub",
27    "wss://upload.iris.to/nostr",
28];
29
30/// Top-level config structure
31#[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}
44
45/// Server configuration
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ServerConfig {
48    #[serde(default = "default_bind_address")]
49    pub bind_address: String,
50    #[serde(default = "default_true")]
51    pub enable_auth: bool,
52    #[serde(default)]
53    pub public_writes: bool,
54    #[serde(default)]
55    pub enable_webrtc: bool,
56    #[serde(default)]
57    pub stun_port: u16,
58}
59
60impl Default for ServerConfig {
61    fn default() -> Self {
62        Self {
63            bind_address: default_bind_address(),
64            enable_auth: true,
65            public_writes: false,
66            enable_webrtc: false,
67            stun_port: 0,
68        }
69    }
70}
71
72fn default_bind_address() -> String {
73    "127.0.0.1:8080".to_string()
74}
75
76fn default_true() -> bool {
77    true
78}
79
80/// Storage backend type
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum StorageBackend {
84    /// LMDB storage - requires lmdb feature
85    #[default]
86    Lmdb,
87    /// Filesystem storage - stores in ~/.hashtree/blobs/{prefix}/{subdir}/{hash}
88    Fs,
89}
90
91/// Storage configuration
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct StorageConfig {
94    /// Storage backend: "lmdb" (default) or "fs"
95    #[serde(default)]
96    pub backend: StorageBackend,
97    #[serde(default = "default_data_dir")]
98    pub data_dir: String,
99    #[serde(default = "default_max_size_gb")]
100    pub max_size_gb: u64,
101    #[serde(default)]
102    pub s3: Option<S3Config>,
103}
104
105impl Default for StorageConfig {
106    fn default() -> Self {
107        Self {
108            backend: StorageBackend::default(),
109            data_dir: default_data_dir(),
110            max_size_gb: default_max_size_gb(),
111            s3: None,
112        }
113    }
114}
115
116fn default_data_dir() -> String {
117    get_hashtree_dir()
118        .join("data")
119        .to_string_lossy()
120        .to_string()
121}
122
123fn default_max_size_gb() -> u64 {
124    10
125}
126
127/// S3-compatible storage configuration
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct S3Config {
130    pub endpoint: String,
131    pub bucket: String,
132    pub region: String,
133    #[serde(default)]
134    pub prefix: Option<String>,
135}
136
137/// Nostr relay configuration
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct NostrConfig {
140    #[serde(default = "default_relays")]
141    pub relays: Vec<String>,
142    #[serde(default)]
143    pub allowed_npubs: Vec<String>,
144    #[serde(default)]
145    pub socialgraph_root: Option<String>,
146    #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
147    pub social_graph_crawl_depth: u32,
148    #[serde(default = "default_max_write_distance")]
149    pub max_write_distance: u32,
150    /// Max size for the trusted social graph store in GB (default: 10)
151    #[serde(default = "default_nostr_db_max_size_gb")]
152    pub db_max_size_gb: u64,
153    /// Max size for the social graph spambox store in GB (default: 1)
154    /// Set to 0 for memory-only spambox (no on-disk DB)
155    #[serde(default = "default_nostr_spambox_max_size_gb")]
156    pub spambox_max_size_gb: u64,
157    /// Require relays to support NIP-77 negentropy for mirror history sync.
158    #[serde(default)]
159    pub negentropy_only: bool,
160    /// Kinds mirrored from upstream relays for the trusted hashtree index.
161    #[serde(default = "default_nostr_mirror_kinds")]
162    pub mirror_kinds: Vec<u16>,
163    /// How many graph authors to reconcile before checkpointing the mirror root.
164    #[serde(default = "default_nostr_history_sync_author_chunk_size")]
165    pub history_sync_author_chunk_size: usize,
166    /// Run a catch-up history sync after relay reconnects.
167    #[serde(default = "default_nostr_history_sync_on_reconnect")]
168    pub history_sync_on_reconnect: bool,
169}
170
171impl Default for NostrConfig {
172    fn default() -> Self {
173        Self {
174            relays: default_relays(),
175            allowed_npubs: vec![],
176            socialgraph_root: None,
177            social_graph_crawl_depth: default_social_graph_crawl_depth(),
178            max_write_distance: default_max_write_distance(),
179            db_max_size_gb: default_nostr_db_max_size_gb(),
180            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
181            negentropy_only: false,
182            mirror_kinds: default_nostr_mirror_kinds(),
183            history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
184            history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
185        }
186    }
187}
188
189fn default_social_graph_crawl_depth() -> u32 {
190    2
191}
192
193fn default_max_write_distance() -> u32 {
194    3
195}
196
197fn default_nostr_db_max_size_gb() -> u64 {
198    10
199}
200
201fn default_nostr_spambox_max_size_gb() -> u64 {
202    1
203}
204
205fn default_nostr_history_sync_on_reconnect() -> bool {
206    true
207}
208
209fn default_nostr_mirror_kinds() -> Vec<u16> {
210    vec![0]
211}
212
213fn default_nostr_history_sync_author_chunk_size() -> usize {
214    5_000
215}
216
217fn default_relays() -> Vec<String> {
218    DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
219}
220
221/// File server (blossom) configuration
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct BlossomConfig {
224    /// Legacy servers field (both read and write)
225    #[serde(default)]
226    pub servers: Vec<String>,
227    /// Read-only file servers
228    #[serde(default = "default_read_servers")]
229    pub read_servers: Vec<String>,
230    /// Write-enabled file servers
231    #[serde(default = "default_write_servers")]
232    pub write_servers: Vec<String>,
233    /// Max upload size in MB
234    #[serde(default = "default_max_upload_mb")]
235    pub max_upload_mb: u64,
236    /// Force upload all blobs, skipping "server already has" check
237    #[serde(default)]
238    pub force_upload: bool,
239}
240
241impl Default for BlossomConfig {
242    fn default() -> Self {
243        Self {
244            servers: vec![],
245            read_servers: default_read_servers(),
246            write_servers: default_write_servers(),
247            max_upload_mb: default_max_upload_mb(),
248            force_upload: false,
249        }
250    }
251}
252
253fn default_read_servers() -> Vec<String> {
254    let mut servers: Vec<String> = DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect();
255    servers.sort();
256    servers
257}
258
259fn default_write_servers() -> Vec<String> {
260    DEFAULT_WRITE_SERVERS
261        .iter()
262        .map(|s| s.to_string())
263        .collect()
264}
265
266fn default_max_upload_mb() -> u64 {
267    100
268}
269
270impl BlossomConfig {
271    /// Get all read servers (legacy + read_servers)
272    pub fn all_read_servers(&self) -> Vec<String> {
273        let mut servers = self.servers.clone();
274        servers.extend(self.read_servers.clone());
275        if servers.is_empty() {
276            servers = default_read_servers();
277        }
278        servers.sort();
279        servers.dedup();
280        servers
281    }
282
283    /// Get all write servers (legacy + write_servers)
284    pub fn all_write_servers(&self) -> Vec<String> {
285        let mut servers = self.servers.clone();
286        servers.extend(self.write_servers.clone());
287        if servers.is_empty() {
288            servers = default_write_servers();
289        }
290        servers.sort();
291        servers.dedup();
292        servers
293    }
294}
295
296/// Background sync configuration
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct SyncConfig {
299    #[serde(default)]
300    pub enabled: bool,
301    #[serde(default = "default_true")]
302    pub sync_own: bool,
303    #[serde(default)]
304    pub sync_followed: bool,
305    #[serde(default = "default_max_concurrent")]
306    pub max_concurrent: usize,
307    #[serde(default = "default_webrtc_timeout_ms")]
308    pub webrtc_timeout_ms: u64,
309    #[serde(default = "default_blossom_timeout_ms")]
310    pub blossom_timeout_ms: u64,
311}
312
313impl Default for SyncConfig {
314    fn default() -> Self {
315        Self {
316            enabled: false,
317            sync_own: true,
318            sync_followed: false,
319            max_concurrent: default_max_concurrent(),
320            webrtc_timeout_ms: default_webrtc_timeout_ms(),
321            blossom_timeout_ms: default_blossom_timeout_ms(),
322        }
323    }
324}
325
326fn default_max_concurrent() -> usize {
327    4
328}
329
330fn default_webrtc_timeout_ms() -> u64 {
331    5000
332}
333
334fn default_blossom_timeout_ms() -> u64 {
335    10000
336}
337
338impl Config {
339    /// Load config from file, or create default if doesn't exist
340    pub fn load() -> Result<Self> {
341        let config_path = get_config_path();
342
343        if config_path.exists() {
344            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
345            toml::from_str(&content).context("Failed to parse config file")
346        } else {
347            let config = Config::default();
348            config.save()?;
349            Ok(config)
350        }
351    }
352
353    /// Load config, returning default on any error (no panic)
354    pub fn load_or_default() -> Self {
355        Self::load().unwrap_or_default()
356    }
357
358    /// Save config to file
359    pub fn save(&self) -> Result<()> {
360        let config_path = get_config_path();
361
362        if let Some(parent) = config_path.parent() {
363            fs::create_dir_all(parent)?;
364        }
365
366        let content = toml::to_string_pretty(self)?;
367        fs::write(&config_path, content)?;
368
369        Ok(())
370    }
371}
372
373/// Get the hashtree directory (~/.hashtree)
374pub fn get_hashtree_dir() -> PathBuf {
375    if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
376        return PathBuf::from(dir);
377    }
378    dirs::home_dir()
379        .unwrap_or_else(|| PathBuf::from("."))
380        .join(".hashtree")
381}
382
383/// Get the config file path (~/.hashtree/config.toml)
384pub fn get_config_path() -> PathBuf {
385    get_hashtree_dir().join("config.toml")
386}
387
388/// Get the keys file path (~/.hashtree/keys)
389pub fn get_keys_path() -> PathBuf {
390    get_hashtree_dir().join("keys")
391}
392
393/// Get the public alias file path (~/.hashtree/aliases)
394pub fn get_aliases_path() -> PathBuf {
395    get_hashtree_dir().join("aliases")
396}
397
398/// A stored key entry from the keys file
399#[derive(Debug, Clone)]
400pub struct KeyEntry {
401    /// The raw identity token (for example nsec, npub, or hex)
402    pub secret: String,
403    /// Optional alias/petname
404    pub alias: Option<String>,
405}
406
407/// Parse the keys file content into key entries
408/// Format: `<identity> [alias]` per line
409/// Lines starting with # are comments
410pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
411    let mut entries = Vec::new();
412    for line in content.lines() {
413        let line = line.trim();
414        if line.is_empty() || line.starts_with('#') {
415            continue;
416        }
417        let parts: Vec<&str> = line.splitn(2, ' ').collect();
418        let secret = parts[0].to_string();
419        let alias = parts.get(1).map(|s| s.trim().to_string());
420        entries.push(KeyEntry { secret, alias });
421    }
422    entries
423}
424
425/// Read and parse keys file, returning the first key's secret
426/// Returns None if file doesn't exist or is empty
427pub fn read_first_key() -> Option<String> {
428    let keys_path = get_keys_path();
429    let content = std::fs::read_to_string(&keys_path).ok()?;
430    let entries = parse_keys_file(&content);
431    entries.into_iter().next().map(|e| e.secret)
432}
433
434/// Get the auth cookie path (~/.hashtree/auth.cookie)
435pub fn get_auth_cookie_path() -> PathBuf {
436    get_hashtree_dir().join("auth.cookie")
437}
438
439/// Get the data directory from config (defaults to ~/.hashtree/data)
440/// Can be overridden with HTREE_DATA_DIR environment variable
441pub fn get_data_dir() -> PathBuf {
442    if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
443        return PathBuf::from(dir);
444    }
445    let config = Config::load_or_default();
446    PathBuf::from(&config.storage.data_dir)
447}
448
449/// Detect a local hashtree daemon on localhost and return its Blossom base URL.
450pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
451    use std::net::{SocketAddr, TcpStream};
452    use std::time::Duration;
453
454    if !prefer_local_daemon() {
455        return None;
456    }
457
458    let port = local_daemon_port(bind_address);
459    if port == 0 {
460        return None;
461    }
462
463    let addr = SocketAddr::from(([127, 0, 0, 1], port));
464    let timeout = Duration::from_millis(100);
465    TcpStream::connect_timeout(&addr, timeout).ok()?;
466    Some(format!("http://127.0.0.1:{}", port))
467}
468
469/// Detect local Nostr relay URLs (e.g., hashtree daemon or local relay on common ports).
470pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
471    let mut relays = Vec::new();
472
473    if let Some(list) =
474        parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
475    {
476        for raw in list {
477            if let Some(url) = normalize_relay_url(&raw) {
478                relays.push(url);
479            }
480        }
481    }
482
483    if let Some(base) = detect_local_daemon_url(bind_address) {
484        if let Some(ws) = normalize_relay_url(&base) {
485            let ws = ws.trim_end_matches('/');
486            let ws = if ws.contains("/ws") {
487                ws.to_string()
488            } else {
489                format!("{}/ws", ws)
490            };
491            relays.push(ws);
492        }
493    }
494
495    let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
496    if ports.is_empty() {
497        ports.push(4869);
498    }
499
500    let daemon_port = local_daemon_port(bind_address);
501    for port in ports {
502        if port == 0 || port == daemon_port {
503            continue;
504        }
505        if local_port_open(port) {
506            relays.push(format!("ws://127.0.0.1:{port}"));
507        }
508    }
509
510    dedupe_relays(relays)
511}
512
513/// Resolve relays using environment overrides and optional local relay discovery.
514pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
515    let mut base = match parse_env_list("NOSTR_RELAYS") {
516        Some(list) => list,
517        None => config_relays.to_vec(),
518    };
519
520    base = base
521        .into_iter()
522        .filter_map(|r| normalize_relay_url(&r))
523        .collect();
524
525    if !prefer_local_relay() {
526        return dedupe_relays(base);
527    }
528
529    let mut combined = detect_local_relay_urls(bind_address);
530    combined.extend(base);
531    dedupe_relays(combined)
532}
533
534fn local_daemon_port(bind_address: Option<&str>) -> u16 {
535    let default_port = 8080;
536    let Some(addr) = bind_address else {
537        return default_port;
538    };
539    if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
540        return sock.port();
541    }
542    if let Some((_, port_str)) = addr.rsplit_once(':') {
543        if let Ok(port) = port_str.parse::<u16>() {
544            return port;
545        }
546    }
547    default_port
548}
549
550fn prefer_local_relay() -> bool {
551    for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
552        if let Ok(val) = std::env::var(key) {
553            let val = val.trim().to_lowercase();
554            return !matches!(val.as_str(), "0" | "false" | "no" | "off");
555        }
556    }
557    true
558}
559
560fn prefer_local_daemon() -> bool {
561    for key in [
562        "HTREE_PREFER_LOCAL_DAEMON",
563        "NOSTR_PREFER_LOCAL",
564        "HTREE_PREFER_LOCAL_RELAY",
565    ] {
566        if let Ok(val) = std::env::var(key) {
567            let val = val.trim().to_lowercase();
568            return !matches!(val.as_str(), "0" | "false" | "no" | "off");
569        }
570    }
571    false
572}
573
574fn parse_env_list(var: &str) -> Option<Vec<String>> {
575    let value = std::env::var(var).ok()?;
576    let mut items = Vec::new();
577    for part in value.split([',', ';', '\n', '\t', ' ']) {
578        let trimmed = part.trim();
579        if !trimmed.is_empty() {
580            items.push(trimmed.to_string());
581        }
582    }
583    if items.is_empty() {
584        None
585    } else {
586        Some(items)
587    }
588}
589
590fn parse_env_ports(var: &str) -> Vec<u16> {
591    let Some(list) = parse_env_list(var) else {
592        return Vec::new();
593    };
594    list.into_iter()
595        .filter_map(|item| item.parse::<u16>().ok())
596        .collect()
597}
598
599fn normalize_relay_url(raw: &str) -> Option<String> {
600    let trimmed = raw.trim();
601    if trimmed.is_empty() {
602        return None;
603    }
604    let trimmed = trimmed.trim_end_matches('/');
605    let lower = trimmed.to_lowercase();
606    if lower.starts_with("ws://") || lower.starts_with("wss://") {
607        return Some(trimmed.to_string());
608    }
609    if lower.starts_with("http://") {
610        return Some(format!("ws://{}", &trimmed[7..]));
611    }
612    if lower.starts_with("https://") {
613        return Some(format!("wss://{}", &trimmed[8..]));
614    }
615    Some(format!("ws://{}", trimmed))
616}
617
618fn local_port_open(port: u16) -> bool {
619    use std::net::{SocketAddr, TcpStream};
620    use std::time::Duration;
621
622    let addr = SocketAddr::from(([127, 0, 0, 1], port));
623    let timeout = Duration::from_millis(100);
624    TcpStream::connect_timeout(&addr, timeout).is_ok()
625}
626
627fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
628    use std::collections::HashSet;
629    let mut seen = HashSet::new();
630    let mut out = Vec::new();
631    for relay in relays {
632        let key = relay.trim_end_matches('/').to_lowercase();
633        if seen.insert(key) {
634            out.push(relay);
635        }
636    }
637    out
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use std::net::TcpListener;
644    use std::sync::Mutex;
645
646    static ENV_LOCK: Mutex<()> = Mutex::new(());
647
648    struct EnvGuard {
649        key: &'static str,
650        prev: Option<String>,
651    }
652
653    impl EnvGuard {
654        fn set(key: &'static str, value: &str) -> Self {
655            let prev = std::env::var(key).ok();
656            std::env::set_var(key, value);
657            Self { key, prev }
658        }
659
660        fn clear(key: &'static str) -> Self {
661            let prev = std::env::var(key).ok();
662            std::env::remove_var(key);
663            Self { key, prev }
664        }
665    }
666
667    impl Drop for EnvGuard {
668        fn drop(&mut self) {
669            if let Some(prev) = &self.prev {
670                std::env::set_var(self.key, prev);
671            } else {
672                std::env::remove_var(self.key);
673            }
674        }
675    }
676
677    #[test]
678    fn test_default_config() {
679        let config = Config::default();
680        assert!(!config.blossom.read_servers.is_empty());
681        assert!(!config.blossom.write_servers.is_empty());
682        assert!(!config.nostr.relays.is_empty());
683        assert!(config
684            .nostr
685            .relays
686            .contains(&"wss://upload.iris.to/nostr".to_string()));
687    }
688
689    #[test]
690    fn test_parse_empty_config() {
691        let config: Config = toml::from_str("").unwrap();
692        assert!(!config.blossom.read_servers.is_empty());
693    }
694
695    #[test]
696    fn test_parse_partial_config() {
697        let toml = r#"
698[blossom]
699write_servers = ["https://custom.server"]
700"#;
701        let config: Config = toml::from_str(toml).unwrap();
702        assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
703        assert!(!config.blossom.read_servers.is_empty());
704    }
705
706    #[test]
707    fn test_all_servers() {
708        let mut config = BlossomConfig::default();
709        config.servers = vec!["https://legacy.server".to_string()];
710
711        let read = config.all_read_servers();
712        assert!(read.contains(&"https://legacy.server".to_string()));
713        assert!(read.contains(&"https://cdn.iris.to".to_string()));
714        assert!(read.contains(&"https://blossom.primal.net".to_string()));
715
716        let write = config.all_write_servers();
717        assert!(write.contains(&"https://legacy.server".to_string()));
718        assert!(write.contains(&"https://upload.iris.to".to_string()));
719    }
720
721    #[test]
722    fn test_all_servers_fall_back_to_defaults_when_explicitly_empty() {
723        let config = BlossomConfig {
724            servers: vec![],
725            read_servers: vec![],
726            write_servers: vec![],
727            max_upload_mb: default_max_upload_mb(),
728            force_upload: false,
729        };
730
731        assert_eq!(config.all_read_servers(), default_read_servers());
732        assert_eq!(config.all_write_servers(), default_write_servers());
733    }
734
735    #[test]
736    fn test_storage_backend_default() {
737        let config = Config::default();
738        assert_eq!(config.storage.backend, StorageBackend::Lmdb);
739    }
740
741    #[test]
742    fn test_storage_backend_lmdb() {
743        let toml = r#"
744[storage]
745backend = "lmdb"
746"#;
747        let config: Config = toml::from_str(toml).unwrap();
748        assert_eq!(config.storage.backend, StorageBackend::Lmdb);
749    }
750
751    #[test]
752    fn test_storage_backend_fs_explicit() {
753        let toml = r#"
754[storage]
755backend = "fs"
756"#;
757        let config: Config = toml::from_str(toml).unwrap();
758        assert_eq!(config.storage.backend, StorageBackend::Fs);
759    }
760
761    #[test]
762    fn test_parse_keys_file() {
763        let content = r#"
764nsec1abc123 self
765# comment line
766nsec1def456 work
767
768nsec1ghi789
769"#;
770        let entries = parse_keys_file(content);
771        assert_eq!(entries.len(), 3);
772        assert_eq!(entries[0].secret, "nsec1abc123");
773        assert_eq!(entries[0].alias, Some("self".to_string()));
774        assert_eq!(entries[1].secret, "nsec1def456");
775        assert_eq!(entries[1].alias, Some("work".to_string()));
776        assert_eq!(entries[2].secret, "nsec1ghi789");
777        assert_eq!(entries[2].alias, None);
778    }
779
780    #[test]
781    fn test_local_daemon_port_default() {
782        assert_eq!(local_daemon_port(None), 8080);
783    }
784
785    #[test]
786    fn test_local_daemon_port_parses_ipv4() {
787        assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
788    }
789
790    #[test]
791    fn test_local_daemon_port_parses_anyhost() {
792        assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
793    }
794
795    #[test]
796    fn test_local_daemon_port_parses_ipv6() {
797        assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
798    }
799
800    #[test]
801    fn test_local_daemon_port_parses_hostname() {
802        assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
803    }
804
805    #[test]
806    fn test_local_daemon_port_invalid() {
807        assert_eq!(local_daemon_port(Some("localhost")), 8080);
808    }
809
810    #[test]
811    fn test_detect_local_daemon_url_respects_prefer_local_flag() {
812        let _lock = ENV_LOCK.lock().unwrap();
813        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
814        let port = listener.local_addr().unwrap().port();
815        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
816
817        assert_eq!(
818            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
819            None
820        );
821    }
822
823    #[test]
824    fn test_detect_local_daemon_url_requires_opt_in() {
825        let _lock = ENV_LOCK.lock().unwrap();
826        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
827        let port = listener.local_addr().unwrap().port();
828        let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
829        let _prefer_nostr = EnvGuard::clear("NOSTR_PREFER_LOCAL");
830        let _prefer_relay = EnvGuard::clear("HTREE_PREFER_LOCAL_RELAY");
831
832        assert_eq!(
833            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
834            None
835        );
836    }
837
838    #[test]
839    fn test_detect_local_daemon_url_uses_opt_in_flag() {
840        let _lock = ENV_LOCK.lock().unwrap();
841        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
842        let port = listener.local_addr().unwrap().port();
843        let _prefer = EnvGuard::set("HTREE_PREFER_LOCAL_DAEMON", "1");
844
845        assert_eq!(
846            detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
847            Some(format!("http://127.0.0.1:{port}"))
848        );
849    }
850
851    #[test]
852    fn test_resolve_relays_prefers_local() {
853        let _lock = ENV_LOCK.lock().unwrap();
854        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
855        let port = listener.local_addr().unwrap().port();
856
857        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
858        let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
859        let _relays = EnvGuard::clear("NOSTR_RELAYS");
860
861        let base = vec!["wss://relay.example".to_string()];
862        let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
863
864        assert!(!resolved.is_empty());
865        assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
866        assert!(resolved.contains(&"wss://relay.example".to_string()));
867    }
868
869    #[test]
870    fn test_resolve_relays_env_override() {
871        let _lock = ENV_LOCK.lock().unwrap();
872        let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
873        let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
874
875        let base = vec!["wss://relay.example".to_string()];
876        let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
877
878        assert_eq!(
879            resolved,
880            vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
881        );
882    }
883
884    #[test]
885    fn test_nostr_config_defaults_include_mirror_settings() {
886        let config = NostrConfig::default();
887
888        assert_eq!(config.social_graph_crawl_depth, 2);
889        assert_eq!(config.max_write_distance, 3);
890        assert!(config.socialgraph_root.is_none());
891        assert!(!config.negentropy_only);
892        assert_eq!(config.mirror_kinds, vec![0]);
893        assert_eq!(config.history_sync_author_chunk_size, 5_000);
894        assert!(config.history_sync_on_reconnect);
895    }
896
897    #[test]
898    fn test_nostr_config_deserializes_mirror_settings() {
899        let config: NostrConfig = toml::from_str(
900            r#"
901relays = ["wss://relay.example"]
902socialgraph_root = "npub1test"
903social_graph_crawl_depth = 6
904max_write_distance = 7
905negentropy_only = true
906mirror_kinds = [0, 3]
907history_sync_author_chunk_size = 512
908history_sync_on_reconnect = false
909"#,
910        )
911        .expect("deserialize nostr config");
912
913        assert_eq!(config.relays, vec!["wss://relay.example".to_string()]);
914        assert_eq!(config.socialgraph_root.as_deref(), Some("npub1test"));
915        assert_eq!(config.social_graph_crawl_depth, 6);
916        assert_eq!(config.max_write_distance, 7);
917        assert!(config.negentropy_only);
918        assert_eq!(config.mirror_kinds, vec![0, 3]);
919        assert_eq!(config.history_sync_author_chunk_size, 512);
920        assert!(!config.history_sync_on_reconnect);
921    }
922
923    #[test]
924    fn test_nostr_config_deserializes_legacy_crawl_depth_alias() {
925        let config: NostrConfig = toml::from_str(
926            r#"
927relays = ["wss://relay.example"]
928crawl_depth = 5
929"#,
930        )
931        .expect("deserialize nostr config");
932
933        assert_eq!(config.social_graph_crawl_depth, 5);
934    }
935}