Skip to main content

reth_cli_commands/download/
mod.rs

1//! Snapshot download command.
2//!
3//! `reth download` prepares a data directory from published snapshot archives. [`DownloadCommand`]
4//! covers both a single-archive path and a manifest-driven path, and owns the steps required to
5//! turn downloaded bytes into a bootable node directory.
6//!
7//! ## Entry modes
8//!
9//! [`DownloadCommand`] has two main execution modes:
10//!
11//! - Single-archive mode processes one `.tar.lz4` or `.tar.zst` archive from `--url`.
12//!   Depending on the source and flags, it either extracts a local `file://` archive, streams a
13//!   remote archive straight into extraction, or downloads the archive to disk first and then
14//!   extracts it.
15//! - Manifest mode resolves a [`SnapshotManifest`], turns CLI or TUI choices into
16//!   [`ComponentSelection`]s, plans the required archives, processes them, and then writes the
17//!   resulting config and database checkpoints.
18//!
19//! [`DownloadDefaults`] defines the discovery endpoints and default help text used when the command
20//! needs to discover a manifest instead of consuming an explicit source.
21//!
22//! ## Selection and planning
23//!
24//! Manifest mode first reduces user input into `ResolvedComponents`: a map of
25//! [`SnapshotComponentType`] to [`ComponentSelection`] plus an optional `SelectionPreset`.
26//! This turns CLI input (`minimal`, `full`, `archive`, or explicit `--with-*` flags) into the
27//! component selections used by the download code.
28//!
29//! The selected components are then expanded into `PlannedDownloads`, which is the set of
30//! `PlannedArchive`s that must be verified, downloaded, or reused. Planning also computes the
31//! total byte count used by progress reporting.
32//!
33//! ## Archive processing
34//!
35//! Each planned archive is processed independently, but `DownloadSession` holds the shared
36//! progress, request limit, and cancellation token for the whole command.
37//! `ArchiveProcessContext` adds the paths needed to process one archive.
38//!
39//! Archive processing is modeled around `ModularDownloadJob`, which schedules work, and
40//! `ArchiveProcessor`, which owns the explicit retry state machine for one archive.
41//! `ArchiveMode` decides whether that archive should be fetched through the cache or streamed
42//! directly:
43//!
44//! - reuse verified plain output files when possible,
45//! - otherwise fetch and extract the archive,
46//! - verify the declared output files,
47//! - retry the entire archive attempt if extraction succeeded but verification failed.
48//!
49//! Reuse and completion are based on verified output files, not on whether an old archive file is
50//! present.
51//!
52//! ## Fetch and extraction
53//!
54//! `stream_and_extract` handles the single-archive path. It supports local files, resumable
55//! downloads to disk, and direct streaming extraction.
56//!
57//! When the code needs to fetch an archive to disk, it uses `ArchiveFetcher`. The fetcher probes
58//! the remote source and chooses between a sequential download and a segmented download plan
59//! (`SegmentedDownloadPlan`). `SequentialDownloadFallback` records why a source could not use the
60//! segmented path, while `SegmentedDownload` runs the worker queue and piece retries for the
61//! parallel path.
62//!
63//! Segmented download retries individual byte ranges. Archive processing retries whole-archive
64//! attempts. These are separate layers: range retries deal with transient request failures, while
65//! archive retries deal with extraction or output verification failures.
66//!
67//! `CompressionFormat` determines how the archive stream is unpacked once bytes are available, and
68//! `OutputVerifier` checks the extracted output files before reuse or completion.
69//!
70//! ## Progress and finalization
71//!
72//! `DownloadProgress` reports progress for the single-archive path. `SharedProgress` reports
73//! aggregate progress for modular downloads. It tracks fetched bytes separately from completed
74//! bytes so repeated fetches during retries do not overstate completion.
75//!
76//! After all required archives are complete, [`DownloadCommand`] finalizes the directory by
77//! writing the derived node configuration and updating prune or index-stage checkpoints. A
78//! successful command leaves a data directory that matches the snapshot shape that was selected.
79
80mod archive;
81pub mod config_gen;
82mod extract;
83mod fetch;
84pub mod manifest;
85pub mod manifest_cmd;
86mod planning;
87mod progress;
88mod session;
89mod source;
90mod tui;
91mod verify;
92
93pub use planning::{DownloadPlan, DownloadPlanArchive};
94
95use crate::common::EnvironmentArgs;
96use archive::run_modular_downloads;
97use clap::{builder::RangedU64ValueParser, Parser};
98use config_gen::{config_for_selections, write_config};
99use extract::stream_and_extract;
100use eyre::Result;
101use manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest};
102use planning::{collect_planned_archives, summarize_download_startup, PlannedDownloads};
103use progress::{DownloadProgress, DownloadRequestLimiter};
104use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks, MAINNET};
105use reth_cli::chainspec::ChainSpecParser;
106use reth_cli_util::cancellation::CancellationToken;
107use reth_db::{init_db, Database};
108use reth_db_api::transaction::DbTx;
109use reth_fs_util as fs;
110use reth_node_core::args::DefaultPruningValues;
111use reth_prune_types::{PruneMode, PruneModes};
112use source::{
113    discover_manifest_url, fetch_manifest_from_source, fetch_snapshot_api_entries,
114    print_snapshot_listing, resolve_manifest_base_url,
115};
116use std::{
117    borrow::Cow,
118    collections::BTreeMap,
119    path::{Path, PathBuf},
120    sync::{Arc, OnceLock},
121    time::Duration,
122};
123use tracing::info;
124use tui::{run_selector, SelectorOutput};
125
126const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
127const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
128const RETH_SNAPSHOTS_SOURCE: &str = "https://snapshots.reth.rs (default)";
129const SNAPSHOT_API_PATH: &str = "/api/snapshots";
130const FORCE_REMOVED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"];
131
132/// Maximum number of simultaneous HTTP downloads across the entire snapshot job.
133const MAX_CONCURRENT_DOWNLOADS: usize = 8;
134
135/// Built-in component presets for snapshot selection.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub(crate) enum SelectionPreset {
138    /// Minimal node data needed to start from a snapshot.
139    Minimal,
140    /// Full-node data matching the default full prune settings.
141    Full,
142    /// All available snapshot data.
143    Archive,
144}
145
146struct ResolvedComponents {
147    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
148    preset: Option<SelectionPreset>,
149}
150
151/// Global static download defaults
152static DOWNLOAD_DEFAULTS: OnceLock<DownloadDefaults> = OnceLock::new();
153
154/// Download configuration defaults
155///
156/// Global defaults can be set via [`DownloadDefaults::try_init`].
157#[derive(Debug, Clone)]
158pub struct DownloadDefaults {
159    /// List of available snapshot sources
160    pub available_snapshots: Vec<Cow<'static, str>>,
161    /// Default base URL for snapshots
162    pub default_base_url: Cow<'static, str>,
163    /// Default base URL for chain-aware snapshots.
164    ///
165    /// When set, the chain ID is appended to form the full URL: `{base_url}/{chain_id}`.
166    /// For example, given a base URL of `https://snapshots.example.com` and chain ID `1`,
167    /// the resulting URL would be `https://snapshots.example.com/1`.
168    ///
169    /// Falls back to [`default_base_url`](Self::default_base_url) when `None`.
170    pub default_chain_aware_base_url: Option<Cow<'static, str>>,
171    /// URL for the snapshot discovery API that lists available snapshots.
172    ///
173    /// Defaults to `https://snapshots.reth.rs/api/snapshots`.
174    pub snapshot_api_url: Cow<'static, str>,
175    /// Optional custom long help text that overrides the generated help
176    pub long_help: Option<String>,
177}
178
179impl DownloadDefaults {
180    /// Initialize the global download defaults with this configuration
181    pub fn try_init(self) -> Result<(), Self> {
182        DOWNLOAD_DEFAULTS.set(self)
183    }
184
185    /// Get a reference to the global download defaults
186    pub fn get_global() -> &'static DownloadDefaults {
187        DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
188    }
189
190    /// Default download configuration with defaults from snapshots.reth.rs and publicnode
191    pub fn default_download_defaults() -> Self {
192        Self {
193            available_snapshots: vec![
194                Cow::Borrowed(RETH_SNAPSHOTS_SOURCE),
195                Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
196            ],
197            default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
198            default_chain_aware_base_url: None,
199            snapshot_api_url: Cow::Borrowed(RETH_SNAPSHOTS_API_URL),
200            long_help: None,
201        }
202    }
203
204    /// Generates the long help text for the download URL argument using these defaults.
205    ///
206    /// If a custom long_help is set, it will be returned. Otherwise, help text is generated
207    /// from the available_snapshots list.
208    pub fn long_help(&self) -> String {
209        if let Some(ref custom_help) = self.long_help {
210            return custom_help.clone();
211        }
212
213        let implicit_download_help = if self.mainnet_only_discovery() {
214            "\nIf no URL is provided, the latest archive snapshot will only be proposed\nfor Ethereum mainnet. For other chains, provide --manifest-url, --manifest-path,\nor -u explicitly."
215        } else {
216            "\nIf no URL is provided, the latest archive snapshot for the selected chain\nwill be proposed for download from "
217        };
218
219        let mut help = format!(
220            "Specify a snapshot URL or let the command propose a default one.\n\n\
221             Browse available snapshots at {}\n\
222             or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
223            self.snapshot_source_url(),
224        );
225
226        for source in &self.available_snapshots {
227            help.push_str("- ");
228            help.push_str(source);
229            help.push('\n');
230        }
231
232        help.push_str(implicit_download_help);
233        if !self.mainnet_only_discovery() {
234            help.push_str(
235                self.default_chain_aware_base_url.as_deref().unwrap_or(&self.default_base_url),
236            );
237            help.push('.');
238        }
239        help.push_str(
240            "\n\nLocal file:// URLs are also supported for extracting snapshots from disk.",
241        );
242        help
243    }
244
245    fn mainnet_only_discovery(&self) -> bool {
246        self.snapshot_api_url.trim_end_matches('/') == RETH_SNAPSHOTS_API_URL
247    }
248
249    fn snapshot_source_url(&self) -> &str {
250        snapshot_source_url_from_api(&self.snapshot_api_url)
251    }
252
253    /// Add a snapshot source to the list
254    pub fn with_snapshot(mut self, source: impl Into<Cow<'static, str>>) -> Self {
255        self.available_snapshots.push(source.into());
256        self
257    }
258
259    /// Replace all snapshot sources
260    pub fn with_snapshots(mut self, sources: Vec<Cow<'static, str>>) -> Self {
261        self.available_snapshots = sources;
262        self
263    }
264
265    /// Set the default base URL, e.g. `https://downloads.merkle.io`.
266    pub fn with_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
267        self.default_base_url = url.into();
268        self
269    }
270
271    /// Set the default chain-aware base URL.
272    pub fn with_chain_aware_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
273        self.default_chain_aware_base_url = Some(url.into());
274        self
275    }
276
277    /// Set the snapshot discovery API URL.
278    ///
279    /// Generated help uses the API root as the default snapshot source unless a custom
280    /// chain-aware base URL or source list was already provided.
281    pub fn with_snapshot_api_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
282        self.snapshot_api_url = url.into();
283
284        let source_url = self.snapshot_source_url().to_string();
285        if self.default_chain_aware_base_url.is_none() {
286            self.default_chain_aware_base_url = Some(Cow::Owned(source_url.clone()));
287        }
288        for source in &mut self.available_snapshots {
289            if source.as_ref() == RETH_SNAPSHOTS_SOURCE {
290                *source = Cow::Owned(format!("{source_url} (default)"));
291            }
292        }
293
294        self
295    }
296
297    /// Set one default snapshot source URL for discovery and generated CLI references.
298    ///
299    /// The provided URL is the public snapshot root, such as `https://snapshots.example.com`.
300    /// The discovery API is derived as `{url}/api/snapshots`.
301    pub fn with_snapshot_source_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
302        let source_url = normalize_snapshot_source_url(url.into());
303        self.available_snapshots = vec![Cow::Owned(format!("{} (default)", source_url.as_ref()))];
304        self.default_base_url = source_url.clone();
305        self.default_chain_aware_base_url = Some(source_url.clone());
306        self.snapshot_api_url = Cow::Owned(format!("{}{SNAPSHOT_API_PATH}", source_url.as_ref()));
307        self
308    }
309
310    /// Builder: Set custom long help text, overriding the generated help
311    pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
312        self.long_help = Some(help.into());
313        self
314    }
315}
316
317fn snapshot_source_url_from_api(api_url: &str) -> &str {
318    api_url.trim_end_matches('/').trim_end_matches(SNAPSHOT_API_PATH)
319}
320
321fn normalize_snapshot_source_url(url: Cow<'static, str>) -> Cow<'static, str> {
322    match url {
323        Cow::Borrowed(url) => Cow::Borrowed(snapshot_source_url_from_api(url)),
324        Cow::Owned(url) => Cow::Owned(snapshot_source_url_from_api(&url).to_string()),
325    }
326}
327
328impl Default for DownloadDefaults {
329    /// Returns the built-in download defaults.
330    fn default() -> Self {
331        Self::default_download_defaults()
332    }
333}
334
335/// CLI command that downloads snapshot archives and configures a reth node from them.
336#[derive(Debug, Parser)]
337pub struct DownloadCommand<C: ChainSpecParser> {
338    #[command(flatten)]
339    env: EnvironmentArgs<C>,
340
341    /// Custom URL to download a single snapshot archive (legacy mode).
342    ///
343    /// When provided, downloads and extracts a single archive without component selection.
344    /// Browse available snapshots with --list-snapshots.
345    #[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
346    url: Option<String>,
347
348    /// URL to a snapshot manifest.json for modular component downloads.
349    ///
350    /// When provided, fetches this manifest instead of discovering it from the default
351    /// base URL. Useful for testing with custom or local manifests.
352    #[arg(long, value_name = "URL", conflicts_with = "url")]
353    manifest_url: Option<String>,
354
355    /// Local path to a snapshot manifest.json for modular component downloads.
356    #[arg(long, value_name = "PATH", conflicts_with_all = ["url", "manifest_url"])]
357    manifest_path: Option<PathBuf>,
358
359    /// Include all transaction static files.
360    #[arg(long, conflicts_with_all = ["with_txs_since", "with_txs_distance", "minimal", "full", "archive"])]
361    with_txs: bool,
362
363    /// Include transaction static files starting at the specified block.
364    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_txs", "with_txs_distance", "minimal", "full", "archive"])]
365    with_txs_since: Option<u64>,
366
367    /// Include transaction static files covering the last N blocks.
368    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_txs", "with_txs_since", "minimal", "full", "archive"])]
369    with_txs_distance: Option<u64>,
370
371    /// Include all receipt static files.
372    #[arg(long, conflicts_with_all = ["with_receipts_since", "with_receipts_distance", "minimal", "full", "archive"])]
373    with_receipts: bool,
374
375    /// Include receipt static files starting at the specified block.
376    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_receipts", "with_receipts_distance", "minimal", "full", "archive"])]
377    with_receipts_since: Option<u64>,
378
379    /// Include receipt static files covering the last N blocks.
380    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_receipts", "with_receipts_since", "minimal", "full", "archive"])]
381    with_receipts_distance: Option<u64>,
382
383    /// Include all account and storage history static files.
384    #[arg(long, alias = "with-changesets", conflicts_with_all = ["with_state_history_since", "with_state_history_distance", "minimal", "full", "archive"])]
385    with_state_history: bool,
386
387    /// Include account and storage history static files starting at the specified block.
388    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_state_history", "with_state_history_distance", "minimal", "full", "archive"])]
389    with_state_history_since: Option<u64>,
390
391    /// Include account and storage history static files covering the last N blocks.
392    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_state_history", "with_state_history_since", "minimal", "full", "archive"])]
393    with_state_history_distance: Option<u64>,
394
395    /// Include transaction sender static files. Requires `--with-txs`.
396    #[arg(long, requires = "with_txs", conflicts_with_all = ["minimal", "full", "archive"])]
397    with_senders: bool,
398
399    /// Include RocksDB index files.
400    #[arg(long, conflicts_with_all = ["minimal", "full", "archive", "without_rocksdb"])]
401    with_rocksdb: bool,
402
403    /// Download all available components (archive node, no pruning).
404    #[arg(long, alias = "all", conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "minimal", "full"])]
405    archive: bool,
406
407    /// Download the minimal component set (same default as --non-interactive).
408    #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "full"])]
409    minimal: bool,
410
411    /// Download the full node component set (matches default full prune settings).
412    #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "minimal"])]
413    full: bool,
414
415    /// Skip optional RocksDB indices even when archive components are selected.
416    ///
417    /// This affects `--archive`/`--all` and TUI archive preset (`a`).
418    #[arg(long, conflicts_with_all = ["url", "with_rocksdb"])]
419    without_rocksdb: bool,
420
421    /// Skip interactive component selection. Downloads the minimal set
422    /// (state + headers + transactions + changesets) unless explicit --with-* flags narrow it.
423    #[arg(long, short = 'y')]
424    non_interactive: bool,
425
426    /// Overwrite existing snapshot data by removing db, rocksdb, static_files, and reth.toml.
427    #[arg(long, conflicts_with = "list")]
428    force: bool,
429
430    /// Enable resumable two-phase downloads (download to disk first, then extract).
431    ///
432    /// Archives are downloaded to a `.part` file with HTTP Range resume support
433    /// before extraction. This is enabled by default because it tolerates
434    /// network interruptions without restarting. Pass `--resumable=false` to
435    /// stream archives directly into the extractor instead.
436    #[arg(long, default_value_t = true, num_args = 0..=1, default_missing_value = "true")]
437    resumable: bool,
438
439    /// Maximum number of simultaneous HTTP downloads.
440    ///
441    /// Applies across the entire snapshot download. Small files use one slot,
442    /// while large files may use multiple slots by splitting into fixed-size pieces.
443    #[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
444    download_concurrency: usize,
445
446    /// Override the delay between retry attempts (for example, 500ms or 5s).
447    ///
448    /// Applies to requests, extraction, output verification, and segmented downloads.
449    /// By default, retries wait five seconds; segmented requests use adaptive backoff.
450    /// This does not change the number of attempts.
451    #[arg(long, value_name = "DURATION", value_parser = reth_cli_util::parse_duration_from_secs_or_ms)]
452    retry_backoff: Option<Duration>,
453
454    /// List available snapshots and exit.
455    ///
456    /// Queries the snapshots API and prints all available snapshots for the selected chain,
457    /// including block number, size, and manifest URL.
458    #[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
459    list: bool,
460
461    /// Print the selected modular archive plan as JSON and exit without downloading.
462    #[arg(long, conflicts_with_all = ["url", "list"])]
463    print_plan_json: bool,
464}
465
466impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
467    /// Runs the download command in single-archive or manifest mode.
468    pub async fn execute<N>(self) -> Result<Option<PreparedSnapshotDownload>> {
469        let chain = self.env.chain.chain();
470
471        // --list: print available snapshots and exit
472        if self.list {
473            let entries = fetch_snapshot_api_entries(chain.id()).await?;
474            print_snapshot_listing(&entries, chain.id());
475            return Ok(None);
476        }
477
478        // Legacy single-URL mode: download one archive and extract it
479        if let Some(ref url) = self.url {
480            let cancel_token = CancellationToken::new();
481            let _cancel_guard = cancel_token.drop_guard();
482            let data_dir = self.env.datadir.clone().resolve_datadir(chain);
483            let target_dir = data_dir.data_dir();
484            if self.force {
485                clear_existing_datadir(target_dir)?;
486            }
487            fs::create_dir_all(target_dir)?;
488
489            let request_limiter = DownloadRequestLimiter::new(self.download_concurrency.max(1));
490            info!(target: "reth::cli",
491                dir = ?data_dir.data_dir(),
492                url = %url,
493                "Starting snapshot download and extraction"
494            );
495
496            stream_and_extract(
497                url,
498                data_dir.data_dir(),
499                None,
500                self.resumable,
501                Some(request_limiter),
502                cancel_token.clone(),
503                self.retry_backoff,
504            )
505            .await?;
506            info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
507
508            return Ok(None);
509        }
510
511        let ResolvedDownload { manifest, selections, preset, planned } =
512            self.resolve_download(chain.id()).await?;
513        let data_dir = self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf();
514        let prepared = PreparedSnapshotDownload { manifest, data_dir };
515        if self.print_plan_json {
516            DownloadPlan::from_planned(&prepared.manifest, &planned)
517                .write_json(std::io::stdout().lock())?;
518            return Ok(Some(prepared))
519        }
520
521        let target_dir = prepared.data_dir.as_path();
522        let cancel_token = CancellationToken::new();
523        let _cancel_guard = cancel_token.drop_guard();
524        if self.force {
525            clear_existing_datadir(target_dir)?;
526        }
527        fs::create_dir_all(target_dir)?;
528        let startup_summary = summarize_download_startup(&planned.archives, target_dir)?;
529        info!(target: "reth::cli",
530            reusable = startup_summary.reusable,
531            needs_download = startup_summary.needs_download,
532            "Startup integrity summary (plain output files)"
533        );
534
535        info!(target: "reth::cli",
536            archives = planned.total_archives(),
537            download_total = %DownloadProgress::format_size(planned.total_download_size),
538            output_total = %DownloadProgress::format_size(planned.total_output_size),
539            "Downloading all archives"
540        );
541
542        run_modular_downloads(
543            planned,
544            target_dir,
545            self.download_concurrency.max(1),
546            cancel_token.clone(),
547            self.retry_backoff,
548        )
549        .await?;
550
551        self.finalize_modular_download(
552            &selections,
553            &prepared.manifest,
554            preset,
555            target_dir,
556            &target_dir.join("db"),
557        )?;
558
559        Ok(Some(prepared))
560    }
561
562    /// Resolves the exact modular archive plan and manifest context without downloading or
563    /// modifying the data dir.
564    pub async fn plan(&self) -> Result<(DownloadPlan, PreparedSnapshotDownload)> {
565        let chain = self.env.chain.chain();
566        let resolved = self.resolve_download(chain.id()).await?;
567        let plan = DownloadPlan::from_planned(&resolved.manifest, &resolved.planned);
568        let prepared = PreparedSnapshotDownload {
569            manifest: resolved.manifest,
570            data_dir: self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf(),
571        };
572        Ok((plan, prepared))
573    }
574
575    async fn resolve_download(&self, chain_id: u64) -> Result<ResolvedDownload> {
576        let manifest = self.load_manifest(chain_id).await?;
577        let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?;
578
579        if matches!(preset, Some(SelectionPreset::Archive)) {
580            inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb);
581        }
582
583        let planned = collect_planned_archives(&manifest, &selections)?;
584        Ok(ResolvedDownload { manifest, selections, preset, planned })
585    }
586
587    /// Loads the manifest and resolves its effective base URL.
588    async fn load_manifest(&self, chain_id: u64) -> Result<SnapshotManifest> {
589        let manifest_source = self.resolve_manifest_source(chain_id).await?;
590
591        info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
592        let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
593        eyre::ensure!(
594            manifest.chain_id == chain_id,
595            "Snapshot chain ID {} does not match selected chain ID {chain_id}",
596            manifest.chain_id
597        );
598        manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?);
599
600        info!(target: "reth::cli",
601            block = manifest.block,
602            chain_id = manifest.chain_id,
603            storage_version = %manifest.storage_version,
604            components = manifest.components.len(),
605            "Loaded snapshot manifest"
606        );
607
608        Ok(manifest)
609    }
610
611    /// Writes config and checkpoint state after all modular archives complete.
612    fn finalize_modular_download(
613        &self,
614        selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
615        manifest: &SnapshotManifest,
616        preset: Option<SelectionPreset>,
617        target_dir: &Path,
618        db_path: &Path,
619    ) -> Result<()> {
620        let config =
621            config_for_selections(selections, manifest, preset, Some(self.env.chain.as_ref()));
622        if write_config(&config, target_dir)? {
623            let desc = config_gen::describe_prune_config(&config);
624            info!(target: "reth::cli", "{}", desc.join(", "));
625        }
626
627        let db = init_db(db_path, self.env.db.database_args())?;
628        let should_write_prune = config.prune.segments != Default::default();
629        let should_reset_indices = should_reset_index_stage_checkpoints(selections);
630        if should_write_prune || should_reset_indices {
631            let tx = db.tx_mut()?;
632
633            if should_write_prune {
634                config_gen::write_prune_checkpoints_tx(&tx, &config, manifest.block)?;
635            }
636
637            if should_reset_indices {
638                config_gen::reset_index_stage_checkpoints_tx(&tx)?;
639            }
640
641            tx.commit()?;
642        }
643
644        let start_command = startup_node_command::<C>(self.env.chain.as_ref());
645        info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command);
646
647        Ok(())
648    }
649
650    /// Determines which components to download based on CLI flags or interactive selection.
651    fn resolve_components(&self, manifest: &SnapshotManifest) -> Result<ResolvedComponents> {
652        let available = |ty: SnapshotComponentType| manifest.component(ty).is_some();
653
654        // --archive/--all: everything available as All
655        if self.archive {
656            return Ok(ResolvedComponents {
657                selections: SnapshotComponentType::ALL
658                    .iter()
659                    .copied()
660                    .filter(|ty| available(*ty))
661                    .filter(|ty| {
662                        !self.without_rocksdb || *ty != SnapshotComponentType::RocksdbIndices
663                    })
664                    .map(|ty| (ty, ComponentSelection::All))
665                    .collect(),
666                preset: Some(SelectionPreset::Archive),
667            });
668        }
669
670        if self.full {
671            return Ok(ResolvedComponents {
672                selections: self.full_preset_selections(manifest),
673                preset: Some(SelectionPreset::Full),
674            });
675        }
676
677        if self.minimal {
678            return Ok(ResolvedComponents {
679                selections: self.minimal_preset_selections(manifest),
680                preset: Some(SelectionPreset::Minimal),
681            });
682        }
683
684        let has_explicit_flags = self.with_txs ||
685            self.with_txs_since.is_some() ||
686            self.with_txs_distance.is_some() ||
687            self.with_receipts ||
688            self.with_receipts_since.is_some() ||
689            self.with_receipts_distance.is_some() ||
690            self.with_state_history ||
691            self.with_state_history_since.is_some() ||
692            self.with_state_history_distance.is_some() ||
693            self.with_senders ||
694            self.with_rocksdb;
695
696        if has_explicit_flags {
697            let mut selections = BTreeMap::new();
698            let tx_selection = explicit_component_selection(
699                self.with_txs,
700                self.with_txs_since,
701                self.with_txs_distance,
702                manifest.block,
703            );
704            let receipt_selection = explicit_component_selection(
705                self.with_receipts,
706                self.with_receipts_since,
707                self.with_receipts_distance,
708                manifest.block,
709            );
710            let state_history_selection = explicit_component_selection(
711                self.with_state_history,
712                self.with_state_history_since,
713                self.with_state_history_distance,
714                manifest.block,
715            );
716
717            // Required components always All
718            if available(SnapshotComponentType::State) {
719                selections.insert(SnapshotComponentType::State, ComponentSelection::All);
720            }
721            if available(SnapshotComponentType::Headers) {
722                selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
723            }
724            if let Some(selection) = tx_selection &&
725                available(SnapshotComponentType::Transactions)
726            {
727                selections.insert(SnapshotComponentType::Transactions, selection);
728            }
729            if let Some(selection) = receipt_selection &&
730                available(SnapshotComponentType::Receipts)
731            {
732                selections.insert(SnapshotComponentType::Receipts, selection);
733            }
734            if let Some(selection) = state_history_selection {
735                if available(SnapshotComponentType::AccountChangesets) {
736                    selections.insert(SnapshotComponentType::AccountChangesets, selection);
737                }
738                if available(SnapshotComponentType::StorageChangesets) {
739                    selections.insert(SnapshotComponentType::StorageChangesets, selection);
740                }
741            }
742            if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
743                selections
744                    .insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
745            }
746            if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
747                selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
748            }
749            return Ok(ResolvedComponents { selections, preset: None });
750        }
751
752        if self.non_interactive {
753            return Ok(ResolvedComponents {
754                selections: self.minimal_preset_selections(manifest),
755                preset: Some(SelectionPreset::Minimal),
756            });
757        }
758
759        // Interactive TUI
760        let minimal_preset = self.minimal_preset_selections(manifest);
761        let full_preset = self.full_preset_selections(manifest);
762        let SelectorOutput { selections, preset } =
763            run_selector(manifest.clone(), &minimal_preset, &full_preset)?;
764        let selected =
765            selections.into_iter().filter(|(_, sel)| *sel != ComponentSelection::None).collect();
766
767        Ok(ResolvedComponents { selections: selected, preset })
768    }
769
770    /// Builds the default minimal component selection for the manifest.
771    fn minimal_preset_selections(
772        &self,
773        manifest: &SnapshotManifest,
774    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
775        self.pruning_preset_selections(manifest, SelectionPreset::Minimal)
776    }
777
778    /// Builds the default full-node component selection for the manifest.
779    fn full_preset_selections(
780        &self,
781        manifest: &SnapshotManifest,
782    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
783        self.pruning_preset_selections(manifest, SelectionPreset::Full)
784    }
785
786    /// Builds component selections from the configured pruning preset.
787    fn pruning_preset_selections(
788        &self,
789        manifest: &SnapshotManifest,
790        preset: SelectionPreset,
791    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
792        let defaults = DefaultPruningValues::get_global();
793        let (prune_modes, bodies_history_use_pre_merge) = match preset {
794            SelectionPreset::Minimal => (&defaults.minimal_prune_modes, false),
795            SelectionPreset::Full => {
796                (&defaults.full_prune_modes, defaults.full_bodies_history_use_pre_merge)
797            }
798            SelectionPreset::Archive => unreachable!("archive selects every component"),
799        };
800        let mut selections = BTreeMap::new();
801
802        for &ty in &SnapshotComponentType::ALL {
803            if manifest.component(ty).is_none() {
804                continue;
805            }
806
807            let selection = self.pruning_selection_for_component(
808                ty,
809                manifest.block,
810                prune_modes,
811                bodies_history_use_pre_merge,
812            );
813            if selection != ComponentSelection::None {
814                selections.insert(ty, selection);
815            }
816        }
817
818        selections
819    }
820
821    /// Returns the component selection for one configured pruning preset.
822    fn pruning_selection_for_component(
823        &self,
824        ty: SnapshotComponentType,
825        snapshot_block: u64,
826        prune_modes: &PruneModes,
827        bodies_history_use_pre_merge: bool,
828    ) -> ComponentSelection {
829        if ty == SnapshotComponentType::Transactions && bodies_history_use_pre_merge {
830            return match self
831                .env
832                .chain
833                .ethereum_fork_activation(EthereumHardfork::Paris)
834                .block_number()
835            {
836                Some(paris) if snapshot_block >= paris => ComponentSelection::Since(paris),
837                Some(_) => ComponentSelection::None,
838                None => ComponentSelection::All,
839            }
840        }
841
842        let mode = match ty {
843            SnapshotComponentType::State | SnapshotComponentType::Headers => {
844                return ComponentSelection::All
845            }
846            SnapshotComponentType::Transactions => prune_modes.bodies_history,
847            SnapshotComponentType::TransactionSenders => prune_modes.sender_recovery,
848            SnapshotComponentType::Receipts => prune_modes.receipts,
849            SnapshotComponentType::AccountChangesets => prune_modes.account_history,
850            SnapshotComponentType::StorageChangesets => prune_modes.storage_history,
851            SnapshotComponentType::RocksdbIndices => return ComponentSelection::None,
852        };
853
854        selection_from_prune_mode(mode, snapshot_block)
855    }
856
857    /// Resolves the manifest source from CLI input or snapshot discovery.
858    async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
859        if let Some(path) = &self.manifest_path {
860            return Ok(path.display().to_string());
861        }
862
863        match &self.manifest_url {
864            Some(url) => Ok(url.clone()),
865            None => {
866                let defaults = DownloadDefaults::get_global();
867                if defaults.mainnet_only_discovery() && chain_id != MAINNET.chain.id() {
868                    eyre::bail!(
869                        "Snapshots are only auto-discovered for Ethereum mainnet.\n\n\
870                         Chain {chain_id} requires an explicit source:\n\
871                         \t--manifest-url <URL>\n\
872                         \t--manifest-path <PATH>\n\
873                         \t-u <SNAPSHOT-URL>\n\n\
874                         Use --list to inspect snapshots exposed by {}.",
875                        defaults.snapshot_source_url(),
876                    );
877                }
878
879                discover_manifest_url(chain_id).await
880            }
881        }
882    }
883}
884
885/// Resolves explicit `--with-*` / `--with-*-since` / `--with-*-distance` flags
886/// into a component selection.
887fn explicit_component_selection(
888    all: bool,
889    since: Option<u64>,
890    distance: Option<u64>,
891    snapshot_block: u64,
892) -> Option<ComponentSelection> {
893    if all {
894        Some(ComponentSelection::All)
895    } else if let Some(block) = since {
896        (block <= snapshot_block).then_some(ComponentSelection::Since(block))
897    } else {
898        distance.map(ComponentSelection::Distance)
899    }
900}
901
902/// Converts a prune mode into the matching component selection.
903fn selection_from_prune_mode(mode: Option<PruneMode>, snapshot_block: u64) -> ComponentSelection {
904    match mode {
905        None => ComponentSelection::All,
906        Some(PruneMode::Full) => ComponentSelection::None,
907        Some(PruneMode::Distance(d)) => ComponentSelection::Distance(d),
908        Some(PruneMode::Before(block)) => {
909            if snapshot_block >= block {
910                ComponentSelection::Since(block)
911            } else {
912                ComponentSelection::None
913            }
914        }
915    }
916}
917
918/// Removes existing snapshot data that is managed by `reth download`.
919fn clear_existing_datadir(target_dir: &Path) -> Result<()> {
920    if !target_dir.try_exists()? {
921        return Ok(());
922    }
923
924    info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data");
925    for entry in FORCE_REMOVED_DATADIR_PATHS {
926        let path = target_dir.join(entry);
927        if !path.try_exists()? {
928            continue;
929        }
930
931        let metadata = fs::metadata(&path)?;
932        if metadata.is_dir() {
933            fs::remove_dir_all(&path)?;
934        } else if metadata.is_file() {
935            fs::remove_file(&path)?;
936        }
937    }
938
939    Ok(())
940}
941
942/// If all data components (txs, receipts, changesets) are `All`, automatically
943/// include hidden archive-only components when available in the manifest.
944fn inject_archive_only_components(
945    selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
946    manifest: &SnapshotManifest,
947    include_rocksdb: bool,
948) {
949    let is_all =
950        |ty: SnapshotComponentType| selections.get(&ty).copied() == Some(ComponentSelection::All);
951
952    let is_archive = is_all(SnapshotComponentType::Transactions) &&
953        is_all(SnapshotComponentType::Receipts) &&
954        is_all(SnapshotComponentType::AccountChangesets) &&
955        is_all(SnapshotComponentType::StorageChangesets);
956
957    if !is_archive {
958        return;
959    }
960
961    for component in
962        [SnapshotComponentType::TransactionSenders, SnapshotComponentType::RocksdbIndices]
963    {
964        if component == SnapshotComponentType::RocksdbIndices && !include_rocksdb {
965            continue;
966        }
967
968        if manifest.component(component).is_some() {
969            selections.insert(component, ComponentSelection::All);
970        }
971    }
972}
973
974/// Returns `true` when RocksDB-backed index stages should be reset after download.
975fn should_reset_index_stage_checkpoints(
976    selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
977) -> bool {
978    !matches!(selections.get(&SnapshotComponentType::RocksdbIndices), Some(ComponentSelection::All))
979}
980
981fn startup_node_command<C>(chain_spec: &C::ChainSpec) -> String
982where
983    C: ChainSpecParser,
984    C::ChainSpec: EthChainSpec,
985{
986    startup_node_command_for_binary::<C>(&current_binary_name(), chain_spec)
987}
988
989fn startup_node_command_for_binary<C>(binary_name: &str, chain_spec: &C::ChainSpec) -> String
990where
991    C: ChainSpecParser,
992    C::ChainSpec: EthChainSpec,
993{
994    let mut command = format!("{binary_name} node");
995
996    if let Some(chain_arg) = startup_chain_arg::<C>(chain_spec) {
997        command.push_str(" --chain ");
998        command.push_str(&chain_arg);
999    }
1000
1001    command
1002}
1003
1004fn current_binary_name() -> String {
1005    std::env::args_os()
1006        .next()
1007        .map(PathBuf::from)
1008        .and_then(|path| path.file_stem().map(|name| name.to_owned()))
1009        .and_then(|name| name.into_string().ok())
1010        .filter(|name| !name.is_empty())
1011        .unwrap_or_else(|| "reth".to_string())
1012}
1013
1014fn download_command() -> String {
1015    download_command_for_binary(&current_binary_name())
1016}
1017
1018fn download_command_for_binary(binary_name: &str) -> String {
1019    format!("{binary_name} download")
1020}
1021
1022fn startup_chain_arg<C>(chain_spec: &C::ChainSpec) -> Option<String>
1023where
1024    C: ChainSpecParser,
1025    C::ChainSpec: EthChainSpec,
1026{
1027    let current_chain = chain_spec.chain();
1028    let current_genesis_hash = chain_spec.genesis_hash();
1029    let default_chain = C::default_value().and_then(|chain_name| C::parse(chain_name).ok());
1030
1031    if default_chain.as_ref().is_some_and(|default_chain| {
1032        default_chain.chain() == current_chain &&
1033            default_chain.genesis_hash() == current_genesis_hash
1034    }) {
1035        return None;
1036    }
1037
1038    C::SUPPORTED_CHAINS
1039        .iter()
1040        .find_map(|chain_name| {
1041            let parsed_chain = C::parse(chain_name).ok()?;
1042            (parsed_chain.chain() == current_chain &&
1043                parsed_chain.genesis_hash() == current_genesis_hash)
1044                .then(|| (*chain_name).to_string())
1045        })
1046        .or_else(|| Some("<chain-or-chainspec>".to_string()))
1047}
1048
1049impl<C: ChainSpecParser> DownloadCommand<C> {
1050    /// Returns a reference to the environment arguments.
1051    pub const fn env(&self) -> &EnvironmentArgs<C> {
1052        &self.env
1053    }
1054
1055    /// Returns the underlying chain being used to run this command
1056    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
1057        Some(&self.env.chain)
1058    }
1059
1060    /// Returns whether this command should print its modular archive plan and exit.
1061    pub const fn prints_plan_json(&self) -> bool {
1062        self.print_plan_json
1063    }
1064}
1065
1066/// A modular snapshot download after manifest and data-directory resolution.
1067#[derive(Debug)]
1068pub struct PreparedSnapshotDownload {
1069    /// Manifest selected by the command, with a normalized `base_url`.
1070    pub manifest: SnapshotManifest,
1071    /// Chain-resolved directory where Reth installs the snapshot.
1072    pub data_dir: PathBuf,
1073}
1074
1075struct ResolvedDownload {
1076    manifest: SnapshotManifest,
1077    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
1078    preset: Option<SelectionPreset>,
1079    planned: PlannedDownloads,
1080}
1081
1082const MAX_DOWNLOAD_RETRIES: u32 = 10;
1083const RETRY_BACKOFF_SECS: u64 = 5;
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use clap::{Args, Parser};
1089    use extract::CompressionFormat;
1090    use manifest::{ComponentManifest, SingleArchive};
1091    use reth_chainspec::{HOLESKY, MAINNET};
1092    use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
1093
1094    #[derive(Parser)]
1095    struct CommandParser<T: Args> {
1096        #[command(flatten)]
1097        args: T,
1098    }
1099
1100    fn manifest_with_archive_only_components() -> SnapshotManifest {
1101        let mut components = BTreeMap::new();
1102        components.insert(
1103            SnapshotComponentType::TransactionSenders.key().to_string(),
1104            ComponentManifest::Single(SingleArchive {
1105                file: "transaction_senders.tar.zst".to_string(),
1106                size: 1,
1107                decompressed_size: 0,
1108                blake3: None,
1109                output_files: vec![],
1110            }),
1111        );
1112        components.insert(
1113            SnapshotComponentType::RocksdbIndices.key().to_string(),
1114            ComponentManifest::Single(SingleArchive {
1115                file: "rocksdb_indices.tar.zst".to_string(),
1116                size: 1,
1117                decompressed_size: 0,
1118                blake3: None,
1119                output_files: vec![],
1120            }),
1121        );
1122        SnapshotManifest {
1123            block: 0,
1124            chain_id: 1,
1125            storage_version: 2,
1126            timestamp: 0,
1127            base_url: Some("https://example.com".to_string()),
1128            reth_version: None,
1129            components,
1130            extensions: Default::default(),
1131        }
1132    }
1133
1134    #[test]
1135    fn test_download_defaults_builder() {
1136        let defaults = DownloadDefaults::default()
1137            .with_snapshot("https://example.com/snapshots (example)")
1138            .with_base_url("https://example.com");
1139
1140        assert_eq!(defaults.default_base_url, "https://example.com");
1141        assert_eq!(defaults.available_snapshots.len(), 3); // 2 defaults + 1 added
1142    }
1143
1144    #[test]
1145    fn test_download_defaults_replace_snapshots() {
1146        let defaults = DownloadDefaults::default().with_snapshots(vec![
1147            Cow::Borrowed("https://custom1.com"),
1148            Cow::Borrowed("https://custom2.com"),
1149        ]);
1150
1151        assert_eq!(defaults.available_snapshots.len(), 2);
1152        assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
1153    }
1154
1155    #[test]
1156    fn test_long_help_generation() {
1157        let defaults = DownloadDefaults::default();
1158        let help = defaults.long_help();
1159
1160        assert!(help.contains("Available snapshot sources:"));
1161        assert!(help.contains("Ethereum mainnet"));
1162        assert!(help.contains("snapshots.reth.rs"));
1163        assert!(help.contains("publicnode.com"));
1164        assert!(help.contains("file://"));
1165    }
1166
1167    #[test]
1168    fn test_custom_snapshot_api_keeps_selected_chain_help() {
1169        let defaults = DownloadDefaults::default()
1170            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots");
1171        let help = defaults.long_help();
1172
1173        assert_eq!(
1174            defaults.default_chain_aware_base_url.as_deref(),
1175            Some("https://snapshots.tempoxyz.dev")
1176        );
1177        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1178        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1179        assert!(help.contains("selected chain"));
1180        assert!(!help.contains("Ethereum mainnet"));
1181        assert!(!help.contains("snapshots.reth.rs"));
1182    }
1183
1184    #[test]
1185    fn test_snapshot_source_url_sets_generated_references() {
1186        let defaults =
1187            DownloadDefaults::default().with_snapshot_source_url("https://snapshots.tempoxyz.dev/");
1188        let help = defaults.long_help();
1189
1190        assert_eq!(defaults.snapshot_api_url, "https://snapshots.tempoxyz.dev/api/snapshots");
1191        assert_eq!(defaults.default_base_url, "https://snapshots.tempoxyz.dev");
1192        assert_eq!(
1193            defaults.default_chain_aware_base_url.as_deref(),
1194            Some("https://snapshots.tempoxyz.dev")
1195        );
1196        assert_eq!(
1197            defaults.available_snapshots.iter().map(|source| source.as_ref()).collect::<Vec<_>>(),
1198            vec!["https://snapshots.tempoxyz.dev (default)"]
1199        );
1200        assert!(!defaults.mainnet_only_discovery());
1201        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1202        assert!(help.contains("from https://snapshots.tempoxyz.dev."));
1203    }
1204
1205    #[test]
1206    fn test_snapshot_api_url_trailing_slash_sets_source_url() {
1207        let defaults = DownloadDefaults::default()
1208            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots/");
1209        let help = defaults.long_help();
1210
1211        assert_eq!(
1212            defaults.default_chain_aware_base_url.as_deref(),
1213            Some("https://snapshots.tempoxyz.dev")
1214        );
1215        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1216        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1217    }
1218
1219    #[test]
1220    fn test_long_help_override() {
1221        let custom_help = "This is custom help text for downloading snapshots.";
1222        let defaults = DownloadDefaults::default().with_long_help(custom_help);
1223
1224        let help = defaults.long_help();
1225        assert_eq!(help, custom_help);
1226        assert!(!help.contains("Available snapshot sources:"));
1227    }
1228
1229    #[test]
1230    fn test_builder_chaining() {
1231        let defaults = DownloadDefaults::default()
1232            .with_base_url("https://custom.example.com")
1233            .with_snapshot("https://snapshot1.com")
1234            .with_snapshot("https://snapshot2.com")
1235            .with_long_help("Custom help for snapshots");
1236
1237        assert_eq!(defaults.default_base_url, "https://custom.example.com");
1238        assert_eq!(defaults.available_snapshots.len(), 4); // 2 defaults + 2 added
1239        assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
1240    }
1241
1242    #[test]
1243    fn test_download_retry_backoff() {
1244        let parse = |args: Vec<&str>| {
1245            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from(args)
1246        };
1247        assert_eq!(parse(vec!["reth"]).unwrap().args.retry_backoff, None);
1248        for (value, expected) in [
1249            ("0ms", Duration::ZERO),
1250            ("250ms", Duration::from_millis(250)),
1251            ("2s", Duration::from_secs(2)),
1252        ] {
1253            assert_eq!(
1254                parse(vec!["reth", "--retry-backoff", value]).unwrap().args.retry_backoff,
1255                Some(expected)
1256            );
1257        }
1258        assert!(parse(vec!["reth", "--retry-backoff=-1s"]).is_err());
1259        assert!(parse(vec!["reth", "--retry-backoff", "invalid"]).is_err());
1260    }
1261
1262    #[test]
1263    fn test_download_resumable_defaults_to_true() {
1264        let args =
1265            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1266
1267        assert!(args.resumable);
1268    }
1269
1270    #[test]
1271    fn test_download_resumable_implicit_true() {
1272        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1273            "reth",
1274            "--resumable",
1275        ])
1276        .args;
1277
1278        assert!(args.resumable);
1279    }
1280
1281    #[test]
1282    fn test_download_resumable_explicit_false() {
1283        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1284            "reth",
1285            "--resumable=false",
1286        ])
1287        .args;
1288
1289        assert!(!args.resumable);
1290    }
1291
1292    #[test]
1293    fn test_download_print_plan_json_parses() {
1294        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1295            "reth",
1296            "--manifest-path",
1297            "manifest.json",
1298            "--minimal",
1299            "--print-plan-json",
1300        ])
1301        .args;
1302
1303        assert!(args.prints_plan_json());
1304    }
1305
1306    #[test]
1307    fn test_download_print_plan_json_rejects_single_archive() {
1308        let result = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from([
1309            "reth",
1310            "--url",
1311            "https://example.com/snapshot.tar.zst",
1312            "--print-plan-json",
1313        ]);
1314
1315        assert!(result.is_err());
1316    }
1317
1318    #[test]
1319    fn minimal_component_selection_uses_configured_prune_modes() {
1320        let args =
1321            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1322        let history_distance = 64_864;
1323        let modes = PruneModes {
1324            sender_recovery: Some(PruneMode::Full),
1325            receipts: Some(PruneMode::Distance(128)),
1326            account_history: Some(PruneMode::Distance(history_distance)),
1327            storage_history: Some(PruneMode::Distance(history_distance)),
1328            bodies_history: Some(PruneMode::Distance(history_distance)),
1329            ..Default::default()
1330        };
1331
1332        assert_eq!(
1333            args.pruning_selection_for_component(
1334                SnapshotComponentType::Transactions,
1335                1_000_000,
1336                &modes,
1337                false,
1338            ),
1339            ComponentSelection::Distance(history_distance)
1340        );
1341        assert_eq!(
1342            args.pruning_selection_for_component(
1343                SnapshotComponentType::Receipts,
1344                1_000_000,
1345                &modes,
1346                false,
1347            ),
1348            ComponentSelection::Distance(128)
1349        );
1350        assert_eq!(
1351            args.pruning_selection_for_component(
1352                SnapshotComponentType::AccountChangesets,
1353                1_000_000,
1354                &modes,
1355                false,
1356            ),
1357            ComponentSelection::Distance(history_distance)
1358        );
1359        assert_eq!(
1360            args.pruning_selection_for_component(
1361                SnapshotComponentType::StorageChangesets,
1362                1_000_000,
1363                &modes,
1364                false,
1365            ),
1366            ComponentSelection::Distance(history_distance)
1367        );
1368        assert_eq!(
1369            args.pruning_selection_for_component(
1370                SnapshotComponentType::TransactionSenders,
1371                1_000_000,
1372                &modes,
1373                false,
1374            ),
1375            ComponentSelection::None
1376        );
1377    }
1378
1379    #[test]
1380    fn resolve_manifest_source_requires_explicit_source_for_non_mainnet_defaults() {
1381        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1382            "reth", "--chain", "holesky",
1383        ])
1384        .args;
1385
1386        let err = tokio::runtime::Runtime::new()
1387            .unwrap()
1388            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1389            .unwrap_err();
1390
1391        let message = err.to_string();
1392        assert!(message.contains("only auto-discovered for Ethereum mainnet"));
1393        assert!(message.contains("--manifest-url <URL>"));
1394        assert!(message.contains("-u <SNAPSHOT-URL>"));
1395    }
1396
1397    #[test]
1398    fn resolve_manifest_source_allows_manifest_path_for_non_mainnet_defaults() {
1399        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1400            "reth",
1401            "--chain",
1402            "holesky",
1403            "--manifest-path",
1404            "./manifest.json",
1405        ])
1406        .args;
1407
1408        let source = tokio::runtime::Runtime::new()
1409            .unwrap()
1410            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1411            .unwrap();
1412
1413        assert_eq!(source, "./manifest.json");
1414    }
1415
1416    #[test]
1417    fn test_compression_format_detection() {
1418        assert!(matches!(
1419            CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
1420            Ok(CompressionFormat::Lz4)
1421        ));
1422        assert!(matches!(
1423            CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
1424            Ok(CompressionFormat::Zstd)
1425        ));
1426        assert!(matches!(
1427            CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
1428            Ok(CompressionFormat::Lz4)
1429        ));
1430        assert!(matches!(
1431            CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
1432            Ok(CompressionFormat::Zstd)
1433        ));
1434        assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
1435    }
1436
1437    #[test]
1438    fn inject_archive_only_components_for_archive_selection() {
1439        let manifest = manifest_with_archive_only_components();
1440        let mut selections = BTreeMap::new();
1441        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1442        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1443        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1444        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1445
1446        inject_archive_only_components(&mut selections, &manifest, true);
1447
1448        assert_eq!(
1449            selections.get(&SnapshotComponentType::TransactionSenders),
1450            Some(&ComponentSelection::All)
1451        );
1452        assert_eq!(
1453            selections.get(&SnapshotComponentType::RocksdbIndices),
1454            Some(&ComponentSelection::All)
1455        );
1456    }
1457
1458    #[test]
1459    fn inject_archive_only_components_without_rocksdb() {
1460        let manifest = manifest_with_archive_only_components();
1461        let mut selections = BTreeMap::new();
1462        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1463        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1464        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1465        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1466
1467        inject_archive_only_components(&mut selections, &manifest, false);
1468
1469        assert_eq!(
1470            selections.get(&SnapshotComponentType::TransactionSenders),
1471            Some(&ComponentSelection::All)
1472        );
1473        assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None);
1474    }
1475
1476    #[test]
1477    fn should_reset_index_stage_checkpoints_without_rocksdb_indices() {
1478        let mut selections = BTreeMap::new();
1479        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1480        assert!(should_reset_index_stage_checkpoints(&selections));
1481
1482        selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1483        assert!(!should_reset_index_stage_checkpoints(&selections));
1484    }
1485
1486    #[test]
1487    fn startup_node_command_omits_default_chain_arg() {
1488        let command =
1489            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", MAINNET.as_ref());
1490
1491        assert_eq!(command, "reth node");
1492    }
1493
1494    #[test]
1495    fn startup_node_command_includes_non_default_chain_arg() {
1496        let command =
1497            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", HOLESKY.as_ref());
1498
1499        assert_eq!(command, "reth node --chain holesky");
1500    }
1501
1502    #[test]
1503    fn startup_node_command_uses_running_binary_name() {
1504        let command =
1505            startup_node_command_for_binary::<EthereumChainSpecParser>("tempo", HOLESKY.as_ref());
1506
1507        assert_eq!(command, "tempo node --chain holesky");
1508    }
1509
1510    #[test]
1511    fn download_command_uses_binary_name() {
1512        assert_eq!(download_command_for_binary("tempo"), "tempo download");
1513    }
1514}