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