summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-26 05:21:49 +0100
committertv <tv@krebsco.de>2026-10-26 05:21:49 +0100
commitc0ab6dbd13f26c7307b57a032da961bd6c49461c (patch)
tree373b3d300ec3252dfcc507d769e74a48286301bf /src
parent968a28c6e68e35e127ec3dd20ed2a8eeb8b30cc0 (diff)
allow humand-friendly durations and sizes in CLI
Diffstat (limited to 'src')
-rw-r--r--src/config/file.rs4
-rw-r--r--src/config/mod.rs131
-rw-r--r--src/document/file.rs4
-rw-r--r--src/main.rs13
-rw-r--r--src/remake/vote.rs2
-rw-r--r--src/state/mod.rs2
-rw-r--r--src/swarm/blob.rs2
-rw-r--r--src/swarm/members.rs2
-rw-r--r--src/units.rs248
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");
+ }
+}