Merge rust-bitcoin/rust-bitcoin#1225: Add `bitcoin-units` crate
396e049a7a
Use InputString instead of String (Tobin C. Harding)acacf45edf
Add ParseDenominationError (Tobin C. Harding)69e56a64ed
Add bitcoin-units crate (Tobin C. Harding)4ecb1fe7da
internals: Add docs to InputString (Tobin C. Harding)fa8d3002cd
internals: Fix docs typo (Tobin C. Harding) Pull request description: Create a new `bitcoin-units` crate as described [here](https://github.com/rust-bitcoin/rust-bitcoin/issues/550#issuecomment-1012103022). Only the `amount` module is currently included. I've resolved the `Encodale/Decodable` issue by keeping the `amount` module in `bitcoin`. ACKs for top commit: Kixunil: ACK396e049a7a
apoelstra: ACK396e049a7a
Tree-SHA512: caf5e9da0458435ab19d00d4506896257e898525a4472d435fdac1d1a37bb747befd56993b106673f938475e5777d952a13ba04a2d3cb710d7afe7f5faebb7b8
This commit is contained in:
commit
4777ec9a90
|
@ -17,7 +17,6 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
fuzz_target: [
|
fuzz_target: [
|
||||||
bitcoin_deserialize_address,
|
bitcoin_deserialize_address,
|
||||||
bitcoin_deserialize_amount,
|
|
||||||
bitcoin_deserialize_block,
|
bitcoin_deserialize_block,
|
||||||
bitcoin_deserialize_prefilled_transaction,
|
bitcoin_deserialize_prefilled_transaction,
|
||||||
bitcoin_deserialize_psbt,
|
bitcoin_deserialize_psbt,
|
||||||
|
@ -34,6 +33,7 @@ jobs:
|
||||||
hashes_sha256,
|
hashes_sha256,
|
||||||
hashes_sha512_256,
|
hashes_sha512_256,
|
||||||
hashes_sha512,
|
hashes_sha512,
|
||||||
|
units_deserialize_amount,
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
|
|
|
@ -39,6 +39,7 @@ dependencies = [
|
||||||
"bincode",
|
"bincode",
|
||||||
"bitcoin-internals",
|
"bitcoin-internals",
|
||||||
"bitcoin-io",
|
"bitcoin-io",
|
||||||
|
"bitcoin-units",
|
||||||
"bitcoin_hashes",
|
"bitcoin_hashes",
|
||||||
"bitcoinconsensus",
|
"bitcoinconsensus",
|
||||||
"hex-conservative",
|
"hex-conservative",
|
||||||
|
@ -72,6 +73,16 @@ dependencies = [
|
||||||
name = "bitcoin-io"
|
name = "bitcoin-io"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitcoin-units"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bitcoin-internals",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_test",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitcoin_hashes"
|
name = "bitcoin_hashes"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
|
|
|
@ -38,6 +38,7 @@ dependencies = [
|
||||||
"bincode",
|
"bincode",
|
||||||
"bitcoin-internals",
|
"bitcoin-internals",
|
||||||
"bitcoin-io",
|
"bitcoin-io",
|
||||||
|
"bitcoin-units",
|
||||||
"bitcoin_hashes",
|
"bitcoin_hashes",
|
||||||
"bitcoinconsensus",
|
"bitcoinconsensus",
|
||||||
"hex-conservative",
|
"hex-conservative",
|
||||||
|
@ -71,6 +72,16 @@ dependencies = [
|
||||||
name = "bitcoin-io"
|
name = "bitcoin-io"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitcoin-units"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bitcoin-internals",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_test",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitcoin_hashes"
|
name = "bitcoin_hashes"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["bitcoin", "hashes", "internals", "fuzz", "io"]
|
members = ["bitcoin", "hashes", "internals", "fuzz", "io", "units"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[patch.crates-io.bitcoin]
|
[patch.crates-io.bitcoin]
|
||||||
|
@ -13,3 +13,6 @@ path = "internals"
|
||||||
|
|
||||||
[patch.crates-io.bitcoin-io]
|
[patch.crates-io.bitcoin-io]
|
||||||
path = "io"
|
path = "io"
|
||||||
|
|
||||||
|
[patch.crates-io.bitcoin-units]
|
||||||
|
path = "units"
|
||||||
|
|
|
@ -15,10 +15,10 @@ exclude = ["tests", "contrib"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = [ "std", "secp-recovery" ]
|
default = [ "std", "secp-recovery" ]
|
||||||
std = ["bech32/std", "hashes/std", "hex/std", "internals/std", "io/std", "secp256k1/std"]
|
std = ["bech32/std", "hashes/std", "hex/std", "internals/std", "io/std", "secp256k1/std", "units/std"]
|
||||||
rand-std = ["secp256k1/rand-std", "std"]
|
rand-std = ["secp256k1/rand-std", "std"]
|
||||||
rand = ["secp256k1/rand"]
|
rand = ["secp256k1/rand"]
|
||||||
serde = ["actual-serde", "hashes/serde", "secp256k1/serde", "internals/serde"]
|
serde = ["actual-serde", "hashes/serde", "secp256k1/serde", "internals/serde", "units/serde"]
|
||||||
secp-lowmemory = ["secp256k1/lowmemory"]
|
secp-lowmemory = ["secp256k1/lowmemory"]
|
||||||
secp-recovery = ["secp256k1/recovery"]
|
secp-recovery = ["secp256k1/recovery"]
|
||||||
bitcoinconsensus-std = ["bitcoinconsensus/std", "std"]
|
bitcoinconsensus-std = ["bitcoinconsensus/std", "std"]
|
||||||
|
@ -35,6 +35,7 @@ hex_lit = "0.1.1"
|
||||||
internals = { package = "bitcoin-internals", version = "0.2.0" }
|
internals = { package = "bitcoin-internals", version = "0.2.0" }
|
||||||
io = { package = "bitcoin-io", version = "0.1", default-features = false, features = ["alloc"] }
|
io = { package = "bitcoin-io", version = "0.1", default-features = false, features = ["alloc"] }
|
||||||
secp256k1 = { version = "0.28.0", default-features = false, features = ["hashes", "alloc"] }
|
secp256k1 = { version = "0.28.0", default-features = false, features = ["hashes", "alloc"] }
|
||||||
|
units = { package = "bitcoin-units", version = "0.1.0", default-features = false, features = ["alloc"] }
|
||||||
|
|
||||||
base64 = { version = "0.21.3", optional = true }
|
base64 = { version = "0.21.3", optional = true }
|
||||||
# Only use this feature for no-std builds, otherwise use bitcoinconsensus-std.
|
# Only use this feature for no-std builds, otherwise use bitcoinconsensus-std.
|
||||||
|
|
|
@ -35,3 +35,6 @@ path = "../../internals"
|
||||||
|
|
||||||
[patch.crates-io.bitcoin-io]
|
[patch.crates-io.bitcoin-io]
|
||||||
path = "../../io"
|
path = "../../io"
|
||||||
|
|
||||||
|
[patch.crates-io.bitcoin-units]
|
||||||
|
path = "../../units"
|
||||||
|
|
|
@ -91,7 +91,6 @@ mod serde_utils;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
pub mod p2p;
|
pub mod p2p;
|
||||||
pub mod address;
|
pub mod address;
|
||||||
pub mod amount;
|
|
||||||
pub mod base58;
|
pub mod base58;
|
||||||
pub mod bip152;
|
pub mod bip152;
|
||||||
pub mod bip158;
|
pub mod bip158;
|
||||||
|
@ -187,3 +186,35 @@ mod prelude {
|
||||||
|
|
||||||
pub use hex::DisplayHex;
|
pub use hex::DisplayHex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod amount {
|
||||||
|
//! Bitcoin amounts.
|
||||||
|
//!
|
||||||
|
//! This module mainly introduces the [Amount] and [SignedAmount] types.
|
||||||
|
//! We refer to the documentation on the types for more information.
|
||||||
|
|
||||||
|
use crate::consensus::{encode, Decodable, Encodable};
|
||||||
|
use crate::io;
|
||||||
|
|
||||||
|
#[rustfmt::skip] // Keep public re-exports separate.
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use units::amount::{
|
||||||
|
Amount, CheckedSum, Denomination, Display, ParseAmountError, SignedAmount,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
pub use units::amount::serde;
|
||||||
|
|
||||||
|
impl Decodable for Amount {
|
||||||
|
#[inline]
|
||||||
|
fn consensus_decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, encode::Error> {
|
||||||
|
Ok(Amount::from_sat(Decodable::consensus_decode(r)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encodable for Amount {
|
||||||
|
#[inline]
|
||||||
|
fn consensus_encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<usize, io::Error> {
|
||||||
|
self.to_sat().consensus_encode(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
CRATES="bitcoin hashes internals fuzz"
|
CRATES="bitcoin hashes units internals fuzz"
|
||||||
DEPS="recent minimal"
|
DEPS="recent minimal"
|
||||||
|
|
||||||
for dep in $DEPS
|
for dep in $DEPS
|
||||||
|
|
|
@ -21,10 +21,6 @@ serde_cbor = "0.9"
|
||||||
name = "bitcoin_deserialize_address"
|
name = "bitcoin_deserialize_address"
|
||||||
path = "fuzz_targets/bitcoin/deserialize_address.rs"
|
path = "fuzz_targets/bitcoin/deserialize_address.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "bitcoin_deserialize_amount"
|
|
||||||
path = "fuzz_targets/bitcoin/deserialize_amount.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "bitcoin_deserialize_block"
|
name = "bitcoin_deserialize_block"
|
||||||
path = "fuzz_targets/bitcoin/deserialize_block.rs"
|
path = "fuzz_targets/bitcoin/deserialize_block.rs"
|
||||||
|
@ -88,3 +84,7 @@ path = "fuzz_targets/hashes/sha512_256.rs"
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "hashes_sha512"
|
name = "hashes_sha512"
|
||||||
path = "fuzz_targets/hashes/sha512.rs"
|
path = "fuzz_targets/hashes/sha512.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "units_deserialize_amount"
|
||||||
|
path = "fuzz_targets/units/deserialize_amount.rs"
|
||||||
|
|
|
@ -36,3 +36,6 @@ path = "../../internals"
|
||||||
|
|
||||||
[patch.crates-io.bitcoin-io]
|
[patch.crates-io.bitcoin-io]
|
||||||
path = "../../io"
|
path = "../../io"
|
||||||
|
|
||||||
|
[patch.crates-io.bitcoin-units]
|
||||||
|
path = "../../units"
|
||||||
|
|
|
@ -24,3 +24,6 @@ path = "../../../internals"
|
||||||
|
|
||||||
[patch.crates-io.bitcoin-io]
|
[patch.crates-io.bitcoin-io]
|
||||||
path = "../../../io"
|
path = "../../../io"
|
||||||
|
|
||||||
|
[patch.crates-io.bitcoin-units]
|
||||||
|
path = "../../../units"
|
||||||
|
|
|
@ -17,7 +17,29 @@ pub struct InputString(Storage);
|
||||||
impl InputString {
|
impl InputString {
|
||||||
/// Displays a message saying `failed to parse <self> as <what>`.
|
/// Displays a message saying `failed to parse <self> as <what>`.
|
||||||
///
|
///
|
||||||
/// This is normally used whith the `write_err!` macro.
|
/// This is normally used with the `write_err!` macro.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use core::fmt;
|
||||||
|
/// use bitcoin_internals::error::InputString;
|
||||||
|
/// use bitcoin_internals::write_err;
|
||||||
|
///
|
||||||
|
/// /// An example parsing error including the parse error from core.
|
||||||
|
/// #[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
/// pub struct ParseError {
|
||||||
|
/// input: InputString,
|
||||||
|
/// error: core::num::ParseIntError,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// impl fmt::Display for ParseError {
|
||||||
|
/// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
/// // Outputs "failed to parse '<input string>' as foo"
|
||||||
|
/// write_err!(f, "{}", self.input.display_cannot_parse("foo"); self.error)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
pub fn display_cannot_parse<'a, T>(&'a self, what: &'a T) -> CannotParse<'a, T>
|
pub fn display_cannot_parse<'a, T>(&'a self, what: &'a T) -> CannotParse<'a, T>
|
||||||
where
|
where
|
||||||
T: fmt::Display + ?Sized,
|
T: fmt::Display + ?Sized,
|
||||||
|
@ -28,6 +50,24 @@ impl InputString {
|
||||||
/// Formats a message saying `<self> is not a known <what>`.
|
/// Formats a message saying `<self> is not a known <what>`.
|
||||||
///
|
///
|
||||||
/// This is normally used in leaf parse errors (with no source) when parsing an enum.
|
/// This is normally used in leaf parse errors (with no source) when parsing an enum.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use core::fmt;
|
||||||
|
/// use bitcoin_internals::error::InputString;
|
||||||
|
///
|
||||||
|
/// /// An example parsing error.
|
||||||
|
/// #[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
/// pub struct ParseError(InputString);
|
||||||
|
///
|
||||||
|
/// impl fmt::Display for ParseError {
|
||||||
|
/// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
/// // Outputs "'<input string>' is not a known foo"
|
||||||
|
/// self.0.unknown_variant("foo", f)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
pub fn unknown_variant<T>(&self, what: &T, f: &mut fmt::Formatter) -> fmt::Result
|
pub fn unknown_variant<T>(&self, what: &T, f: &mut fmt::Formatter) -> fmt::Result
|
||||||
where
|
where
|
||||||
T: fmt::Display + ?Sized,
|
T: fmt::Display + ?Sized,
|
||||||
|
|
|
@ -36,7 +36,7 @@ macro_rules! parse_error_type {
|
||||||
|
|
||||||
impl core::fmt::Display for $name {
|
impl core::fmt::Display for $name {
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
|
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
|
||||||
$crate::error::write_err!("{}", self.input.display_cannot_parse($subject); self.source)
|
$crate::error::write_err!(f, "{}", self.input.display_cannot_parse($subject); self.source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
[package]
|
||||||
|
name = "bitcoin-units"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Andrew Poelstra <apoelstra@wpsoftware.net>"]
|
||||||
|
license = "CC0-1.0"
|
||||||
|
repository = "https://github.com/rust-bitcoin/rust-bitcoin/"
|
||||||
|
description = "Basic Bitcoin numeric units such as amount"
|
||||||
|
categories = ["cryptography::cryptocurrencies"]
|
||||||
|
keywords = ["bitcoin", "newtypes"]
|
||||||
|
readme = "README.md"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.56.1"
|
||||||
|
exclude = ["tests", "contrib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["std"]
|
||||||
|
std = ["alloc", "internals/std"]
|
||||||
|
alloc = ["internals/alloc"]
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
internals = { package = "bitcoin-internals", version = "0.2.0" }
|
||||||
|
|
||||||
|
serde = { version = "1.0.103", default-features = false, features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_test = "1.0"
|
||||||
|
serde_json = "1.0"
|
|
@ -0,0 +1,15 @@
|
||||||
|
Bitcoin Units
|
||||||
|
=============
|
||||||
|
|
||||||
|
This crate provides basic Bitcoin numeric units such as `Amount`.
|
||||||
|
|
||||||
|
|
||||||
|
## Minimum Supported Rust Version (MSRV)
|
||||||
|
|
||||||
|
This library should always compile with any combination of features on **Rust 1.56.1**.
|
||||||
|
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
The code in this project is licensed under the [Creative Commons CC0 1.0 Universal license](LICENSE).
|
||||||
|
We use the [SPDX license list](https://spdx.org/licenses/) and [SPDX IDs](https://spdx.dev/ids/).
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
FEATURES="std alloc serde"
|
||||||
|
|
||||||
|
cargo --version
|
||||||
|
rustc --version
|
||||||
|
|
||||||
|
# Work out if we are using a nightly toolchain.
|
||||||
|
NIGHTLY=false
|
||||||
|
if cargo --version | grep nightly >/dev/null; then
|
||||||
|
NIGHTLY=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make all cargo invocations verbose
|
||||||
|
export CARGO_TERM_VERBOSE=true
|
||||||
|
|
||||||
|
# Defaults / sanity checks
|
||||||
|
cargo build
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
if [ "$DO_LINT" = true ]
|
||||||
|
then
|
||||||
|
cargo clippy --locked --all-features --all-targets -- -D warnings
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DO_FEATURE_MATRIX" = true ]; then
|
||||||
|
# No features
|
||||||
|
cargo build --locked --no-default-features
|
||||||
|
cargo test --locked --no-default-features
|
||||||
|
|
||||||
|
# Default features (this is std and alloc)
|
||||||
|
cargo build --locked
|
||||||
|
cargo test --locked
|
||||||
|
|
||||||
|
# All features
|
||||||
|
cargo build --locked --no-default-features --all-features
|
||||||
|
cargo test --locked --no-default-features --all-features
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_DIR=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
# Build the docs if told to (this only works with the nightly toolchain)
|
||||||
|
if [ "$DO_DOCSRS" = true ]; then
|
||||||
|
RUSTDOCFLAGS="--cfg docsrs -D warnings -D rustdoc::broken-intra-doc-links" cargo +nightly doc --all-features
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the docs with a stable toolchain, in unison with the DO_DOCSRS command
|
||||||
|
# above this checks that we feature guarded docs imports correctly.
|
||||||
|
if [ "$DO_DOCS" = true ]; then
|
||||||
|
RUSTDOCFLAGS="-D warnings" cargo +stable doc --all-features
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run formatter if told to.
|
||||||
|
if [ "$DO_FMT" = true ]; then
|
||||||
|
if [ "$NIGHTLY" = false ]; then
|
||||||
|
echo "DO_FMT requires a nightly toolchain (consider using RUSTUP_TOOLCHAIN)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rustup component add rustfmt
|
||||||
|
cargo fmt --check
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bench if told to, only works with non-stable toolchain (nightly, beta).
|
||||||
|
if [ "$DO_BENCH" = true ]
|
||||||
|
then
|
||||||
|
if [ "$NIGHTLY" = false ]
|
||||||
|
then
|
||||||
|
if [ -n "$RUSTUP_TOOLCHAIN" ]
|
||||||
|
then
|
||||||
|
echo "RUSTUP_TOOLCHAIN is set to a non-nightly toolchain but DO_BENCH requires a nightly toolchain"
|
||||||
|
else
|
||||||
|
echo "DO_BENCH requires a nightly toolchain"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RUSTFLAGS='--cfg=bench' cargo bench
|
||||||
|
fi
|
|
@ -4,22 +4,26 @@
|
||||||
//!
|
//!
|
||||||
//! This module mainly introduces the [Amount] and [SignedAmount] types.
|
//! This module mainly introduces the [Amount] and [SignedAmount] types.
|
||||||
//! We refer to the documentation on the types for more information.
|
//! We refer to the documentation on the types for more information.
|
||||||
//!
|
|
||||||
|
|
||||||
use core::cmp::Ordering;
|
use core::cmp::Ordering;
|
||||||
use core::fmt::{self, Write};
|
use core::fmt::{self, Write};
|
||||||
use core::str::FromStr;
|
use core::str::FromStr;
|
||||||
use core::{default, ops};
|
use core::{default, ops};
|
||||||
|
|
||||||
use crate::consensus::encode::{self, Decodable, Encodable};
|
#[cfg(feature = "serde")]
|
||||||
use crate::prelude::*;
|
use ::serde::{Deserialize, Serialize};
|
||||||
|
use internals::error::InputString;
|
||||||
|
use internals::write_err;
|
||||||
|
|
||||||
|
#[cfg(feature = "alloc")]
|
||||||
|
use crate::prelude::{String, ToString};
|
||||||
|
|
||||||
/// A set of denominations in which amounts can be expressed.
|
/// A set of denominations in which amounts can be expressed.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```
|
/// ```
|
||||||
/// # use core::str::FromStr;
|
/// # use core::str::FromStr;
|
||||||
/// # use bitcoin::Amount;
|
/// # use bitcoin_units::Amount;
|
||||||
///
|
///
|
||||||
/// assert_eq!(Amount::from_str("1 BTC").unwrap(), Amount::from_sat(100_000_000));
|
/// assert_eq!(Amount::from_str("1 BTC").unwrap(), Amount::from_sat(100_000_000));
|
||||||
/// assert_eq!(Amount::from_str("1 cBTC").unwrap(), Amount::from_sat(1_000_000));
|
/// assert_eq!(Amount::from_str("1 cBTC").unwrap(), Amount::from_sat(1_000_000));
|
||||||
|
@ -113,7 +117,7 @@ impl fmt::Display for Denomination {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for Denomination {
|
impl FromStr for Denomination {
|
||||||
type Err = ParseAmountError;
|
type Err = ParseDenominationError;
|
||||||
|
|
||||||
/// Convert from a str to Denomination.
|
/// Convert from a str to Denomination.
|
||||||
///
|
///
|
||||||
|
@ -123,15 +127,15 @@ impl FromStr for Denomination {
|
||||||
///
|
///
|
||||||
/// Due to ambiguity between mega and milli, pico and peta we prohibit usage of leading capital 'M', 'P'.
|
/// Due to ambiguity between mega and milli, pico and peta we prohibit usage of leading capital 'M', 'P'.
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
use self::ParseAmountError::*;
|
use self::ParseDenominationError::*;
|
||||||
|
|
||||||
if CONFUSING_FORMS.contains(&s) {
|
if CONFUSING_FORMS.contains(&s) {
|
||||||
return Err(PossiblyConfusingDenomination(s.to_owned()));
|
return Err(PossiblyConfusing(PossiblyConfusingDenominationError(s.into())));
|
||||||
};
|
};
|
||||||
|
|
||||||
let form = self::Denomination::forms(s);
|
let form = self::Denomination::forms(s);
|
||||||
|
|
||||||
form.ok_or_else(|| UnknownDenomination(s.to_owned()))
|
form.ok_or_else(|| Unknown(UnknownDenominationError(s.into())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,10 +155,8 @@ pub enum ParseAmountError {
|
||||||
InputTooLarge,
|
InputTooLarge,
|
||||||
/// Invalid character in input.
|
/// Invalid character in input.
|
||||||
InvalidCharacter(char),
|
InvalidCharacter(char),
|
||||||
/// The denomination was unknown.
|
/// Invalid denomination.
|
||||||
UnknownDenomination(String),
|
InvalidDenomination(ParseDenominationError),
|
||||||
/// The denomination has multiple possible interpretations.
|
|
||||||
PossiblyConfusingDenomination(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ParseAmountError {
|
impl fmt::Display for ParseAmountError {
|
||||||
|
@ -168,16 +170,7 @@ impl fmt::Display for ParseAmountError {
|
||||||
InvalidFormat => f.write_str("invalid number format"),
|
InvalidFormat => f.write_str("invalid number format"),
|
||||||
InputTooLarge => f.write_str("input string was too large"),
|
InputTooLarge => f.write_str("input string was too large"),
|
||||||
InvalidCharacter(c) => write!(f, "invalid character in input: {}", c),
|
InvalidCharacter(c) => write!(f, "invalid character in input: {}", c),
|
||||||
UnknownDenomination(ref d) => write!(f, "unknown denomination: {}", d),
|
InvalidDenomination(ref e) => write_err!(f, "invalid denomination"; e),
|
||||||
PossiblyConfusingDenomination(ref d) => {
|
|
||||||
let (letter, upper, lower) = match d.chars().next() {
|
|
||||||
Some('M') => ('M', "Mega", "milli"),
|
|
||||||
Some('P') => ('P', "Peta", "pico"),
|
|
||||||
// This panic could be avoided by adding enum ConfusingDenomination { Mega, Peta } but is it worth it?
|
|
||||||
_ => panic!("invalid error information"),
|
|
||||||
};
|
|
||||||
write!(f, "the '{}' at the beginning of {} should technically mean '{}' but that denomination is uncommon and maybe '{}' was intended", letter, d, upper, lower)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -188,18 +181,81 @@ impl std::error::Error for ParseAmountError {
|
||||||
use ParseAmountError::*;
|
use ParseAmountError::*;
|
||||||
|
|
||||||
match *self {
|
match *self {
|
||||||
Negative
|
Negative | TooBig | TooPrecise | InvalidFormat | InputTooLarge
|
||||||
| TooBig
|
| InvalidCharacter(_) => None,
|
||||||
| TooPrecise
|
InvalidDenomination(ref e) => Some(e),
|
||||||
| InvalidFormat
|
|
||||||
| InputTooLarge
|
|
||||||
| InvalidCharacter(_)
|
|
||||||
| UnknownDenomination(_)
|
|
||||||
| PossiblyConfusingDenomination(_) => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ParseDenominationError> for ParseAmountError {
|
||||||
|
fn from(e: ParseDenominationError) -> Self { Self::InvalidDenomination(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An error during amount parsing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ParseDenominationError {
|
||||||
|
/// The denomination was unknown.
|
||||||
|
Unknown(UnknownDenominationError),
|
||||||
|
/// The denomination has multiple possible interpretations.
|
||||||
|
PossiblyConfusing(PossiblyConfusingDenominationError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ParseDenominationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
use ParseDenominationError::*;
|
||||||
|
|
||||||
|
match *self {
|
||||||
|
Unknown(ref e) => write_err!(f, "denomination parse error"; e),
|
||||||
|
PossiblyConfusing(ref e) => write_err!(f, "denomination parse error"; e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
impl std::error::Error for ParseDenominationError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
use ParseDenominationError::*;
|
||||||
|
|
||||||
|
match *self {
|
||||||
|
Unknown(_) | PossiblyConfusing(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsing error, unknown denomination.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct UnknownDenominationError(InputString);
|
||||||
|
|
||||||
|
impl fmt::Display for UnknownDenominationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
self.0.unknown_variant("bitcoin denomination", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
impl std::error::Error for UnknownDenominationError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsing error, possibly confusing denomination.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct PossiblyConfusingDenominationError(InputString);
|
||||||
|
|
||||||
|
impl fmt::Display for PossiblyConfusingDenominationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(f, "{}: possibly confusing denomination - we intentionally do not support 'M' and 'P' so as to not confuse mega/milli and peta/pico", self.0.display_cannot_parse("bitcoin denomination"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
impl std::error::Error for PossiblyConfusingDenominationError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None }
|
||||||
|
}
|
||||||
|
|
||||||
fn is_too_precise(s: &str, precision: usize) -> bool {
|
fn is_too_precise(s: &str, precision: usize) -> bool {
|
||||||
match s.find('.') {
|
match s.find('.') {
|
||||||
Some(pos) =>
|
Some(pos) =>
|
||||||
|
@ -493,7 +549,6 @@ fn fmt_satoshi_in(
|
||||||
///
|
///
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
#[cfg_attr(feature = "serde", serde(crate = "actual_serde"))]
|
|
||||||
pub struct Amount(u64);
|
pub struct Amount(u64);
|
||||||
|
|
||||||
impl Amount {
|
impl Amount {
|
||||||
|
@ -582,7 +637,7 @@ impl Amount {
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```
|
/// ```
|
||||||
/// # use bitcoin::{Amount, Denomination};
|
/// # use bitcoin_units::amount::{Amount, Denomination};
|
||||||
/// let amount = Amount::from_sat(100_000);
|
/// let amount = Amount::from_sat(100_000);
|
||||||
/// assert_eq!(amount.to_btc(), amount.to_float_in(Denomination::Bitcoin))
|
/// assert_eq!(amount.to_btc(), amount.to_float_in(Denomination::Bitcoin))
|
||||||
/// ```
|
/// ```
|
||||||
|
@ -687,20 +742,6 @@ impl Amount {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Decodable for Amount {
|
|
||||||
#[inline]
|
|
||||||
fn consensus_decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, encode::Error> {
|
|
||||||
Ok(Amount(Decodable::consensus_decode(r)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Encodable for Amount {
|
|
||||||
#[inline]
|
|
||||||
fn consensus_encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<usize, io::Error> {
|
|
||||||
self.0.consensus_encode(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl default::Default for Amount {
|
impl default::Default for Amount {
|
||||||
fn default() -> Self { Amount::ZERO }
|
fn default() -> Self { Amount::ZERO }
|
||||||
}
|
}
|
||||||
|
@ -1215,12 +1256,11 @@ pub mod serde {
|
||||||
//!
|
//!
|
||||||
//! ```rust,ignore
|
//! ```rust,ignore
|
||||||
//! use serde::{Serialize, Deserialize};
|
//! use serde::{Serialize, Deserialize};
|
||||||
//! use bitcoin::Amount;
|
//! use bitcoin_units::Amount;
|
||||||
//!
|
//!
|
||||||
//! #[derive(Serialize, Deserialize)]
|
//! #[derive(Serialize, Deserialize)]
|
||||||
//! # #[serde(crate = "actual_serde")]
|
|
||||||
//! pub struct HasAmount {
|
//! pub struct HasAmount {
|
||||||
//! #[serde(with = "bitcoin::amount::serde::as_btc")]
|
//! #[serde(with = "bitcoin_units::amount::serde::as_btc")]
|
||||||
//! pub amount: Amount,
|
//! pub amount: Amount,
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
@ -1979,13 +2019,24 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(clippy::inconsistent_digit_grouping)] // Group to show 100,000,000 sats per bitcoin.
|
#[allow(clippy::inconsistent_digit_grouping)] // Group to show 100,000,000 sats per bitcoin.
|
||||||
fn from_str() {
|
fn from_str() {
|
||||||
|
use ParseDenominationError::*;
|
||||||
|
|
||||||
use super::ParseAmountError as E;
|
use super::ParseAmountError as E;
|
||||||
|
|
||||||
assert_eq!(Amount::from_str("x BTC"), Err(E::InvalidCharacter('x')));
|
assert_eq!(Amount::from_str("x BTC"), Err(E::InvalidCharacter('x')));
|
||||||
assert_eq!(Amount::from_str("xBTC"), Err(E::UnknownDenomination("xBTC".into())));
|
assert_eq!(
|
||||||
assert_eq!(Amount::from_str("5 BTC BTC"), Err(E::UnknownDenomination("BTC BTC".into())));
|
Amount::from_str("xBTC"),
|
||||||
|
Err(Unknown(UnknownDenominationError("xBTC".into())).into()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Amount::from_str("5 BTC BTC"),
|
||||||
|
Err(Unknown(UnknownDenominationError("BTC BTC".into())).into()),
|
||||||
|
);
|
||||||
assert_eq!(Amount::from_str("5BTC BTC"), Err(E::InvalidCharacter('B')));
|
assert_eq!(Amount::from_str("5BTC BTC"), Err(E::InvalidCharacter('B')));
|
||||||
assert_eq!(Amount::from_str("5 5 BTC"), Err(E::UnknownDenomination("5 BTC".into())));
|
assert_eq!(
|
||||||
|
Amount::from_str("5 5 BTC"),
|
||||||
|
Err(Unknown(UnknownDenominationError("5 BTC".into())).into()),
|
||||||
|
);
|
||||||
|
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn case(s: &str, expected: Result<Amount, ParseAmountError>) {
|
fn case(s: &str, expected: Result<Amount, ParseAmountError>) {
|
||||||
|
@ -1999,7 +2050,7 @@ mod tests {
|
||||||
assert_eq!(SignedAmount::from_str(&s.replace(' ', "")), expected);
|
assert_eq!(SignedAmount::from_str(&s.replace(' ', "")), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
case("5 BCH", Err(E::UnknownDenomination("BCH".to_owned())));
|
case("5 BCH", Err(Unknown(UnknownDenominationError("BCH".into())).into()));
|
||||||
|
|
||||||
case("-1 BTC", Err(E::Negative));
|
case("-1 BTC", Err(E::Negative));
|
||||||
case("-0.0 BTC", Err(E::Negative));
|
case("-0.0 BTC", Err(E::Negative));
|
||||||
|
@ -2109,7 +2160,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn to_string_with_denomination_from_str_roundtrip() {
|
fn to_string_with_denomination_from_str_roundtrip() {
|
||||||
|
use ParseDenominationError::*;
|
||||||
|
|
||||||
use super::Denomination as D;
|
use super::Denomination as D;
|
||||||
|
|
||||||
let amt = Amount::from_sat(42);
|
let amt = Amount::from_sat(42);
|
||||||
let denom = Amount::to_string_with_denomination;
|
let denom = Amount::to_string_with_denomination;
|
||||||
assert_eq!(Amount::from_str(&denom(amt, D::Bitcoin)), Ok(amt));
|
assert_eq!(Amount::from_str(&denom(amt, D::Bitcoin)), Ok(amt));
|
||||||
|
@ -2123,11 +2177,11 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Amount::from_str("42 satoshi BTC"),
|
Amount::from_str("42 satoshi BTC"),
|
||||||
Err(ParseAmountError::UnknownDenomination("satoshi BTC".into())),
|
Err(Unknown(UnknownDenominationError("satoshi BTC".into())).into()),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SignedAmount::from_str("-42 satoshi BTC"),
|
SignedAmount::from_str("-42 satoshi BTC"),
|
||||||
Err(ParseAmountError::UnknownDenomination("satoshi BTC".into())),
|
Err(Unknown(UnknownDenominationError("satoshi BTC".into())).into()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2135,7 +2189,6 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn serde_as_sat() {
|
fn serde_as_sat() {
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||||
#[serde(crate = "actual_serde")]
|
|
||||||
struct T {
|
struct T {
|
||||||
#[serde(with = "crate::amount::serde::as_sat")]
|
#[serde(with = "crate::amount::serde::as_sat")]
|
||||||
pub amt: Amount,
|
pub amt: Amount,
|
||||||
|
@ -2163,7 +2216,6 @@ mod tests {
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||||
#[serde(crate = "actual_serde")]
|
|
||||||
struct T {
|
struct T {
|
||||||
#[serde(with = "crate::amount::serde::as_btc")]
|
#[serde(with = "crate::amount::serde::as_btc")]
|
||||||
pub amt: Amount,
|
pub amt: Amount,
|
||||||
|
@ -2199,7 +2251,6 @@ mod tests {
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Eq)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Eq)]
|
||||||
#[serde(crate = "actual_serde")]
|
|
||||||
struct T {
|
struct T {
|
||||||
#[serde(default, with = "crate::amount::serde::as_btc::opt")]
|
#[serde(default, with = "crate::amount::serde::as_btc::opt")]
|
||||||
pub amt: Option<Amount>,
|
pub amt: Option<Amount>,
|
||||||
|
@ -2241,7 +2292,6 @@ mod tests {
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Debug, Eq)]
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Eq)]
|
||||||
#[serde(crate = "actual_serde")]
|
|
||||||
struct T {
|
struct T {
|
||||||
#[serde(default, with = "crate::amount::serde::as_sat::opt")]
|
#[serde(default, with = "crate::amount::serde::as_sat::opt")]
|
||||||
pub amt: Option<Amount>,
|
pub amt: Option<Amount>,
|
||||||
|
@ -2351,7 +2401,7 @@ mod tests {
|
||||||
for denom in confusing.iter() {
|
for denom in confusing.iter() {
|
||||||
match Denomination::from_str(denom) {
|
match Denomination::from_str(denom) {
|
||||||
Ok(_) => panic!("from_str should error for {}", denom),
|
Ok(_) => panic!("from_str should error for {}", denom),
|
||||||
Err(ParseAmountError::PossiblyConfusingDenomination(_)) => {}
|
Err(ParseDenominationError::PossiblyConfusing(_)) => {}
|
||||||
Err(e) => panic!("unexpected error: {}", e),
|
Err(e) => panic!("unexpected error: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2364,7 +2414,7 @@ mod tests {
|
||||||
for denom in unknown.iter() {
|
for denom in unknown.iter() {
|
||||||
match Denomination::from_str(denom) {
|
match Denomination::from_str(denom) {
|
||||||
Ok(_) => panic!("from_str should error for {}", denom),
|
Ok(_) => panic!("from_str should error for {}", denom),
|
||||||
Err(ParseAmountError::UnknownDenomination(_)) => {}
|
Err(ParseDenominationError::Unknown(_)) => {}
|
||||||
Err(e) => panic!("unexpected error: {}", e),
|
Err(e) => panic!("unexpected error: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
// SPDX-License-Identifier: CC0-1.0
|
||||||
|
|
||||||
|
//! Rust Bitcoin units library
|
||||||
|
//!
|
||||||
|
//! This library provides basic types used by the Rust Bitcoin ecosystem.
|
||||||
|
|
||||||
|
#![cfg_attr(all(not(test), not(feature = "std")), no_std)]
|
||||||
|
// Experimental features we need.
|
||||||
|
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||||
|
// Coding conventions
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
// Exclude clippy lints we don't think are valuable
|
||||||
|
#![allow(clippy::needless_question_mark)] // https://github.com/rust-bitcoin/rust-bitcoin/pull/2134
|
||||||
|
|
||||||
|
// Disable 16-bit support at least for now as we can't guarantee it yet.
|
||||||
|
#[cfg(target_pointer_width = "16")]
|
||||||
|
compile_error!(
|
||||||
|
"rust-bitcoin currently only supports architectures with pointers wider than 16 bits, let us
|
||||||
|
know if you want 16-bit support. Note that we do NOT guarantee that we will implement it!"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(all(feature = "alloc", not(feature = "std")))]
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
extern crate core;
|
||||||
|
|
||||||
|
/// A generic serialization/deserialization framework.
|
||||||
|
#[cfg(feature = "serde")]
|
||||||
|
pub extern crate serde;
|
||||||
|
|
||||||
|
// TODO: Make amount module less dependent on an allocator.
|
||||||
|
#[cfg(feature = "alloc")]
|
||||||
|
pub mod amount;
|
||||||
|
|
||||||
|
#[cfg(feature = "alloc")]
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use self::amount::{Amount, ParseAmountError, SignedAmount};
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
|
mod prelude {
|
||||||
|
#[cfg(all(feature = "alloc", not(feature = "std"), not(test)))]
|
||||||
|
pub use alloc::{string::{String, ToString}, borrow::ToOwned};
|
||||||
|
|
||||||
|
#[cfg(any(feature = "std", test))]
|
||||||
|
pub use std::{string::{String, ToString}, borrow::ToOwned};
|
||||||
|
}
|
Loading…
Reference in New Issue