use crate::error::{NonoError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ResourceLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_processes: Option<u64>,
}
impl ResourceLimits {
#[must_use]
pub fn is_empty(&self) -> bool {
self.memory_bytes.is_none() && self.max_processes.is_none()
}
#[must_use]
pub fn summary(&self) -> String {
let mem = self
.memory_bytes
.map_or_else(|| "unlimited".to_string(), format_bytes);
let procs = self
.max_processes
.map_or_else(|| "unlimited".to_string(), |n| n.to_string());
format!("memory={mem}, processes={procs}")
}
}
pub fn parse_size(input: &str) -> Result<u64> {
let s = input.trim();
if s.is_empty() {
return Err(NonoError::ConfigParse("size cannot be empty".to_string()));
}
let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let (num_str, unit) = s.split_at(digits_end);
if num_str.is_empty() {
return Err(NonoError::ConfigParse(format!(
"invalid size '{input}': missing numeric value (decimals are not supported, e.g. use 512M not 0.5G)"
)));
}
let value: u64 = num_str.parse().map_err(|_| {
NonoError::ConfigParse(format!(
"invalid size '{input}': '{num_str}' is not a valid integer"
))
})?;
let multiplier: u64 = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1,
"k" | "ki" | "kib" => 1024_u64.pow(1),
"kb" => 1000_u64.pow(1),
"m" | "mi" | "mib" => 1024_u64.pow(2),
"mb" => 1000_u64.pow(2),
"g" | "gi" | "gib" => 1024_u64.pow(3),
"gb" => 1000_u64.pow(3),
"t" | "ti" | "tib" => 1024_u64.pow(4),
"tb" => 1000_u64.pow(4),
other => {
return Err(NonoError::ConfigParse(format!(
"invalid size '{input}': unknown unit '{other}' (use B, K/KiB, M/MiB, G/GiB, T/TiB)"
)));
}
};
let bytes = value.checked_mul(multiplier).ok_or_else(|| {
NonoError::ConfigParse(format!("size '{input}' overflows a 64-bit byte count"))
})?;
if bytes == 0 {
return Err(NonoError::ConfigParse(format!(
"size '{input}' must be greater than zero"
)));
}
Ok(bytes)
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut val = bytes as f64;
let mut idx = 0;
while val >= 1024.0 && idx < UNITS.len() - 1 {
val /= 1024.0;
idx += 1;
}
if idx == 0 {
format!("{bytes} B")
} else {
format!("{val:.1} {}", UNITS[idx])
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_size_plain_bytes() {
assert_eq!(parse_size("1024").unwrap(), 1024);
assert_eq!(parse_size("1B").unwrap(), 1);
}
#[test]
fn parse_size_binary_units() {
assert_eq!(parse_size("512M").unwrap(), 512 * 1024 * 1024);
assert_eq!(parse_size("1Gi").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_size("2KiB").unwrap(), 2048);
assert_eq!(parse_size("1t").unwrap(), 1024_u64.pow(4));
}
#[test]
fn parse_size_decimal_units() {
assert_eq!(parse_size("1KB").unwrap(), 1000);
assert_eq!(parse_size("1MB").unwrap(), 1_000_000);
assert_eq!(parse_size("1GB").unwrap(), 1_000_000_000);
assert_eq!(parse_size("1TB").unwrap(), 1_000_000_000_000);
}
#[test]
fn parse_size_is_case_insensitive_and_trims() {
assert_eq!(parse_size(" 512m ").unwrap(), 512 * 1024 * 1024);
assert_eq!(parse_size("1GIB").unwrap(), 1024 * 1024 * 1024);
}
#[test]
fn parse_size_rejects_bad_input() {
assert!(parse_size("").is_err());
assert!(parse_size("abc").is_err());
assert!(parse_size("M").is_err());
assert!(parse_size("12x").is_err());
assert!(parse_size("0").is_err(), "zero must be rejected");
assert!(parse_size("0M").is_err(), "zero must be rejected");
assert!(parse_size("1.5G").is_err(), "decimals are not supported");
}
#[test]
fn parse_size_rejects_overflow() {
assert!(parse_size("99999999999999999999T").is_err());
}
#[test]
fn parse_size_rejection_messages_name_the_cause() {
let msg = |s: &str| parse_size(s).unwrap_err().to_string();
assert!(msg("").contains("cannot be empty"));
assert!(msg("abc").contains("missing numeric value"));
assert!(msg("12x").contains("unknown unit"));
assert!(msg("0").contains("greater than zero"));
assert!(msg("99999999999999999999").contains("not a valid integer"));
assert!(msg("18014398509481984K").contains("overflows"));
}
#[test]
fn limits_is_empty_and_summary() {
let none = ResourceLimits::default();
assert!(none.is_empty());
let some = ResourceLimits {
memory_bytes: Some(512 * 1024 * 1024),
max_processes: None,
};
assert!(!some.is_empty());
let s = some.summary();
assert_eq!(s, "memory=512.0 MiB, processes=unlimited");
let procs = ResourceLimits {
memory_bytes: None,
max_processes: Some(64),
};
assert!(!procs.is_empty());
assert_eq!(procs.summary(), "memory=unlimited, processes=64");
let both = ResourceLimits {
memory_bytes: Some(512 * 1024 * 1024),
max_processes: Some(64),
};
assert_eq!(both.summary(), "memory=512.0 MiB, processes=64");
let unset = ResourceLimits::default();
assert_eq!(unset.summary(), "memory=unlimited, processes=unlimited");
}
#[test]
fn limits_serde_roundtrip() {
let limits = ResourceLimits {
memory_bytes: Some(1024),
max_processes: Some(32),
};
let json = serde_json::to_string(&limits).unwrap();
let back: ResourceLimits = serde_json::from_str(&json).unwrap();
assert_eq!(limits, back);
}
#[test]
fn parse_size_zero_forms_and_leading_chars() {
assert!(parse_size("0").is_err());
assert!(parse_size("00").is_err());
assert!(parse_size("000").is_err());
assert!(parse_size("0B").is_err());
assert!(parse_size("0K").is_err());
assert!(parse_size("000K").is_err());
assert!(parse_size("+5").is_err());
assert_eq!(parse_size("007").unwrap(), 7);
assert_eq!(parse_size("0007K").unwrap(), 7 * 1024);
}
#[test]
fn parse_size_unit_overflow_boundaries_per_unit() {
assert_eq!(
parse_size("18014398509481983K").unwrap(),
18_014_398_509_481_983_u64 * 1024
);
assert!(parse_size("18014398509481984K").is_err());
assert_eq!(
parse_size("18446744073709551KB").unwrap(),
18_446_744_073_709_551_u64 * 1000
);
assert!(parse_size("18446744073709552KB").is_err());
assert_eq!(
parse_size("17592186044415M").unwrap(),
17_592_186_044_415_u64 * 1024_u64.pow(2)
);
assert!(parse_size("17592186044416M").is_err());
assert_eq!(
parse_size("16777215T").unwrap(),
16_777_215_u64 * 1024_u64.pow(4)
);
assert!(parse_size("16777216T").is_err());
assert_eq!(parse_size("18446744073709551615").unwrap(), u64::MAX);
}
#[test]
fn parse_size_unit_distinctions_and_internal_whitespace() {
assert_eq!(parse_size("1KB").unwrap(), 1000);
assert_eq!(parse_size("1KiB").unwrap(), 1024);
assert_eq!(parse_size("1K").unwrap(), 1024);
assert_eq!(parse_size("1Ki").unwrap(), 1024);
assert_eq!(parse_size("1MB").unwrap(), 1_000_000);
assert_eq!(parse_size("1MiB").unwrap(), 1024 * 1024);
assert_eq!(parse_size("1 K").unwrap(), 1024);
assert_eq!(parse_size("512 MiB").unwrap(), 512 * 1024 * 1024);
assert!(parse_size("5 12").is_err());
assert!(parse_size("1 2K").is_err());
}
#[test]
fn format_bytes_exact_boundaries() {
assert_eq!(format_bytes(1), "1 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.0 KiB");
assert_eq!(format_bytes(1536), "1.5 KiB");
assert_eq!(format_bytes(1587), "1.5 KiB");
assert_eq!(format_bytes(1100), "1.1 KiB");
assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
assert_eq!(format_bytes(1024_u64.pow(4)), "1.0 TiB");
assert_eq!(
format_bytes(1024_u64.pow(4) + 1024_u64.pow(4) / 2),
"1.5 TiB"
);
assert_eq!(format_bytes(1024_u64.pow(5)), "1024.0 TiB");
}
#[test]
fn parse_size_format_bytes_roundtrip_on_exact_binary_values() {
for (bytes, unit) in [
(1024_u64, "KiB"),
(512 * 1024, "KiB"),
(1024 * 1024, "MiB"),
(512 * 1024 * 1024, "MiB"),
(1024 * 1024 * 1024, "GiB"),
(1024_u64.pow(4), "TiB"),
] {
let rendered = format_bytes(bytes);
let (value, suffix) = rendered.split_once(' ').unwrap();
assert_eq!(suffix, unit, "unexpected unit for {bytes}");
let int_part = value.strip_suffix(".0").unwrap();
let reparsed = parse_size(&format!("{int_part}{unit}")).unwrap();
assert_eq!(reparsed, bytes, "round-trip failed for {bytes}");
}
}
#[test]
fn is_empty_tracks_both_fields_and_summary_unlimited() {
let empty = ResourceLimits {
memory_bytes: None,
max_processes: None,
};
assert!(empty.is_empty());
assert_eq!(empty.summary(), "memory=unlimited, processes=unlimited");
let mem = ResourceLimits {
memory_bytes: Some(1),
max_processes: None,
};
assert!(!mem.is_empty());
assert_eq!(mem.summary(), "memory=1 B, processes=unlimited");
let procs = ResourceLimits {
memory_bytes: None,
max_processes: Some(1),
};
assert!(!procs.is_empty());
assert_eq!(procs.summary(), "memory=unlimited, processes=1");
assert!(ResourceLimits::default().is_empty());
}
#[test]
fn none_memory_serializes_to_empty_object_with_no_key() {
let none = ResourceLimits::default();
assert_eq!(serde_json::to_string(&none).unwrap(), "{}");
let v: serde_json::Value = serde_json::to_value(none).unwrap();
let obj = v.as_object().expect("serializes to a JSON object");
assert!(obj.is_empty(), "None must emit no keys, got {obj:?}");
assert!(!obj.contains_key("memory_bytes"));
}
#[test]
fn empty_and_null_object_deserialize_to_none() {
let from_empty: ResourceLimits = serde_json::from_str("{}").unwrap();
assert!(from_empty.memory_bytes.is_none());
assert!(from_empty.is_empty());
let from_null: ResourceLimits = serde_json::from_str(r#"{"memory_bytes":null}"#).unwrap();
assert!(from_null.memory_bytes.is_none());
}
#[test]
fn some_memory_serializes_with_exactly_one_key_and_roundtrips() {
let some = ResourceLimits {
memory_bytes: Some(536_870_912),
max_processes: None,
};
let v: serde_json::Value = serde_json::to_value(some).unwrap();
let obj = v.as_object().expect("object");
assert_eq!(obj.len(), 1, "exactly one serialized key, got {obj:?}");
assert_eq!(
obj.get("memory_bytes").and_then(serde_json::Value::as_u64),
Some(536_870_912)
);
assert!(!obj.contains_key("max_processes"), "None field is skipped");
let back: ResourceLimits = serde_json::from_value(v).unwrap();
assert_eq!(back, some);
}
#[test]
fn max_processes_serializes_independently_and_roundtrips() {
let procs = ResourceLimits {
memory_bytes: None,
max_processes: Some(64),
};
let v: serde_json::Value = serde_json::to_value(procs).unwrap();
let obj = v.as_object().expect("object");
assert_eq!(obj.len(), 1, "exactly one serialized key, got {obj:?}");
assert_eq!(
obj.get("max_processes").and_then(serde_json::Value::as_u64),
Some(64)
);
assert!(!obj.contains_key("memory_bytes"), "None field is skipped");
let back: ResourceLimits = serde_json::from_value(v).unwrap();
assert_eq!(back, procs);
let from_null: ResourceLimits = serde_json::from_str(r#"{"max_processes":null}"#).unwrap();
assert!(from_null.max_processes.is_none());
assert!(from_null.is_empty());
}
}