Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bytescale/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ default = ["std", "derive"]
std = ["humanbyte/std"]
derive = []
arbitrary = ["dep:arbitrary", "std"]
schemars = ["humanbyte/schemars"]
serde = ["humanbyte/serde"]

[dependencies]
arbitrary = { version = "1", features = ["derive"], optional = true }
humanbyte = { version = "0.2.1-alpha.0", path = "../humanbyte", features = ["derive"] }

[dev-dependencies]
schemars = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["std"] }
toml = "0.8"
95 changes: 94 additions & 1 deletion bytescale/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,14 @@ mod tests {
#[test]
fn when_err() {
// shortcut for writing test cases
fn parse(s: &str) -> Result<ByteScale, String> {
fn parse(s: &str) -> Result<ByteScale, humanbyte::ParseError> {
s.parse::<ByteScale>()
}

// the error type chains with `?` in std contexts
fn assert_error<E: std::error::Error>(_: &E) {}
assert_error(&parse("oops").unwrap_err());

assert!(parse("").is_err());
assert!(parse("a124GB").is_err());
assert!(parse("1.3 42.0 B").is_err());
Expand All @@ -158,6 +162,37 @@ mod tests {
assert!(parse("1 000 B").is_err());
}

#[test]
fn test_div() {
let file_size = ByteScale::gib(4);
let chunk_size = ByteScale::mib(64);
assert_eq!(file_size / chunk_size, 64u64);
assert_eq!(file_size / 4u64, ByteScale::gib(1));
assert_eq!(file_size % chunk_size, ByteScale::b(0));

let mut x = ByteScale::mb(10);
x /= 2u64;
assert_eq!(x, ByteScale::mb(5));
}

#[test]
fn test_to_string_with_precision() {
use humanbyte::Format;
let x = ByteScale::tib(1);
assert_eq!(x.to_string_with_precision(Format::IEC, 2), "1.00 TiB");
assert_eq!(x.to_string_with_precision(Format::IEC, 0), "1 TiB");
assert_eq!(
ByteScale::kib(1) + 512u64,
"1.500 KiB".parse::<ByteScale>().unwrap()
);
}

#[test]
fn test_standalone_parse() {
// no newtype required
assert_eq!(humanbyte::parse("1.5 KiB"), Ok(1536));
}

#[test]
#[should_panic(expected = "byte size overflows u64")]
fn test_constructor_overflow() {
Expand Down Expand Up @@ -200,4 +235,62 @@ mod tests {
let s: S = toml::from_str(r#"x = "9223372036854775807""#).unwrap();
assert_eq!(s.x, "9223372036854775807".parse::<ByteScale>().unwrap());
}

#[cfg(feature = "serde")]
#[test]
fn test_serde_with_plain_integers() {
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Config {
#[serde(with = "humanbyte::serde")]
buffer_size: usize,
#[serde(with = "humanbyte::serde")]
max_size: u64,
#[serde(with = "humanbyte::serde::map_keys")]
pools: BTreeMap<u64, String>,
}

let config: Config = serde_json::from_str(
r#"{
"buffer_size": "1.5 KiB",
"max_size": 1048576,
"pools": { "4 KiB": "small", "2 MiB": "large" }
}"#,
)
.unwrap();
assert_eq!(config.buffer_size, 1536);
assert_eq!(config.max_size, 1_048_576);
assert_eq!(config.pools[&4096], "small");
assert_eq!(config.pools[&2_097_152], "large");

// roundtrip: serializes human-readable, parses back to the same values
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains(r#""buffer_size":"1.5 KiB""#));
assert!(json.contains(r#""4.0 KiB":"small""#));
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back, config);

// toml too
let config: Config = toml::from_str(
"buffer_size = \"2 KiB\"\nmax_size = \"1 MiB\"\n[pools]\n\"64 KiB\" = \"medium\"",
)
.unwrap();
assert_eq!(config.buffer_size, 2048);
assert_eq!(config.pools[&65536], "medium");
}

#[cfg(feature = "schemars")]
#[test]
fn test_json_schema() {
let schema = schemars::schema_for!(ByteScale);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(
json["type"],
serde_json::json!(["string", "integer"]),
"schema should accept both forms: {json}"
);
}
}
1 change: 1 addition & 0 deletions humanbyte-derive/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ proc-macro = true

[features]
default = []
schemars = []
serde = []

[dependencies]
Expand Down
126 changes: 95 additions & 31 deletions humanbyte-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ pub fn humanbyte(input: TokenStream) -> TokenStream {
if cfg!(feature = "serde") {
combined.extend(serde_tokens(name));
}
if cfg!(feature = "schemars") {
combined.extend(schemars_tokens(name));
}
TokenStream::from(combined)
}

Expand Down Expand Up @@ -185,6 +188,47 @@ fn ops_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
}
}

impl core::ops::Div<#name> for #name {
/// Dividing two byte sizes yields a dimensionless count,
/// e.g. `file_size / chunk_size` chunks.
type Output = u64;

#[inline(always)]
fn div(self, rhs: #name) -> u64 {
self.0 / rhs.0
}
}

impl<T> core::ops::Div<T> for #name
where
T: Into<u64>,
{
type Output = #name;
#[inline(always)]
fn div(self, rhs: T) -> #name {
#name(self.0 / rhs.into())
}
}

impl<T> core::ops::DivAssign<T> for #name
where
T: Into<u64>,
{
#[inline(always)]
fn div_assign(&mut self, rhs: T) {
self.0 /= rhs.into();
}
}

impl core::ops::Rem<#name> for #name {
type Output = #name;

#[inline(always)]
fn rem(self, rhs: #name) -> #name {
#name(self.0 % rhs.0)
}
}

