diff --git a/.gitignore b/.gitignore index 3d8dac3c..0983ff94 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,10 @@ Cargo.lock # Ignore IDE/editor files .idea/ +.zed/ .vscode/ docs # ignore IOTA build artifacts & package locks build - diff --git a/Cargo.toml b/Cargo.toml index a31ec98a..505577be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ serde_json = { version = "1.0", default-features = false } strum = { version = "0.25", default-features = false, features = ["std", "derive"] } thiserror = { version = "1.0", default-features = false } tokio = { version = "1.46.1", default-features = false, features = ["process"] } +tempfile = "3.20.0" +toml = "0.8" [workspace.lints.clippy] result_large_err = "allow" diff --git a/iota_interaction/Cargo.toml b/iota_interaction/Cargo.toml index b1bcad2a..989baa1a 100644 --- a/iota_interaction/Cargo.toml +++ b/iota_interaction/Cargo.toml @@ -17,7 +17,7 @@ async-trait = { version = "0.1.81", default-features = false } bcs.workspace = true cfg-if.workspace = true fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto", features = ["copy_key"] } -indexmap = "2.9" +indexmap = "2.11.0" jsonpath-rust = { version = "0.5.1", optional = true } secret-storage.workspace = true serde.workspace = true diff --git a/product_common/Cargo.toml b/product_common/Cargo.toml index 519ca874..30f25832 100644 --- a/product_common/Cargo.toml +++ b/product_common/Cargo.toml @@ -11,85 +11,88 @@ repository.workspace = true rust-version.workspace = true description = "Sources shared by IOTA products." +[package.metadata.docs.rs] +# To build locally: +# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [dependencies] anyhow.workspace = true async-trait.workspace = true bcs = { workspace = true, optional = true } cfg-if.workspace = true fastcrypto = { workspace = true, optional = true } -iota-keys = { git = "https://github.com/iotaledger/iota.git", package = "iota-keys", tag = "v1.10.0", optional = true } +iota-keys = { package = "iota-keys", git = "https://github.com/iotaledger/iota.git", tag = "v1.10.0", optional = true } itertools = { version = "0.13.0", optional = true } lazy_static = { version = "1.5.0", optional = true } phf.workspace = true reqwest = { version = "0.12", default-features = false, optional = true } -secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", optional = true, default-features = false } +secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false, optional = true } serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true -toml = "0.8" -url = { version = "2", default-features = false, optional = true, features = ["serde"] } +toml = { workspace = true, optional = true } +url = { version = "2", default-features = false, features = ["serde"], optional = true } + +[dev-dependencies] +iota_interaction = { path = "../iota_interaction", version = "0.8.5" } +iota_interaction_rust = { path = "../iota_interaction_rust", version = "0.8.5" } +tempfile.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -iota_interaction = { version = "0.8.5", path = "../iota_interaction", features = ["keytool"] } -iota_interaction_rust = { version = "0.8.5", path = "../iota_interaction_rust", optional = true } +iota_interaction = { path = "../iota_interaction", version = "0.8.5", features = ["keytool"] } +iota_interaction_rust = { path = "../iota_interaction_rust", version = "0.8.5", optional = true } iota-sdk.workspace = true tokio.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] -iota_interaction = { version = "0.8.5", path = "../iota_interaction", default-features = false } -iota_interaction_ts = { version = "0.8.5", path = "../bindings/wasm/iota_interaction_ts" } +iota_interaction = { path = "../iota_interaction", version = "0.8.5", default-features = false } +iota_interaction_ts = { path = "../bindings/wasm/iota_interaction_ts", version = "0.8.5" } +js-sys = { version = "0.3", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } wasm-bindgen = { version = "0.2.100", optional = true } wasm-bindgen-futures = { version = "0.4", default-features = false, optional = true } -serde-wasm-bindgen = { version = "0.6", optional = true } -js-sys = { version = "0.3", optional = true } - -[dev-dependencies] -iota_interaction = { version = "0.8.5", path = "../iota_interaction" } -iota_interaction_rust = { version = "0.8.5", path = "../iota_interaction_rust" } [features] default = [] -send-sync = ["secret-storage/send-sync-storage"] +binding-utils = ["bindings", "core-client", "transaction"] bindings = [ - "dep:wasm-bindgen", - "dep:wasm-bindgen-futures", "dep:js-sys", "dep:serde-wasm-bindgen", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", ] -binding-utils = ["bindings", "transaction", "core-client"] +core-client = ["dep:secret-storage"] +# APIs that rely on an HTTP Client won't require the user to provide an HttpClient instance but will +# instead use reqwest::Client. +default-http-client = ["dep:reqwest", "http-client"] +gas-station = ["http-client", "transaction"] +http-client = ["dep:url"] +# Management functions to read Move.lock files and create/update Move.history.json files +move-history-manager = ["dep:toml"] +send-sync = ["secret-storage/send-sync-storage"] test-utils = [ - "dep:lazy_static", - "dep:secret-storage", - "dep:iota-keys", "dep:bcs", "dep:fastcrypto", + "dep:iota-keys", + "dep:lazy_static", + "dep:secret-storage", "send-sync", ] -core-client = ["dep:secret-storage"] transaction = [ "core-client", + "dep:bcs", + "dep:fastcrypto", "dep:iota_interaction_rust", "dep:itertools", "dep:secret-storage", - "dep:fastcrypto", - "dep:bcs", ] -http-client = ["dep:url"] -gas-station = ["transaction", "http-client"] -# APIs that rely on an HTTP Client won't require the user to provide an HttpClient instance but will -# instead use reqwest::Client. -default-http-client = ["http-client", "dep:reqwest"] - -[package.metadata.docs.rs] -# To build locally: -# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open -all-features = true -rustdoc-args = ["--cfg", "docsrs"] [lints.clippy] result_large_err = "allow" [lints.rust] # from local sdk types -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(msim)'] } +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(msim)"] } diff --git a/product_common/src/lib.rs b/product_common/src/lib.rs index 7f3bb4cb..92a317eb 100644 --- a/product_common/src/lib.rs +++ b/product_common/src/lib.rs @@ -10,6 +10,8 @@ pub mod error; pub mod gas_station; #[cfg(feature = "http-client")] pub mod http_client; +#[cfg(feature = "move-history-manager")] +pub mod move_history_manager; pub mod network_name; pub mod object; pub mod package_registry; diff --git a/product_common/src/move_history_manager.rs b/product_common/src/move_history_manager.rs new file mode 100644 index 00000000..c60d26f8 --- /dev/null +++ b/product_common/src/move_history_manager.rs @@ -0,0 +1,840 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use iota_interaction::types::base_types::ObjectID; + +use super::package_registry::{Env, PackageRegistry}; + +/// Helper function to extract an ID field from a TOML table with proper error handling. +/// +/// # Arguments +/// * `table` - The TOML table to extract the value from. +/// * `key` - The key to extract from the table. +/// * `alias` - The environment alias for error context. +/// +/// # Returns +/// The extracted string value or an error with context. +fn get_id_from_table(table: &toml::Table, key: &str, alias: &str) -> Result { + Ok( + table + .get(key) + .with_context(|| format!("invalid Move.lock file: missing `{key}` for env {alias}"))? + .as_str() + .with_context(|| format!("invalid Move.lock file: `{key}` for env {alias} is not a string"))? + .to_string(), + ) +} + +impl PackageRegistry { + /// Creates a [`PackageRegistry`] from the content of a `Move.lock` file. + /// + /// # Arguments + /// * `move_lock` - A string containing the content of the `Move.lock` file. + /// * `aliases_to_watch` - A vector of environment aliases to include in the registry. Only environments with aliases + /// in this list will be processed and added to the registry. Other environments in the `Move.lock` file will be + /// ignored. + /// + /// # Returns + /// A `PackageRegistry` instance populated with data from the `Move.lock` file. + /// + /// # Errors + /// Returns an error if the `Move.lock` file content is invalid or cannot be parsed. + pub fn from_move_lock_content(move_lock: &str, aliases_to_watch: &[String]) -> anyhow::Result { + let mut move_lock: toml::Table = move_lock.parse()?; + + let Some(mut env_value) = move_lock.remove("env") else { + return Ok(Self::default()); + }; + + let mut move_lock_iter = env_value + .as_table_mut() + .map(std::mem::take) + .context("invalid Move.lock file: `env` is not a table")? + .into_iter(); + + move_lock_iter.try_fold(Self::default(), |mut registry, (alias, table)| { + if !aliases_to_watch.contains(&alias) { + return Ok(registry); + } + let toml::Value::Table(mut table) = table else { + anyhow::bail!("invalid Move.lock file: invalid `env` table"); + }; + let chain_id: String = table + .remove("chain-id") + .with_context(|| format!("invalid Move.lock file: missing `chain-id` for env {alias}"))? + .try_into() + .context("invalid Move.lock file: invalid `chain-id`")?; + + let original_published_id: String = get_id_from_table(&table, "original-published-id", &alias)?; + let latest_published_id: String = get_id_from_table(&table, "latest-published-id", &alias)?; + + let mut metadata = vec![ObjectID::from_hex_literal(original_published_id.as_str())?]; + if original_published_id != latest_published_id { + metadata.push(ObjectID::from_hex_literal(latest_published_id.as_str())?); + } + + let env = Env::new_with_alias(chain_id, alias.clone()); + registry.insert_env(env, metadata); + + Ok(registry) + }) + } +} + +/// Manages the content of `Move.history.json` files, including initialization and updates. +/// Provides the main functionality needed to implement a `build.rs` script in IOTA product repositories. +/// +/// Libraries in IOTA product repositories, depending on Move packages **provided in the same repository**, +/// should have a `build.rs` script (contained in the libraries root folder), that manages the content of +/// the `Move.history.json` file. +/// +/// ## `Move.history.json` file +/// The `Move.history.json` file is used to store the history of package versions for Move packages +/// that the library depends on. See the `PackageRegistry` documentation for more details. +/// +/// The `Move.history.json` file: +/// * should be located in the same directory as the `Move.lock` file of the Move package that the library depends on +/// * contains all data that is provided by the `PackageRegistry`, which is used by the library to interact with the +/// Move package +/// * will be integrated into build rust binaries at build time (using include_str!()) when the library is built +/// * should not contain the package versions of the `localnet` environment, as this would probably blow up the size of +/// the file and is not needed for production use cases +/// * should be added to the git repository also containing the library and the Move package +/// * should be updated by a `build.rs` script in the library package, whenever the `Move.lock` file of the Move package +/// changes - see below for a `build.rs` example using the `MoveHistoryManager` +/// +/// Edge cases in `Move.history.json` handling: +/// * Network Resets
In case of network resets (for example on devnet), packages already published on the network +/// are deleted. In this case, developers need to redeploy the package on the reset network. Since the +/// `Move.history.json` file shall only contain the history of available package versions (still stored onchain), the +/// file needs to be manually edited by the developer to remove the deleted package versions from the address list of +/// the specific environment (in the`env` entry). Alternatively the whole `env` entry and associated alias can be +/// removed. Make sure to maintain the consistency of the `Move.history.json` file when doing so. +/// * Redeployment (Breaking Changes)
In general, breaking changes of packages already published on mainnet and +/// testnet, must be avoided. However, if a breaking change is needed (for example in case of a redesigned alpha +/// version), the package will be initially redeployed even if this package is already used by community developers. +/// Due to the initial redeployment of the package, the `MoveHistoryManager` will fetch the new `latest-published-id` +/// (equaling the new `original-published-id`) from the `Move.lock` file and add the id to the address list of the +/// environment. The addresses of the old package versions will remain in the history as well, so that objects created +/// with old package versions can still be found. +/// +/// ## `build.rs` scripts +/// The `MoveHistoryManager` is designed to be used in a `build.rs` script of a library that depends on a Move package +/// provided in the same repository. The `build.rs` script described in the example below will be build and +/// executed every time when the library, containing the `build.rs` script, is built and the `Move.lock` +/// file of the Move package has changed. +/// +/// When the library is built and the timestamp of the `Move.lock` file has changed, the `MoveHistoryManager` +/// will check if the `Move.lock` file exists and the `Move.history.json` file will be: +/// * created, if a `Move.lock` file exists, but the `Move.history.json` file does not exist yet +/// * updated, if both the `Move.lock` file and the `Move.history.json` file exist +/// +/// If the `Move.lock` file doesn't exist the whole processing is skipped. +/// +/// ## Example `build.rs` script +/// This example shows how to use the `MoveHistoryManager` in a `build.rs` script. +/// +/// * Please replace `` with the actual name of the Move package +/// * In this example, the Move package is expected to be located in the parent directory of the library package. If +/// this is not the case, please edit the file paths accordingly +/// +/// ``` ignore +/// use std::path::PathBuf; +/// +/// use product_common::move_history_manager::MoveHistoryManager; +/// +/// fn main() { +/// let move_lock_path = "..//Move.lock"; +/// println!("[build.rs] move_lock_path: {move_lock_path}"); +/// let move_history_path = "..//Move.history.json"; +/// println!("[build.rs] move_history_path: {move_history_path}"); +/// +/// MoveHistoryManager::new( +/// &PathBuf::from(move_lock_path), +/// &PathBuf::from(move_history_path), +/// // We will watch the default watch list (`get_default_aliases_to_watch()`) in this build script +/// // so we leave the `additional_aliases_to_watch` argument vec empty. +/// // Use for example `vec!["localnet".to_string()]` instead, if you don't want to ignore `localnet`. +/// vec![], +/// ) +/// .manage_history_file(|message| { +/// println!("[build.rs] {}", message); +/// }) +/// .expect("Successfully managed Move history file"); +/// +/// // Tell Cargo to rerun this build script if the Move.lock file changes. +/// println!("cargo::rerun-if-changed={move_lock_path}"); +/// } +/// ``` +/// +/// To use the `MoveHistoryManager` in a `build.rs` script in an IOTA product library package, you need +/// to add the following build dependency in the `cargo.toml` of the crate containing the `build.rs` +/// file: +/// +/// ``` toml +/// [build-dependencies] +/// product_common = { workspace = true, default-features = false, features = ["move-history-manager"] } +/// ``` +#[derive(Debug)] +pub struct MoveHistoryManager { + move_lock_path: PathBuf, + history_file_path: PathBuf, + aliases_to_watch: Vec, +} + +impl MoveHistoryManager { + /// Creates a new `MoveHistoryManager` instance. + /// + /// # Arguments + /// * `move_lock_path` - Path to the `Move.lock` file. + /// * `history_file_path` - Path to the `Move.history.toml` file. + /// * `additional_aliases_to_watch` - List of environment aliases to be watched additionally to those environments, + /// being watched per default (see function `get_default_aliases_to_watch()` for more details). Examples: + /// * Watch only defaults environments: `new(move_lock_path, history_file_path, vec![])` + /// * Additionally watch the `localnet` environment: `new(move_lock_path, history_file_path, vec!["localnet"])` + /// + /// # Returns + /// A new `MoveHistoryManager` instance. + /// + /// Doesn't check if any of the provided paths are invalid or if the `Move.lock` file cannot be parsed. + /// Functions `manage_history_file`, `init` and `update` will handle those checks. + pub fn new(move_lock_path: &Path, history_file_path: &Path, mut additional_aliases_to_watch: Vec) -> Self { + let mut aliases_to_watch = Self::get_default_aliases_to_watch(); + aliases_to_watch.append(&mut additional_aliases_to_watch); + + Self { + move_lock_path: move_lock_path.to_owned(), + history_file_path: history_file_path.to_owned(), + aliases_to_watch, + } + } + + /// Returns the default list of environment aliases being watched if no additional + /// `additional_aliases_to_watch` is provided in the `new()` function. + /// Returns a vector containing `mainnet`, `testnet` and `devnet`. + /// + /// Use the `additional_aliases_to_watch` argument of the `new()` function to specify aliases + /// of additional environments to be watched, e.g. `localnet`. + /// + /// Use method `aliases_to_watch()` to evaluate the complete list of environment aliases being watched + /// by a `MoveHistoryManager` instance. + pub fn get_default_aliases_to_watch() -> Vec { + vec!["mainnet".to_string(), "testnet".to_string(), "devnet".to_string()] + } + + /// Returns the list of environment aliases being watched by this `MoveHistoryManager` instance. + pub fn aliases_to_watch(&self) -> &Vec { + &self.aliases_to_watch + } + + /// Checks if the Move.history.json file exists. + pub fn history_file_exists(&self) -> bool { + self.history_file_path.exists() && self.history_file_path.is_file() + } + + /// Checks if the Move.lock file exists. + pub fn move_lock_file_exists(&self) -> bool { + self.move_lock_path.exists() && self.move_lock_path.is_file() + } + + /// Returns the path to the Move.lock file. + pub fn move_lock_path(&self) -> &Path { + &self.move_lock_path + } + + /// Returns the path to the Move.history.json file. + pub fn history_file_path(&self) -> &Path { + &self.history_file_path + } + + /// Manages the Move history file by either initializing a new one or updating an existing one + /// based on the current Move.lock file. + /// + /// This method checks for the existence of both the Move.lock and Move history files, + /// and performs the appropriate action: + /// - If Move.lock exists and the history file exists: Updates the history file + /// - If Move.lock exists but the history file doesn't: Creates a new history file + /// - If Move.lock doesn't exist: Skips any action + /// + /// Progress messages can be printed to the app console during the operation via the callback function + /// provided using the `console_out` argument. + /// + /// # Arguments + /// * `console_out` - Can be used to output status messages in the app console. It should be a closure that takes a + /// `String` as an argument. Example: `|message| { println!("{}", message); }` + /// # Returns + /// A `Result` that indicates success or contains an error if something went wrong during the process. + /// + /// # Errors + /// This method may return errors from the underlying `init()` or `update()` functions + /// if there are issues reading or writing files. + pub fn manage_history_file(&self, console_out: impl Fn(String)) -> anyhow::Result<()> { + let move_history_path = self.history_file_path.to_string_lossy(); + let move_lock_path = self.move_lock_path.to_string_lossy(); + + if !self.move_lock_file_exists() { + console_out(format!("File `{move_lock_path}` does not exist, skipping...")); + return Ok(()); + } + + // The move_lock_file exists + if self.history_file_exists() { + // If the output file already exists, update it. + console_out(format!("File `{move_history_path}` already exists, updating...")); + self.update()?; + console_out(format!( + "Successfully updated `{move_history_path}` with content of `{move_lock_path}`" + )); + } else { + // If the output file does not exist, create it. + console_out(format!("File `{move_history_path}` does not exist, creating...")); + self.init()?; + console_out(format!( + "Successfully created file `{move_history_path}` with content of `{move_lock_path}` content" + )); + } + Ok(()) + } + + /// Creates an initial Move.history.json file from a Move.lock file + /// Will only take those environment aliases into account, listed in `aliases_to_watch()`. + pub fn init(&self) -> anyhow::Result<()> { + let move_lock_content = fs::read_to_string(&self.move_lock_path) + .with_context(|| format!("Failed to read Move.lock file: {}", &self.move_lock_path.display()))?; + + let registry = PackageRegistry::from_move_lock_content(&move_lock_content, &self.aliases_to_watch) + .context("Failed to parse Move.lock file")?; + + let json_content = serde_json::to_string_pretty(®istry)?; + + fs::write(&self.history_file_path, json_content) + .with_context(|| format!("Failed to write to output file: {}", self.history_file_path.display()))?; + + Ok(()) + } + + /// Updates an existing Move.history.json file with new package versions from a Move.lock file + pub fn update(&self) -> anyhow::Result<()> { + // Read and deserialize existing package history + let history_content = fs::read_to_string(&self.history_file_path).with_context(|| { + format!( + "Failed to read Move.history.json file: {}", + self.history_file_path.display() + ) + })?; + + let mut registry = PackageRegistry::from_package_history_json_str(&history_content) + .context("Failed to parse existing Move.history.json file")?; + + // Read and parse Move.lock file + let move_lock_content = fs::read_to_string(&self.move_lock_path) + .with_context(|| format!("Failed to read Move.lock file: {}", self.move_lock_path.display()))?; + + let new_registry = PackageRegistry::from_move_lock_content(&move_lock_content, &self.aliases_to_watch) + .context("Failed to parse Move.lock file")?; + + // Add new package versions from Move.lock to existing registry + for (chain_id, versions) in new_registry.envs().iter() { + if let Some(latest_version) = versions.last() { + registry.insert_new_package_version(chain_id, *latest_version); + } + } + + // Update aliases from Move.lock to existing registry + for (alias, chain_id) in new_registry.aliases().iter() { + registry.update_alias(alias.clone(), chain_id.clone()); + } + + // Serialize and write updated registry + let updated_json_content = serde_json::to_string_pretty(®istry)?; + + fs::write(&self.history_file_path, updated_json_content).with_context(|| { + format!( + "Failed to write updated content to: {}", + self.history_file_path.display() + ) + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn create_test_move_lock() -> String { + r#" +[env.mainnet] +chain-id = "6364aad5" +original-published-id = "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" +latest-published-id = "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" +latest-published-id = "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + +[env.localnet] +chain-id = "ecc0606a" +original-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478b" +latest-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478b" +"# + .to_string() + } + + fn create_test_package_history() -> String { + r#" +{ + "aliases": { + "testnet": "2304aa97", + "mainnet": "6364aad5" + }, + "envs": { + "6364aad5": ["0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08"], + "2304aa97": ["0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555"] + } +} +"# + .to_string() + } + + enum InitialTestFile { + None, + MoveLock, + HistoryFile, + } + + fn setup_missing_history_file_test( + history_path: &str, + move_lock_path: &str, + initial_file: InitialTestFile, + ) -> (TempDir, PathBuf, PathBuf, MoveHistoryManager) { + let temp_dir = TempDir::new().unwrap(); + let history_path = temp_dir.path().join(history_path); + let move_lock_path = temp_dir.path().join(move_lock_path); + + match initial_file { + InitialTestFile::None => { + // Do not create any initial files + } + InitialTestFile::MoveLock => { + fs::write(&move_lock_path, create_test_move_lock()).unwrap(); + } + InitialTestFile::HistoryFile => { + fs::write(&history_path, create_test_package_history()).unwrap(); + } + } + + let history_manager = MoveHistoryManager::new(&move_lock_path, &history_path, vec![]); + (temp_dir, history_path, move_lock_path, history_manager) + } + + #[test] + fn manage_history_file_creates_new_file_when_move_lock_exists_and_history_file_does_not() { + let (_temp_dir, history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::MoveLock); + + history_manager + .manage_history_file(|message| { + println!("{}", message); + }) + .unwrap(); + + assert!(history_path.exists()); + let content = fs::read_to_string(&history_path).unwrap(); + assert!(content.contains("\"aliases\": {")); + assert!(content.contains("\"mainnet\": \"6364aad5\"")); + assert!(content.contains("\"testnet\": \"2304aa97\"")); + // localnet should not be included per default + assert!(!content.contains("\"localnet\": \"ecc0606a\"")); + } + + #[test] + fn manage_history_file_updates_existing_file_when_both_files_exist() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + let updated_move_lock = r#" +[env.mainnet] +chain-id = "6364aad5" +latest-published-id = "0x94cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de09" +original-published-id = "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" +"#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager + .manage_history_file(|message| { + println!("{}", message); + }) + .unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + assert_eq!(registry.history("6364aad5").unwrap().len(), 2); + } + + #[test] + fn manage_history_file_skips_action_when_move_lock_does_not_exist() { + let (_temp_dir, history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "nonexistent.lock", InitialTestFile::None); + + history_manager + .manage_history_file(|message| { + println!("{}", message); + }) + .unwrap(); + + assert!(!history_path.exists()); + } + + #[test] + fn init_creates_package_history_from_move_lock() { + let (_temp_dir, output_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::MoveLock); + + history_manager.init().unwrap(); + + assert!(output_path.exists()); + let content = fs::read_to_string(&output_path).unwrap(); + assert!(content.contains("\"aliases\": {")); + assert!(content.contains("\"mainnet\": \"6364aad5\"")); + assert!(content.contains("\"testnet\": \"2304aa97\"")); + assert!(!content.contains("\"localnet\": \"ecc0606a\"")); + + assert!(content.contains("\"envs\": {")); + assert!(content.contains("\"2304aa97\": [")); + assert!(content.contains("\"0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555\"")); + assert!(content.contains("\"6364aad5\": [")); + assert!(content.contains("\"0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08\"")); + assert!(!content.contains("\"ecc0606a\": [")); + assert!(!content.contains("\"0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478b\"")); + } + + #[test] + fn init_fails_with_nonexistent_move_lock() { + let (_temp_dir, _history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("output.json", "nonexistent.lock", InitialTestFile::None); + + let result = history_manager.init(); + assert!(result.is_err()); + } + + #[test] + fn update_adds_new_package_versions() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + let updated_move_lock = r#" +[env.mainnet] +chain-id = "6364aad5" +latest-published-id = "0x94cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de09" +original-published-id = "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + +[env.testnet] +chain-id = "2304aa97" +latest-published-id = "0x332741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc666" +original-published-id = "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + +[env.localnet] +chain-id = "ecc0606a" +original-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478b" +latest-published-id = "0x0d88bcecde97585d50207a029a85d7ea0bacf73ab741cbaa975a6e279251033a" +"#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + assert_eq!(registry.history("6364aad5").unwrap().len(), 2); + assert_eq!(registry.history("2304aa97").unwrap().len(), 2); + assert_eq!(registry.history("ecc0606a"), None); + } + + #[test] + fn update_fails_with_nonexistent_history_file() { + let (_temp_dir, _history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("nonexistent.json", "Move.lock", InitialTestFile::MoveLock); + + let result = history_manager.update(); + assert!(result.is_err()); + } + + #[test] + fn update_does_not_duplicate_same_package_version() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + fs::write(&move_lock_path, create_test_move_lock()).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + // Should still have only 1 version each since we're adding the same versions + assert_eq!(registry.history("6364aad5").unwrap().len(), 1); + assert_eq!(registry.history("2304aa97").unwrap().len(), 1); + } + + #[test] + fn history_file_exists_returns_true_when_file_exists() { + let (_temp_dir, _history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + assert!(history_manager.history_file_exists()); + } + + #[test] + fn history_file_exists_returns_false_when_file_does_not_exist() { + let (_temp_dir, _history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("nonexistent.json", "Move.lock", InitialTestFile::None); + + assert!(!history_manager.history_file_exists()); + } + + #[test] + fn history_file_exists_returns_false_when_path_is_directory() { + let (_temp_dir, history_path, _move_lock_path, history_manager) = + setup_missing_history_file_test("directory", "Move.lock", InitialTestFile::None); + + // Create a directory instead of a file + fs::create_dir(&history_path).unwrap(); + assert!(!history_manager.history_file_exists()); + } + + #[test] + fn new_includes_additional_aliases_to_watch() { + let move_lock_path = PathBuf::from("Move.lock"); + let history_file_path = PathBuf::from("Move.history.json"); + let additional = vec!["localnet".to_string(), "customnet".to_string()]; + let manager = MoveHistoryManager::new(&move_lock_path, &history_file_path, additional.clone()); + + let mut expected = MoveHistoryManager::get_default_aliases_to_watch(); + expected.extend(additional); + + assert_eq!(manager.aliases_to_watch(), &expected); + } + + #[test] + fn aliases_to_watch_returns_only_defaults_when_no_additional_provided() { + let move_lock_path = PathBuf::from("Move.lock"); + let history_file_path = PathBuf::from("Move.history.json"); + let manager = MoveHistoryManager::new(&move_lock_path, &history_file_path, vec![]); + + let expected = MoveHistoryManager::get_default_aliases_to_watch(); + assert_eq!(manager.aliases_to_watch(), &expected); + } + + #[test] + fn from_move_lock_content_handles_missing_env_table() { + // Fresh Move.lock without any deployments + let move_lock_content = r#" + [move] + version = 2 + + [move.package] + name = "test_package" + "#; + + let registry = + PackageRegistry::from_move_lock_content(move_lock_content, &["mainnet".to_string(), "testnet".to_string()]) + .unwrap(); + + // Should return empty registry without error + assert_eq!(registry.envs().len(), 0); + assert_eq!(registry.aliases().len(), 0); + } + + #[test] + fn manage_history_file_handles_fresh_move_project() { + let temp_dir = TempDir::new().unwrap(); + let history_path = temp_dir.path().join("Move.history.json"); + let move_lock_path = temp_dir.path().join("Move.lock"); + + // Fresh Move.lock without env section + let fresh_move_lock = r#" + [move] + version = 2 + + [move.package] + name = "test_package" + "#; + fs::write(&move_lock_path, fresh_move_lock).unwrap(); + + let history_manager = MoveHistoryManager::new(&move_lock_path, &history_path, vec![]); + + // Should succeed without errors + history_manager + .manage_history_file(|message| { + println!("{}", message); + }) + .unwrap(); + + assert!(history_path.exists()); + let content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&content).unwrap(); + + // Empty registry is valid for unpublished packages + assert_eq!(registry.envs().len(), 0); + assert_eq!(registry.aliases().len(), 0); + } + + #[test] + fn update_syncs_new_and_existing_aliases() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + // Update mainnet version AND add devnet with alias + let updated_move_lock = r#" + [env.mainnet] + chain-id = "6364aad5" + latest-published-id="0x94cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de09" + original-published-id="0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + + [env.testnet] + chain-id = "2304aa97" + original-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + latest-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + + [env.devnet] + chain-id = "e678123a" + original-published-id = "0xabc123def456789012345678901234567890abcd" + latest-published-id = "0xabc123def456789012345678901234567890abcd" + "#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + // All aliases should be present (existing + new) + assert_eq!(registry.chain_alias("6364aad5"), Some("mainnet")); + assert_eq!(registry.chain_alias("2304aa97"), Some("testnet")); + assert_eq!(registry.chain_alias("e678123a"), Some("devnet")); + + // Check version history updated correctly + assert_eq!(registry.history("6364aad5").unwrap().len(), 2); + assert_eq!(registry.history("devnet").unwrap().len(), 1); + } + + #[test] + fn update_preserves_existing_aliases_when_no_new_envs() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + // Same envs, no additions - just update mainnet version + let updated_move_lock = r#" + [env.mainnet] + chain-id = "6364aad5" + latest-published-id="0x94cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de09" + original-published-id="0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + + [env.testnet] + chain-id = "2304aa97" + original-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + latest-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + "#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + // Original aliases must be preserved + assert_eq!(registry.chain_alias("6364aad5"), Some("mainnet")); + assert_eq!(registry.chain_alias("2304aa97"), Some("testnet")); + } + + #[test] + fn update_syncs_only_aliases_included_in_aliases_to_watch_list() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + // Same envs, no additions - just update mainnet version + let updated_move_lock = r#" + [env.mainnet] + chain-id = "6364aad5" + latest-published-id="0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + original-published-id="0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + + [env.testnet] + chain-id = "2304aa97" + original-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + latest-published-id="0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + + [env.localnet] + chain-id = "12345678" + original-published-id="0xabc123def456789012345678901234567890abcd" + latest-published-id="0xabc123def456789012345678901234567890abcd" + "#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + // Original aliases must be preserved + assert_eq!(registry.chain_alias("6364aad5"), Some("mainnet")); + assert_eq!(registry.chain_alias("2304aa97"), Some("testnet")); + // Localnet is not in the aliases_to_watch list per default, so it should not be added + assert_eq!(registry.chain_alias("12345678"), None); + } + + #[test] + fn update_handles_redeployment_breaking_changes() { + let (_temp_dir, history_path, move_lock_path, history_manager) = + setup_missing_history_file_test("Move.history.json", "Move.lock", InitialTestFile::HistoryFile); + + // Simulate a breaking change redeployment where the original-published-id for mainnet changes + // (previous value has been 0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08) + // The new original-published-id will equal the new latest-published-id. env.testnet remains unchanged. + let updated_move_lock = r#" +[env.mainnet] +chain-id = "6364aad5" +original-published-id = "0xa4cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de10" +latest-published-id = "0xa4cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de10" + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" +latest-published-id = "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" +"#; + fs::write(&move_lock_path, updated_move_lock).unwrap(); + + history_manager.update().unwrap(); + + let updated_content = fs::read_to_string(&history_path).unwrap(); + let registry = PackageRegistry::from_package_history_json_str(&updated_content).unwrap(); + + // Old version from initial history should still be present + let mainnet_history = registry.history("6364aad5").unwrap(); + assert_eq!(mainnet_history.len(), 2); + assert_eq!( + mainnet_history[0].to_hex_literal(), + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + ); + // New redeployed version should be added + assert_eq!( + mainnet_history[1].to_hex_literal(), + "0xa4cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de10" + ); + + // Testnet should remain unchanged with only one version + assert_eq!(registry.history("2304aa97").unwrap().len(), 1); + } +} diff --git a/product_common/src/package_registry.rs b/product_common/src/package_registry.rs index 52d6b286..7d1d9925 100644 --- a/product_common/src/package_registry.rs +++ b/product_common/src/package_registry.rs @@ -5,7 +5,8 @@ use std::collections::HashMap; use anyhow::Context; use iota_interaction::types::base_types::ObjectID; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; pub const MAINNET_CHAIN_ID: &str = "6364aad5"; @@ -34,53 +35,52 @@ impl Env { } } -/// A published package's metadata for a certain environment. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct Metadata { - pub original_published_id: ObjectID, - pub latest_published_id: ObjectID, - #[serde(deserialize_with = "deserialize_u64_from_str")] - pub published_version: u64, -} - -impl Metadata { - /// Create a new [Metadata] assuming a newly published package. - pub fn from_package_id(package: ObjectID) -> Self { - Self { - original_published_id: package, - latest_published_id: package, - published_version: 1, - } - } -} - -fn deserialize_u64_from_str<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - use serde::de::Error; - - String::deserialize(deserializer)?.parse().map_err(D::Error::custom) -} - -#[derive(Debug, Clone, Default)] +/// A registry that tracks package versions across different blockchain environments. +/// +/// The `PackageRegistry` stores: +/// - Aliases that map human-readable network names (like "mainnet", "testnet") to chain IDs. +/// - Environment mappings that associate chain IDs with the history of package versions. The history of package +/// versions is ordered chronologically, with the latest version at the end of the array. +/// +/// # Initialization using `Move.history.json` files +/// +/// The registry can be initialized from a `Move.history.json` file using the function +/// `from_package_history_json_str()`. A `Move.history.json` file has the following structure: +/// ```json +/// { +/// "aliases": { +/// "networkName": "chainId", +/// // e.g., "mainnet": "6364aad5" +/// }, +/// "envs": { +/// "chainId": ["0xpackageId1", "0xpackageId2"], +/// // Where the last ID is the most recent version +/// } +/// } +/// ``` +/// `Move.history.json` files can automatically be generated and updated using `build.rs` +/// scripts in your Rust projects. The `product_common` crate provides a `MoveHistoryManager` +/// that can be used to manage the `Move.history.json` file. See there for more details. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct PackageRegistry { aliases: HashMap, - envs: HashMap, + envs: HashMap>, } impl PackageRegistry { - /// Returns the package [Metadata] for a given `chain`. + /// Returns the historical list of this package's versions for a given `chain`. /// `chain` can either be a chain identifier or its alias. - pub fn metadata(&self, chain: &str) -> Option<&Metadata> { + /// + /// ID at position `0` is the first ever published version of the package, `1` is + /// the second, and so forth until the last, which is the currently active version. + pub fn history(&self, chain: &str) -> Option<&[ObjectID]> { let from_alias = || self.aliases.get(chain).and_then(|chain_id| self.envs.get(chain_id)); - self.envs.get(chain).or_else(from_alias) + self.envs.get(chain).or_else(from_alias).map(|v| v.as_slice()) } /// Returns this package's latest version ID for a given chain. pub fn package_id(&self, chain: &str) -> Option { - self.metadata(chain).map(|meta| meta.latest_published_id) + self.history(chain).and_then(|versions| versions.last()).copied() } /// Returns the alias of a given chain-id. @@ -91,8 +91,28 @@ impl PackageRegistry { .find_map(|(alias, chain)| (chain == chain_id).then_some(alias.as_str())) } + /// Removes the environment specified by the alias from the registry. + /// Returns the removed environment's versions if it existed, or `None` if the alias was not found. + pub fn remove_env_by_alias(&mut self, alias: &str) -> Option> { + if let Some(chain_id) = self.aliases.remove(alias) { + self.envs.remove(&chain_id) + } else { + None + } + } + + /// Returns the envs of this package registry. + pub fn envs(&self) -> &HashMap> { + &self.envs + } + + /// Returns the aliases of this package registry. + pub fn aliases(&self) -> &HashMap { + &self.aliases + } + /// Adds or replaces this package's metadata for a given environment. - pub fn insert_env(&mut self, env: Env, metadata: Metadata) { + pub fn insert_env(&mut self, env: Env, metadata: Vec) { let Env { chain_id, alias } = env; if let Some(alias) = alias { @@ -101,40 +121,259 @@ impl PackageRegistry { self.envs.insert(chain_id, metadata); } + /// Updates or adds an alias for a given chain ID. + /// Does not affect the version history. + pub fn update_alias(&mut self, alias: String, chain_id: String) { + self.aliases.insert(alias, chain_id); + } + + /// Inserts a new package version for a given chain. If the chain does not exist, it is created. + /// If the new package version is the same as the last one, it is not added again. + pub fn insert_new_package_version(&mut self, chain_id: &str, package: ObjectID) { + let history = self.envs.entry(chain_id.to_string()).or_default(); + if history.last() != Some(&package) { + history.push(package) + } + } + /// Merges another [PackageRegistry] into this one. pub fn join(&mut self, other: PackageRegistry) { self.aliases.extend(other.aliases); self.envs.extend(other.envs); } - /// Creates a [PackageRegistry] from a Move.lock file. - pub fn from_move_lock_content(move_lock: &str) -> anyhow::Result { - let mut move_lock: toml::Table = move_lock.parse()?; + /// Creates a [PackageRegistry] from a Move.history.json file. + pub fn from_package_history_json_str(package_history: &str) -> anyhow::Result { + let package_history: Value = serde_json::from_str(package_history)?; - move_lock - .remove("env") - .context("invalid Move.lock file: missing `env` table")? - .as_table_mut() - .map(std::mem::take) - .context("invalid Move.lock file: `env` is not a table")? + let ret_val = package_history + .get("aliases") + .context("invalid Move.history.json file: missing `aliases` object")? + .as_object() + .context("invalid Move.history.json file: `aliases` is not a JSON object literal")? .into_iter() - .try_fold(Self::default(), |mut registry, (alias, table)| { - let toml::Value::Table(mut table) = table else { - anyhow::bail!("invalid Move.lock file: invalid `env` table"); - }; - let chain_id: String = table - .remove("chain-id") - .context(format!("invalid Move.lock file: missing `chain-id` for env {alias}"))? - .try_into() - .context("invalid Move.lock file: invalid `chain-id`")?; - - let env = Env::new_with_alias(chain_id, alias.clone()); - let metadata = table - .try_into() - .context(format!("invalid Move.lock file: invalid env metadata for {alias}"))?; - registry.insert_env(env, metadata); + .try_fold(Self::default(), |mut registry, (alias, chain_id)| { + let chain_id: String = chain_id + .as_str() + .context(format!( + "invalid Move.history.json file: invalid `chain-id` '{chain_id}' for alias {alias}" + ))? + .to_string(); + registry.aliases.insert(alias.clone(), chain_id); + Ok::(registry) + })?; + package_history + .get("envs") + .context("invalid Move.history.json file: missing `envs` object")? + .as_object() + .context("invalid Move.history.json file: `envs` is not a JSON object literal")? + .into_iter() + .try_fold(ret_val, |mut registry, (chain_id, versions)| { + let versions: Vec = versions + .as_array() + .context(format!("invalid Move.history.json file: invalid versions for {chain_id}. versions is not an array"))? + .iter() + .try_fold(Vec::::new(), |mut arr, v| { + let obj_id = ObjectID::from_hex_literal( + v.as_str() + .context(format!("invalid Move.history.json file: invalid versions array element for {chain_id}. Elements need to be strings"))? + )?; + arr.push(obj_id); + Ok::, anyhow::Error>(arr) + })?; + registry.envs.insert(chain_id.clone(), versions); Ok(registry) }) } } + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! object_id { + ($id:literal) => { + ObjectID::from_hex_literal($id).unwrap() + }; + } + + const PACKAGE_HISTORY_JSON: &str = r#" +{ + "aliases": { + "localnet": "594fb3ed", + "devnet": "e678123a", + "testnet": "2304aa97", + "mainnet": "6364aad5" + }, + "envs": { + "6364aad5": ["0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08"], + "e678123a": [ + "0xe6fa03d273131066036f1d2d4c3d919b9abbca93910769f26a924c7a01811103", + "0x6a976d3da90db5d27f8a0c13b3268a37e582b455cfc7bf72d6461f6e8f668823" + ], + "594fb3ed": ["0xd097794267324a58734ff754919f4a16461e39ed39901b29778b86a1261930ba"], + "2304aa97": [ + "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555", + "0x3403da7ec4cd2ff9bdf6f34c0b8df5a2bd62c798089feb0d2ebf1c2e953296dc" + ] + } +} +"#; + + #[test] + fn deserialize_package_registry_from_valid_json() { + let registry = PackageRegistry::from_package_history_json_str(PACKAGE_HISTORY_JSON).unwrap(); + assert_eq!(registry.aliases.get("mainnet"), Some(&"6364aad5".to_string())); + assert_eq!(registry.aliases.get("testnet"), Some(&"2304aa97".to_string())); + assert_eq!(registry.envs.get("6364aad5").unwrap().len(), 1); + assert_eq!(registry.envs.get("2304aa97").unwrap().len(), 2); + assert_eq!(registry.history("mainnet").unwrap().len(), 1); + assert_eq!(registry.history("testnet").unwrap().len(), 2); + assert_eq!( + registry.history("testnet").unwrap()[0], + object_id!("0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555") + ); + assert_eq!( + registry.history("testnet").unwrap()[1], + object_id!("0x3403da7ec4cd2ff9bdf6f34c0b8df5a2bd62c798089feb0d2ebf1c2e953296dc") + ); + assert_eq!( + registry.package_id("mainnet"), + Some(object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )) + ); + assert_eq!( + registry.package_id("testnet"), + Some(object_id!( + "0x3403da7ec4cd2ff9bdf6f34c0b8df5a2bd62c798089feb0d2ebf1c2e953296dc" + )) + ); + assert_eq!( + registry.package_id("devnet"), + Some(object_id!( + "0x6a976d3da90db5d27f8a0c13b3268a37e582b455cfc7bf72d6461f6e8f668823" + )) + ); + } + + #[test] + fn package_id_returns_correct_id() { + let registry = PackageRegistry::from_package_history_json_str(PACKAGE_HISTORY_JSON).unwrap(); + let package_id = registry.package_id("mainnet"); + assert_eq!( + package_id, + Some(object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )) + ); + } + + #[test] + fn test_serialize_package_registry_to_json() { + let mut registry = PackageRegistry::default(); + // Add well-known networks. + registry.insert_env( + Env::new_with_alias("6364aad5", "mainnet"), + vec![object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )], + ); + registry.insert_env( + Env::new_with_alias("2304aa97", "testnet"), + vec![ + object_id!("0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555"), + object_id!("0x3403da7ec4cd2ff9bdf6f34c0b8df5a2bd62c798089feb0d2ebf1c2e953296dc"), + ], + ); + registry.insert_env( + Env::new_with_alias("e678123a", "devnet"), + vec![ + object_id!("0xe6fa03d273131066036f1d2d4c3d919b9abbca93910769f26a924c7a01811103"), + object_id!("0x6a976d3da90db5d27f8a0c13b3268a37e582b455cfc7bf72d6461f6e8f668823"), + ], + ); + + let json_content = serde_json::to_string(®istry).unwrap(); + let _ = PackageRegistry::from_package_history_json_str(json_content.as_str()) + .expect("Serialized json string can be deserialized back to PackageRegistry"); + } + + #[test] + fn package_id_returns_none_for_unknown_chain() { + let registry = PackageRegistry::from_package_history_json_str(PACKAGE_HISTORY_JSON).unwrap(); + let package_id = registry.package_id("unknown_chain"); + assert_eq!(package_id, None); + } + + #[test] + fn chain_alias_returns_none_for_unknown_chain_id() { + let registry = PackageRegistry::from_package_history_json_str(PACKAGE_HISTORY_JSON).unwrap(); + let alias = registry.chain_alias("unknown_chain_id"); + assert_eq!(alias, None); + } + + #[test] + fn insert_env_overwrites_existing_alias() { + let mut registry = PackageRegistry::default(); + registry.insert_env( + Env::new_with_alias("6364aad5", "mainnet"), + vec![object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )], + ); + registry.insert_env( + Env::new_with_alias("2304aa97", "mainnet"), + vec![object_id!( + "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + )], + ); + assert_eq!(registry.aliases.get("mainnet"), Some(&"2304aa97".to_string())); + } + + #[test] + fn insert_new_package_version_does_not_duplicate_last_version() { + let mut registry = PackageRegistry::default(); + registry.insert_new_package_version( + "6364aad5", + object_id!("0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08"), + ); + registry.insert_new_package_version( + "6364aad5", + object_id!("0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08"), + ); + assert_eq!( + registry.history("6364aad5").unwrap(), + &[object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )] + ); + } + + #[test] + fn join_merges_aliases_and_envs() { + let mut registry1 = PackageRegistry::default(); + registry1.insert_env( + Env::new_with_alias("6364aad5", "mainnet"), + vec![object_id!( + "0x84cf5d12de2f9731a89bb519bc0c982a941b319a33abefdd5ed2054ad931de08" + )], + ); + + let mut registry2 = PackageRegistry::default(); + registry2.insert_env( + Env::new_with_alias("2304aa97", "testnet"), + vec![object_id!( + "0x222741bbdff74b42df48a7b4733185e9b24becb8ccfbafe8eac864ab4e4cc555" + )], + ); + + registry1.join(registry2); + + assert_eq!(registry1.aliases.get("mainnet"), Some(&"6364aad5".to_string())); + assert_eq!(registry1.aliases.get("testnet"), Some(&"2304aa97".to_string())); + assert!(registry1.envs.contains_key("6364aad5")); + assert!(registry1.envs.contains_key("2304aa97")); + } +}