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