impl core::ops::Add<#name> for u64 {
type Output = #name;
#[inline(always)]
Expand Down Expand Up @@ -327,29 +371,10 @@ pub fn humanbyte_fromstr(input: TokenStream) -> TokenStream {
fn fromstr_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
quote! {
impl core::str::FromStr for #name {
type Err = ::humanbyte::String;
type Err = ::humanbyte::ParseError;

fn from_str(value: &str) -> core::result::Result<Self, Self::Err> {
if let Ok(v) = value.parse::<u64>() {
return Ok(Self(v));
}
let number = ::humanbyte::take_while(value, |c| c.is_ascii_digit() || c == '.');
match number.parse::<f64>() {
Ok(v) => {
let suffix = ::humanbyte::skip_while(&value[number.len()..], char::is_whitespace);
match suffix.parse::<::humanbyte::Unit>() {
Ok(u) => Ok(Self((v * u64::from(u) as f64) as u64)),
Err(error) => Err(::humanbyte::format!(
"couldn't parse {:?} into a known SI unit, {}",
suffix, error
)),
}
}
Err(error) => Err(::humanbyte::format!(
"couldn't parse {:?} into a ByteSize, {}",
value, error
)),
}
::humanbyte::parse(value).map(Self)
}
}
}
Expand All @@ -370,6 +395,16 @@ fn parse_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
::humanbyte::to_string(self.0, format)
}

/// Returns the size as a string with the given number of decimals.
#[inline(always)]
pub fn to_string_with_precision(
&self,
format: ::humanbyte::Format,
precision: usize,
) -> ::humanbyte::String {
::humanbyte::to_string_with_precision(self.0, format, precision)
}

