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        let data_dir = self.env.datadir.clone().resolve_datadir(chain);
479        let static_files_dir = data_dir.static_files();
480        let static_files_dir = (static_files_dir != data_dir.data_dir().join("static_files"))
481            .then_some(static_files_dir);
482
483        // Legacy single-URL mode: download one archive and extract it
484        if let Some(ref url) = self.url {
485            let cancel_token = CancellationToken::new();
486            let _cancel_guard = cancel_token.drop_guard();
487            let data_dir = self.env.datadir.clone().resolve_datadir(chain);
488            let target_dir = data_dir.data_dir();
489            if self.force {
490                clear_existing_datadir(target_dir, static_files_dir.as_deref())?;
491            }
492            fs::create_dir_all(target_dir)?;
493
494            let request_limiter = DownloadRequestLimiter::new(self.download_concurrency.max(1));
495            info!(target: "reth::cli",
496                dir = ?data_dir.data_dir(),
497                url = %url,
498                "Starting snapshot download and extraction"
499            );
500
501            stream_and_extract(
502                url,
503                data_dir.data_dir(),
504                static_files_dir.as_deref(),
505                self.resumable,
506                Some(request_limiter),
507                cancel_token.clone(),
508                self.retry_backoff,
509            )
510            .await?;
511            info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
512
513            return Ok(None);
514        }
515
516        let ResolvedDownload { manifest, selections, preset, planned } =
517            self.resolve_download(chain.id()).await?;
518        let data_dir = self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf();
519        let prepared = PreparedSnapshotDownload { manifest, data_dir };
520        if self.print_plan_json {
521            DownloadPlan::from_planned(&prepared.manifest, &planned)
522                .write_json(std::io::stdout().lock())?;
523            return Ok(Some(prepared))
524        }
525
526        let target_dir = prepared.data_dir.as_path();
527        let cancel_token = CancellationToken::new();
528        let _cancel_guard = cancel_token.drop_guard();
529        if self.force {
530            clear_existing_datadir(target_dir, static_files_dir.as_deref())?;
531        }
532        fs::create_dir_all(target_dir)?;
533        let startup_summary =
534            summarize_download_startup(&planned.archives, target_dir, static_files_dir.as_deref())?;
535        info!(target: "reth::cli",
536            reusable = startup_summary.reusable,
537            needs_download = startup_summary.needs_download,
538            "Startup integrity summary (plain output files)"
539        );
540
541        info!(target: "reth::cli",
542            archives = planned.total_archives(),
543            download_total = %DownloadProgress::format_size(planned.total_download_size),
544            output_total = %DownloadProgress::format_size(planned.total_output_size),
545            "Downloading all archives"
546        );
547
548        run_modular_downloads(
549            planned,
550            target_dir,
551            static_files_dir.as_deref(),
552            self.download_concurrency.max(1),
553            cancel_token.clone(),
554            self.retry_backoff,
555        )
556        .await?;
557
558        self.finalize_modular_download(
559            &selections,
560            &prepared.manifest,
561            preset,
562            target_dir,
563            &target_dir.join("db"),
564        )?;
565
566        Ok(Some(prepared))
567    }
568
569    /// Resolves the exact modular archive plan and manifest context without downloading or
570    /// modifying the data dir.
571    pub async fn plan(&self) -> Result<(DownloadPlan, PreparedSnapshotDownload)> {
572        let chain = self.env.chain.chain();
573        let resolved = self.resolve_download(chain.id()).await?;
574        let plan = DownloadPlan::from_planned(&resolved.manifest, &resolved.planned);
575        let prepared = PreparedSnapshotDownload {
576            manifest: resolved.manifest,
577            data_dir: self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf(),
578        };
579        Ok((plan, prepared))
580    }
581
582    async fn resolve_download(&self, chain_id: u64) -> Result<ResolvedDownload> {
583        let manifest = self.load_manifest(chain_id).await?;
584        let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?;
585
586        if matches!(preset, Some(SelectionPreset::Archive)) {
587            inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb);
588        }
589
590        let planned = collect_planned_archives(&manifest, &selections)?;
591        Ok(ResolvedDownload { manifest, selections, preset, planned })
592    }
593
594    /// Loads the manifest and resolves its effective base URL.
595    async fn load_manifest(&self, chain_id: u64) -> Result<SnapshotManifest> {
596        let manifest_source = self.resolve_manifest_source(chain_id).await?;
597
598        info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
599        let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
600        eyre::ensure!(
601            manifest.chain_id == chain_id,
602            "Snapshot chain ID {} does not match selected chain ID {chain_id}",
603            manifest.chain_id
604        );
605        manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?);
606
607        info!(target: "reth::cli",
608            block = manifest.block,
609            chain_id = manifest.chain_id,
610            storage_version = %manifest.storage_version,
611            components = manifest.components.len(),
612            "Loaded snapshot manifest"
613        );
614
615        Ok(manifest)
616    }
617
618    /// Writes config and checkpoint state after all modular archives complete.
619    fn finalize_modular_download(
620        &self,
621        selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
622        manifest: &SnapshotManifest,
623        preset: Option<SelectionPreset>,
624        target_dir: &Path,
625        db_path: &Path,
626    ) -> Result<()> {
627        let config =
628            config_for_selections(selections, manifest, preset, Some(self.env.chain.as_ref()));
629        if write_config(&config, target_dir)? {
630            let desc = config_gen::describe_prune_config(&config);
631            info!(target: "reth::cli", "{}", desc.join(", "));
632        }
633
634        let db = init_db(db_path, self.env.db.database_args())?;
635        let should_write_prune = config.prune.segments != Default::default();
636        let should_reset_indices = should_reset_index_stage_checkpoints(selections);
637        if should_write_prune || should_reset_indices {
638            let tx = db.tx_mut()?;
639
640            if should_write_prune {
641                config_gen::write_prune_checkpoints_tx(&tx, &config, manifest.block)?;
642            }
643
644            if should_reset_indices {
645                config_gen::reset_index_stage_checkpoints_tx(&tx)?;
646            }
647
648            tx.commit()?;
649        }
650
651        let start_command = startup_node_command::<C>(self.env.chain.as_ref());
652        info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command);
653
654        Ok(())
655    }
656
657    /// Determines which components to download based on CLI flags or interactive selection.
658    fn resolve_components(&self, manifest: &SnapshotManifest) -> Result<ResolvedComponents> {
659        let available = |ty: SnapshotComponentType| manifest.component(ty).is_some();
660
661        // --archive/--all: everything available as All
662        if self.archive {
663            return Ok(ResolvedComponents {
664                selections: SnapshotComponentType::ALL
665                    .iter()
666                    .copied()
667                    .filter(|ty| available(*ty))
668                    .filter(|ty| {
669                        !self.without_rocksdb || *ty != SnapshotComponentType::RocksdbIndices
670                    })
671                    .map(|ty| (ty, ComponentSelection::All))
672                    .collect(),
673                preset: Some(SelectionPreset::Archive),
674            });
675        }
676
677        if self.full {
678            return Ok(ResolvedComponents {
679                selections: self.full_preset_selections(manifest),
680                preset: Some(SelectionPreset::Full),
681            });
682        }
683
684        if self.minimal {
685            return Ok(ResolvedComponents {
686                selections: self.minimal_preset_selections(manifest),
687                preset: Some(SelectionPreset::Minimal),
688            });
689        }
690
691        let has_explicit_flags = self.with_txs ||
692            self.with_txs_since.is_some() ||
693            self.with_txs_distance.is_some() ||
694            self.with_receipts ||
695            self.with_receipts_since.is_some() ||
696            self.with_receipts_distance.is_some() ||
697            self.with_state_history ||
698            self.with_state_history_since.is_some() ||
699            self.with_state_history_distance.is_some() ||
700            self.with_senders ||
701            self.with_rocksdb;
702
703        if has_explicit_flags {
704            let mut selections = BTreeMap::new();
705            let tx_selection = explicit_component_selection(
706                self.with_txs,
707                self.with_txs_since,
708                self.with_txs_distance,
709                manifest.block,
710            );
711            let receipt_selection = explicit_component_selection(
712                self.with_receipts,
713                self.with_receipts_since,
714                self.with_receipts_distance,
715                manifest.block,
716            );
717            let state_history_selection = explicit_component_selection(
718                self.with_state_history,
719                self.with_state_history_since,
720                self.with_state_history_distance,
721                manifest.block,
722            );
723
724            // Required components always All
725            if available(SnapshotComponentType::State) {
726                selections.insert(SnapshotComponentType::State, ComponentSelection::All);
727            }
728            if available(SnapshotComponentType::Headers) {
729                selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
730            }
731            if let Some(selection) = tx_selection &&
732                available(SnapshotComponentType::Transactions)
733            {
734                selections.insert(SnapshotComponentType::Transactions, selection);
735            }
736            if let Some(selection) = receipt_selection &&
737                available(SnapshotComponentType::Receipts)
738            {
739                selections.insert(SnapshotComponentType::Receipts, selection);
740            }
741            if let Some(selection) = state_history_selection {
742                if available(SnapshotComponentType::AccountChangesets) {
743                    selections.insert(SnapshotComponentType::AccountChangesets, selection);
744                }
745                if available(SnapshotComponentType::StorageChangesets) {
746                    selections.insert(SnapshotComponentType::StorageChangesets, selection);
747                }
748            }
749            if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
750                selections
751                    .insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
752            }
753            if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
754                selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
755            }
756            return Ok(ResolvedComponents { selections, preset: None });
757        }
758
759        if self.non_interactive {
760            return Ok(ResolvedComponents {
761                selections: self.minimal_preset_selections(manifest),
762                preset: Some(SelectionPreset::Minimal),
763            });
764        }
765
766        // Interactive TUI
767        let minimal_preset = self.minimal_preset_selections(manifest);
768        let full_preset = self.full_preset_selections(manifest);
769        let SelectorOutput { selections, preset } =
770            run_selector(manifest.clone(), &minimal_preset, &full_preset)?;
771        let selected =
772            selections.into_iter().filter(|(_, sel)| *sel != ComponentSelection::None).collect();
773
774        Ok(ResolvedComponents { selections: selected, preset })
775    }
776
777    /// Builds the default minimal component selection for the manifest.
778    fn minimal_preset_selections(
779        &self,
780        manifest: &SnapshotManifest,
781    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
782        self.pruning_preset_selections(manifest, SelectionPreset::Minimal)
783    }
784
785    /// Builds the default full-node component selection for the manifest.
786    fn full_preset_selections(
787        &self,
788        manifest: &SnapshotManifest,
789    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
790        self.pruning_preset_selections(manifest, SelectionPreset::Full)
791    }
792
793    /// Builds component selections from the configured pruning preset.
794    fn pruning_preset_selections(
795        &self,
796        manifest: &SnapshotManifest,
797        preset: SelectionPreset,
798    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
799        let defaults = DefaultPruningValues::get_global();
800        let (prune_modes, bodies_history_use_pre_merge) = match preset {
801            SelectionPreset::Minimal => (&defaults.minimal_prune_modes, false),
802            SelectionPreset::Full => {
803                (&defaults.full_prune_modes, defaults.full_bodies_history_use_pre_merge)
804            }
805            SelectionPreset::Archive => unreachable!("archive selects every component"),
806        };
807        let mut selections = BTreeMap::new();
808
809        for &ty in &SnapshotComponentType::ALL {
810            if manifest.component(ty).is_none() {
811                continue;
812            }
813
814            let selection = self.pruning_selection_for_component(
815                ty,
816                manifest.block,
817                prune_modes,
818                bodies_history_use_pre_merge,
819            );
820            if selection != ComponentSelection::None {
821                selections.insert(ty, selection);
822            }
823        }
824
825        selections
826    }
827
828    /// Returns the component selection for one configured pruning preset.
829    fn pruning_selection_for_component(
830        &self,
831        ty: SnapshotComponentType,
832        snapshot_block: u64,
833        prune_modes: &PruneModes,
834        bodies_history_use_pre_merge: bool,
835    ) -> ComponentSelection {
836        if ty == SnapshotComponentType::Transactions && bodies_history_use_pre_merge {
837            return match self
838                .env
839                .chain
840                .ethereum_fork_activation(EthereumHardfork::Paris)
841                .block_number()
842            {
843                Some(paris) if snapshot_block >= paris => ComponentSelection::Since(paris),
844                Some(_) => ComponentSelection::None,
845                None => ComponentSelection::All,
846            }
847        }
848
849        let mode = match ty {
850            SnapshotComponentType::State | SnapshotComponentType::Headers => {
851                return ComponentSelection::All
852            }
853            SnapshotComponentType::Transactions => prune_modes.bodies_history,
854            SnapshotComponentType::TransactionSenders => prune_modes.sender_recovery,
855            SnapshotComponentType::Receipts => prune_modes.receipts,
856            SnapshotComponentType::AccountChangesets => prune_modes.account_history,
857            SnapshotComponentType::StorageChangesets => prune_modes.storage_history,
858            SnapshotComponentType::RocksdbIndices => return ComponentSelection::None,
859        };
860
861        selection_from_prune_mode(mode, snapshot_block)
862    }
863
864    /// Resolves the manifest source from CLI input or snapshot discovery.
865    async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
866        if let Some(path) = &self.manifest_path {
867            return Ok(path.display().to_string());
868        }
869
870        match &self.manifest_url {
871            Some(url) => Ok(url.clone()),
872            None => {
873                let defaults = DownloadDefaults::get_global();
874                if defaults.mainnet_only_discovery() && chain_id != MAINNET.chain.id() {
875                    eyre::bail!(
876                        "Snapshots are only auto-discovered for Ethereum mainnet.\n\n\
877                         Chain {chain_id} requires an explicit source:\n\
878                         \t--manifest-url <URL>\n\
879                         \t--manifest-path <PATH>\n\
880                         \t-u <SNAPSHOT-URL>\n\n\
881                         Use --list to inspect snapshots exposed by {}.",
882                        defaults.snapshot_source_url(),
883                    );
884                }
885
886                discover_manifest_url(chain_id).await
887            }
888        }
889    }
890}
891
892/// Resolves explicit `--with-*` / `--with-*-since` / `--with-*-distance` flags
893/// into a component selection.
894fn explicit_component_selection(
895    all: bool,
896    since: Option<u64>,
897    distance: Option<u64>,
898    snapshot_block: u64,
899) -> Option<ComponentSelection> {
900    if all {
901        Some(ComponentSelection::All)
902    } else if let Some(block) = since {
903        (block <= snapshot_block).then_some(ComponentSelection::Since(block))
904    } else {
905        distance.map(ComponentSelection::Distance)
906    }
907}
908
909/// Converts a prune mode into the matching component selection.
910fn selection_from_prune_mode(mode: Option<PruneMode>, snapshot_block: u64) -> ComponentSelection {
911    match mode {
912        None => ComponentSelection::All,
913        Some(PruneMode::Full) => ComponentSelection::None,
914        Some(PruneMode::Distance(d)) => ComponentSelection::Distance(d),
915        Some(PruneMode::Before(block)) => {
916            if snapshot_block >= block {
917                ComponentSelection::Since(block)
918            } else {
919                ComponentSelection::None
920            }
921        }
922    }
923}
924
925/// Removes existing snapshot data that is managed by `reth download`.
926fn clear_existing_datadir(target_dir: &Path, static_files_dir: Option<&Path>) -> Result<()> {
927    info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data");
928    for entry in FORCE_REMOVED_DATADIR_PATHS {
929        let path = if *entry == "static_files" {
930            static_files_dir.map_or_else(|| target_dir.join(entry), Path::to_path_buf)
931        } else {
932            target_dir.join(entry)
933        };
934        if !path.try_exists()? {
935            continue;
936        }
937
938        let metadata = fs::metadata(&path)?;
939        if metadata.is_dir() {
940            fs::remove_dir_all(&path)?;
941        } else if metadata.is_file() {
942            fs::remove_file(&path)?;
943        }
944    }
945
946    Ok(())
947}
948
949/// If all data components (txs, receipts, changesets) are `All`, automatically
950/// include hidden archive-only components when available in the manifest.
951fn inject_archive_only_components(
952    selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
953    manifest: &SnapshotManifest,
954    include_rocksdb: bool,
955) {
956    let is_all =
957        |ty: SnapshotComponentType| selections.get(&ty).copied() == Some(ComponentSelection::All);
958
959    let is_archive = is_all(SnapshotComponentType::Transactions) &&
960        is_all(SnapshotComponentType::Receipts) &&
961        is_all(SnapshotComponentType::AccountChangesets) &&
962        is_all(SnapshotComponentType::StorageChangesets);
963
964    if !is_archive {
965        return;
966    }
967
968    for component in
969        [SnapshotComponentType::TransactionSenders, SnapshotComponentType::RocksdbIndices]
970    {
971        if component == SnapshotComponentType::RocksdbIndices && !include_rocksdb {
972            continue;
973        }
974
975        if manifest.component(component).is_some() {
976            selections.insert(component, ComponentSelection::All);
977        }
978    }
979}
980
981/// Returns `true` when RocksDB-backed index stages should be reset after download.
982fn should_reset_index_stage_checkpoints(
983    selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
984) -> bool {
985    !matches!(selections.get(&SnapshotComponentType::RocksdbIndices), Some(ComponentSelection::All))
986}
987
988fn startup_node_command<C>(chain_spec: &C::ChainSpec) -> String
989where
990    C: ChainSpecParser,
991    C::ChainSpec: EthChainSpec,
992{
993    startup_node_command_for_binary::<C>(&current_binary_name(), chain_spec)
994}
995
996fn startup_node_command_for_binary<C>(binary_name: &str, chain_spec: &C::ChainSpec) -> String
997where
998    C: ChainSpecParser,
999    C::ChainSpec: EthChainSpec,
1000{
1001    let mut command = format!("{binary_name} node");
1002
1003    if let Some(chain_arg) = startup_chain_arg::<C>(chain_spec) {
1004        command.push_str(" --chain ");
1005        command.push_str(&chain_arg);
1006    }
1007
1008    command
1009}
1010
1011fn current_binary_name() -> String {
1012    std::env::args_os()
1013        .next()
1014        .map(PathBuf::from)
1015        .and_then(|path| path.file_stem().map(|name| name.to_owned()))
1016        .and_then(|name| name.into_string().ok())
1017        .filter(|name| !name.is_empty())
1018        .unwrap_or_else(|| "reth".to_string())
1019}
1020
1021fn download_command() -> String {
1022    download_command_for_binary(&current_binary_name())
1023}
1024
1025fn download_command_for_binary(binary_name: &str) -> String {
1026    format!("{binary_name} download")
1027}
1028
1029fn startup_chain_arg<C>(chain_spec: &C::ChainSpec) -> Option<String>
1030where
1031    C: ChainSpecParser,
1032    C::ChainSpec: EthChainSpec,
1033{
1034    let current_chain = chain_spec.chain();
1035    let current_genesis_hash = chain_spec.genesis_hash();
1036    let default_chain = C::default_value().and_then(|chain_name| C::parse(chain_name).ok());
1037
1038    if default_chain.as_ref().is_some_and(|default_chain| {
1039        default_chain.chain() == current_chain &&
1040            default_chain.genesis_hash() == current_genesis_hash
1041    }) {
1042        return None;
1043    }
1044
1045    C::SUPPORTED_CHAINS
1046        .iter()
1047        .find_map(|chain_name| {
1048            let parsed_chain = C::parse(chain_name).ok()?;
1049            (parsed_chain.chain() == current_chain &&
1050                parsed_chain.genesis_hash() == current_genesis_hash)
1051                .then(|| (*chain_name).to_string())
1052        })
1053        .or_else(|| Some("<chain-or-chainspec>".to_string()))
1054}
1055
1056impl<C: ChainSpecParser> DownloadCommand<C> {
1057    /// Returns a reference to the environment arguments.
1058    pub const fn env(&self) -> &EnvironmentArgs<C> {
1059        &self.env
1060    }
1061
1062    /// Returns the underlying chain being used to run this command
1063    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
1064        Some(&self.env.chain)
1065    }
1066
1067    /// Returns whether this command should print its modular archive plan and exit.
1068    pub const fn prints_plan_json(&self) -> bool {
1069        self.print_plan_json
1070    }
1071}
1072
1073/// A modular snapshot download after manifest and data-directory resolution.
1074#[derive(Debug)]
1075pub struct PreparedSnapshotDownload {
1076    /// Manifest selected by the command, with a normalized `base_url`.
1077    pub manifest: SnapshotManifest,
1078    /// Chain-resolved directory where Reth installs the snapshot.
1079    pub data_dir: PathBuf,
1080}
1081
1082struct ResolvedDownload {
1083    manifest: SnapshotManifest,
1084    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
1085    preset: Option<SelectionPreset>,
1086    planned: PlannedDownloads,
1087}
1088
1089const MAX_DOWNLOAD_RETRIES: u32 = 10;
1090const RETRY_BACKOFF_SECS: u64 = 5;
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use clap::{Args, Parser};
1096    use extract::CompressionFormat;
1097    use manifest::{ComponentManifest, SingleArchive};
1098    use reth_chainspec::{HOLESKY, MAINNET};
1099    use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
1100
1101    #[derive(Parser)]
1102    struct CommandParser<T: Args> {
1103        #[command(flatten)]
1104        args: T,
1105    }
1106
1107    fn manifest_with_archive_only_components() -> SnapshotManifest {
1108        let mut components = BTreeMap::new();
1109        components.insert(
1110            SnapshotComponentType::TransactionSenders.key().to_string(),
1111            ComponentManifest::Single(SingleArchive {
1112                file: "transaction_senders.tar.zst".to_string(),
1113                size: 1,
1114                decompressed_size: 0,
1115                blake3: None,
1116                output_files: vec![],
1117            }),
1118        );
1119        components.insert(
1120            SnapshotComponentType::RocksdbIndices.key().to_string(),
1121            ComponentManifest::Single(SingleArchive {
1122                file: "rocksdb_indices.tar.zst".to_string(),
1123                size: 1,
1124                decompressed_size: 0,
1125                blake3: None,
1126                output_files: vec![],
1127            }),
1128        );
1129        SnapshotManifest {
1130            block: 0,
1131            chain_id: 1,
1132            storage_version: 2,
1133            timestamp: 0,
1134            base_url: Some("https://example.com".to_string()),
1135            reth_version: None,
1136            components,
1137            extensions: Default::default(),
1138        }
1139    }
1140
1141    #[test]
1142    fn test_download_defaults_builder() {
1143        let defaults = DownloadDefaults::default()
1144            .with_snapshot("https://example.com/snapshots (example)")
1145            .with_base_url("https://example.com");
1146
1147        assert_eq!(defaults.default_base_url, "https://example.com");
1148        assert_eq!(defaults.available_snapshots.len(), 3); // 2 defaults + 1 added
1149    }
1150
1151    #[test]
1152    fn test_download_defaults_replace_snapshots() {
1153        let defaults = DownloadDefaults::default().with_snapshots(vec![
1154            Cow::Borrowed("https://custom1.com"),
1155            Cow::Borrowed("https://custom2.com"),
1156        ]);
1157
1158        assert_eq!(defaults.available_snapshots.len(), 2);
1159        assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
1160    }
1161
1162    #[test]
1163    fn test_long_help_generation() {
1164        let defaults = DownloadDefaults::default();
1165        let help = defaults.long_help();
1166
1167        assert!(help.contains("Available snapshot sources:"));
1168        assert!(help.contains("Ethereum mainnet"));
1169        assert!(help.contains("snapshots.reth.rs"));
1170        assert!(help.contains("publicnode.com"));
1171        assert!(help.contains("file://"));
1172    }
1173
1174    #[test]
1175    fn test_custom_snapshot_api_keeps_selected_chain_help() {
1176        let defaults = DownloadDefaults::default()
1177            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots");
1178        let help = defaults.long_help();
1179
1180        assert_eq!(
1181            defaults.default_chain_aware_base_url.as_deref(),
1182            Some("https://snapshots.tempoxyz.dev")
1183        );
1184        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1185        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1186        assert!(help.contains("selected chain"));
1187        assert!(!help.contains("Ethereum mainnet"));
1188        assert!(!help.contains("snapshots.reth.rs"));
1189    }
1190
1191    #[test]
1192    fn test_snapshot_source_url_sets_generated_references() {
1193        let defaults =
1194            DownloadDefaults::default().with_snapshot_source_url("https://snapshots.tempoxyz.dev/");
1195        let help = defaults.long_help();
1196
1197        assert_eq!(defaults.snapshot_api_url, "https://snapshots.tempoxyz.dev/api/snapshots");
1198        assert_eq!(defaults.default_base_url, "https://snapshots.tempoxyz.dev");
1199        assert_eq!(
1200            defaults.default_chain_aware_base_url.as_deref(),
1201            Some("https://snapshots.tempoxyz.dev")
1202        );
1203        assert_eq!(
1204            defaults.available_snapshots.iter().map(|source| source.as_ref()).collect::<Vec<_>>(),
1205            vec!["https://snapshots.tempoxyz.dev (default)"]
1206        );
1207        assert!(!defaults.mainnet_only_discovery());
1208        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1209        assert!(help.contains("from https://snapshots.tempoxyz.dev."));
1210    }
1211
1212    #[test]
1213    fn test_snapshot_api_url_trailing_slash_sets_source_url() {
1214        let defaults = DownloadDefaults::default()
1215            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots/");
1216        let help = defaults.long_help();
1217
1218        assert_eq!(
1219            defaults.default_chain_aware_base_url.as_deref(),
1220            Some("https://snapshots.tempoxyz.dev")
1221        );
1222        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1223        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1224    }
1225
1226    #[test]
1227    fn test_long_help_override() {
1228        let custom_help = "This is custom help text for downloading snapshots.";
1229        let defaults = DownloadDefaults::default().with_long_help(custom_help);
1230
1231        let help = defaults.long_help();
1232        assert_eq!(help, custom_help);
1233        assert!(!help.contains("Available snapshot sources:"));
1234    }
1235
1236    #[test]
1237    fn test_builder_chaining() {
1238        let defaults = DownloadDefaults::default()
1239            .with_base_url("https://custom.example.com")
1240            .with_snapshot("https://snapshot1.com")
1241            .with_snapshot("https://snapshot2.com")
1242            .with_long_help("Custom help for snapshots");
1243
1244        assert_eq!(defaults.default_base_url, "https://custom.example.com");
1245        assert_eq!(defaults.available_snapshots.len(), 4); // 2 defaults + 2 added
1246        assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
1247    }
1248
1249    #[test]
1250    fn test_download_retry_backoff() {
1251        let parse = |args: Vec<&str>| {
1252            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from(args)
1253        };
1254        assert_eq!(parse(vec!["reth"]).unwrap().args.retry_backoff, None);
1255        for (value, expected) in [
1256            ("0ms", Duration::ZERO),
1257            ("250ms", Duration::from_millis(250)),
1258            ("2s", Duration::from_secs(2)),
1259        ] {
1260            assert_eq!(
1261                parse(vec!["reth", "--retry-backoff", value]).unwrap().args.retry_backoff,
1262                Some(expected)
1263            );
1264        }
1265        assert!(parse(vec!["reth", "--retry-backoff=-1s"]).is_err());
1266        assert!(parse(vec!["reth", "--retry-backoff", "invalid"]).is_err());
1267    }
1268
1269    #[test]
1270    fn test_download_resumable_defaults_to_true() {
1271        let args =
1272            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1273
1274        assert!(args.resumable);
1275    }
1276
1277    #[test]
1278    fn test_download_resumable_implicit_true() {
1279        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1280            "reth",
1281            "--resumable",
1282        ])
1283        .args;
1284
1285        assert!(args.resumable);
1286    }
1287
1288    #[test]
1289    fn test_download_resumable_explicit_false() {
1290        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1291            "reth",
1292            "--resumable=false",
1293        ])
1294        .args;
1295
1296        assert!(!args.resumable);
1297    }
1298
1299    #[test]
1300    fn test_download_print_plan_json_parses() {
1301        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1302            "reth",
1303            "--manifest-path",
1304            "manifest.json",
1305            "--minimal",
1306            "--print-plan-json",
1307        ])
1308        .args;
1309
1310        assert!(args.prints_plan_json());
1311    }
1312
1313    #[test]
1314    fn test_download_print_plan_json_rejects_single_archive() {
1315        let result = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from([
1316            "reth",
1317            "--url",
1318            "https://example.com/snapshot.tar.zst",
1319            "--print-plan-json",
1320        ]);
1321
1322        assert!(result.is_err());
1323    }
1324
1325    #[test]
1326    fn minimal_component_selection_uses_configured_prune_modes() {
1327        let args =
1328            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1329        let history_distance = 64_864;
1330        let modes = PruneModes {
1331            sender_recovery: Some(PruneMode::Full),
1332            receipts: Some(PruneMode::Distance(128)),
1333            account_history: Some(PruneMode::Distance(history_distance)),
1334            storage_history: Some(PruneMode::Distance(history_distance)),
1335            bodies_history: Some(PruneMode::Distance(history_distance)),
1336            ..Default::default()
1337        };
1338
1339        assert_eq!(
1340            args.pruning_selection_for_component(
1341                SnapshotComponentType::Transactions,
1342                1_000_000,
1343                &modes,
1344                false,
1345            ),
1346            ComponentSelection::Distance(history_distance)
1347        );
1348        assert_eq!(
1349            args.pruning_selection_for_component(
1350                SnapshotComponentType::Receipts,
1351                1_000_000,
1352                &modes,
1353                false,
1354            ),
1355            ComponentSelection::Distance(128)
1356        );
1357        assert_eq!(
1358            args.pruning_selection_for_component(
1359                SnapshotComponentType::AccountChangesets,
1360                1_000_000,
1361                &modes,
1362                false,
1363            ),
1364            ComponentSelection::Distance(history_distance)
1365        );
1366        assert_eq!(
1367            args.pruning_selection_for_component(
1368                SnapshotComponentType::StorageChangesets,
1369                1_000_000,
1370                &modes,
1371                false,
1372            ),
1373            ComponentSelection::Distance(history_distance)
1374        );
1375        assert_eq!(
1376            args.pruning_selection_for_component(
1377                SnapshotComponentType::TransactionSenders,
1378                1_000_000,
1379                &modes,
1380                false,
1381            ),
1382            ComponentSelection::None
1383        );
1384    }
1385
1386    #[test]
1387    fn resolve_manifest_source_requires_explicit_source_for_non_mainnet_defaults() {
1388        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1389            "reth", "--chain", "holesky",
1390        ])
1391        .args;
1392
1393        let err = tokio::runtime::Runtime::new()
1394            .unwrap()
1395            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1396            .unwrap_err();
1397
1398        let message = err.to_string();
1399        assert!(message.contains("only auto-discovered for Ethereum mainnet"));
1400        assert!(message.contains("--manifest-url <URL>"));
1401        assert!(message.contains("-u <SNAPSHOT-URL>"));
1402    }
1403
1404    #[test]
1405    fn resolve_manifest_source_allows_manifest_path_for_non_mainnet_defaults() {
1406        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1407            "reth",
1408            "--chain",
1409            "holesky",
1410            "--manifest-path",
1411            "./manifest.json",
1412        ])
1413        .args;
1414
1415        let source = tokio::runtime::Runtime::new()
1416            .unwrap()
1417            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1418            .unwrap();
1419
1420        assert_eq!(source, "./manifest.json");
1421    }
1422
1423    #[test]
1424    fn test_compression_format_detection() {
1425        assert!(matches!(
1426            CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
1427            Ok(CompressionFormat::Lz4)
1428        ));
1429        assert!(matches!(
1430            CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
1431            Ok(CompressionFormat::Zstd)
1432        ));
1433        assert!(matches!(
1434            CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
1435            Ok(CompressionFormat::Lz4)
1436        ));
1437        assert!(matches!(
1438            CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
1439            Ok(CompressionFormat::Zstd)
1440        ));
1441        assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
1442    }
1443
1444    #[test]
1445    fn inject_archive_only_components_for_archive_selection() {
1446        let manifest = manifest_with_archive_only_components();
1447        let mut selections = BTreeMap::new();
1448        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1449        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1450        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1451        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1452
1453        inject_archive_only_components(&mut selections, &manifest, true);
1454
1455        assert_eq!(
1456            selections.get(&SnapshotComponentType::TransactionSenders),
1457            Some(&ComponentSelection::All)
1458        );
1459        assert_eq!(
1460            selections.get(&SnapshotComponentType::RocksdbIndices),
1461            Some(&ComponentSelection::All)
1462        );
1463    }
1464
1465    #[test]
1466    fn inject_archive_only_components_without_rocksdb() {
1467        let manifest = manifest_with_archive_only_components();
1468        let mut selections = BTreeMap::new();
1469        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1470        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1471        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1472        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1473
1474        inject_archive_only_components(&mut selections, &manifest, false);
1475
1476        assert_eq!(
1477            selections.get(&SnapshotComponentType::TransactionSenders),
1478            Some(&ComponentSelection::All)
1479        );
1480        assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None);
1481    }
1482
1483    #[test]
1484    fn should_reset_index_stage_checkpoints_without_rocksdb_indices() {
1485        let mut selections = BTreeMap::new();
1486        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1487        assert!(should_reset_index_stage_checkpoints(&selections));
1488
1489        selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1490        assert!(!should_reset_index_stage_checkpoints(&selections));
1491    }
1492
1493    #[test]
1494    fn startup_node_command_omits_default_chain_arg() {
1495        let command =
1496            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", MAINNET.as_ref());
1497
1498        assert_eq!(command, "reth node");
1499    }
1500
1501    #[test]
1502    fn startup_node_command_includes_non_default_chain_arg() {
1503        let command =
1504            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", HOLESKY.as_ref());
1505
1506        assert_eq!(command, "reth node --chain holesky");
1507    }
1508
1509    #[test]
1510    fn startup_node_command_uses_running_binary_name() {
1511        let command =
1512            startup_node_command_for_binary::<EthereumChainSpecParser>("tempo", HOLESKY.as_ref());
1513
1514        assert_eq!(command, "tempo node --chain holesky");
1515    }
1516
1517    #[test]
1518    fn download_command_uses_binary_name() {
1519        assert_eq!(download_command_for_binary("tempo"), "tempo download");
1520    }
1521}