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