/// Returns the inner u64 value.
#[inline(always)]
pub const fn as_u64(&self) -> u64 {
Expand All @@ -394,41 +429,41 @@ pub fn humanbyte_serde(input: TokenStream) -> TokenStream {

fn serde_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
quote! {
impl<'de> ::humanbyte::serde::Deserialize<'de> for #name {
impl<'de> ::humanbyte::serde_crate::Deserialize<'de> for #name {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: ::humanbyte::serde::Deserializer<'de>,
D: ::humanbyte::serde_crate::Deserializer<'de>,
{
struct ByteSizeVisitor;

impl<'de> ::humanbyte::serde::de::Visitor<'de> for ByteSizeVisitor {
impl<'de> ::humanbyte::serde_crate::de::Visitor<'de> for ByteSizeVisitor {
type Value = #name;

fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str("an integer or string")
}

fn visit_i64<E: ::humanbyte::serde::de::Error>(self, value: i64) -> core::result::Result<Self::Value, E> {
fn visit_i64<E: ::humanbyte::serde_crate::de::Error>(self, value: i64) -> core::result::Result<Self::Value, E> {
if let Ok(val) = u64::try_from(value) {
Ok(#name(val))
} else {
Err(E::invalid_value(
::humanbyte::serde::de::Unexpected::Signed(value),
::humanbyte::serde_crate::de::Unexpected::Signed(value),
&"integer overflow",
))
}
}

fn visit_u64<E: ::humanbyte::serde::de::Error>(self, value: u64) -> core::result::Result<Self::Value, E> {
fn visit_u64<E: ::humanbyte::serde_crate::de::Error>(self, value: u64) -> core::result::Result<Self::Value, E> {
Ok(#name(value))
}

fn visit_str<E: ::humanbyte::serde::de::Error>(self, value: &str) -> core::result::Result<Self::Value, E> {
fn visit_str<E: ::humanbyte::serde_crate::de::Error>(self, value: &str) -> core::result::Result<Self::Value, E> {
if let Ok(val) = value.parse() {
Ok(val)
} else {
Err(E::invalid_value(
::humanbyte::serde::de::Unexpected::Str(value),
::humanbyte::serde_crate::de::Unexpected::Str(value),
&"parsable string",
))
}
Expand All @@ -442,10 +477,10 @@ fn serde_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
}
}
}
impl ::humanbyte::serde::Serialize for #name {
impl ::humanbyte::serde_crate::Serialize for #name {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: ::humanbyte::serde::Serializer,
S: ::humanbyte::serde_crate::Serializer,
{
if serializer.is_human_readable() {
<str>::serialize(self.to_string().as_str(), serializer)
Expand All @@ -456,3 +491,32 @@ fn serde_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
}
}
}

#[proc_macro_derive(HumanByteSchema)]
pub fn humanbyte_schema(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
TokenStream::from(schemars_tokens(&input.ident))
}

fn schemars_tokens(name: &syn::Ident) -> proc_macro2::TokenStream {
quote! {
impl ::humanbyte::schemars_crate::JsonSchema for #name {
fn schema_name() -> ::humanbyte::Cow<'static, str> {
::humanbyte::Cow::Borrowed(stringify!(#name))
}

fn schema_id() -> ::humanbyte::Cow<'static, str> {
::humanbyte::Cow::Borrowed(concat!(module_path!(), "::", stringify!(#name)))
}

fn json_schema(
_generator: &mut ::humanbyte::schemars_crate::SchemaGenerator,
) -> ::humanbyte::schemars_crate::Schema {
::humanbyte::schemars_crate::json_schema!({
"type": ["string", "integer"],
"description": "A byte size, as either a human-readable string (e.g. \"1.5 KiB\") or a number of bytes",
})
}
}
}
}
2 changes: 2 additions & 0 deletions humanbyte/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ default = ["std"]
std = []
derive = ["dep:humanbyte-derive"]
serde = ["dep:serde", "std", "humanbyte-derive/serde"]
schemars = ["dep:schemars", "std", "humanbyte-derive/schemars"]

[dependencies]
humanbyte-derive = { version = "0.2.1-alpha.0", path = "../humanbyte-derive", optional = true }
schemars = { version = "1", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
32 changes: 32 additions & 0 deletions humanbyte/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,38 @@ a la carte fashion:
* HumanByteOps
* HumanByteFromStr
* HumanByteSerde (requires the `serde` feature)
* HumanByteSchema (requires the `schemars` feature)

## Without a newtype

Plain `u64`/`usize` fields can use human-readable serde directly — no newtype required:

```rust,ignore
#[derive(Serialize, Deserialize)]
struct Config {
#[serde(with = "humanbyte::serde")]
buffer_size: usize,
#[serde(with = "humanbyte::serde::map_keys")]
pools: BTreeMap<u64, PoolConfig>,
}
```

And free functions mirror the derived methods:

```rust
assert_eq!(humanbyte::parse("1.5 KiB"), Ok(1536));
assert_eq!(humanbyte::to_string(1536, humanbyte::Format::IEC), "1.5 KiB");
assert_eq!(
humanbyte::to_string_with_precision(1536, humanbyte::Format::IEC, 2),
"1.50 KiB"
);
```

## JSON schema

With the `schemars` feature, derived types implement `schemars::JsonSchema` (accepting a
string like `"1.5 KiB"` or a raw byte count), so config types need no manual
`#[schemars(with = "String")]` annotations.

[bytescale]: https://docs.rs/bytescale/latest/bytescale
[bytesize]: https://docs.rs/bytesize/latest/bytesize
Loading
Loading