thruput-node-supervisor 0.43.0

Local node supervisor for Thruput development
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//! Configuration for the node supervisor.

use std::{
    collections::BTreeMap,
    ffi::OsString,
    path::{Path, PathBuf},
    time::Duration,
};

use thruput_bundle_identity::BundleIdentity;
use thruput_json_rpc_types::bootstrap_manifest::{
    BootstrapManifest, BootstrapManifestAllocation, BootstrapManifestStorageCostModel,
};
use thruput_json_rpc_types::{Ed25519PublicKey, NetworkHashText};
use thruput_tx_validation::ConfigParamName;

/// Default JSON-RPC listen address for `thruput node`.
pub const DEFAULT_RPC_LISTEN_ADDRESS: &str = "127.0.0.1:9545";

/// Default maximum simultaneous public JSON-RPC connections for `thruput node`.
pub const DEFAULT_RPC_MAX_CONNECTIONS: u32 = 2_000;

/// Default timeout for one hogd-dev `ViewGateway` execution RPC.
pub const DEFAULT_HOGD_DEV_VIEW_TIMEOUT: Duration = Duration::from_secs(30);

/// Default interval for coordinator-like local block production.
pub const DEFAULT_BLOCK_DRIVER_INTERVAL: Duration = Duration::from_millis(500);

/// Default deadline for one explicitly requested block-production cycle.
pub const DEFAULT_MANUAL_BLOCK_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

/// Default listen host passed to hogd-dev for internal services.
pub const DEFAULT_HOGD_DEV_LISTEN_HOST: &str = "127.0.0.1";

/// Default graceful shutdown timeout for ephemeral hogd-dev.
pub const DEFAULT_HOGD_DEV_SHUTDOWN_GRACE: Duration = Duration::from_secs(5);

/// Default graceful shutdown timeout for hogd-dev with persistent state.
pub const DEFAULT_HOGD_DEV_PERSISTENT_SHUTDOWN_GRACE: Duration = Duration::from_secs(30);

/// Default startup timeout while waiting for hogd-dev readiness.
pub const DEFAULT_HOGD_DEV_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);

/// Fixed local faucet allocation in raw native token units.
pub const LOCAL_FAUCET_STORAGE_TOKENS: u64 = 10_000_000_000_000;

/// Default ordinary user allocation in raw native token units (100 native tokens).
pub const DEFAULT_LOCAL_ACCOUNT_TOKENS: u64 = 1_000_000_000;

/// Bootstrap manifest kind consumed by hog-devnode.
pub const HOGD_DEV_BOOTSTRAP_MANIFEST_KIND: &str = "groundhog-bootstrap";

/// Default bootstrap profile requested from hogd-dev.
pub const DEFAULT_HOGD_DEV_BOOTSTRAP_PROFILE: &str = "local-dev";

/// Default storage-cost model profile requested from hogd-dev.
pub const DEFAULT_HOGD_DEV_STORAGE_COST_MODEL_PROFILE: &str = "hogd-default";

/// Default local airdrop amount: 10 native tokens in indivisible units.
pub const DEFAULT_LOCAL_AIRDROP_RAW_AMOUNT: u128 = 10 * thruput_signing::NATIVE_TOKEN_SCALE;

/// Resource bounds and transfer amount for the optional local airdrop RPC.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct LocalAirdropConfig {
    /// Native amount transferred by each logical attempt.
    pub raw_amount: u128,
    /// Maximum retained requests for the chain lifetime; completed requests remain retained.
    pub attempt_capacity: usize,
    /// Maximum concurrently advancing attempts.
    pub max_concurrency: usize,
    /// End-to-end backend deadline for one attempt advancement.
    pub request_timeout: Duration,
}

impl Default for LocalAirdropConfig {
    fn default() -> Self {
        Self {
            raw_amount: DEFAULT_LOCAL_AIRDROP_RAW_AMOUNT,
            attempt_capacity: 1_024,
            max_concurrency: 16,
            request_timeout: Duration::from_secs(30),
        }
    }
}

impl LocalAirdropConfig {
    pub(crate) fn validate(self) -> Result<Self, &'static str> {
        if self.raw_amount == 0 {
            return Err("raw amount must be greater than zero");
        }
        if self.attempt_capacity == 0 {
            return Err("attempt capacity must be greater than zero");
        }
        if self.max_concurrency == 0 {
            return Err("maximum concurrency must be greater than zero");
        }
        if self.request_timeout.is_zero() {
            return Err("request timeout must be greater than zero");
        }
        Ok(self)
    }
}

