diff options
| author | tv <tv@krebsco.de> | 2026-10-26 05:21:49 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-26 05:21:49 +0100 |
| commit | c0ab6dbd13f26c7307b57a032da961bd6c49461c (patch) | |
| tree | 373b3d300ec3252dfcc507d769e74a48286301bf /src | |
| parent | 968a28c6e68e35e127ec3dd20ed2a8eeb8b30cc0 (diff) | |
allow humand-friendly durations and sizes in CLI
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/file.rs | 4 | ||||
| -rw-r--r-- | src/config/mod.rs | 131 | ||||
| -rw-r--r-- | src/document/file.rs | 4 | ||||
| -rw-r--r-- | src/main.rs | 13 | ||||
| -rw-r--r-- | src/remake/vote.rs | 2 | ||||
| -rw-r--r-- | src/state/mod.rs | 2 | ||||
| -rw-r--r-- | src/swarm/blob.rs | 2 | ||||
| -rw-r--r-- | src/swarm/members.rs | 2 | ||||
| -rw-r--r-- | src/units.rs | 248 |
9 files changed, 341 insertions, 67 deletions
diff --git a/src/config/file.rs b/src/config/file.rs index b6eb94d..50b3d48 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -223,7 +223,7 @@ mod tests { write( &dir, "base.toml", - "alias = \"base\"\nneighbours = 8\nkeepalive-secs = 30\n", + "alias = \"base\"\nneighbours = 8\nkeepalive = \"30s\"\n", ); let toml = write( &dir, @@ -240,7 +240,7 @@ mod tests { ); let layer = load(&json).unwrap(); assert!(layer["neighbours"].origin.unset); - assert_eq!(layer["keepalive-secs"].value, json!(30)); + assert_eq!(layer["keepalive"].value, json!("30s")); let unknown = write(&dir, "bad.toml", "unset = [\"nope\"]\n"); assert!(load(&unknown).is_err()); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 538689e..0aaccd6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,6 +16,7 @@ mod import; use std::collections::BTreeMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use clap::parser::ValueSource; @@ -33,6 +34,10 @@ pub(crate) enum Kind { Flag, Text, Integer, + /// Seconds, or with units: `500ms`, `30s`, `1h30m` (`units::parse_duration`). + Duration, + /// Bytes, or with a unit: `64KiB` (`units::parse_size`). + Size, /// Comma- or line-separated; repeatable on the command line. List, } @@ -138,20 +143,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::Text, "PATH", None, - "This node's document on disk. Read at start: the node resumes with it, ready at once. Kept up to date while ready: changes are appended every --document-append-secs, and the file is rewritten after a switch, a rejoin or a restart, and when the appended changes outgrew it.", + "This node's document on disk. Read at start: the node resumes with it, ready at once. Kept up to date while ready: changes are appended every --document-append, and the file is rewritten after a switch, a rejoin or a restart, and when the appended changes outgrew it.", ), option( - "document-append-secs", - Kind::Integer, - "SECONDS", - Some("5"), + "document-append", + Kind::Duration, + "DURATION", + Some("5s"), "How often changes are appended to the document file.", ), option( - "document-rewrite-secs", - Kind::Integer, - "SECONDS", - Some("3600"), + "document-rewrite", + Kind::Duration, + "DURATION", + Some("1h"), "Shortest time between rewrites of the document file because its appended changes outgrew its full save (a new epoch or a restart rewrites it at once).", ), option( @@ -176,10 +181,10 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "A name for this node, shown next to its public key (/peers, metrics). Published in the swarm's member list; not unique, never an identity.", ), option( - "keepalive-secs", - Kind::Integer, - "SECONDS", - Some("60"), + "keepalive", + Kind::Duration, + "DURATION", + Some("1m"), "Keepalive interval; dormancy, resync and probe timings derive from it.", ), option( @@ -190,18 +195,18 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Neighbours each node keeps (at least 2). Swarms of up to this many + 1 nodes are a full mesh; larger ones sync over several hops.", ), option( - "electorate-window-days", - Kind::Integer, - "DAYS", - Some("7"), - "Members that renewed their alive stamp within this many days vote on remakes (a majority of them must agree). Matters on the node that coordinates a remake.", + "electorate-window", + Kind::Duration, + "DURATION", + Some("7d"), + "Members that renewed their alive stamp within this time vote on remakes (a majority of them must agree). Matters on the node that coordinates a remake.", ), option( - "blob-chunk-kib", - Kind::Integer, - "KIB", - Some("64"), - "Chunk size when serving large transfers to other nodes (e.g. an epoch's snapshot to a rejoining node), in KiB (1 to 4096).", + "blob-chunk", + Kind::Size, + "SIZE", + Some("64KiB"), + "Chunk size when serving large transfers to other nodes (e.g. an epoch's snapshot to a rejoining node), 1KiB to 4MiB.", ), option( "mycelium-rest-url", @@ -276,24 +281,34 @@ pub(crate) struct Settings { pub(crate) genesis: bool, pub(crate) state_file: Option<PathBuf>, pub(crate) document_file: Option<PathBuf>, - #[serde(default = "default_document_append_secs")] - pub(crate) document_append_secs: u64, - #[serde(default = "default_document_rewrite_secs")] - pub(crate) document_rewrite_secs: u64, + #[serde( + default = "default_document_append", + with = "crate::units::duration_option" + )] + pub(crate) document_append: Duration, + #[serde( + default = "default_document_rewrite", + with = "crate::units::duration_option" + )] + pub(crate) document_rewrite: Duration, pub(crate) recover_from: Option<PathBuf>, pub(crate) remake: Option<String>, #[serde(default)] pub(crate) force: Vec<String>, pub(crate) swarm_key_file: Option<PathBuf>, pub(crate) alias: Option<String>, - #[serde(default = "default_keepalive_secs")] - pub(crate) keepalive_secs: u64, + #[serde(default = "default_keepalive", with = "crate::units::duration_option")] + pub(crate) keepalive: Duration, #[serde(default = "default_neighbours")] pub(crate) neighbours: usize, - #[serde(default = "default_electorate_window_days")] - pub(crate) electorate_window_days: u64, - #[serde(default = "default_blob_chunk_kib")] - pub(crate) blob_chunk_kib: u32, + #[serde( + default = "default_electorate_window", + with = "crate::units::duration_option" + )] + pub(crate) electorate_window: Duration, + /// In bytes. + #[serde(default = "default_blob_chunk", with = "crate::units::size_option")] + pub(crate) blob_chunk: u64, pub(crate) mycelium_rest_url: Option<String>, pub(crate) mycelium_key_file: Option<PathBuf>, pub(crate) mycelium_network_key_file: Option<PathBuf>, @@ -307,23 +322,23 @@ pub(crate) struct Settings { fn default_bind_addr() -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], 3000)) } -fn default_document_append_secs() -> u64 { - 5 +fn default_document_append() -> Duration { + Duration::from_secs(5) } -fn default_document_rewrite_secs() -> u64 { - 3600 +fn default_document_rewrite() -> Duration { + Duration::from_secs(3600) } -fn default_keepalive_secs() -> u64 { - 60 +fn default_keepalive() -> Duration { + Duration::from_secs(60) } fn default_neighbours() -> usize { 12 } -fn default_electorate_window_days() -> u64 { - 7 +fn default_electorate_window() -> Duration { + Duration::from_secs(7 * 24 * 3600) } -fn default_blob_chunk_kib() -> u32 { - 64 +fn default_blob_chunk() -> u64 { + 64 * 1024 } fn default_mycelium_peers() -> Vec<String> { vec!["tls://127.0.0.1:9651".to_owned()] @@ -383,17 +398,17 @@ impl Settings { if self.neighbours < 2 { bail!("neighbours must be at least 2"); } - if self.document_append_secs == 0 || self.document_rewrite_secs == 0 { - bail!("document-append-secs and document-rewrite-secs must be at least 1"); + if self.document_append.is_zero() || self.document_rewrite.is_zero() { + bail!("document-append and document-rewrite must be longer than 0"); } - if self.keepalive_secs == 0 { - bail!("keepalive-secs must be at least 1"); + if self.keepalive < Duration::from_millis(100) { + bail!("keepalive must be at least 100ms"); } - if !(1..=4096).contains(&self.blob_chunk_kib) { - bail!("blob-chunk-kib must be between 1 and 4096"); + if !(1024..=4 << 20).contains(&self.blob_chunk) { + bail!("blob-chunk must be between 1KiB and 4MiB"); } - if self.electorate_window_days == 0 { - bail!("electorate-window-days must be at least 1"); + if self.electorate_window.is_zero() { + bail!("electorate-window must be longer than 0"); } if let Some(alias) = &self.alias { check_alias(alias)?; @@ -598,6 +613,14 @@ fn from_text(kind: Kind, text: &str) -> Result<JsonValue> { .map_err(|_| anyhow!("{text:?} is not a non-negative integer"))?; JsonValue::from(number) } + Kind::Duration => { + crate::units::parse_duration(text).map_err(|reason| anyhow!(reason))?; + JsonValue::String(text.trim().to_owned()) + } + Kind::Size => { + crate::units::parse_size(text).map_err(|reason| anyhow!(reason))?; + JsonValue::String(text.trim().to_owned()) + } Kind::Flag => match text.trim() { "true" | "yes" | "1" => JsonValue::Bool(true), "false" | "no" | "0" => JsonValue::Bool(false), @@ -719,7 +742,9 @@ fn command() -> Command { command = command.arg(match spec.kind { Kind::Flag => arg.action(ArgAction::SetTrue), Kind::List => arg.value_name(spec.value_name).action(ArgAction::Append), - Kind::Text | Kind::Integer => arg.value_name(spec.value_name).action(ArgAction::Set), + Kind::Text | Kind::Integer | Kind::Duration | Kind::Size => { + arg.value_name(spec.value_name).action(ArgAction::Set) + } }); } command @@ -1089,7 +1114,7 @@ mod tests { assert!(resolve(layer(json!({ "htpasswd": "u", "alias": " " })), Vec::new()).is_err()); assert!( resolve( - layer(json!({ "htpasswd": "u", "keepalive-secs": "x" })), + layer(json!({ "htpasswd": "u", "keepalive": "x" })), Vec::new() ) .is_err() diff --git a/src/document/file.rs b/src/document/file.rs index 6e7a4b6..ed29e9b 100644 --- a/src/document/file.rs +++ b/src/document/file.rs @@ -9,10 +9,10 @@ //! //! Written by a worker (`run`), only while the node is ready and not rejoining (a rejoin //! keeps the old file until it is done): -//! * every `--document-append-secs`, what changed is appended; +//! * every `--document-append`, what changed is appended; //! * the whole file is rewritten, atomically, when the epoch or this node's actors //! changed (a switch, a rejoin, a restart), and when the appended increments outgrew -//! the full save, at most every `--document-rewrite-secs`; +//! the full save, at most every `--document-rewrite`; //! * at shutdown, what changed is appended. use std::collections::HashSet; diff --git a/src/main.rs b/src/main.rs index a79c6a2..5ae2c50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ use std::future::IntoFuture; use std::sync::Arc; -use std::time::Duration; use anyhow::{Context, Result}; use automerge::AutoCommit; @@ -30,6 +29,7 @@ mod swarm; #[cfg(test)] mod testrand; mod transport; +mod units; use api::auth::AuthState; use document::replica::Epoch; @@ -162,8 +162,8 @@ async fn main() -> Result<()> { Arc::new(document::file::DocumentFile::new( path, Arc::clone(&files), - Duration::from_secs(args.document_append_secs), - Duration::from_secs(args.document_rewrite_secs), + args.document_append, + args.document_rewrite, )) }); let node = node::Node::new(node::NodeConfig { @@ -171,11 +171,12 @@ async fn main() -> Result<()> { initial_epoch, initial_doc, earlier_actors, - timing: state::Timing::from_keepalive(Duration::from_secs(args.keepalive_secs)), + timing: state::Timing::from_keepalive(args.keepalive), neighbours_k: args.neighbours, alias: args.alias.clone(), - electorate_window: Duration::from_secs(args.electorate_window_days * 24 * 3600), - blob_chunk_bytes: args.blob_chunk_kib * 1024, + electorate_window: args.electorate_window, + // At most 4MiB (`Settings::check`). + blob_chunk_bytes: args.blob_chunk as u32, swarm_key, protocol_writers: args.protocol_writers.iter().cloned().collect(), files, diff --git a/src/remake/vote.rs b/src/remake/vote.rs index 9e28cde..40ec6bf 100644 --- a/src/remake/vote.rs +++ b/src/remake/vote.rs @@ -2,7 +2,7 @@ //! it. //! //! The electorate is fixed in the proposal: the members that renewed their alive stamp -//! within the coordinator's `--electorate-window-days` (`swarm::members::electorate`), +//! within the coordinator's `--electorate-window` (`swarm::members::electorate`), //! the coordinator always included. //! //! Votes live in the votes register (`_continuum/votes/<pk>`, written only by that diff --git a/src/state/mod.rs b/src/state/mod.rs index 55a260d..f93bbef 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -91,7 +91,7 @@ pub(crate) struct AppState { pub(crate) neighbours_k: usize, /// Our alias (`--alias`), published in the member list (`swarm::members`). pub(crate) alias: Option<String>, - /// Members alive within this window vote on remakes (`--electorate-window-days`). + /// Members alive within this window vote on remakes (`--electorate-window`). pub(crate) electorate_window: Duration, /// Chunked transfers served and fetched (`swarm::blob`). pub(crate) blobs: Arc<crate::swarm::blob::Blobs>, diff --git a/src/swarm/blob.rs b/src/swarm/blob.rs index 58522fa..c153a17 100644 --- a/src/swarm/blob.rs +++ b/src/swarm/blob.rs @@ -17,7 +17,7 @@ //! that doesn't arrive in time is asked of another peer. The caller checks the whole //! (for a change: its hash). //! -//! The chunk size is the serving node's (`--blob-chunk-kib`); the manifest carries it. +//! The chunk size is the serving node's (`--blob-chunk`); the manifest carries it. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex, MutexGuard}; diff --git a/src/swarm/members.rs b/src/swarm/members.rs index cba46f9..19abf2b 100644 --- a/src/swarm/members.rs +++ b/src/swarm/members.rs @@ -11,7 +11,7 @@ //! Besides its tier, an entry holds the alias a node gives itself (`--alias`), a name //! for people reading /peers and metrics (not unique and never an identity: the public //! key stays that), and an `alive` stamp the node renews now and then: the members -//! renewed within `--electorate-window-days` are the electorate of a remake. +//! renewed within `--electorate-window` are the electorate of a remake. use std::collections::HashMap; use std::time::Duration; diff --git a/src/units.rs b/src/units.rs new file mode 100644 index 0000000..e4576bc --- /dev/null +++ b/src/units.rs @@ -0,0 +1,248 @@ +//! Durations and sizes as operators write them: in options, configuration files and +//! API requests. +//! +//! * A duration is a bare number of seconds (`30`, `0.5`), or components with units in +//! descending order: `ms`, `s`, `m`, `h`, `d` (`500ms`, `500s`, `1h30m`, `1d2h3s`, +//! `1.5h`; spaces between components are allowed). Resolution is a millisecond: a +//! value with a finer part is an error, not rounded. +//! * A size is a bare number of bytes, or a number with a unit: `B`, `KiB`, `MiB`, `GiB` +//! (`64KiB`, `1.5MiB`). A value that isn't a whole number of bytes is an error. +//! +//! Output (stats, reports) stays numeric, with the unit in the field's name. + +use std::time::Duration; + +const DURATION_UNITS: [(&str, u64); 5] = [ + ("d", 86_400_000), + ("h", 3_600_000), + ("m", 60_000), + ("s", 1_000), + ("ms", 1), +]; + +const SIZE_UNITS: [(&str, u64); 4] = [ + ("GiB", 1 << 30), + ("MiB", 1 << 20), + ("KiB", 1 << 10), + ("B", 1), +]; + +/// `number` (digits, optionally a fraction) times `scale`, if that is a whole number +/// that fits. +fn scaled(number: &str, scale: u64) -> Option<u64> { + let (whole, fraction) = number.split_once('.').unwrap_or((number, "")); + if whole.is_empty() && fraction.is_empty() { + return None; + } + if !whole.bytes().all(|b| b.is_ascii_digit()) || !fraction.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let whole: u64 = if whole.is_empty() { + 0 + } else { + whole.parse().ok()? + }; + let mut value = whole.checked_mul(scale)?; + if !fraction.is_empty() { + let digits = u32::try_from(fraction.len()).ok()?; + let denominator = 10u128.checked_pow(digits)?; + let numerator = fraction.parse::<u128>().ok()? * u128::from(scale); + if numerator % denominator != 0 { + return None; + } + value = value.checked_add(u64::try_from(numerator / denominator).ok()?)?; + } + Some(value) +} + +/// A duration as written by an operator (see the module doc). +pub(crate) fn parse_duration(text: &str) -> Result<Duration, String> { + let text = text.trim(); + let invalid = || { + format!( + "{text:?} is not a duration (seconds, or e.g. 500ms, 30s, 1h30m, 1d2h3s; at most millisecond precision)" + ) + }; + if text.is_empty() { + return Err(invalid()); + } + if let Some(millis) = scaled(text, 1_000) { + return Ok(Duration::from_millis(millis)); + } + let mut rest = text; + let mut total: u64 = 0; + // Units must descend: the index of the next one allowed. + let mut next_unit = 0; + while !rest.is_empty() { + let number_len = rest + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .ok_or_else(invalid)?; + let (number, after) = rest.split_at(number_len); + let unit_len = after + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(after.len()); + let (unit, after) = after.split_at(unit_len); + let index = DURATION_UNITS + .iter() + .position(|(name, _)| *name == unit) + .filter(|&index| index >= next_unit) + .ok_or_else(invalid)?; + let millis = scaled(number, DURATION_UNITS[index].1).ok_or_else(invalid)?; + total = total.checked_add(millis).ok_or_else(invalid)?; + next_unit = index + 1; + rest = after.trim_start(); + } + Ok(Duration::from_millis(total)) +} + +/// A duration the way [`parse_duration`] reads it: `1d2h3s`, `500ms`, `0s`. +pub(crate) fn format_duration(duration: Duration) -> String { + let mut millis = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + if millis == 0 { + return "0s".to_owned(); + } + let mut out = String::new(); + for (name, scale) in DURATION_UNITS { + if millis >= scale { + out.push_str(&format!("{}{name}", millis / scale)); + millis %= scale; + } + } + out +} + +/// A size in bytes as written by an operator (see the module doc). +pub(crate) fn parse_size(text: &str) -> Result<u64, String> { + let text = text.trim(); + let invalid = || format!("{text:?} is not a size (bytes, or e.g. 512B, 64KiB, 1.5MiB, 1GiB)"); + let number_len = text + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(text.len()); + let (number, unit) = text.split_at(number_len); + let scale = match unit.trim() { + "" => 1, + unit => SIZE_UNITS + .iter() + .find(|(name, _)| *name == unit) + .map(|(_, scale)| *scale) + .ok_or_else(invalid)?, + }; + scaled(number, scale).ok_or_else(invalid) +} + +/// A size the way [`parse_size`] reads it: the largest unit that divides it. +pub(crate) fn format_size(bytes: u64) -> String { + SIZE_UNITS + .iter() + .find(|(_, scale)| bytes != 0 && bytes % scale == 0) + .map(|(name, scale)| format!("{}{name}", bytes / scale)) + .unwrap_or_else(|| "0B".to_owned()) +} + +/// Serde for a `Duration` option: a string as [`parse_duration`] reads it, or a number +/// of seconds (configuration files may write either); written as [`format_duration`]. +pub(crate) mod duration_option { + use std::time::Duration; + + use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + use serde_json::Value as JsonValue; + + pub(crate) fn serialize<S: Serializer>(value: &Duration, s: S) -> Result<S::Ok, S::Error> { + s.serialize_str(&super::format_duration(*value)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> { + match JsonValue::deserialize(d)? { + JsonValue::String(text) => super::parse_duration(&text).map_err(D::Error::custom), + JsonValue::Number(number) => { + super::parse_duration(&number.to_string()).map_err(D::Error::custom) + } + other => Err(D::Error::custom(format!("{other} is not a duration"))), + } + } +} + +/// Serde for a size option: a string as [`parse_size`] reads it, or a number of bytes; +/// written as [`format_size`]. +pub(crate) mod size_option { + use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + use serde_json::Value as JsonValue; + + pub(crate) fn serialize<S: Serializer>(value: &u64, s: S) -> Result<S::Ok, S::Error> { + s.serialize_str(&super::format_size(*value)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> { + match JsonValue::deserialize(d)? { + JsonValue::String(text) => super::parse_size(&text).map_err(D::Error::custom), + JsonValue::Number(number) => { + super::parse_size(&number.to_string()).map_err(D::Error::custom) + } + other => Err(D::Error::custom(format!("{other} is not a size"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(text: &str) -> u64 { + parse_duration(text).unwrap().as_millis() as u64 + } + + #[test] + fn durations() { + assert_eq!(ms("30"), 30_000); + assert_eq!(ms("0.5"), 500); + assert_eq!(ms(".25"), 250); + assert_eq!(ms("500s"), 500_000); + assert_eq!(ms("500ms"), 500); + assert_eq!(ms("1h30m"), 5_400_000); + assert_eq!(ms("1d2h3s"), 93_603_000); + assert_eq!(ms("1d 2h 3s"), 93_603_000); + assert_eq!(ms("1.5h"), 5_400_000); + assert_eq!(ms("0.001"), 1); + assert_eq!(ms(" 7d "), 7 * 86_400_000); + assert_eq!(ms("0"), 0); + } + + #[test] + fn bad_durations() { + for text in [ + "", "s", "1x", "1h1d", "1s1s", "1ms1s", "-1", "1.0005", "0.0001s", "1..5s", "1h 2", + "1e3", + ] { + assert!(parse_duration(text).is_err(), "{text:?} parsed"); + } + } + + #[test] + fn durations_format_as_they_parse() { + for millis in [0, 1, 500, 1_000, 60_000, 5_400_000, 93_603_000, 86_400_001] { + let duration = Duration::from_millis(millis); + assert_eq!( + parse_duration(&format_duration(duration)).unwrap(), + duration + ); + } + assert_eq!(format_duration(Duration::from_millis(93_603_000)), "1d2h3s"); + } + + #[test] + fn sizes() { + assert_eq!(parse_size("4096"), Ok(4096)); + assert_eq!(parse_size("512B"), Ok(512)); + assert_eq!(parse_size("64KiB"), Ok(65_536)); + assert_eq!(parse_size("64 KiB"), Ok(65_536)); + assert_eq!(parse_size("1.5MiB"), Ok(1_572_864)); + assert_eq!(parse_size("1GiB"), Ok(1 << 30)); + for text in ["", "KiB", "1.5", "1kib", "1KB", "0.3KiB", "-1"] { + assert!(parse_size(text).is_err(), "{text:?} parsed"); + } + for bytes in [1, 1024, 1536, 65_536, 1 << 30] { + assert_eq!(parse_size(&format_size(bytes)), Ok(bytes)); + } + assert_eq!(format_size(65_536), "64KiB"); + } +} |
