Skip to main content

ant_node/
config.rs

1//! Configuration for ant-node.
2
3use serde::{Deserialize, Serialize};
4use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
5use std::path::{Path, PathBuf};
6
7/// Filename for the persisted node identity keypair.
8pub const NODE_IDENTITY_FILENAME: &str = "node_identity.key";
9
10/// Subdirectory under the root dir that contains per-node data directories.
11pub const NODES_SUBDIR: &str = "nodes";
12
13/// Upgrade channel for auto-updates.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum UpgradeChannel {
17    /// Stable releases only.
18    #[default]
19    Stable,
20    /// Beta releases (includes stable).
21    Beta,
22}
23
24/// Network mode for different deployment scenarios.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum NetworkMode {
28    /// Production mode with full anti-Sybil protection.
29    #[default]
30    Production,
31    /// Testnet mode with relaxed diversity requirements.
32    /// Suitable for single-provider deployments (e.g., Digital Ocean).
33    Testnet,
34    /// Development mode with minimal restrictions.
35    /// Only use for local testing.
36    Development,
37}
38
39/// Testnet-specific configuration for relaxed IP diversity limits.
40///
41/// saorsa-core uses a simple 2-tier model: per-exact-IP and per-subnet
42/// (/24 IPv4, /64 IPv6) limits.  Testnet defaults are permissive so
43/// nodes co-located on a single provider (e.g. Digital Ocean) can join.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TestnetConfig {
46    /// Maximum nodes sharing an exact IP address.
47    /// Default: `usize::MAX` (effectively unlimited for testnet).
48    #[serde(default = "default_testnet_max_per_ip")]
49    pub max_per_ip: Option<usize>,
50
51    /// Maximum nodes in the same /24 (IPv4) or /64 (IPv6) subnet.
52    /// Default: `usize::MAX` (effectively unlimited for testnet).
53    #[serde(default = "default_testnet_max_per_subnet")]
54    pub max_per_subnet: Option<usize>,
55}
56
57impl Default for TestnetConfig {
58    fn default() -> Self {
59        Self {
60            max_per_ip: default_testnet_max_per_ip(),
61            max_per_subnet: default_testnet_max_per_subnet(),
62        }
63    }
64}
65
66// These return `Option` because `serde(default = "...")` requires the function's
67// return type to match the field type (`Option<usize>`).
68#[allow(clippy::unnecessary_wraps)]
69const fn default_testnet_max_per_ip() -> Option<usize> {
70    Some(usize::MAX)
71}
72
73#[allow(clippy::unnecessary_wraps)]
74const fn default_testnet_max_per_subnet() -> Option<usize> {
75    Some(usize::MAX)
76}
77
78/// Node configuration.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct NodeConfig {
81    /// Root directory for node data.
82    #[serde(default = "default_root_dir")]
83    pub root_dir: PathBuf,
84
85    /// Listening port (0 for auto-select).
86    #[serde(default)]
87    pub port: u16,
88
89    /// Force IPv4-only mode.
90    ///
91    /// When true, the node binds only on IPv4 instead of dual-stack.
92    /// Use this on hosts without working IPv6 to avoid advertising
93    /// unreachable addresses to the DHT.
94    #[serde(default)]
95    pub ipv4_only: bool,
96
97    /// Bootstrap peer addresses.
98    #[serde(default)]
99    pub bootstrap: Vec<SocketAddr>,
100
101    /// Network mode (production, testnet, or development).
102    #[serde(default)]
103    pub network_mode: NetworkMode,
104
105    /// Testnet-specific configuration.
106    /// Only used when `network_mode` is `Testnet`.
107    #[serde(default)]
108    pub testnet: TestnetConfig,
109
110    /// Upgrade configuration.
111    #[serde(default)]
112    pub upgrade: UpgradeConfig,
113
114    /// Payment verification configuration.
115    #[serde(default)]
116    pub payment: PaymentConfig,
117
118    /// Bootstrap cache configuration for persistent peer storage.
119    #[serde(default)]
120    pub bootstrap_cache: BootstrapCacheConfig,
121
122    /// Storage configuration for chunk persistence.
123    #[serde(default)]
124    pub storage: StorageConfig,
125
126    /// Directory for persisting the close group cache.
127    ///
128    /// When `None` (default), the node's `root_dir` is used — the cache
129    /// file lands alongside `node_identity.key`.
130    #[serde(default)]
131    pub close_group_cache_dir: Option<PathBuf>,
132
133    /// Maximum application-layer message size in bytes.
134    ///
135    /// Tunes the QUIC stream receive window and per-stream read buffer.
136    /// Default: the larger of
137    /// [`MAX_WIRE_MESSAGE_SIZE`](crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE) (5 MiB)
138    /// and [`MAX_REPLICATION_MESSAGE_SIZE`](crate::replication::config::MAX_REPLICATION_MESSAGE_SIZE)
139    /// (10 MiB), so both chunk and replication traffic fit within the transport
140    /// ceiling.
141    #[serde(default = "default_max_message_size")]
142    pub max_message_size: usize,
143
144    /// Log level.
145    #[serde(default = "default_log_level")]
146    pub log_level: String,
147}
148
149/// Auto-upgrade configuration.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct UpgradeConfig {
152    /// Release channel.
153    #[serde(default)]
154    pub channel: UpgradeChannel,
155
156    /// Check interval in hours.
157    #[serde(default = "default_check_interval")]
158    pub check_interval_hours: u64,
159
160    /// GitHub repository in "owner/repo" format for release monitoring.
161    #[serde(default = "default_github_repo")]
162    pub github_repo: String,
163
164    /// Staged rollout window in hours.
165    ///
166    /// When a new version is detected, each node waits a deterministic delay
167    /// based on its node ID before applying the upgrade. This prevents mass
168    /// restarts and ensures network stability during upgrades.
169    ///
170    /// Set to 0 to disable staged rollout (apply upgrades immediately).
171    #[serde(default = "default_staged_rollout_hours")]
172    pub staged_rollout_hours: u64,
173
174    /// Exit cleanly on upgrade instead of spawning a new process.
175    ///
176    /// When true, the node exits after applying an upgrade and relies on
177    /// an external service manager (systemd, launchd, Windows Service) to
178    /// restart it. When false (default), the node spawns the new binary
179    /// as a child process before exiting.
180    #[serde(default)]
181    pub stop_on_upgrade: bool,
182}
183
184/// EVM network for payment processing.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum EvmNetworkConfig {
188    /// Arbitrum One mainnet.
189    #[default]
190    ArbitrumOne,
191    /// Arbitrum Sepolia testnet.
192    ArbitrumSepolia,
193}
194
195/// Payment verification configuration.
196///
197/// All new data requires EVM payment on Arbitrum — there is no way to
198/// disable payment verification. The cache stores previously verified
199/// payments to avoid redundant on-chain lookups.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct PaymentConfig {
202    /// Cache capacity for verified `XorNames`.
203    #[serde(default = "default_cache_capacity")]
204    pub cache_capacity: usize,
205
206    /// EVM wallet address for receiving payments (e.g., "0x...").
207    /// If not set, the node will not be able to receive payments.
208    #[serde(default)]
209    pub rewards_address: Option<String>,
210
211    /// EVM network for payment processing.
212    #[serde(default)]
213    pub evm_network: EvmNetworkConfig,
214
215    /// Metrics port for Prometheus scraping.
216    /// Set to 0 to disable metrics endpoint.
217    #[serde(default = "default_metrics_port")]
218    pub metrics_port: u16,
219}
220
221impl Default for PaymentConfig {
222    fn default() -> Self {
223        Self {
224            cache_capacity: default_cache_capacity(),
225            rewards_address: None,
226            evm_network: EvmNetworkConfig::default(),
227            metrics_port: default_metrics_port(),
228        }
229    }
230}
231
232const fn default_metrics_port() -> u16 {
233    9100
234}
235
236const fn default_cache_capacity() -> usize {
237    100_000
238}
239
240impl Default for NodeConfig {
241    fn default() -> Self {
242        Self {
243            root_dir: default_root_dir(),
244            port: 0,
245            ipv4_only: false,
246            bootstrap: Vec::new(),
247            network_mode: NetworkMode::default(),
248            testnet: TestnetConfig::default(),
249            upgrade: UpgradeConfig::default(),
250            payment: PaymentConfig::default(),
251            bootstrap_cache: BootstrapCacheConfig::default(),
252            storage: StorageConfig::default(),
253            close_group_cache_dir: None,
254            max_message_size: default_max_message_size(),
255            log_level: default_log_level(),
256        }
257    }
258}
259
260impl NodeConfig {
261    /// Create a testnet configuration preset.
262    ///
263    /// This is a convenience method for setting up a testnet node with
264    /// relaxed anti-Sybil protection, suitable for single-provider deployments.
265    /// Includes default bootstrap nodes for the Autonomi testnet.
266    #[must_use]
267    pub fn testnet() -> Self {
268        Self {
269            network_mode: NetworkMode::Testnet,
270            testnet: TestnetConfig::default(),
271            bootstrap: default_testnet_bootstrap(),
272            ..Self::default()
273        }
274    }
275
276    /// Create a development configuration preset.
277    ///
278    /// This has minimal restrictions and is only suitable for local testing.
279    #[must_use]
280    pub fn development() -> Self {
281        Self {
282            network_mode: NetworkMode::Development,
283            testnet: TestnetConfig {
284                max_per_ip: Some(usize::MAX),
285                max_per_subnet: Some(usize::MAX),
286            },
287            ..Self::default()
288        }
289    }
290
291    /// Check if this configuration is using relaxed security settings.
292    #[must_use]
293    pub fn is_relaxed(&self) -> bool {
294        !matches!(self.network_mode, NetworkMode::Production)
295    }
296
297    /// Load configuration from a TOML file.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the file cannot be read or parsed.
302    pub fn from_file(path: &std::path::Path) -> crate::Result<Self> {
303        let content = std::fs::read_to_string(path)?;
304        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
305    }
306
307    /// Save configuration to a TOML file.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the file cannot be written.
312    pub fn to_file(&self, path: &std::path::Path) -> crate::Result<()> {
313        let content =
314            toml::to_string_pretty(self).map_err(|e| crate::Error::Config(e.to_string()))?;
315        std::fs::write(path, content)?;
316        Ok(())
317    }
318}
319
320impl Default for UpgradeConfig {
321    fn default() -> Self {
322        Self {
323            channel: UpgradeChannel::default(),
324            check_interval_hours: default_check_interval(),
325            github_repo: default_github_repo(),
326            staged_rollout_hours: default_staged_rollout_hours(),
327            stop_on_upgrade: false,
328        }
329    }
330}
331
332fn default_github_repo() -> String {
333    "WithAutonomi/ant-node".to_string()
334}
335
336/// Default base directory for node data (platform data dir for "ant").
337#[must_use]
338pub fn default_root_dir() -> PathBuf {
339    directories::ProjectDirs::from("", "", "ant").map_or_else(
340        || PathBuf::from(".ant"),
341        |dirs| dirs.data_dir().to_path_buf(),
342    )
343}
344
345/// Default directory containing per-node data subdirectories.
346///
347/// Each node gets `{default_root_dir}/nodes/{peer_id}/` where `peer_id` is the
348/// full 64-character hex-encoded node ID.
349#[must_use]
350pub fn default_nodes_dir() -> PathBuf {
351    default_root_dir().join(NODES_SUBDIR)
352}
353
354fn default_max_message_size() -> usize {
355    // Use the larger of the chunk protocol and replication protocol ceilings
356    // so that replication hint batches (up to 10 MiB) are not silently dropped
357    // by the transport layer.
358    crate::replication::config::MAX_REPLICATION_MESSAGE_SIZE
359        .max(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE)
360}
361
362fn default_log_level() -> String {
363    "info".to_string()
364}
365
366const fn default_check_interval() -> u64 {
367    1 // 1 hour
368}
369
370const fn default_staged_rollout_hours() -> u64 {
371    24 // 24 hour window for staged rollout
372}
373
374// ============================================================================
375// Bootstrap Cache Configuration
376// ============================================================================
377
378/// Bootstrap cache configuration for persistent peer storage.
379///
380/// The bootstrap cache stores discovered peers across node restarts,
381/// ranking them by quality metrics (success rate, latency, recency).
382/// This reduces dependency on hardcoded bootstrap nodes and enables
383/// faster network reconnection after restarts.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct BootstrapCacheConfig {
386    /// Enable persistent bootstrap cache.
387    /// Default: true
388    #[serde(default = "default_bootstrap_cache_enabled")]
389    pub enabled: bool,
390
391    /// Directory for cache files.
392    /// Default: `{root_dir}/bootstrap_cache/`
393    #[serde(default)]
394    pub cache_dir: Option<PathBuf>,
395
396    /// Maximum contacts to store in the cache.
397    /// Default: 10,000
398    #[serde(default = "default_bootstrap_max_contacts")]
399    pub max_contacts: usize,
400
401    /// Stale contact threshold in days.
402    /// Contacts older than this are removed during cleanup.
403    /// Default: 7 days
404    #[serde(default = "default_bootstrap_stale_days")]
405    pub stale_threshold_days: u64,
406}
407
408impl Default for BootstrapCacheConfig {
409    fn default() -> Self {
410        Self {
411            enabled: default_bootstrap_cache_enabled(),
412            cache_dir: None,
413            max_contacts: default_bootstrap_max_contacts(),
414            stale_threshold_days: default_bootstrap_stale_days(),
415        }
416    }
417}
418
419const fn default_bootstrap_cache_enabled() -> bool {
420    true
421}
422
423const fn default_bootstrap_max_contacts() -> usize {
424    10_000
425}
426
427const fn default_bootstrap_stale_days() -> u64 {
428    7
429}
430
431// ============================================================================
432// Storage Configuration
433// ============================================================================
434
435/// Storage configuration for chunk persistence.
436///
437/// Controls how chunks are stored, including:
438/// - Whether storage is enabled
439/// - Content verification on read
440/// - Database size limits (auto-scales with available disk by default)
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct StorageConfig {
443    /// Enable chunk storage.
444    /// Default: true
445    #[serde(default = "default_storage_enabled")]
446    pub enabled: bool,
447
448    /// Verify content hash matches address on read.
449    /// Default: true
450    #[serde(default = "default_storage_verify_on_read")]
451    pub verify_on_read: bool,
452
453    /// Explicit LMDB database size cap in GiB.
454    ///
455    /// When set to 0 (default), the map size is computed automatically from
456    /// available disk space at startup and grows on demand when the operator
457    /// adds storage.  Set a non-zero value to impose a hard cap.
458    #[serde(default)]
459    pub db_size_gb: usize,
460
461    /// Minimum free disk space (in MiB) to preserve on the storage partition.
462    ///
463    /// Writes are refused when available space drops below this threshold,
464    /// preventing the node from filling the disk completely.  Default: 500 MiB.
465    #[serde(default = "default_disk_reserve_mb")]
466    pub disk_reserve_mb: u64,
467}
468
469impl Default for StorageConfig {
470    fn default() -> Self {
471        Self {
472            enabled: default_storage_enabled(),
473            verify_on_read: default_storage_verify_on_read(),
474            db_size_gb: 0,
475            disk_reserve_mb: default_disk_reserve_mb(),
476        }
477    }
478}
479
480/// Default: 500 MiB — matches `DEFAULT_DISK_RESERVE` in `storage::lmdb`.
481const fn default_disk_reserve_mb() -> u64 {
482    500
483}
484
485const fn default_storage_enabled() -> bool {
486    true
487}
488
489const fn default_storage_verify_on_read() -> bool {
490    true
491}
492
493// ============================================================================
494// Bootstrap Peers Configuration (shipped config file)
495// ============================================================================
496
497/// The filename for the bootstrap peers configuration file.
498pub const BOOTSTRAP_PEERS_FILENAME: &str = "bootstrap_peers.toml";
499
500/// Environment variable that overrides the bootstrap peers file search path.
501pub const BOOTSTRAP_PEERS_ENV: &str = "ANT_BOOTSTRAP_PEERS_PATH";
502
503/// Bootstrap peers loaded from a shipped configuration file.
504///
505/// This file provides initial peers for first-time network joins.
506/// It is separate from the bootstrap *cache* (which stores quality-ranked
507/// peers discovered at runtime).
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct BootstrapPeersConfig {
510    /// The bootstrap peer socket addresses.
511    #[serde(default)]
512    pub peers: Vec<SocketAddr>,
513}
514
515/// The source from which bootstrap peers were resolved.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub enum BootstrapSource {
518    /// Provided via `--bootstrap` CLI argument or `ANT_BOOTSTRAP` env var.
519    Cli,
520    /// Loaded from an explicit `--config` file.
521    ConfigFile,
522    /// Auto-discovered from a `bootstrap_peers.toml` file.
523    AutoDiscovered(PathBuf),
524    /// No bootstrap peers were found from any source.
525    None,
526}
527
528impl BootstrapPeersConfig {
529    /// Load bootstrap peers from a TOML file at the given path.
530    ///
531    /// # Errors
532    ///
533    /// Returns an error if the file cannot be read or contains invalid TOML.
534    pub fn from_file(path: &Path) -> crate::Result<Self> {
535        let content = std::fs::read_to_string(path)?;
536        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
537    }
538
539    /// Search well-known locations for a `bootstrap_peers.toml` file and load it.
540    ///
541    /// Search order (first match wins):
542    /// 1. `$ANT_BOOTSTRAP_PEERS_PATH` environment variable (path to file)
543    /// 2. Same directory as the running executable
544    /// 3. Platform config directory (`~/.config/ant/` on Linux,
545    ///    `~/Library/Application Support/ant/` on macOS,
546    ///    `%APPDATA%\ant\` on Windows)
547    /// 4. System config: `/etc/ant/` (Unix only)
548    ///
549    /// Returns `None` if no file is found in any location.
550    #[must_use]
551    pub fn discover() -> Option<(Self, PathBuf)> {
552        let candidates = Self::search_paths();
553        for path in candidates {
554            if path.is_file() {
555                match Self::from_file(&path) {
556                    Ok(config) if !config.peers.is_empty() => return Some((config, path)),
557                    Ok(_) => {}
558                    Err(err) => {
559                        crate::logging::warn!(
560                            "Failed to load bootstrap peers from {}: {err}",
561                            path.display(),
562                        );
563                    }
564                }
565            }
566        }
567        None
568    }
569
570    /// Build the ordered list of candidate paths to search.
571    fn search_paths() -> Vec<PathBuf> {
572        let mut paths = Vec::new();
573
574        // 1. Environment variable override.
575        if let Ok(env_path) = std::env::var(BOOTSTRAP_PEERS_ENV) {
576            paths.push(PathBuf::from(env_path));
577        }
578
579        // 2. Next to the running executable.
580        if let Ok(exe) = std::env::current_exe() {
581            if let Some(exe_dir) = exe.parent() {
582                paths.push(exe_dir.join(BOOTSTRAP_PEERS_FILENAME));
583            }
584        }
585
586        // 3. Platform config directory.
587        if let Some(proj_dirs) = directories::ProjectDirs::from("", "", "ant") {
588            paths.push(proj_dirs.config_dir().join(BOOTSTRAP_PEERS_FILENAME));
589        }
590
591        // 4. System config (Unix only).
592        #[cfg(unix)]
593        {
594            paths.push(PathBuf::from("/etc/ant").join(BOOTSTRAP_PEERS_FILENAME));
595        }
596
597        paths
598    }
599}
600
601/// Default testnet bootstrap nodes.
602///
603/// These are well-known bootstrap nodes for the Autonomi testnet.
604/// - ant-bootstrap-1 (NYC): 165.22.4.178:12000
605/// - ant-bootstrap-2 (SFO): 164.92.111.156:12000
606fn default_testnet_bootstrap() -> Vec<SocketAddr> {
607    vec![
608        // ant-bootstrap-1 (Digital Ocean NYC1)
609        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(165, 22, 4, 178), 12000)),
610        // ant-bootstrap-2 (Digital Ocean SFO3)
611        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(164, 92, 111, 156), 12000)),
612    ]
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used, clippy::expect_used)]
617mod tests {
618    use super::*;
619    use serial_test::serial;
620
621    #[test]
622    fn test_default_config_has_cache_capacity() {
623        let config = PaymentConfig::default();
624        assert!(config.cache_capacity > 0, "Cache capacity must be positive");
625    }
626
627    #[test]
628    fn test_default_evm_network() {
629        use crate::payment::EvmVerifierConfig;
630        let _config = EvmVerifierConfig::default();
631        // EVM verification is always on — no enabled field
632    }
633
634    #[test]
635    fn test_bootstrap_peers_parse_valid_toml() {
636        let toml_str = r#"
637            peers = [
638                "127.0.0.1:10000",
639                "192.168.1.1:10001",
640            ]
641        "#;
642        let config: BootstrapPeersConfig =
643            toml::from_str(toml_str).expect("valid TOML should parse");
644        assert_eq!(config.peers.len(), 2);
645        assert_eq!(config.peers[0].port(), 10000);
646        assert_eq!(config.peers[1].port(), 10001);
647    }
648
649    #[test]
650    fn test_bootstrap_peers_parse_empty_peers() {
651        let toml_str = r"peers = []";
652        let config: BootstrapPeersConfig =
653            toml::from_str(toml_str).expect("empty peers should parse");
654        assert!(config.peers.is_empty());
655    }
656
657    #[test]
658    fn test_bootstrap_peers_parse_missing_peers_field() {
659        let toml_str = "";
660        let config: BootstrapPeersConfig =
661            toml::from_str(toml_str).expect("missing field should use default");
662        assert!(config.peers.is_empty());
663    }
664
665    #[test]
666    fn test_bootstrap_peers_from_file() {
667        let dir = tempfile::tempdir().expect("create temp dir");
668        let path = dir.path().join("bootstrap_peers.toml");
669        std::fs::write(&path, r#"peers = ["10.0.0.1:10000", "10.0.0.2:10000"]"#)
670            .expect("write file");
671
672        let config = BootstrapPeersConfig::from_file(&path).expect("load from file");
673        assert_eq!(config.peers.len(), 2);
674    }
675
676    #[test]
677    fn test_bootstrap_peers_from_file_invalid_toml() {
678        let dir = tempfile::tempdir().expect("create temp dir");
679        let path = dir.path().join("bootstrap_peers.toml");
680        std::fs::write(&path, "not valid toml [[[").expect("write file");
681
682        assert!(BootstrapPeersConfig::from_file(&path).is_err());
683    }
684
685    /// Env-var-based discovery tests must run serially because they mutate
686    /// a shared process-wide environment variable.
687    #[test]
688    #[serial]
689    fn test_bootstrap_peers_discover_env_var() {
690        // Sub-test 1: valid file with peers is discovered.
691        {
692            let dir = tempfile::tempdir().expect("create temp dir");
693            let path = dir.path().join("bootstrap_peers.toml");
694            std::fs::write(&path, r#"peers = ["10.0.0.1:10000"]"#).expect("write file");
695
696            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
697            let result = BootstrapPeersConfig::discover();
698            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
699
700            let (config, discovered_path) = result.expect("should discover from env var");
701            assert_eq!(config.peers.len(), 1);
702            assert_eq!(discovered_path, path);
703        }
704
705        // Sub-test 2: file with empty peers list is skipped.
706        {
707            let dir = tempfile::tempdir().expect("create temp dir");
708            let path = dir.path().join("bootstrap_peers.toml");
709            std::fs::write(&path, r"peers = []").expect("write file");
710
711            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
712            let result = BootstrapPeersConfig::discover();
713            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
714
715            assert!(result.is_none(), "empty peers file should be skipped");
716        }
717    }
718
719    #[test]
720    fn test_bootstrap_peers_search_paths_contains_exe_dir() {
721        let paths = BootstrapPeersConfig::search_paths();
722        // At minimum, the exe-dir candidate should be present.
723        assert!(
724            paths
725                .iter()
726                .any(|p| p.file_name().is_some_and(|f| f == BOOTSTRAP_PEERS_FILENAME)),
727            "search paths should include a candidate with the bootstrap peers filename"
728        );
729    }
730}