/// Supervisor configuration for a local Thruput node.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct NodeSupervisorConfig {
    /// Public JSON-RPC listen address exposed by the Rust facade.
    pub rpc_listen_address: String,
    /// Maximum simultaneous public JSON-RPC connections.
    pub rpc_max_connections: u32,
    /// Active plus buffered entries per JSON-RPC array (1..=1024, default 128).
    pub rpc_batch_concurrency: thruput_json_rpc_server::batch::RpcBatchConcurrency,
    /// Configuration for the supervised hogd-dev child process.
    pub hogd_dev: HogdDevConfig,
    /// Optional protocol string used for hogd-dev intro handshakes.
    ///
    /// Defaults to the protocol embedded in the linked `thruput-xdr` crate.
    /// This override is intended for local cross-repo smoke tests before a
    /// matching Rust protocol crate has been published.
    pub hogd_protocol_override: Option<String>,
    /// Gas budget assigned to hogd-dev `ViewGateway` root invocations.
    pub view_gas_limit: u64,
    /// Maximum time to wait for one hogd-dev `ViewGateway` execution RPC.
    pub view_timeout: Duration,
    /// Aggregate limits for shared-snapshot reads.
    pub view_batch: thruput_json_rpc_server::ViewBatchConfig,
    /// Local block production policy for hogd-dev scheduler coordination.
    pub block_driver: BlockDriverConfig,
    /// Optional local airdrop behavior and resource bounds.
    pub local_airdrop: LocalAirdropConfig,
    /// Validated release identity supplied by the binary embedding the supervisor.
    pub bundle_identity: Option<BundleIdentity>,
}

impl Default for NodeSupervisorConfig {
    fn default() -> Self {
        Self {
            rpc_listen_address: DEFAULT_RPC_LISTEN_ADDRESS.to_string(),
            rpc_max_connections: DEFAULT_RPC_MAX_CONNECTIONS,
            rpc_batch_concurrency: thruput_json_rpc_server::batch::RpcBatchConcurrency::default(),
            hogd_dev: HogdDevConfig::default(),
            hogd_protocol_override: None,
            view_gas_limit: thruput_json_rpc_server::DEFAULT_VIEW_GAS_LIMIT,
            view_timeout: DEFAULT_HOGD_DEV_VIEW_TIMEOUT,
            view_batch: thruput_json_rpc_server::ViewBatchConfig::default(),
            block_driver: BlockDriverConfig::default(),
            local_airdrop: LocalAirdropConfig::default(),
            bundle_identity: None,
        }
    }
}

impl NodeSupervisorConfig {
    /// Set aggregate shared-read limits. Per-call view limits also apply.
    #[must_use]
    pub const fn with_view_batch(
        mut self,
        limits: thruput_json_rpc_server::ViewBatchConfig,
    ) -> Self {
        self.view_batch = limits;
        self
    }
    /// Return a copy with a validated per-array JSON-RPC concurrency limit.
    #[must_use]
    pub const fn with_rpc_batch_concurrency(
        mut self,
        concurrency: thruput_json_rpc_server::batch::RpcBatchConcurrency,
    ) -> Self {
        self.rpc_batch_concurrency = concurrency;
        self
    }
    /// Return a copy with a different public JSON-RPC listen address.
    #[must_use]
    pub fn with_rpc_listen_address(mut self, listen_address: impl Into<String>) -> Self {
        self.rpc_listen_address = listen_address.into();
        self
    }

    /// Return a copy with a different public JSON-RPC connection cap.
    #[must_use]
    pub const fn with_rpc_max_connections(mut self, max_connections: u32) -> Self {
        self.rpc_max_connections = max_connections;
        self
    }

    /// Return a copy with an explicit hogd-dev binary path.
    #[must_use]
    pub fn with_hogd_dev_binary(mut self, binary_path: impl Into<PathBuf>) -> Self {
        self.hogd_dev.binary_path = Some(binary_path.into());
        self
    }

    /// Return a copy with the supplied hogd-dev configuration.
    #[must_use]
    pub fn with_hogd_dev(mut self, hogd_dev: HogdDevConfig) -> Self {
        self.hogd_dev = hogd_dev;
        self
    }

    /// Return a copy with an explicit hogd protocol string for intro probes.
    ///
    /// This is a development escape hatch. It should only be used when the
    /// caller has verified that the linked Rust protocol structs are compatible
    /// with the hogd-dev binary being tested.
    #[must_use]
    pub fn with_hogd_protocol_override(mut self, protocol: impl Into<String>) -> Self {
        self.hogd_protocol_override = Some(protocol.into());
        self
    }

