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