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