    /// Return a copy with a different hogd-dev `ViewGateway` gas budget.
    #[must_use]
    pub fn with_view_gas_limit(mut self, view_gas_limit: u64) -> Self {
        self.view_gas_limit = view_gas_limit;
        self
    }

    /// Return a copy with a different hogd-dev `ViewGateway` RPC timeout.
    #[must_use]
    pub fn with_view_timeout(mut self, view_timeout: Duration) -> Self {
        self.view_timeout = view_timeout;
        self
    }

    /// Return a copy with a different local block production policy.
    #[must_use]
    pub fn with_block_driver(mut self, block_driver: BlockDriverConfig) -> Self {
        self.block_driver = block_driver;
        self
    }

    /// Return a copy with different local airdrop controls.
    #[must_use]
    pub const fn with_local_airdrop(mut self, local_airdrop: LocalAirdropConfig) -> Self {
        self.local_airdrop = local_airdrop;
        self
    }

    /// Return a copy bound to the embedding binary's validated release identity.
    #[must_use]
    pub fn with_bundle_identity(mut self, bundle_identity: BundleIdentity) -> Self {
        self.bundle_identity = Some(bundle_identity);
        self
    }
}

/// Local block production policy used by the hogd-dev supervisor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct BlockDriverConfig {
    /// Driver wake mode.
    pub mode: BlockDriverMode,
    /// Timer interval for [`BlockDriverMode::Interval`].
    pub interval: Duration,
    /// Execution deadline once a request reaches the manual driver.
    ///
    /// Time spent waiting behind an earlier request is not included.
    pub manual_request_timeout: Duration,
}

impl BlockDriverConfig {
    /// Return an instant submit-wake policy.
    #[must_use]
    pub const fn instant() -> Self {
        Self {
            mode: BlockDriverMode::Instant,
            interval: DEFAULT_BLOCK_DRIVER_INTERVAL,
            manual_request_timeout: DEFAULT_MANUAL_BLOCK_REQUEST_TIMEOUT,
        }
    }

    /// Return a fixed-interval policy.
    #[must_use]
    pub const fn interval(interval: Duration) -> Self {
        Self {
            mode: BlockDriverMode::Interval,
            interval,
            manual_request_timeout: DEFAULT_MANUAL_BLOCK_REQUEST_TIMEOUT,
        }
    }

    /// Return an explicitly-triggered block production policy.
    #[must_use]
    pub const fn manual() -> Self {
        Self {
            mode: BlockDriverMode::Manual,
            interval: DEFAULT_BLOCK_DRIVER_INTERVAL,
            manual_request_timeout: DEFAULT_MANUAL_BLOCK_REQUEST_TIMEOUT,
        }
    }

    /// Return a copy with a different mode.
    #[must_use]
    pub const fn with_mode(mut self, mode: BlockDriverMode) -> Self {
        self.mode = mode;
        self
    }

    /// Return a copy with a different interval.
    #[must_use]
    pub const fn with_interval(mut self, interval: Duration) -> Self {
        self.interval = interval;
        self
    }

    /// Return a copy with a different manual request deadline.
    #[must_use]
    pub const fn with_manual_request_timeout(mut self, timeout: Duration) -> Self {
        self.manual_request_timeout = timeout;
        self
    }

    pub(crate) fn effective_interval(self) -> Duration {
        if self.interval.is_zero() {
            DEFAULT_BLOCK_DRIVER_INTERVAL
        } else {
            self.interval
        }
    }

    pub(crate) fn effective_manual_request_timeout(self) -> Duration {
        if self.manual_request_timeout.is_zero() {
            DEFAULT_MANUAL_BLOCK_REQUEST_TIMEOUT
        } else {
            self.manual_request_timeout
        }
    }
}

impl Default for BlockDriverConfig {
    fn default() -> Self {
        Self::instant()
    }
}

/// Block driver wake mode.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum BlockDriverMode {
    /// Submit-plane successes wake the block driver immediately.
    #[default]
    Instant,
    /// A fixed timer drives nomination/commit attempts.
    Interval,
    /// Blocks are produced only through the supervisor's in-process control handle.
    Manual,
}

/// Log verbosity level for hog-devnode
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LogLevel {
    Critical,
    Error,
    #[default]
    Warn,
    Info,
    Debug,
    Trace,
}

impl LogLevel {
    pub fn as_str(&self) -> &str {
        match self {
            LogLevel::Critical => "critical",
            LogLevel::Error => "error",
            LogLevel::Warn => "warn",
            LogLevel::Info => "info",
            LogLevel::Debug => "debug",
            LogLevel::Trace => "trace",
        }
    }
}

