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
93use crate::common::EnvironmentArgs;
94use archive::run_modular_downloads;
95use clap::{builder::RangedU64ValueParser, Parser};
96use config_gen::{config_for_selections, write_config};
97use extract::stream_and_extract;
98use eyre::Result;
99use manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest};
100use planning::{collect_planned_archives, summarize_download_startup};
101use progress::{DownloadProgress, DownloadRequestLimiter};
102use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks, MAINNET};
103use reth_cli::chainspec::ChainSpecParser;
104use reth_cli_util::cancellation::CancellationToken;
105use reth_db::{init_db, Database};
106use reth_db_api::transaction::DbTx;
107use reth_fs_util as fs;
108use reth_node_core::args::DefaultPruningValues;
109use reth_prune_types::PruneMode;
110use source::{
111    discover_manifest_url, fetch_manifest_from_source, fetch_snapshot_api_entries,
112    print_snapshot_listing, resolve_manifest_base_url,
113};
114use std::{
115    borrow::Cow,
116    collections::BTreeMap,
117    path::{Path, PathBuf},
118    sync::{Arc, OnceLock},
119};
120use tracing::info;
121use tui::{run_selector, SelectorOutput};
122
123const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
124const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
125const RETH_SNAPSHOTS_SOURCE: &str = "https://snapshots.reth.rs (default)";
126const SNAPSHOT_API_PATH: &str = "/api/snapshots";
127const FORCE_REMOVED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"];
128
129/// Maximum number of simultaneous HTTP downloads across the entire snapshot job.
130const MAX_CONCURRENT_DOWNLOADS: usize = 8;
131
132/// Built-in component presets for snapshot selection.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) enum SelectionPreset {
135    /// Minimal node data needed to start from a snapshot.
136    Minimal,
137    /// Full-node data matching the default full prune settings.
138    Full,
139    /// All available snapshot data.
140    Archive,
141}
142
143struct ResolvedComponents {
144    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
145    preset: Option<SelectionPreset>,
146}
147
148/// Global static download defaults
149static DOWNLOAD_DEFAULTS: OnceLock<DownloadDefaults> = OnceLock::new();
150
151/// Download configuration defaults
152///
153/// Global defaults can be set via [`DownloadDefaults::try_init`].
154#[derive(Debug, Clone)]
155pub struct DownloadDefaults {
156    /// List of available snapshot sources
157    pub available_snapshots: Vec<Cow<'static, str>>,
158    /// Default base URL for snapshots
159    pub default_base_url: Cow<'static, str>,
160    /// Default base URL for chain-aware snapshots.
161    ///
162    /// When set, the chain ID is appended to form the full URL: `{base_url}/{chain_id}`.
163    /// For example, given a base URL of `https://snapshots.example.com` and chain ID `1`,
164    /// the resulting URL would be `https://snapshots.example.com/1`.
165    ///
166    /// Falls back to [`default_base_url`](Self::default_base_url) when `None`.
167    pub default_chain_aware_base_url: Option<Cow<'static, str>>,
168    /// URL for the snapshot discovery API that lists available snapshots.
169    ///
170    /// Defaults to `https://snapshots.reth.rs/api/snapshots`.
171    pub snapshot_api_url: Cow<'static, str>,
172    /// Optional custom long help text that overrides the generated help
173    pub long_help: Option<String>,
174}
175
176impl DownloadDefaults {
177    /// Initialize the global download defaults with this configuration
178    pub fn try_init(self) -> Result<(), Self> {
179        DOWNLOAD_DEFAULTS.set(self)
180    }
181
182    /// Get a reference to the global download defaults
183    pub fn get_global() -> &'static DownloadDefaults {
184        DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
185    }
186
187    /// Default download configuration with defaults from snapshots.reth.rs and publicnode
188    pub fn default_download_defaults() -> Self {
189        Self {
190            available_snapshots: vec![
191                Cow::Borrowed(RETH_SNAPSHOTS_SOURCE),
192                Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
193            ],
194            default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
195            default_chain_aware_base_url: None,
196            snapshot_api_url: Cow::Borrowed(RETH_SNAPSHOTS_API_URL),
197            long_help: None,
198        }
199    }
200
201    /// Generates the long help text for the download URL argument using these defaults.
202    ///
203    /// If a custom long_help is set, it will be returned. Otherwise, help text is generated
204    /// from the available_snapshots list.
205    pub fn long_help(&self) -> String {
206        if let Some(ref custom_help) = self.long_help {
207            return custom_help.clone();
208        }
209
210        let implicit_download_help = if self.mainnet_only_discovery() {
211            "\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."
212        } else {
213            "\nIf no URL is provided, the latest archive snapshot for the selected chain\nwill be proposed for download from "
214        };
215
216        let mut help = format!(
217            "Specify a snapshot URL or let the command propose a default one.\n\n\
218             Browse available snapshots at {}\n\
219             or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
220            self.snapshot_source_url(),
221        );
222
223        for source in &self.available_snapshots {
224            help.push_str("- ");
225            help.push_str(source);
226            help.push('\n');
227        }
228
229        help.push_str(implicit_download_help);
230        if !self.mainnet_only_discovery() {
231            help.push_str(
232                self.default_chain_aware_base_url.as_deref().unwrap_or(&self.default_base_url),
233            );
234            help.push('.');
235        }
236        help.push_str(
237            "\n\nLocal file:// URLs are also supported for extracting snapshots from disk.",
238        );
239        help
240    }
241
242    fn mainnet_only_discovery(&self) -> bool {
243        self.snapshot_api_url.trim_end_matches('/') == RETH_SNAPSHOTS_API_URL
244    }
245
246    fn snapshot_source_url(&self) -> &str {
247        snapshot_source_url_from_api(&self.snapshot_api_url)
248    }
249
250    /// Add a snapshot source to the list
251    pub fn with_snapshot(mut self, source: impl Into<Cow<'static, str>>) -> Self {
252        self.available_snapshots.push(source.into());
253        self
254    }
255
256    /// Replace all snapshot sources
257    pub fn with_snapshots(mut self, sources: Vec<Cow<'static, str>>) -> Self {
258        self.available_snapshots = sources;
259        self
260    }
261
262    /// Set the default base URL, e.g. `https://downloads.merkle.io`.
263    pub fn with_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
264        self.default_base_url = url.into();
265        self
266    }
267
268    /// Set the default chain-aware base URL.
269    pub fn with_chain_aware_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
270        self.default_chain_aware_base_url = Some(url.into());
271        self
272    }
273
274    /// Set the snapshot discovery API URL.
275    ///
276    /// Generated help uses the API root as the default snapshot source unless a custom
277    /// chain-aware base URL or source list was already provided.
278    pub fn with_snapshot_api_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
279        self.snapshot_api_url = url.into();
280
281        let source_url = self.snapshot_source_url().to_string();
282        if self.default_chain_aware_base_url.is_none() {
283            self.default_chain_aware_base_url = Some(Cow::Owned(source_url.clone()));
284        }
285        for source in &mut self.available_snapshots {
286            if source.as_ref() == RETH_SNAPSHOTS_SOURCE {
287                *source = Cow::Owned(format!("{source_url} (default)"));
288            }
289        }
290
291        self
292    }
293
294    /// Set one default snapshot source URL for discovery and generated CLI references.
295    ///
296    /// The provided URL is the public snapshot root, such as `https://snapshots.example.com`.
297    /// The discovery API is derived as `{url}/api/snapshots`.
298    pub fn with_snapshot_source_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
299        let source_url = normalize_snapshot_source_url(url.into());
300        self.available_snapshots = vec![Cow::Owned(format!("{} (default)", source_url.as_ref()))];
301        self.default_base_url = source_url.clone();
302        self.default_chain_aware_base_url = Some(source_url.clone());
303        self.snapshot_api_url = Cow::Owned(format!("{}{SNAPSHOT_API_PATH}", source_url.as_ref()));
304        self
305    }
306
307    /// Builder: Set custom long help text, overriding the generated help
308    pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
309        self.long_help = Some(help.into());
310        self
311    }
312}
313
314fn snapshot_source_url_from_api(api_url: &str) -> &str {
315    api_url.trim_end_matches('/').trim_end_matches(SNAPSHOT_API_PATH)
316}
317
318fn normalize_snapshot_source_url(url: Cow<'static, str>) -> Cow<'static, str> {
319    match url {
320        Cow::Borrowed(url) => Cow::Borrowed(snapshot_source_url_from_api(url)),
321        Cow::Owned(url) => Cow::Owned(snapshot_source_url_from_api(&url).to_string()),
322    }
323}
324
325impl Default for DownloadDefaults {
326    /// Returns the built-in download defaults.
327    fn default() -> Self {
328        Self::default_download_defaults()
329    }
330}
331
332/// CLI command that downloads snapshot archives and configures a reth node from them.
333#[derive(Debug, Parser)]
334pub struct DownloadCommand<C: ChainSpecParser> {
335    #[command(flatten)]
336    env: EnvironmentArgs<C>,
337
338    /// Custom URL to download a single snapshot archive (legacy mode).
339    ///
340    /// When provided, downloads and extracts a single archive without component selection.
341    /// Browse available snapshots with --list-snapshots.
342    #[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
343    url: Option<String>,
344
345    /// URL to a snapshot manifest.json for modular component downloads.
346    ///
347    /// When provided, fetches this manifest instead of discovering it from the default
348    /// base URL. Useful for testing with custom or local manifests.
349    #[arg(long, value_name = "URL", conflicts_with = "url")]
350    manifest_url: Option<String>,
351
352    /// Local path to a snapshot manifest.json for modular component downloads.
353    #[arg(long, value_name = "PATH", conflicts_with_all = ["url", "manifest_url"])]
354    manifest_path: Option<PathBuf>,
355
356    /// Include all transaction static files.
357    #[arg(long, conflicts_with_all = ["with_txs_since", "with_txs_distance", "minimal", "full", "archive"])]
358    with_txs: bool,
359
360    /// Include transaction static files starting at the specified block.
361    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_txs", "with_txs_distance", "minimal", "full", "archive"])]
362    with_txs_since: Option<u64>,
363
364    /// Include transaction static files covering the last N blocks.
365    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_txs", "with_txs_since", "minimal", "full", "archive"])]
366    with_txs_distance: Option<u64>,
367
368    /// Include all receipt static files.
369    #[arg(long, conflicts_with_all = ["with_receipts_since", "with_receipts_distance", "minimal", "full", "archive"])]
370    with_receipts: bool,
371
372    /// Include receipt static files starting at the specified block.
373    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_receipts", "with_receipts_distance", "minimal", "full", "archive"])]
374    with_receipts_since: Option<u64>,
375
376    /// Include receipt static files covering the last N blocks.
377    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_receipts", "with_receipts_since", "minimal", "full", "archive"])]
378    with_receipts_distance: Option<u64>,
379
380    /// Include all account and storage history static files.
381    #[arg(long, alias = "with-changesets", conflicts_with_all = ["with_state_history_since", "with_state_history_distance", "minimal", "full", "archive"])]
382    with_state_history: bool,
383
384    /// Include account and storage history static files starting at the specified block.
385    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_state_history", "with_state_history_distance", "minimal", "full", "archive"])]
386    with_state_history_since: Option<u64>,
387
388    /// Include account and storage history static files covering the last N blocks.
389    #[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"])]
390    with_state_history_distance: Option<u64>,
391
392    /// Include transaction sender static files. Requires `--with-txs`.
393    #[arg(long, requires = "with_txs", conflicts_with_all = ["minimal", "full", "archive"])]
394    with_senders: bool,
395
396    /// Include RocksDB index files.
397    #[arg(long, conflicts_with_all = ["minimal", "full", "archive", "without_rocksdb"])]
398    with_rocksdb: bool,
399
400    /// Download all available components (archive node, no pruning).
401    #[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"])]
402    archive: bool,
403
404    /// Download the minimal component set (same default as --non-interactive).
405    #[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"])]
406    minimal: bool,
407
408    /// Download the full node component set (matches default full prune settings).
409    #[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"])]
410    full: bool,
411
412    /// Skip optional RocksDB indices even when archive components are selected.
413    ///
414    /// This affects `--archive`/`--all` and TUI archive preset (`a`).
415    #[arg(long, conflicts_with_all = ["url", "with_rocksdb"])]
416    without_rocksdb: bool,
417
418    /// Skip interactive component selection. Downloads the minimal set
419    /// (state + headers + transactions + changesets) unless explicit --with-* flags narrow it.
420    #[arg(long, short = 'y')]
421    non_interactive: bool,
422
423    /// Overwrite existing snapshot data by removing db, rocksdb, static_files, and reth.toml.
424    #[arg(long, conflicts_with = "list")]
425    force: bool,
426
427    /// Enable resumable two-phase downloads (download to disk first, then extract).
428    ///
429    /// Archives are downloaded to a `.part` file with HTTP Range resume support
430    /// before extraction. This is enabled by default because it tolerates
431    /// network interruptions without restarting. Pass `--resumable=false` to
432    /// stream archives directly into the extractor instead.
433    #[arg(long, default_value_t = true, num_args = 0..=1, default_missing_value = "true")]
434    resumable: bool,
435
436    /// Maximum number of simultaneous HTTP downloads.
437    ///
438    /// Applies across the entire snapshot download. Small files use one slot,
439    /// while large files may use multiple slots by splitting into fixed-size pieces.
440    #[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
441    download_concurrency: usize,
442
443    /// List available snapshots and exit.
444    ///
445    /// Queries the snapshots API and prints all available snapshots for the selected chain,
446    /// including block number, size, and manifest URL.
447    #[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
448    list: bool,
449}
450
451impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
452    /// Runs the download command in single-archive or manifest mode.
453    pub async fn execute<N>(self) -> Result<()> {
454        let chain = self.env.chain.chain();
455        let chain_id = chain.id();
456
457        // --list: print available snapshots and exit
458        if self.list {
459            let entries = fetch_snapshot_api_entries(chain_id).await?;
460            print_snapshot_listing(&entries, chain_id);
461            return Ok(());
462        }
463
464        let data_dir = self.env.datadir.clone().resolve_datadir(chain);
465
466        let cancel_token = CancellationToken::new();
467        let _cancel_guard = cancel_token.drop_guard();
468
469        // Legacy single-URL mode: download one archive and extract it
470        if let Some(ref url) = self.url {
471            let target_dir = data_dir.data_dir();
472            if self.force {
473                clear_existing_datadir(target_dir)?;
474            }
475            fs::create_dir_all(target_dir)?;
476
477            let request_limiter = DownloadRequestLimiter::new(self.download_concurrency.max(1));
478            info!(target: "reth::cli",
479                dir = ?data_dir.data_dir(),
480                url = %url,
481                "Starting snapshot download and extraction"
482            );
483
484            stream_and_extract(
485                url,
486                data_dir.data_dir(),
487                None,
488                self.resumable,
489                Some(request_limiter),
490                cancel_token.clone(),
491            )
492            .await?;
493            info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
494
495            return Ok(());
496        }
497
498        let manifest = self.load_manifest(chain_id).await?;
499        let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?;
500
501        if matches!(preset, Some(SelectionPreset::Archive)) {
502            inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb);
503        }
504
505        let target_dir = data_dir.data_dir();
506        let planned_downloads = collect_planned_archives(&manifest, &selections)?;
507        if self.force {
508            clear_existing_datadir(target_dir)?;
509        }
510        fs::create_dir_all(target_dir)?;
511        let startup_summary = summarize_download_startup(&planned_downloads.archives, target_dir)?;
512        info!(target: "reth::cli",
513            reusable = startup_summary.reusable,
514            needs_download = startup_summary.needs_download,
515            "Startup integrity summary (plain output files)"
516        );
517
518        info!(target: "reth::cli",
519            archives = planned_downloads.total_archives(),
520            download_total = %DownloadProgress::format_size(planned_downloads.total_download_size),
521            output_total = %DownloadProgress::format_size(planned_downloads.total_output_size),
522            "Downloading all archives"
523        );
524
525        run_modular_downloads(
526            planned_downloads,
527            target_dir,
528            self.download_concurrency.max(1),
529            cancel_token.clone(),
530        )
531        .await?;
532
533        self.finalize_modular_download(&selections, &manifest, preset, target_dir, &data_dir.db())?;
534
535        Ok(())
536    }
537
538    /// Loads the manifest and resolves its effective base URL.
539    async fn load_manifest(&self, chain_id: u64) -> Result<SnapshotManifest> {
540        let manifest_source = self.resolve_manifest_source(chain_id).await?;
541
542        info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
543        let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
544        manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?);
545
546        info!(target: "reth::cli",
547            block = manifest.block,
548            chain_id = manifest.chain_id,
549            storage_version = %manifest.storage_version,
550            components = manifest.components.len(),
551            "Loaded snapshot manifest"
552        );
553
554        Ok(manifest)
555    }
556
557    /// Writes config and checkpoint state after all modular archives complete.
558    fn finalize_modular_download(
559        &self,
560        selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
561        manifest: &SnapshotManifest,
562        preset: Option<SelectionPreset>,
563        target_dir: &Path,
564        db_path: &Path,
565    ) -> Result<()> {
566        let config =
567            config_for_selections(selections, manifest, preset, Some(self.env.chain.as_ref()));
568        if write_config(&config, target_dir)? {
569            let desc = config_gen::describe_prune_config(&config);
570            info!(target: "reth::cli", "{}", desc.join(", "));
571        }
572
573        let db = init_db(db_path, self.env.db.database_args())?;
574        let should_write_prune = config.prune.segments != Default::default();
575        let should_reset_indices = should_reset_index_stage_checkpoints(selections);
576        if should_write_prune || should_reset_indices {
577            let tx = db.tx_mut()?;
578
579            if should_write_prune {
580                config_gen::write_prune_checkpoints_tx(&tx, &config, manifest.block)?;
581            }
582
583            if should_reset_indices {
584                config_gen::reset_index_stage_checkpoints_tx(&tx)?;
585            }
586
587            tx.commit()?;
588        }
589
590        let start_command = startup_node_command::<C>(self.env.chain.as_ref());
591        info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command);
592
593        Ok(())
594    }
595
596    /// Determines which components to download based on CLI flags or interactive selection.
597    fn resolve_components(&self, manifest: &SnapshotManifest) -> Result<ResolvedComponents> {
598        let available = |ty: SnapshotComponentType| manifest.component(ty).is_some();
599
600        // --archive/--all: everything available as All
601        if self.archive {
602            return Ok(ResolvedComponents {
603                selections: SnapshotComponentType::ALL
604                    .iter()
605                    .copied()
606                    .filter(|ty| available(*ty))
607                    .filter(|ty| {
608                        !self.without_rocksdb || *ty != SnapshotComponentType::RocksdbIndices
609                    })
610                    .map(|ty| (ty, ComponentSelection::All))
611                    .collect(),
612                preset: Some(SelectionPreset::Archive),
613            });
614        }
615
616        if self.full {
617            return Ok(ResolvedComponents {
618                selections: self.full_preset_selections(manifest),
619                preset: Some(SelectionPreset::Full),
620            });
621        }
622
623        if self.minimal {
624            return Ok(ResolvedComponents {
625                selections: self.minimal_preset_selections(manifest),
626                preset: Some(SelectionPreset::Minimal),
627            });
628        }
629
630        let has_explicit_flags = self.with_txs ||
631            self.with_txs_since.is_some() ||
632            self.with_txs_distance.is_some() ||
633            self.with_receipts ||
634            self.with_receipts_since.is_some() ||
635            self.with_receipts_distance.is_some() ||
636            self.with_state_history ||
637            self.with_state_history_since.is_some() ||
638            self.with_state_history_distance.is_some() ||
639            self.with_senders ||
640            self.with_rocksdb;
641
642        if has_explicit_flags {
643            let mut selections = BTreeMap::new();
644            let tx_selection = explicit_component_selection(
645                self.with_txs,
646                self.with_txs_since,
647                self.with_txs_distance,
648                manifest.block,
649            );
650            let receipt_selection = explicit_component_selection(
651                self.with_receipts,
652                self.with_receipts_since,
653                self.with_receipts_distance,
654                manifest.block,
655            );
656            let state_history_selection = explicit_component_selection(
657                self.with_state_history,
658                self.with_state_history_since,
659                self.with_state_history_distance,
660                manifest.block,
661            );
662
663            // Required components always All
664            if available(SnapshotComponentType::State) {
665                selections.insert(SnapshotComponentType::State, ComponentSelection::All);
666            }
667            if available(SnapshotComponentType::Headers) {
668                selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
669            }
670            if let Some(selection) = tx_selection &&
671                available(SnapshotComponentType::Transactions)
672            {
673                selections.insert(SnapshotComponentType::Transactions, selection);
674            }
675            if let Some(selection) = receipt_selection &&
676                available(SnapshotComponentType::Receipts)
677            {
678                selections.insert(SnapshotComponentType::Receipts, selection);
679            }
680            if let Some(selection) = state_history_selection {
681                if available(SnapshotComponentType::AccountChangesets) {
682                    selections.insert(SnapshotComponentType::AccountChangesets, selection);
683                }
684                if available(SnapshotComponentType::StorageChangesets) {
685                    selections.insert(SnapshotComponentType::StorageChangesets, selection);
686                }
687            }
688            if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
689                selections
690                    .insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
691            }
692            if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
693                selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
694            }
695            return Ok(ResolvedComponents { selections, preset: None });
696        }
697
698        if self.non_interactive {
699            return Ok(ResolvedComponents {
700                selections: self.minimal_preset_selections(manifest),
701                preset: Some(SelectionPreset::Minimal),
702            });
703        }
704
705        // Interactive TUI
706        let full_preset = self.full_preset_selections(manifest);
707        let SelectorOutput { selections, preset } = run_selector(manifest.clone(), &full_preset)?;
708        let selected =
709            selections.into_iter().filter(|(_, sel)| *sel != ComponentSelection::None).collect();
710
711        Ok(ResolvedComponents { selections: selected, preset })
712    }
713
714    /// Builds the default minimal component selection for the manifest.
715    fn minimal_preset_selections(
716        &self,
717        manifest: &SnapshotManifest,
718    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
719        SnapshotComponentType::ALL
720            .iter()
721            .copied()
722            .filter(|ty| manifest.component(*ty).is_some())
723            .map(|ty| (ty, ty.minimal_selection()))
724            .collect()
725    }
726
727    /// Builds the default full-node component selection for the manifest.
728    fn full_preset_selections(
729        &self,
730        manifest: &SnapshotManifest,
731    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
732        let mut selections = BTreeMap::new();
733
734        for ty in [
735            SnapshotComponentType::State,
736            SnapshotComponentType::Headers,
737            SnapshotComponentType::Transactions,
738            SnapshotComponentType::Receipts,
739            SnapshotComponentType::AccountChangesets,
740            SnapshotComponentType::StorageChangesets,
741            SnapshotComponentType::TransactionSenders,
742            SnapshotComponentType::RocksdbIndices,
743        ] {
744            if manifest.component(ty).is_none() {
745                continue;
746            }
747
748            let selection = self.full_selection_for_component(ty, manifest.block);
749            if selection != ComponentSelection::None {
750                selections.insert(ty, selection);
751            }
752        }
753
754        selections
755    }
756
757    /// Returns the full preset selection for one component type.
758    fn full_selection_for_component(
759        &self,
760        ty: SnapshotComponentType,
761        snapshot_block: u64,
762    ) -> ComponentSelection {
763        let defaults = DefaultPruningValues::get_global();
764        match ty {
765            SnapshotComponentType::State | SnapshotComponentType::Headers => {
766                ComponentSelection::All
767            }
768            SnapshotComponentType::Transactions => {
769                if defaults.full_bodies_history_use_pre_merge {
770                    match self
771                        .env
772                        .chain
773                        .ethereum_fork_activation(EthereumHardfork::Paris)
774                        .block_number()
775                    {
776                        Some(paris) if snapshot_block >= paris => ComponentSelection::Since(paris),
777                        Some(_) => ComponentSelection::None,
778                        None => ComponentSelection::All,
779                    }
780                } else {
781                    selection_from_prune_mode(
782                        defaults.full_prune_modes.bodies_history,
783                        snapshot_block,
784                    )
785                }
786            }
787            SnapshotComponentType::Receipts => {
788                selection_from_prune_mode(defaults.full_prune_modes.receipts, snapshot_block)
789            }
790            SnapshotComponentType::AccountChangesets => {
791                selection_from_prune_mode(defaults.full_prune_modes.account_history, snapshot_block)
792            }
793            SnapshotComponentType::StorageChangesets => {
794                selection_from_prune_mode(defaults.full_prune_modes.storage_history, snapshot_block)
795            }
796            SnapshotComponentType::TransactionSenders => {
797                selection_from_prune_mode(defaults.full_prune_modes.sender_recovery, snapshot_block)
798            }
799            // Keep hidden by default in full mode; if users want indices they can use archive.
800            SnapshotComponentType::RocksdbIndices => ComponentSelection::None,
801        }
802    }
803
804    /// Resolves the manifest source from CLI input or snapshot discovery.
805    async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
806        if let Some(path) = &self.manifest_path {
807            return Ok(path.display().to_string());
808        }
809
810        match &self.manifest_url {
811            Some(url) => Ok(url.clone()),
812            None => {
813                let defaults = DownloadDefaults::get_global();
814                if defaults.mainnet_only_discovery() && chain_id != MAINNET.chain.id() {
815                    eyre::bail!(
816                        "Snapshots are only auto-discovered for Ethereum mainnet.\n\n\
817                         Chain {chain_id} requires an explicit source:\n\
818                         \t--manifest-url <URL>\n\
819                         \t--manifest-path <PATH>\n\
820                         \t-u <SNAPSHOT-URL>\n\n\
821                         Use --list to inspect snapshots exposed by {}.",
822                        defaults.snapshot_source_url(),
823                    );
824                }
825
826                discover_manifest_url(chain_id).await
827            }
828        }
829    }
830}
831
832/// Resolves explicit `--with-*` / `--with-*-since` / `--with-*-distance` flags
833/// into a component selection.
834fn explicit_component_selection(
835    all: bool,
836    since: Option<u64>,
837    distance: Option<u64>,
838    snapshot_block: u64,
839) -> Option<ComponentSelection> {
840    if all {
841        Some(ComponentSelection::All)
842    } else if let Some(block) = since {
843        (block <= snapshot_block).then_some(ComponentSelection::Since(block))
844    } else {
845        distance.map(ComponentSelection::Distance)
846    }
847}
848
849/// Converts a prune mode into the matching component selection.
850fn selection_from_prune_mode(mode: Option<PruneMode>, snapshot_block: u64) -> ComponentSelection {
851    match mode {
852        None => ComponentSelection::All,
853        Some(PruneMode::Full) => ComponentSelection::None,
854        Some(PruneMode::Distance(d)) => ComponentSelection::Distance(d),
855        Some(PruneMode::Before(block)) => {
856            if snapshot_block >= block {
857                ComponentSelection::Since(block)
858            } else {
859                ComponentSelection::None
860            }
861        }
862    }
863}
864
865/// Removes existing snapshot data that is managed by `reth download`.
866fn clear_existing_datadir(target_dir: &Path) -> Result<()> {
867    if !target_dir.try_exists()? {
868        return Ok(());
869    }
870
871    info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data");
872    for entry in FORCE_REMOVED_DATADIR_PATHS {
873        let path = target_dir.join(entry);
874        if !path.try_exists()? {
875            continue;
876        }
877
878        let metadata = fs::metadata(&path)?;
879        if metadata.is_dir() {
880            fs::remove_dir_all(&path)?;
881        } else if metadata.is_file() {
882            fs::remove_file(&path)?;
883        }
884    }
885
886    Ok(())
887}
888
889/// If all data components (txs, receipts, changesets) are `All`, automatically
890/// include hidden archive-only components when available in the manifest.
891fn inject_archive_only_components(
892    selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
893    manifest: &SnapshotManifest,
894    include_rocksdb: bool,
895) {
896    let is_all =
897        |ty: SnapshotComponentType| selections.get(&ty).copied() == Some(ComponentSelection::All);
898
899    let is_archive = is_all(SnapshotComponentType::Transactions) &&
900        is_all(SnapshotComponentType::Receipts) &&
901        is_all(SnapshotComponentType::AccountChangesets) &&
902        is_all(SnapshotComponentType::StorageChangesets);
903
904    if !is_archive {
905        return;
906    }
907
908    for component in
909        [SnapshotComponentType::TransactionSenders, SnapshotComponentType::RocksdbIndices]
910    {
911        if component == SnapshotComponentType::RocksdbIndices && !include_rocksdb {
912            continue;
913        }
914
915        if manifest.component(component).is_some() {
916            selections.insert(component, ComponentSelection::All);
917        }
918    }
919}
920
921/// Returns `true` when RocksDB-backed index stages should be reset after download.
922fn should_reset_index_stage_checkpoints(
923    selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
924) -> bool {
925    !matches!(selections.get(&SnapshotComponentType::RocksdbIndices), Some(ComponentSelection::All))
926}
927
928fn startup_node_command<C>(chain_spec: &C::ChainSpec) -> String
929where
930    C: ChainSpecParser,
931    C::ChainSpec: EthChainSpec,
932{
933    startup_node_command_for_binary::<C>(&current_binary_name(), chain_spec)
934}
935
936fn startup_node_command_for_binary<C>(binary_name: &str, chain_spec: &C::ChainSpec) -> String
937where
938    C: ChainSpecParser,
939    C::ChainSpec: EthChainSpec,
940{
941    let mut command = format!("{binary_name} node");
942
943    if let Some(chain_arg) = startup_chain_arg::<C>(chain_spec) {
944        command.push_str(" --chain ");
945        command.push_str(&chain_arg);
946    }
947
948    command
949}
950
951fn current_binary_name() -> String {
952    std::env::args_os()
953        .next()
954        .map(PathBuf::from)
955        .and_then(|path| path.file_stem().map(|name| name.to_owned()))
956        .and_then(|name| name.into_string().ok())
957        .filter(|name| !name.is_empty())
958        .unwrap_or_else(|| "reth".to_string())
959}
960
961fn download_command() -> String {
962    download_command_for_binary(&current_binary_name())
963}
964
965fn download_command_for_binary(binary_name: &str) -> String {
966    format!("{binary_name} download")
967}
968
969fn startup_chain_arg<C>(chain_spec: &C::ChainSpec) -> Option<String>
970where
971    C: ChainSpecParser,
972    C::ChainSpec: EthChainSpec,
973{
974    let current_chain = chain_spec.chain();
975    let current_genesis_hash = chain_spec.genesis_hash();
976    let default_chain = C::default_value().and_then(|chain_name| C::parse(chain_name).ok());
977
978    if default_chain.as_ref().is_some_and(|default_chain| {
979        default_chain.chain() == current_chain &&
980            default_chain.genesis_hash() == current_genesis_hash
981    }) {
982        return None;
983    }
984
985    C::SUPPORTED_CHAINS
986        .iter()
987        .find_map(|chain_name| {
988            let parsed_chain = C::parse(chain_name).ok()?;
989            (parsed_chain.chain() == current_chain &&
990                parsed_chain.genesis_hash() == current_genesis_hash)
991                .then(|| (*chain_name).to_string())
992        })
993        .or_else(|| Some("<chain-or-chainspec>".to_string()))
994}
995
996impl<C: ChainSpecParser> DownloadCommand<C> {
997    /// Returns the underlying chain being used to run this command
998    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
999        Some(&self.env.chain)
1000    }
1001}
1002
1003const MAX_DOWNLOAD_RETRIES: u32 = 10;
1004const RETRY_BACKOFF_SECS: u64 = 5;
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use clap::{Args, Parser};
1010    use extract::CompressionFormat;
1011    use manifest::{ComponentManifest, SingleArchive};
1012    use reth_chainspec::{HOLESKY, MAINNET};
1013    use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
1014
1015    #[derive(Parser)]
1016    struct CommandParser<T: Args> {
1017        #[command(flatten)]
1018        args: T,
1019    }
1020
1021    fn manifest_with_archive_only_components() -> SnapshotManifest {
1022        let mut components = BTreeMap::new();
1023        components.insert(
1024            SnapshotComponentType::TransactionSenders.key().to_string(),
1025            ComponentManifest::Single(SingleArchive {
1026                file: "transaction_senders.tar.zst".to_string(),
1027                size: 1,
1028                decompressed_size: 0,
1029                blake3: None,
1030                output_files: vec![],
1031            }),
1032        );
1033        components.insert(
1034            SnapshotComponentType::RocksdbIndices.key().to_string(),
1035            ComponentManifest::Single(SingleArchive {
1036                file: "rocksdb_indices.tar.zst".to_string(),
1037                size: 1,
1038                decompressed_size: 0,
1039                blake3: None,
1040                output_files: vec![],
1041            }),
1042        );
1043        SnapshotManifest {
1044            block: 0,
1045            chain_id: 1,
1046            storage_version: 2,
1047            timestamp: 0,
1048            base_url: Some("https://example.com".to_string()),
1049            reth_version: None,
1050            components,
1051        }
1052    }
1053
1054    #[test]
1055    fn test_download_defaults_builder() {
1056        let defaults = DownloadDefaults::default()
1057            .with_snapshot("https://example.com/snapshots (example)")
1058            .with_base_url("https://example.com");
1059
1060        assert_eq!(defaults.default_base_url, "https://example.com");
1061        assert_eq!(defaults.available_snapshots.len(), 3); // 2 defaults + 1 added
1062    }
1063
1064    #[test]
1065    fn test_download_defaults_replace_snapshots() {
1066        let defaults = DownloadDefaults::default().with_snapshots(vec![
1067            Cow::Borrowed("https://custom1.com"),
1068            Cow::Borrowed("https://custom2.com"),
1069        ]);
1070
1071        assert_eq!(defaults.available_snapshots.len(), 2);
1072        assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
1073    }
1074
1075    #[test]
1076    fn test_long_help_generation() {
1077        let defaults = DownloadDefaults::default();
1078        let help = defaults.long_help();
1079
1080        assert!(help.contains("Available snapshot sources:"));
1081        assert!(help.contains("Ethereum mainnet"));
1082        assert!(help.contains("snapshots.reth.rs"));
1083        assert!(help.contains("publicnode.com"));
1084        assert!(help.contains("file://"));
1085    }
1086
1087    #[test]
1088    fn test_custom_snapshot_api_keeps_selected_chain_help() {
1089        let defaults = DownloadDefaults::default()
1090            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots");
1091        let help = defaults.long_help();
1092
1093        assert_eq!(
1094            defaults.default_chain_aware_base_url.as_deref(),
1095            Some("https://snapshots.tempoxyz.dev")
1096        );
1097        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1098        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1099        assert!(help.contains("selected chain"));
1100        assert!(!help.contains("Ethereum mainnet"));
1101        assert!(!help.contains("snapshots.reth.rs"));
1102    }
1103
1104    #[test]
1105    fn test_snapshot_source_url_sets_generated_references() {
1106        let defaults =
1107            DownloadDefaults::default().with_snapshot_source_url("https://snapshots.tempoxyz.dev/");
1108        let help = defaults.long_help();
1109
1110        assert_eq!(defaults.snapshot_api_url, "https://snapshots.tempoxyz.dev/api/snapshots");
1111        assert_eq!(defaults.default_base_url, "https://snapshots.tempoxyz.dev");
1112        assert_eq!(
1113            defaults.default_chain_aware_base_url.as_deref(),
1114            Some("https://snapshots.tempoxyz.dev")
1115        );
1116        assert_eq!(
1117            defaults.available_snapshots.iter().map(|source| source.as_ref()).collect::<Vec<_>>(),
1118            vec!["https://snapshots.tempoxyz.dev (default)"]
1119        );
1120        assert!(!defaults.mainnet_only_discovery());
1121        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1122        assert!(help.contains("from https://snapshots.tempoxyz.dev."));
1123    }
1124
1125    #[test]
1126    fn test_snapshot_api_url_trailing_slash_sets_source_url() {
1127        let defaults = DownloadDefaults::default()
1128            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots/");
1129        let help = defaults.long_help();
1130
1131        assert_eq!(
1132            defaults.default_chain_aware_base_url.as_deref(),
1133            Some("https://snapshots.tempoxyz.dev")
1134        );
1135        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1136        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1137    }
1138
1139    #[test]
1140    fn test_long_help_override() {
1141        let custom_help = "This is custom help text for downloading snapshots.";
1142        let defaults = DownloadDefaults::default().with_long_help(custom_help);
1143
1144        let help = defaults.long_help();
1145        assert_eq!(help, custom_help);
1146        assert!(!help.contains("Available snapshot sources:"));
1147    }
1148
1149    #[test]
1150    fn test_builder_chaining() {
1151        let defaults = DownloadDefaults::default()
1152            .with_base_url("https://custom.example.com")
1153            .with_snapshot("https://snapshot1.com")
1154            .with_snapshot("https://snapshot2.com")
1155            .with_long_help("Custom help for snapshots");
1156
1157        assert_eq!(defaults.default_base_url, "https://custom.example.com");
1158        assert_eq!(defaults.available_snapshots.len(), 4); // 2 defaults + 2 added
1159        assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
1160    }
1161
1162    #[test]
1163    fn test_download_resumable_defaults_to_true() {
1164        let args =
1165            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1166
1167        assert!(args.resumable);
1168    }
1169
1170    #[test]
1171    fn test_download_resumable_implicit_true() {
1172        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1173            "reth",
1174            "--resumable",
1175        ])
1176        .args;
1177
1178        assert!(args.resumable);
1179    }
1180
1181    #[test]
1182    fn test_download_resumable_explicit_false() {
1183        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1184            "reth",
1185            "--resumable=false",
1186        ])
1187        .args;
1188
1189        assert!(!args.resumable);
1190    }
1191
1192    #[test]
1193    fn resolve_manifest_source_requires_explicit_source_for_non_mainnet_defaults() {
1194        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1195            "reth", "--chain", "holesky",
1196        ])
1197        .args;
1198
1199        let err = tokio::runtime::Runtime::new()
1200            .unwrap()
1201            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1202            .unwrap_err();
1203
1204        let message = err.to_string();
1205        assert!(message.contains("only auto-discovered for Ethereum mainnet"));
1206        assert!(message.contains("--manifest-url <URL>"));
1207        assert!(message.contains("-u <SNAPSHOT-URL>"));
1208    }
1209
1210    #[test]
1211    fn resolve_manifest_source_allows_manifest_path_for_non_mainnet_defaults() {
1212        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1213            "reth",
1214            "--chain",
1215            "holesky",
1216            "--manifest-path",
1217            "./manifest.json",
1218        ])
1219        .args;
1220
1221        let source = tokio::runtime::Runtime::new()
1222            .unwrap()
1223            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1224            .unwrap();
1225
1226        assert_eq!(source, "./manifest.json");
1227    }
1228
1229    #[test]
1230    fn test_compression_format_detection() {
1231        assert!(matches!(
1232            CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
1233            Ok(CompressionFormat::Lz4)
1234        ));
1235        assert!(matches!(
1236            CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
1237            Ok(CompressionFormat::Zstd)
1238        ));
1239        assert!(matches!(
1240            CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
1241            Ok(CompressionFormat::Lz4)
1242        ));
1243        assert!(matches!(
1244            CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
1245            Ok(CompressionFormat::Zstd)
1246        ));
1247        assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
1248    }
1249
1250    #[test]
1251    fn inject_archive_only_components_for_archive_selection() {
1252        let manifest = manifest_with_archive_only_components();
1253        let mut selections = BTreeMap::new();
1254        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1255        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1256        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1257        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1258
1259        inject_archive_only_components(&mut selections, &manifest, true);
1260
1261        assert_eq!(
1262            selections.get(&SnapshotComponentType::TransactionSenders),
1263            Some(&ComponentSelection::All)
1264        );
1265        assert_eq!(
1266            selections.get(&SnapshotComponentType::RocksdbIndices),
1267            Some(&ComponentSelection::All)
1268        );
1269    }
1270
1271    #[test]
1272    fn inject_archive_only_components_without_rocksdb() {
1273        let manifest = manifest_with_archive_only_components();
1274        let mut selections = BTreeMap::new();
1275        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1276        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1277        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1278        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1279
1280        inject_archive_only_components(&mut selections, &manifest, false);
1281
1282        assert_eq!(
1283            selections.get(&SnapshotComponentType::TransactionSenders),
1284            Some(&ComponentSelection::All)
1285        );
1286        assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None);
1287    }
1288
1289    #[test]
1290    fn should_reset_index_stage_checkpoints_without_rocksdb_indices() {
1291        let mut selections = BTreeMap::new();
1292        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1293        assert!(should_reset_index_stage_checkpoints(&selections));
1294
1295        selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1296        assert!(!should_reset_index_stage_checkpoints(&selections));
1297    }
1298
1299    #[test]
1300    fn startup_node_command_omits_default_chain_arg() {
1301        let command =
1302            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", MAINNET.as_ref());
1303
1304        assert_eq!(command, "reth node");
1305    }
1306
1307    #[test]
1308    fn startup_node_command_includes_non_default_chain_arg() {
1309        let command =
1310            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", HOLESKY.as_ref());
1311
1312        assert_eq!(command, "reth node --chain holesky");
1313    }
1314
1315    #[test]
1316    fn startup_node_command_uses_running_binary_name() {
1317        let command =
1318            startup_node_command_for_binary::<EthereumChainSpecParser>("tempo", HOLESKY.as_ref());
1319
1320        assert_eq!(command, "tempo node --chain holesky");
1321    }
1322
1323    #[test]
1324    fn download_command_uses_binary_name() {
1325        assert_eq!(download_command_for_binary("tempo"), "tempo download");
1326    }
1327}