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