/// Configuration for the supervised hogd-dev process.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HogdDevConfig {
    /// Explicit binary path, if supplied by CLI flag or higher-level config.
    ///
    /// When unset, the supervisor falls back to the documented hogd-dev binary
    /// lookup order.
    pub binary_path: Option<PathBuf>,
    /// Contract artifact directories searched by hog-devnode before its defaults.
    pub contract_artifact_directories: Vec<PathBuf>,
    /// Directory where hog-devnode persists recoverable node state.
    ///
    /// When unset, hog-devnode runs with ephemeral state.
    pub data_dir: Option<PathBuf>,
    /// Log level to request from hogd-dev
    pub log_level: Option<LogLevel>,
    /// Host address hogd-dev should bind its internal services to.
    pub listen_host: String,
    /// Grace period after SIGTERM before the supervisor escalates to SIGKILL.
    ///
    /// This allows log drain and orderly task cancellation before forced
    /// process termination.
    pub shutdown_grace: Duration,
    /// Maximum time to wait for the readiness line after spawning hogd-dev.
    pub startup_timeout: Duration,
    /// Explicit public allocations for a fresh local chain.
    ///
    /// On restart the persisted public intent remains authoritative.
    pub bootstrap_accounts: Vec<BootstrapManifestAllocation>,
    /// RAM database capacity in bytes hog-devnode reserves at startup.
    ///
    /// When unset, hog-devnode uses its built-in default capacity.
    pub ramdb_bytes: Option<u64>,
    /// Genesis execution parameter values forwarded to hog-devnode in place
    /// of the ones stock genesis states. The child still rejects a value
    /// outside the ceilings its build was compiled with.
    pub network_config_overrides: BTreeMap<ConfigParamName, u64>,
    /// Raw arguments appended after every flag the supervisor sets, so a
    /// repeated flag here overrides the supervisor's value. Not persisted.
    pub extra_args: Vec<OsString>,
    /// Signing domain for a new chain. Omission initializes the zero development domain.
    ///
    /// An initialized directory rejects this option, even if the value matches.
    /// Leave it unset on restart to load the persisted identity.
    pub init_network_hash: Option<NetworkHashText>,
    /// PID file used to detect and clean up orphaned hogd-dev children.
    pub pid_file: Option<PathBuf>,
    /// Environment for the child only, so tests can steer the fake hogd-dev
    /// without changing the test process's environment.
    #[cfg(test)]
    pub(crate) test_child_env: Vec<(OsString, OsString)>,
}

impl Default for HogdDevConfig {
    fn default() -> Self {
        Self {
            binary_path: None,
            contract_artifact_directories: Vec::new(),
            data_dir: None,
            log_level: None,
            listen_host: DEFAULT_HOGD_DEV_LISTEN_HOST.to_string(),
            shutdown_grace: DEFAULT_HOGD_DEV_SHUTDOWN_GRACE,
            startup_timeout: DEFAULT_HOGD_DEV_STARTUP_TIMEOUT,
            bootstrap_accounts: Vec::new(),
            ramdb_bytes: None,
            network_config_overrides: BTreeMap::new(),
            extra_args: Vec::new(),
            init_network_hash: None,
            pid_file: None,
            #[cfg(test)]
            test_child_env: Vec::new(),
        }
    }
}

impl HogdDevConfig {
    /// Return a copy that sets `key` in the child's environment.
    #[cfg(test)]
    #[must_use]
    pub(crate) fn with_test_child_env(
        mut self,
        key: impl Into<OsString>,
        value: impl Into<OsString>,
    ) -> Self {
        self.test_child_env.push((key.into(), value.into()));
        self
    }

    /// Select a new chain's signing domain. Initialized directories reject this option.
    #[must_use]
    pub fn with_init_network_hash(mut self, network_hash: NetworkHashText) -> Self {
        self.init_network_hash = Some(network_hash);
        self
    }

    /// Return a copy with genesis network parameter overrides.
    #[must_use]
    pub fn with_network_config_overrides(
        mut self,
        overrides: BTreeMap<ConfigParamName, u64>,
    ) -> Self {
        self.network_config_overrides = overrides;
        self
    }

    /// Return a copy with an explicit hogd-dev binary path.
    #[must_use]
    pub fn with_binary_path(mut self, binary_path: impl Into<PathBuf>) -> Self {
        self.binary_path = Some(binary_path.into());
        self
    }

