mithril_client_cli/commands/tools/
snapshot_converter.rs

1use std::{
2    env, fmt,
3    fs::{create_dir, read_dir, remove_dir_all, rename},
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8use anyhow::{Context, anyhow};
9use clap::{Parser, ValueEnum};
10
11use mithril_client::{
12    MithrilError, MithrilResult,
13    common::{CardanoNetwork, MagicId},
14};
15
16use crate::utils::{
17    ArchiveUnpacker, GitHubReleaseRetriever, HttpDownloader, ReqwestGitHubApiClient,
18    ReqwestHttpDownloader, copy_dir, remove_dir_contents,
19};
20
21const GITHUB_ORGANIZATION: &str = "IntersectMBO";
22const GITHUB_REPOSITORY: &str = "cardano-node";
23
24const LATEST_DISTRIBUTION_TAG: &str = "latest";
25const PRERELEASE_DISTRIBUTION_TAG: &str = "pre-release";
26
27const WORK_DIR: &str = "tmp";
28const CARDANO_DISTRIBUTION_DIR: &str = "cardano-node-distribution";
29const SNAPSHOTS_DIR: &str = "snapshots";
30
31const SNAPSHOT_CONVERTER_BIN_DIR: &str = "bin";
32const SNAPSHOT_CONVERTER_BIN_NAME_UNIX: &str = "snapshot-converter";
33const SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS: &str = "snapshot-converter.exe";
34const SNAPSHOT_CONVERTER_CONFIG_DIR: &str = "share";
35const SNAPSHOT_CONVERTER_CONFIG_FILE: &str = "config.json";
36
37const LEDGER_DIR: &str = "ledger";
38const PROTOCOL_MAGIC_ID_FILE: &str = "protocolMagicId";
39
40const CONVERSION_FALLBACK_LIMIT: usize = 2;
41
42#[derive(Debug, Clone, ValueEnum, Eq, PartialEq)]
43enum UTxOHDFlavor {
44    #[clap(name = "Legacy")]
45    Legacy,
46    #[clap(name = "LMDB")]
47    Lmdb,
48}
49
50impl fmt::Display for UTxOHDFlavor {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Legacy => write!(f, "Legacy"),
54            Self::Lmdb => write!(f, "LMDB"),
55        }
56    }
57}
58
59#[derive(Debug, Clone, ValueEnum, Eq, PartialEq)]
60enum CardanoNetworkCliArg {
61    Preview,
62    Preprod,
63    Mainnet,
64}
65
66impl fmt::Display for CardanoNetworkCliArg {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Preview => write!(f, "preview"),
70            Self::Preprod => write!(f, "preprod"),
71            Self::Mainnet => write!(f, "mainnet"),
72        }
73    }
74}
75
76impl TryFrom<CardanoNetwork> for CardanoNetworkCliArg {
77    type Error = MithrilError;
78
79    fn try_from(network: CardanoNetwork) -> Result<Self, Self::Error> {
80        match network {
81            CardanoNetwork::MainNet => Ok(Self::Mainnet),
82            CardanoNetwork::TestNet(magic_id) => match magic_id {
83                CardanoNetwork::PREVIEW_MAGIC_ID => Ok(Self::Preview),
84                CardanoNetwork::PREPROD_MAGIC_ID => Ok(Self::Preprod),
85                _ => Err(anyhow!(
86                    "Cardano network not supported for ledger state snapshot conversion: {network:?}",
87                )),
88            },
89        }
90    }
91}
92
93#[cfg_attr(test, mockall::automock)]
94trait SnapshotConverter {
95    fn convert(&self, input_path: &Path, output_path: &Path) -> MithrilResult<()>;
96}
97
98struct SnapshotConverterBin {
99    pub converter_bin: PathBuf,
100    pub config_path: PathBuf,
101    pub utxo_hd_flavor: UTxOHDFlavor,
102}
103
104impl SnapshotConverter for SnapshotConverterBin {
105    fn convert(&self, input_path: &Path, output_path: &Path) -> MithrilResult<()> {
106        let status = Command::new(self.converter_bin.clone())
107            .arg("Mem")
108            .arg(input_path)
109            .arg(self.utxo_hd_flavor.to_string())
110            .arg(output_path)
111            .arg("cardano")
112            .arg("--config")
113            .arg(self.config_path.clone())
114            .status()
115            .with_context(|| {
116                format!(
117                    "Failed to execute snapshot-converter binary at {}",
118                    self.converter_bin.display()
119                )
120            })?;
121
122        if !status.success() {
123            return Err(anyhow!(
124                "Failure while running snapshot-converter binary, exited with status code: {:?}",
125                status.code().map_or(String::from("unknown"), |c| c.to_string())
126            ));
127        }
128
129        Ok(())
130    }
131}
132
133/// Clap command to convert a restored `InMemory` Mithril snapshot to another flavor.
134#[derive(Parser, Debug, Clone)]
135pub struct SnapshotConverterCommand {
136    /// Path to the Cardano node database directory.
137    #[clap(long)]
138    db_directory: PathBuf,
139
140    /// Cardano node version of the Mithril signed snapshot (`latest` and `pre-release` are also supported to download the latest or pre-release distribution).
141    ///
142    /// `latest` and `pre-release` are also supported to download the latest or pre-release distribution.
143    #[clap(long)]
144    cardano_node_version: String,
145
146    /// Cardano network.
147    #[clap(long)]
148    #[deprecated(
149        since = "0.12.12",
150        note = "optional: automatically detected from the protocolMagicId file"
151    )]
152    cardano_network: Option<CardanoNetworkCliArg>,
153
154    /// UTxO-HD flavor to convert the ledger snapshot to (`Legacy` or `LMDB`).
155    #[clap(long)]
156    utxo_hd_flavor: UTxOHDFlavor,
157
158    /// Replaces the current ledger state in the `db_directory`.
159    #[clap(long)]
160    commit: bool,
161
162    /// GitHub token for authenticated API calls.
163    #[clap(long, env = "GITHUB_TOKEN")]
164    github_token: Option<String>,
165}
166
167impl SnapshotConverterCommand {
168    /// Main command execution
169    pub async fn execute(&self) -> MithrilResult<()> {
170        let work_dir = self.db_directory.join(WORK_DIR);
171        create_dir(&work_dir).with_context(|| {
172            format!(
173                "Failed to create snapshot converter work directory: {}",
174                work_dir.display()
175            )
176        })?;
177        let distribution_dir = work_dir.join(CARDANO_DISTRIBUTION_DIR);
178
179        let result = {
180            create_dir(&distribution_dir).with_context(|| {
181                format!(
182                    "Failed to create distribution directory: {}",
183                    distribution_dir.display()
184                )
185            })?;
186            let archive_path = Self::download_cardano_node_distribution(
187                ReqwestGitHubApiClient::new(self.github_token.clone())?,
188                ReqwestHttpDownloader::new()?,
189                &self.cardano_node_version,
190                &distribution_dir,
191            )
192            .await
193            .with_context(|| "Failed to download Cardano node distribution")?;
194
195            println!(
196                "Unpacking distribution from archive: {}",
197                archive_path.display()
198            );
199            ArchiveUnpacker::default()
200                .unpack(&archive_path, &distribution_dir)
201                .with_context(|| {
202                    format!(
203                        "Failed to unpack distribution to directory: {}",
204                        distribution_dir.display()
205                    )
206                })?;
207            println!(
208                "Distribution unpacked successfully to: {}",
209                distribution_dir.display()
210            );
211
212            #[allow(deprecated)]
213            let cardano_network = if let Some(network) = &self.cardano_network {
214                network.clone()
215            } else {
216                Self::detect_cardano_network(&self.db_directory).with_context(|| {
217                    format!(
218                        "Could not detect Cardano network from the database directory: {}",
219                        self.db_directory.display()
220                    )
221                })?
222            };
223            Self::convert_ledger_state_snapshot(
224                &work_dir,
225                &self.db_directory,
226                &distribution_dir,
227                &cardano_network,
228                &self.utxo_hd_flavor,
229                self.commit,
230            )
231            .with_context(|| {
232                format!(
233                    "Failed to convert ledger snapshot to flavor: {}",
234                    self.utxo_hd_flavor
235                )
236            })?;
237
238            Ok(())
239        };
240
241        if let Err(e) = Self::cleanup(&work_dir, &distribution_dir, self.commit, result.is_ok()) {
242            eprintln!(
243                "Failed to clean up temporary directory {} after execution: {}",
244                distribution_dir.display(),
245                e
246            );
247        }
248
249        result
250    }
251
252    async fn download_cardano_node_distribution(
253        github_api_client: impl GitHubReleaseRetriever,
254        http_downloader: impl HttpDownloader,
255        tag: &str,
256        target_dir: &Path,
257    ) -> MithrilResult<PathBuf> {
258        println!("Downloading Cardano node distribution for tag: '{tag}'...");
259        let release = match tag {
260            LATEST_DISTRIBUTION_TAG => github_api_client
261                .get_latest_release(GITHUB_ORGANIZATION, GITHUB_REPOSITORY)
262                .await
263                .with_context(|| "Failed to get latest release")?,
264            PRERELEASE_DISTRIBUTION_TAG => github_api_client
265                .get_prerelease(GITHUB_ORGANIZATION, GITHUB_REPOSITORY)
266                .await
267                .with_context(|| "Failed to get pre-release")?,
268            _ => github_api_client
269                .get_release_by_tag(GITHUB_ORGANIZATION, GITHUB_REPOSITORY, tag)
270                .await
271                .with_context(|| format!("Failed to get release by tag: {tag}"))?,
272        };
273        let asset = release
274            .get_asset_for_os(env::consts::OS)?
275            .ok_or_else(|| anyhow!("No asset found for platform: {}", env::consts::OS))
276            .with_context(|| {
277                format!(
278                    "Failed to find asset for current platform: {}",
279                    env::consts::OS
280                )
281            })?;
282        let archive_path = http_downloader
283            .download_file(asset.browser_download_url.parse()?, target_dir, &asset.name)
284            .await?;
285
286        println!(
287            "Distribution downloaded successfully. Archive location: {}",
288            archive_path.display()
289        );
290
291        Ok(archive_path)
292    }
293
294    fn convert_ledger_state_snapshot(
295        work_dir: &Path,
296        db_dir: &Path,
297        distribution_dir: &Path,
298        cardano_network: &CardanoNetworkCliArg,
299        utxo_hd_flavor: &UTxOHDFlavor,
300        commit: bool,
301    ) -> MithrilResult<()> {
302        println!("Converting ledger state snapshot to '{utxo_hd_flavor}' flavor");
303        let converter_bin =
304            Self::get_snapshot_converter_binary_path(distribution_dir, env::consts::OS)?;
305        let config_path =
306            Self::get_snapshot_converter_config_path(distribution_dir, cardano_network);
307        let snapshots = Self::find_most_recent_snapshots(db_dir, CONVERSION_FALLBACK_LIMIT)?;
308        let converter_bin = SnapshotConverterBin {
309            converter_bin,
310            config_path,
311            utxo_hd_flavor: utxo_hd_flavor.clone(),
312        };
313        let converted_snapshot_path = Self::try_convert(
314            work_dir,
315            utxo_hd_flavor,
316            &snapshots,
317            Box::new(converter_bin),
318        )?;
319
320        if commit {
321            Self::commit_converted_snapshot(db_dir, &converted_snapshot_path).with_context(
322                || "Failed to overwrite the ledger state with the converted snapshot.",
323            )?;
324        } else {
325            println!("Snapshot location: {}", converted_snapshot_path.display());
326        }
327
328        Ok(())
329    }
330
331    fn try_convert(
332        work_dir: &Path,
333        utxo_hd_flavor: &UTxOHDFlavor,
334        snapshots: &[PathBuf],
335        converter: Box<dyn SnapshotConverter>,
336    ) -> MithrilResult<PathBuf> {
337        let snapshots_dir = work_dir.join(SNAPSHOTS_DIR);
338
339        for (i, snapshot) in snapshots.iter().enumerate() {
340            let attempt = i + 1;
341            println!("Converting '{}' (attempt #{})", snapshot.display(), attempt);
342
343            let input_path = copy_dir(snapshot, &snapshots_dir)?;
344            let output_path = Self::compute_converted_snapshot_output_path(
345                &snapshots_dir,
346                &input_path,
347                utxo_hd_flavor,
348            )?;
349
350            match converter.convert(&input_path, &output_path) {
351                Ok(()) => {
352                    return {
353                        println!(
354                            "Successfully converted ledger state snapshot: '{}'",
355                            snapshot.display()
356                        );
357
358                        Ok(output_path)
359                    };
360                }
361                Err(e) => {
362                    eprintln!(
363                        "Failed to convert ledger state snapshot '{}': {e}",
364                        snapshot.display()
365                    );
366                    continue;
367                }
368            };
369        }
370
371        Err(anyhow!(
372            "Failed to convert any of the provided ledger state snapshots to the desired flavor: {}",
373            utxo_hd_flavor
374        ))
375    }
376
377    fn get_snapshot_converter_binary_path(
378        distribution_dir: &Path,
379        target_os: &str,
380    ) -> MithrilResult<PathBuf> {
381        let base_path = distribution_dir.join(SNAPSHOT_CONVERTER_BIN_DIR);
382        let binary_name = match target_os {
383            "linux" | "macos" => SNAPSHOT_CONVERTER_BIN_NAME_UNIX,
384            "windows" => SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS,
385            _ => return Err(anyhow!("Unsupported platform: {}", target_os)),
386        };
387
388        Ok(base_path.join(binary_name))
389    }
390
391    fn get_snapshot_converter_config_path(
392        distribution_dir: &Path,
393        network: &CardanoNetworkCliArg,
394    ) -> PathBuf {
395        distribution_dir
396            .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
397            .join(network.to_string())
398            .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
399    }
400
401    /// Returns the list of valid ledger snapshot directories sorted in ascending order of slot number.
402    ///
403    /// Only directories with numeric names are considered valid snapshots.
404    fn get_sorted_snapshot_dirs(ledger_dir: &Path) -> MithrilResult<Vec<(u64, PathBuf)>> {
405        let entries = read_dir(ledger_dir).with_context(|| {
406            format!(
407                "Failed to read ledger state snapshots directory: {}",
408                ledger_dir.display()
409            )
410        })?;
411
412        let mut snapshots = entries
413            .filter_map(|entry| {
414                let path = entry.ok()?.path();
415                if !path.is_dir() {
416                    return None;
417                }
418                SnapshotConverterCommand::extract_slot_number(&path)
419                    .ok()
420                    .map(|slot| (slot, path))
421            })
422            .collect::<Vec<_>>();
423
424        snapshots.sort_by_key(|(slot, _)| *slot);
425
426        Ok(snapshots)
427    }
428
429    fn find_most_recent_snapshots(db_dir: &Path, count: usize) -> MithrilResult<Vec<PathBuf>> {
430        let ledger_dir = db_dir.join(LEDGER_DIR);
431        let snapshots = Self::get_sorted_snapshot_dirs(&ledger_dir)?
432            .into_iter()
433            .rev()
434            .take(count)
435            .map(|(_, path)| path)
436            .collect::<Vec<_>>();
437
438        if snapshots.is_empty() {
439            return Err(anyhow!(
440                "No valid ledger state snapshots found in directory: {}",
441                ledger_dir.display()
442            ));
443        }
444
445        Ok(snapshots)
446    }
447
448    fn compute_converted_snapshot_output_path(
449        snapshots_dir: &Path,
450        input_snapshot: &Path,
451        flavor: &UTxOHDFlavor,
452    ) -> MithrilResult<PathBuf> {
453        let slot_number = Self::extract_slot_number(input_snapshot).with_context(|| {
454            format!(
455                "Failed to extract slot number from: {}",
456                input_snapshot.display()
457            )
458        })?;
459        let converted_snapshot_path = snapshots_dir.join(format!(
460            "{}_{}",
461            slot_number,
462            flavor.to_string().to_lowercase()
463        ));
464
465        Ok(converted_snapshot_path)
466    }
467
468    fn extract_slot_number(path: &Path) -> MithrilResult<u64> {
469        let file_name = path
470            .file_name()
471            .ok_or_else(|| anyhow!("No filename in path: {}", path.display()))?;
472        let file_name_str = file_name
473            .to_str()
474            .ok_or_else(|| anyhow!("Invalid UTF-8 in path filename: {:?}", file_name))?;
475
476        file_name_str
477            .parse::<u64>()
478            .with_context(|| format!("Invalid slot number in path filename: {file_name_str}"))
479    }
480
481    /// Commits the converted snapshot by replacing the current ledger state snapshots in the database directory.
482    fn commit_converted_snapshot(
483        db_dir: &Path,
484        converted_snapshot_path: &Path,
485    ) -> MithrilResult<()> {
486        let ledger_dir = db_dir.join(LEDGER_DIR);
487        println!(
488            "Upgrading and replacing ledger state in {} with converted snapshot: {}",
489            ledger_dir.display(),
490            converted_snapshot_path.display()
491        );
492        let filename = converted_snapshot_path
493            .file_name()
494            .ok_or_else(|| anyhow!("Missing filename in converted snapshot path"))?
495            .to_string_lossy();
496        let (slot_number, _) = filename
497            .split_once('_')
498            .ok_or_else(|| anyhow!("Invalid converted snapshot name format: {}", filename))?;
499        remove_dir_contents(&ledger_dir).with_context(|| {
500            format!(
501                "Failed to remove contents of ledger directory: {}",
502                ledger_dir.display()
503            )
504        })?;
505        let destination = ledger_dir.join(slot_number);
506        rename(converted_snapshot_path, &destination).with_context(|| {
507            format!(
508                "Failed to move converted snapshot to ledger directory: {}",
509                destination.display()
510            )
511        })?;
512
513        Ok(())
514    }
515
516    fn cleanup(
517        work_dir: &Path,
518        distribution_dir: &Path,
519        commit: bool,
520        success: bool,
521    ) -> MithrilResult<()> {
522        match (success, commit) {
523            (true, true) => {
524                remove_dir_all(distribution_dir)?;
525                remove_dir_all(work_dir)?;
526            }
527            (true, false) => {
528                remove_dir_all(distribution_dir)?;
529            }
530            (false, _) => {
531                remove_dir_all(distribution_dir)?;
532                remove_dir_all(work_dir)?;
533            }
534        }
535
536        Ok(())
537    }
538
539    fn detect_cardano_network(db_dir: &Path) -> MithrilResult<CardanoNetworkCliArg> {
540        let magic_id_path = db_dir.join(PROTOCOL_MAGIC_ID_FILE);
541        let content = std::fs::read_to_string(&magic_id_path).with_context(|| {
542            format!(
543                "Failed to read protocolMagicId file: {}",
544                magic_id_path.display()
545            )
546        })?;
547        let id: MagicId = content
548            .trim()
549            .parse()
550            .with_context(|| format!("Invalid protocolMagicId value: '{}'", content.trim()))?;
551        let network = CardanoNetwork::from(id);
552
553        CardanoNetworkCliArg::try_from(network)
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use std::fs::File;
560
561    use mithril_common::temp_dir_create;
562
563    use super::*;
564
565    mod download_cardano_node_distribution {
566        use mockall::predicate::eq;
567        use reqwest::Url;
568
569        use crate::utils::{GitHubRelease, MockGitHubReleaseRetriever, MockHttpDownloader};
570
571        use super::*;
572
573        #[tokio::test]
574        async fn downloads_latest_release_distribution() {
575            let temp_dir = temp_dir_create!();
576            let release = GitHubRelease::dummy_with_all_supported_assets();
577            let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
578
579            let cloned_release = release.clone();
580            let mut github_api_client = MockGitHubReleaseRetriever::new();
581            github_api_client
582                .expect_get_latest_release()
583                .with(eq(GITHUB_ORGANIZATION), eq(GITHUB_REPOSITORY))
584                .returning(move |_, _| Ok(cloned_release.clone()));
585
586            let mut http_downloader = MockHttpDownloader::new();
587            http_downloader
588                .expect_download_file()
589                .with(
590                    eq(Url::parse(&asset.browser_download_url).unwrap()),
591                    eq(temp_dir.clone()),
592                    eq(asset.name.clone()),
593                )
594                .returning(|_, _, _| Ok(PathBuf::new()));
595
596            SnapshotConverterCommand::download_cardano_node_distribution(
597                github_api_client,
598                http_downloader,
599                LATEST_DISTRIBUTION_TAG,
600                &temp_dir,
601            )
602            .await
603            .unwrap();
604        }
605
606        #[tokio::test]
607        async fn downloads_prerelease_distribution() {
608            let temp_dir = temp_dir_create!();
609            let release = GitHubRelease::dummy_with_all_supported_assets();
610            let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
611
612            let cloned_release = release.clone();
613            let mut github_api_client = MockGitHubReleaseRetriever::new();
614            github_api_client
615                .expect_get_prerelease()
616                .with(eq(GITHUB_ORGANIZATION), eq(GITHUB_REPOSITORY))
617                .returning(move |_, _| Ok(cloned_release.clone()));
618
619            let mut http_downloader = MockHttpDownloader::new();
620            http_downloader
621                .expect_download_file()
622                .with(
623                    eq(Url::parse(&asset.browser_download_url).unwrap()),
624                    eq(temp_dir.clone()),
625                    eq(asset.name.clone()),
626                )
627                .returning(|_, _, _| Ok(PathBuf::new()));
628
629            SnapshotConverterCommand::download_cardano_node_distribution(
630                github_api_client,
631                http_downloader,
632                PRERELEASE_DISTRIBUTION_TAG,
633                &temp_dir,
634            )
635            .await
636            .unwrap();
637        }
638
639        #[tokio::test]
640        async fn downloads_tagged_release_distribution() {
641            let cardano_node_version = "10.3.1";
642            let temp_dir = temp_dir_create!();
643            let release = GitHubRelease::dummy_with_all_supported_assets();
644            let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
645
646            let cloned_release = release.clone();
647            let mut github_api_client = MockGitHubReleaseRetriever::new();
648            github_api_client
649                .expect_get_release_by_tag()
650                .with(
651                    eq(GITHUB_ORGANIZATION),
652                    eq(GITHUB_REPOSITORY),
653                    eq(cardano_node_version),
654                )
655                .returning(move |_, _, _| Ok(cloned_release.clone()));
656
657            let mut http_downloader = MockHttpDownloader::new();
658            http_downloader
659                .expect_download_file()
660                .with(
661                    eq(Url::parse(&asset.browser_download_url).unwrap()),
662                    eq(temp_dir.clone()),
663                    eq(asset.name.clone()),
664                )
665                .returning(|_, _, _| Ok(PathBuf::new()));
666
667            SnapshotConverterCommand::download_cardano_node_distribution(
668                github_api_client,
669                http_downloader,
670                cardano_node_version,
671                &temp_dir,
672            )
673            .await
674            .unwrap();
675        }
676    }
677
678    mod get_snapshot_converter_binary_path {
679        use super::*;
680
681        #[test]
682        fn returns_correct_binary_path_for_linux() {
683            let distribution_dir = PathBuf::from("/path/to/distribution");
684
685            let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
686                &distribution_dir,
687                "linux",
688            )
689            .unwrap();
690
691            assert_eq!(
692                binary_path,
693                distribution_dir
694                    .join(SNAPSHOT_CONVERTER_BIN_DIR)
695                    .join(SNAPSHOT_CONVERTER_BIN_NAME_UNIX)
696            );
697        }
698
699        #[test]
700        fn returns_correct_binary_path_for_macos() {
701            let distribution_dir = PathBuf::from("/path/to/distribution");
702
703            let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
704                &distribution_dir,
705                "macos",
706            )
707            .unwrap();
708
709            assert_eq!(
710                binary_path,
711                distribution_dir
712                    .join(SNAPSHOT_CONVERTER_BIN_DIR)
713                    .join(SNAPSHOT_CONVERTER_BIN_NAME_UNIX)
714            );
715        }
716
717        #[test]
718        fn returns_correct_binary_path_for_windows() {
719            let distribution_dir = PathBuf::from("/path/to/distribution");
720
721            let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
722                &distribution_dir,
723                "windows",
724            )
725            .unwrap();
726
727            assert_eq!(
728                binary_path,
729                distribution_dir
730                    .join(SNAPSHOT_CONVERTER_BIN_DIR)
731                    .join(SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS)
732            );
733        }
734    }
735
736    mod get_snapshot_converter_config_path {
737        use super::*;
738
739        #[test]
740        fn returns_config_path_for_mainnet() {
741            let distribution_dir = PathBuf::from("/path/to/distribution");
742            let network = CardanoNetworkCliArg::Mainnet;
743
744            let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
745                &distribution_dir,
746                &network,
747            );
748
749            assert_eq!(
750                config_path,
751                distribution_dir
752                    .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
753                    .join(network.to_string())
754                    .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
755            );
756        }
757
758        #[test]
759        fn returns_config_path_for_preprod() {
760            let distribution_dir = PathBuf::from("/path/to/distribution");
761            let network = CardanoNetworkCliArg::Preprod;
762
763            let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
764                &distribution_dir,
765                &network,
766            );
767
768            assert_eq!(
769                config_path,
770                distribution_dir
771                    .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
772                    .join(network.to_string())
773                    .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
774            );
775        }
776
777        #[test]
778        fn returns_config_path_for_preview() {
779            let distribution_dir = PathBuf::from("/path/to/distribution");
780            let network = CardanoNetworkCliArg::Preview;
781
782            let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
783                &distribution_dir,
784                &network,
785            );
786
787            assert_eq!(
788                config_path,
789                distribution_dir
790                    .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
791                    .join(network.to_string())
792                    .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
793            );
794        }
795    }
796
797    mod extract_slot_number {
798        use super::*;
799
800        #[test]
801        fn parses_valid_numeric_path() {
802            let path = PathBuf::from("/whatever").join("123456");
803
804            let slot = SnapshotConverterCommand::extract_slot_number(&path).unwrap();
805
806            assert_eq!(slot, 123456);
807        }
808
809        #[test]
810        fn fails_with_non_numeric_filename() {
811            let path = PathBuf::from("/whatever").join("notanumber");
812
813            SnapshotConverterCommand::extract_slot_number(&path)
814                .expect_err("Should fail with non-numeric filename");
815        }
816
817        #[test]
818        fn fails_if_no_filename() {
819            let path = PathBuf::from("/");
820
821            SnapshotConverterCommand::extract_slot_number(&path)
822                .expect_err("Should fail if path has no filename");
823        }
824    }
825
826    mod compute_converted_snapshot_output_path {
827        use super::*;
828
829        #[test]
830        fn compute_output_path_from_numeric_file_name() {
831            let snapshots_dir = PathBuf::from("/snapshots");
832            let input_snapshot = PathBuf::from("/whatever").join("123456");
833
834            {
835                let snapshot_path =
836                    SnapshotConverterCommand::compute_converted_snapshot_output_path(
837                        &snapshots_dir,
838                        &input_snapshot,
839                        &UTxOHDFlavor::Lmdb,
840                    )
841                    .unwrap();
842
843                assert_eq!(snapshot_path, snapshots_dir.join("123456_lmdb"));
844            }
845
846            {
847                let snapshot_path =
848                    SnapshotConverterCommand::compute_converted_snapshot_output_path(
849                        &snapshots_dir,
850                        &input_snapshot,
851                        &UTxOHDFlavor::Legacy,
852                    )
853                    .unwrap();
854
855                assert_eq!(snapshot_path, snapshots_dir.join("123456_legacy"));
856            }
857        }
858
859        #[test]
860        fn fails_with_invalid_slot_number() {
861            let snapshots_dir = PathBuf::from("/snapshots");
862            let input_snapshot = PathBuf::from("/whatever/notanumber");
863
864            SnapshotConverterCommand::compute_converted_snapshot_output_path(
865                &snapshots_dir,
866                &input_snapshot,
867                &UTxOHDFlavor::Lmdb,
868            )
869            .expect_err("Should fail with invalid slot number");
870        }
871    }
872
873    mod commit_converted_snapshot {
874        use super::*;
875
876        #[test]
877        fn moves_converted_snapshot_to_ledger_directory() {
878            let tmp_dir = temp_dir_create!();
879            let ledger_dir = tmp_dir.join(LEDGER_DIR);
880            create_dir(&ledger_dir).unwrap();
881            let previous_snapshot = ledger_dir.join("123");
882            File::create(&previous_snapshot).unwrap();
883
884            let converted_snapshot = tmp_dir.join("456_lmdb");
885            File::create(&converted_snapshot).unwrap();
886
887            assert!(previous_snapshot.exists());
888            SnapshotConverterCommand::commit_converted_snapshot(&tmp_dir, &converted_snapshot)
889                .unwrap();
890
891            assert!(!previous_snapshot.exists());
892            assert!(ledger_dir.join("456").exists());
893        }
894
895        #[test]
896        fn fails_if_converted_snapshot_has_invalid_filename() {
897            let tmp_dir = temp_dir_create!();
898            let ledger_dir = tmp_dir.join(LEDGER_DIR);
899            create_dir(&ledger_dir).unwrap();
900            let previous_snapshot = ledger_dir.join("123");
901            File::create(&previous_snapshot).unwrap();
902
903            let converted_snapshot = tmp_dir.join("456");
904            File::create(&converted_snapshot).unwrap();
905
906            SnapshotConverterCommand::commit_converted_snapshot(&tmp_dir, &converted_snapshot)
907                .expect_err("Should fail if converted snapshot has invalid filename");
908
909            assert!(previous_snapshot.exists());
910        }
911    }
912
913    mod cleanup {
914        use super::*;
915
916        #[test]
917        fn removes_both_dirs_on_success_when_commit_is_true() {
918            let tmp = temp_dir_create!();
919            let work_dir = tmp.join("workdir_dir");
920            let distribution_dir = tmp.join("distribution_dir");
921            create_dir(&work_dir).unwrap();
922            create_dir(&distribution_dir).unwrap();
923
924            SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, true, true).unwrap();
925
926            assert!(!distribution_dir.exists());
927            assert!(!work_dir.exists());
928        }
929
930        #[test]
931        fn removes_only_distribution_on_success_when_commit_is_false() {
932            let tmp = temp_dir_create!();
933            let work_dir = tmp.join("workdir_dir");
934            let distribution_dir = tmp.join("distribution_dir");
935            create_dir(&work_dir).unwrap();
936            create_dir(&distribution_dir).unwrap();
937
938            SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, true).unwrap();
939
940            assert!(!distribution_dir.exists());
941            assert!(work_dir.exists());
942        }
943
944        #[test]
945        fn removes_both_dirs_on_success_when_commit_is_true_and_distribution_is_nested() {
946            let tmp = temp_dir_create!();
947            let work_dir = tmp.join("workdir_dir");
948            let distribution_dir = work_dir.join("distribution_dir");
949            create_dir(&work_dir).unwrap();
950            create_dir(&distribution_dir).unwrap();
951
952            SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, true, true).unwrap();
953
954            assert!(!distribution_dir.exists());
955            assert!(!work_dir.exists());
956        }
957
958        #[test]
959        fn removes_only_distribution_on_success_when_commit_is_false_and_distribution_is_nested() {
960            let tmp = temp_dir_create!();
961            let work_dir = tmp.join("workdir_dir");
962            let distribution_dir = work_dir.join("distribution_dir");
963            create_dir(&work_dir).unwrap();
964            create_dir(&distribution_dir).unwrap();
965
966            SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, true).unwrap();
967
968            assert!(!distribution_dir.exists());
969            assert!(work_dir.exists());
970        }
971
972        #[test]
973        fn removes_both_dirs_on_failure() {
974            let tmp = temp_dir_create!();
975            let work_dir = tmp.join("workdir_dir");
976            let distribution_dir = tmp.join("distribution_dir");
977            create_dir(&work_dir).unwrap();
978            create_dir(&distribution_dir).unwrap();
979
980            SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, false).unwrap();
981
982            assert!(!distribution_dir.exists());
983            assert!(!work_dir.exists());
984        }
985    }
986
987    mod detect_cardano_network {
988        use super::*;
989
990        fn create_protocol_magic_id_file(db_dir: &Path, magic_id: MagicId) -> PathBuf {
991            let file_path = db_dir.join(PROTOCOL_MAGIC_ID_FILE);
992            std::fs::write(&file_path, magic_id.to_string()).unwrap();
993
994            file_path
995        }
996
997        #[test]
998        fn detects_mainnet() {
999            let db_dir = temp_dir_create!();
1000            create_protocol_magic_id_file(&db_dir, CardanoNetwork::MAINNET_MAGIC_ID);
1001
1002            let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1003
1004            assert_eq!(network, CardanoNetworkCliArg::Mainnet);
1005        }
1006
1007        #[test]
1008        fn detects_preprod() {
1009            let db_dir = temp_dir_create!();
1010            create_protocol_magic_id_file(&db_dir, CardanoNetwork::PREPROD_MAGIC_ID);
1011
1012            let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1013
1014            assert_eq!(network, CardanoNetworkCliArg::Preprod);
1015        }
1016
1017        #[test]
1018        fn detects_preview() {
1019            let db_dir = temp_dir_create!();
1020            create_protocol_magic_id_file(&db_dir, CardanoNetwork::PREVIEW_MAGIC_ID);
1021
1022            let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1023
1024            assert_eq!(network, CardanoNetworkCliArg::Preview);
1025        }
1026
1027        #[test]
1028        fn fails_on_invalid_network() {
1029            let db_dir = temp_dir_create!();
1030            let invalid_magic_id = 999999;
1031            create_protocol_magic_id_file(&db_dir, invalid_magic_id);
1032
1033            SnapshotConverterCommand::detect_cardano_network(&db_dir)
1034                .expect_err("Should fail with invalid network magic ID");
1035        }
1036
1037        #[test]
1038        fn fails_when_protocol_magic_id_file_is_empty() {
1039            let db_dir = temp_dir_create!();
1040            File::create(db_dir.join(PROTOCOL_MAGIC_ID_FILE)).unwrap();
1041
1042            SnapshotConverterCommand::detect_cardano_network(&db_dir)
1043                .expect_err("Should fail when protocol magic ID file is empty");
1044        }
1045
1046        #[test]
1047        fn fails_when_protocol_magic_id_file_is_missing() {
1048            let db_dir = temp_dir_create!();
1049
1050            assert!(!db_dir.join(PROTOCOL_MAGIC_ID_FILE).exists());
1051
1052            SnapshotConverterCommand::detect_cardano_network(&db_dir)
1053                .expect_err("Should fail when protocol magic ID file is missing");
1054        }
1055    }
1056
1057    mod snapshots_indexing {
1058        use mithril_common::temp_dir_create;
1059
1060        use super::*;
1061
1062        #[test]
1063        fn returns_the_two_most_recent_snapshots() {
1064            let db_dir = temp_dir_create!();
1065            let ledger_dir = db_dir.join(LEDGER_DIR);
1066            create_dir(&ledger_dir).unwrap();
1067
1068            create_dir(ledger_dir.join("1500")).unwrap();
1069            create_dir(ledger_dir.join("500")).unwrap();
1070            create_dir(ledger_dir.join("1000")).unwrap();
1071
1072            let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 2).unwrap();
1073
1074            assert_eq!(
1075                found,
1076                vec![ledger_dir.join("1500"), ledger_dir.join("1000")]
1077            );
1078        }
1079
1080        #[test]
1081        fn returns_list_with_one_entry_if_only_one_valid_snapshot() {
1082            let db_dir = temp_dir_create!();
1083            let ledger_dir = db_dir.join(LEDGER_DIR);
1084            create_dir(&ledger_dir).unwrap();
1085
1086            create_dir(ledger_dir.join("500")).unwrap();
1087
1088            let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 2).unwrap();
1089
1090            assert_eq!(found, vec![ledger_dir.join("500")]);
1091        }
1092
1093        #[test]
1094        fn ignores_non_numeric_and_non_directory_entries() {
1095            let temp_dir = temp_dir_create!();
1096            let ledger_dir = temp_dir.join(LEDGER_DIR);
1097            create_dir(&ledger_dir).unwrap();
1098
1099            create_dir(ledger_dir.join("1000")).unwrap();
1100            File::create(ledger_dir.join("500")).unwrap();
1101            create_dir(ledger_dir.join("invalid")).unwrap();
1102
1103            let found = SnapshotConverterCommand::find_most_recent_snapshots(&temp_dir, 2).unwrap();
1104
1105            assert_eq!(found, vec![ledger_dir.join("1000")]);
1106        }
1107
1108        #[test]
1109        fn returns_all_available_snapshots_when_count_exceeds_available() {
1110            let db_dir = temp_dir_create!();
1111            let ledger_dir = db_dir.join(LEDGER_DIR);
1112            create_dir(&ledger_dir).unwrap();
1113
1114            create_dir(ledger_dir.join("1000")).unwrap();
1115            create_dir(ledger_dir.join("1500")).unwrap();
1116
1117            let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 99).unwrap();
1118
1119            assert_eq!(
1120                found,
1121                vec![ledger_dir.join("1500"), ledger_dir.join("1000")]
1122            );
1123        }
1124
1125        #[test]
1126        fn returns_error_if_no_valid_snapshot_found() {
1127            let temp_dir = temp_dir_create!();
1128            let ledger_dir = temp_dir.join(LEDGER_DIR);
1129            create_dir(&ledger_dir).unwrap();
1130
1131            File::create(ledger_dir.join("invalid")).unwrap();
1132
1133            SnapshotConverterCommand::find_most_recent_snapshots(&temp_dir, 2)
1134                .expect_err("Should return error if no valid ledger snapshot directory found");
1135        }
1136
1137        #[test]
1138        fn get_sorted_snapshot_dirs_returns_sorted_valid_directories() {
1139            let temp_dir = temp_dir_create!();
1140            let ledger_dir = temp_dir.join(LEDGER_DIR);
1141            create_dir(&ledger_dir).unwrap();
1142
1143            create_dir(ledger_dir.join("1500")).unwrap();
1144            create_dir(ledger_dir.join("1000")).unwrap();
1145            create_dir(ledger_dir.join("2000")).unwrap();
1146            File::create(ledger_dir.join("500")).unwrap();
1147            create_dir(ledger_dir.join("notanumber")).unwrap();
1148
1149            let snapshots =
1150                SnapshotConverterCommand::get_sorted_snapshot_dirs(&ledger_dir).unwrap();
1151
1152            assert_eq!(
1153                snapshots,
1154                vec![
1155                    (1000, ledger_dir.join("1000")),
1156                    (1500, ledger_dir.join("1500")),
1157                    (2000, ledger_dir.join("2000")),
1158                ]
1159            );
1160        }
1161    }
1162
1163    mod snapshot_conversion_fallback {
1164        use mockall::predicate::{self, always};
1165
1166        use super::*;
1167
1168        fn create_dummy_snapshots(dir: &Path, count: usize) -> Vec<PathBuf> {
1169            (1..=count)
1170                .map(|i| {
1171                    let snapshot_path = dir.join(format!("{i}"));
1172                    create_dir(&snapshot_path).unwrap();
1173                    snapshot_path
1174                })
1175                .collect()
1176        }
1177
1178        fn contains_filename(expected: &Path) -> impl Fn(&Path) -> bool + use<> {
1179            let filename = expected.file_name().unwrap().to_string_lossy().to_string();
1180
1181            move |p: &Path| p.to_string_lossy().contains(&filename)
1182        }
1183
1184        #[test]
1185        fn conversion_succeed_and_is_called_once_if_first_conversion_succeeds() {
1186            let temp_dir = temp_dir_create!();
1187            let snapshots = create_dummy_snapshots(&temp_dir, 2);
1188
1189            let mut converter = MockSnapshotConverter::new();
1190            converter
1191                .expect_convert()
1192                .with(
1193                    predicate::function(contains_filename(&snapshots[0])),
1194                    always(),
1195                )
1196                .return_once(|_, _| Ok(()))
1197                .once();
1198
1199            SnapshotConverterCommand::try_convert(
1200                Path::new(""),
1201                &UTxOHDFlavor::Lmdb,
1202                &snapshots,
1203                Box::new(converter),
1204            )
1205            .unwrap();
1206        }
1207
1208        #[test]
1209        fn conversion_falls_back_if_first_conversion_fails() {
1210            let temp_dir = temp_dir_create!();
1211            let snapshots = create_dummy_snapshots(&temp_dir, 2);
1212
1213            let mut converter = MockSnapshotConverter::new();
1214            converter
1215                .expect_convert()
1216                .with(
1217                    predicate::function(contains_filename(&snapshots[0])),
1218                    always(),
1219                )
1220                .return_once(|_, _| Err(anyhow!("Error during conversion")))
1221                .once();
1222            converter
1223                .expect_convert()
1224                .with(
1225                    predicate::function(contains_filename(&snapshots[1])),
1226                    always(),
1227                )
1228                .return_once(|_, _| Ok(()))
1229                .once();
1230
1231            SnapshotConverterCommand::try_convert(
1232                Path::new(""),
1233                &UTxOHDFlavor::Lmdb,
1234                &snapshots,
1235                Box::new(converter),
1236            )
1237            .expect("Should succeed even if the first conversion fails");
1238        }
1239
1240        #[test]
1241        fn conversion_fails_if_all_attempts_fail() {
1242            let temp_dir = temp_dir_create!();
1243            let snapshots = create_dummy_snapshots(&temp_dir, 2);
1244            let mut converter = MockSnapshotConverter::new();
1245            converter
1246                .expect_convert()
1247                .returning(|_, _| Err(anyhow!("Conversion failed")))
1248                .times(2);
1249
1250            SnapshotConverterCommand::try_convert(
1251                Path::new(""),
1252                &UTxOHDFlavor::Lmdb,
1253                &snapshots,
1254                Box::new(converter),
1255            )
1256            .expect_err("Should fail if all conversion attempts fail");
1257        }
1258    }
1259}