Skip to main content

hashtree_config/
lib.rs

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