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