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