    /// Return a copy with explicit contract artifact search directories.
    #[must_use]
    pub fn with_contract_artifact_directories<I, P>(mut self, directories: I) -> Self
    where
        I: IntoIterator<Item = P>,
        P: Into<PathBuf>,
    {
        self.contract_artifact_directories = directories
            .into_iter()
            .map(Into::into)
            .map(resolve_path)
            .collect();
        self
    }

    /// Return a copy with a directory for persistent hog-devnode state.
    #[must_use]
    pub fn with_data_dir(mut self, data_dir: impl Into<PathBuf>) -> Self {
        self.data_dir = Some(resolve_path(data_dir.into()));
        self
    }

    /// Return a copy with an explicit hogd-dev log level.
    #[must_use]
    pub fn with_log_level(mut self, log_level: LogLevel) -> Self {
        self.log_level = Some(log_level);
        self
    }

    /// Return a copy with a different internal listen host.
    #[must_use]
    pub fn with_listen_host(mut self, listen_host: impl Into<String>) -> Self {
        self.listen_host = listen_host.into();
        self
    }

    /// Return a copy with a different graceful shutdown timeout.
    #[must_use]
    pub fn with_shutdown_grace(mut self, shutdown_grace: Duration) -> Self {
        self.shutdown_grace = shutdown_grace;
        self
    }

    /// Return a copy with a different startup readiness timeout.
    #[must_use]
    pub fn with_startup_timeout(mut self, startup_timeout: Duration) -> Self {
        self.startup_timeout = startup_timeout;
        self
    }

    /// Return a copy with caller-owned public bootstrap allocations.
    #[must_use]
    pub fn with_bootstrap_accounts(mut self, accounts: Vec<BootstrapManifestAllocation>) -> Self {
        self.bootstrap_accounts = accounts;
        self
    }

    /// Return a copy with an explicit hog-devnode RAM database capacity in bytes.
    #[must_use]
    pub fn with_ramdb_bytes(mut self, ramdb_bytes: u64) -> Self {
        self.ramdb_bytes = Some(ramdb_bytes);
        self
    }

    /// Return a copy with raw arguments appended to the hog-devnode command line.
    #[must_use]
    pub fn with_extra_args<I, A>(mut self, args: I) -> Self
    where
        I: IntoIterator<Item = A>,
        A: Into<OsString>,
    {
        self.extra_args = args.into_iter().map(Into::into).collect();
        self
    }

    /// Return a copy with a PID file for orphan detection.
    #[must_use]
    pub fn with_pid_file(mut self, pid_file: impl Into<PathBuf>) -> Self {
        self.pid_file = Some(pid_file.into());
        self
    }

    /// Build public intent using the supervisor-owned faucet's public key.
    pub fn to_bootstrap_manifest(
        &self,
        network_hash: NetworkHashText,
        faucet_public_key: Ed25519PublicKey,
    ) -> BootstrapManifest {
        BootstrapManifest::new(
            HOGD_DEV_BOOTSTRAP_MANIFEST_KIND,
            network_hash,
            DEFAULT_HOGD_DEV_BOOTSTRAP_PROFILE,
            BootstrapManifestAllocation::new(
                faucet_public_key,
                LOCAL_FAUCET_STORAGE_TOKENS.to_string(),
            ),
            self.bootstrap_accounts.clone(),
            BootstrapManifestStorageCostModel::profile(DEFAULT_HOGD_DEV_STORAGE_COST_MODEL_PROFILE),
        )
    }
}

fn resolve_path(path: PathBuf) -> PathBuf {
    let expanded = match path.strip_prefix(Path::new("~")) {
        Ok(rest) => std::env::var_os("HOME")
            .map(PathBuf::from)
            .map(|home| home.join(rest))
            .unwrap_or(path),
        Err(_) => path,
    };
    std::path::absolute(&expanded).unwrap_or(expanded)
}

#[cfg(test)]
mod tests {
    use super::LocalAirdropConfig;

    #[test]
    fn local_airdrop_default_is_valid() {
        assert!(LocalAirdropConfig::default().validate().is_ok());
    }

    #[test]
    fn local_airdrop_rejects_zero_resource_bounds() {
        let default = LocalAirdropConfig::default();
        let invalid = [
            LocalAirdropConfig {
                raw_amount: 0,
                ..default
            },
            LocalAirdropConfig {
                attempt_capacity: 0,
                ..default
            },
            LocalAirdropConfig {
                max_concurrency: 0,
                ..default
            },
            LocalAirdropConfig {
                request_timeout: std::time::Duration::ZERO,
                ..default
            },
        ];

        assert!(invalid.into_iter().all(|config| config.validate().is_err()));
    }
}