use std::{fmt, str::FromStr};
use serde::{Deserialize, Serialize};
use crate::{
error::{MemoryError, MemoryResult},
resource::MemoryResource,
};
const AUTO_AVAILABLE_FRACTION: f64 = 0.80;
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryBudget {
#[default]
Auto,
Bytes(u64),
PercentTotal(f64),
PercentAvailable(f64),
}
impl MemoryBudget {
pub const fn bytes(bytes: u64) -> Self {
Self::Bytes(bytes)
}
pub fn percent_total(percent: f64) -> MemoryResult<Self> {
Ok(Self::PercentTotal(validate_percent(percent)? / 100.0))
}
pub fn percent_available(percent: f64) -> MemoryResult<Self> {
Ok(Self::PercentAvailable(validate_percent(percent)? / 100.0))
}
pub fn resolve(self, resource: &MemoryResource) -> MemoryResult<u64> {
let resolved = match self {
Self::Auto => resource
.available_bytes
.map(|bytes| scaled_bytes(bytes, AUTO_AVAILABLE_FRACTION))
.or(resource.total_bytes.map(|bytes| scaled_bytes(bytes, 0.5)))
.ok_or_else(|| MemoryError::UnknownCapacity {
resource: resource.name.clone(),
budget: self,
basis: "available",
})?,
Self::Bytes(bytes) => bytes,
Self::PercentTotal(fraction) => {
validate_fraction(fraction)?;
scaled_bytes(
resource
.total_bytes
.ok_or_else(|| MemoryError::UnknownCapacity {
resource: resource.name.clone(),
budget: self,
basis: "total",
})?,
fraction,
)
}
Self::PercentAvailable(fraction) => {
validate_fraction(fraction)?;
scaled_bytes(
resource
.available_bytes
.ok_or_else(|| MemoryError::UnknownCapacity {
resource: resource.name.clone(),
budget: self,
basis: "available",
})?,
fraction,
)
}
};
if resolved == 0 {
return Err(MemoryError::InvalidBudget(
"resolved budget must be greater than zero".into(),
));
}
Ok(resolved)
}
}
impl fmt::Display for MemoryBudget {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => formatter.write_str("auto"),
Self::Bytes(bytes) => write!(formatter, "{bytes} B"),
Self::PercentTotal(value) => write!(formatter, "{}% total", value * 100.0),
Self::PercentAvailable(value) => write!(formatter, "{}% available", value * 100.0),
}
}
}
impl FromStr for MemoryBudget {
type Err = MemoryError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let normalized = input.trim().to_ascii_lowercase();
if normalized == "auto" {
return Ok(Self::Auto);
}
if let Some((percent, suffix)) = normalized.split_once('%') {
let percent = percent
.trim()
.parse::<f64>()
.map_err(|_| MemoryError::InvalidBudget(input.into()))?;
return match suffix.trim() {
"" | "total" => Self::percent_total(percent),
"available" | "free" | "remaining" => Self::percent_available(percent),
_ => Err(MemoryError::InvalidBudget(input.into())),
};
}
parse_bytes(&normalized).map(Self::Bytes)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MemoryPlan {
pub host: MemoryBudget,
pub device: Option<MemoryBudget>,
}
impl Default for MemoryPlan {
fn default() -> Self {
Self {
host: MemoryBudget::Auto,
device: Some(MemoryBudget::Auto),
}
}
}
impl MemoryPlan {
pub const fn host(host: MemoryBudget) -> Self {
Self { host, device: None }
}
pub const fn host_device(host: MemoryBudget, device: MemoryBudget) -> Self {
Self {
host,
device: Some(device),
}
}
}
fn validate_percent(percent: f64) -> MemoryResult<f64> {
if percent.is_finite() && percent > 0.0 && percent <= 100.0 {
Ok(percent)
} else {
Err(MemoryError::InvalidBudget(
"percentage must be finite and in (0, 100]".into(),
))
}
}
fn validate_fraction(fraction: f64) -> MemoryResult<()> {
validate_percent(fraction * 100.0).map(|_| ())
}
fn scaled_bytes(bytes: u64, fraction: f64) -> u64 {
((bytes as f64) * fraction).floor().min(u64::MAX as f64) as u64
}
fn parse_bytes(input: &str) -> MemoryResult<u64> {
let split = input
.find(|character: char| !character.is_ascii_digit() && character != '.')
.unwrap_or(input.len());
let (number, unit) = input.split_at(split);
let multiplier = match unit.trim() {
"" | "b" | "byte" | "bytes" => 1,
"kb" => 1_000,
"mb" => 1_000_000,
"gb" => 1_000_000_000,
"tb" => 1_000_000_000_000,
"kib" => 1 << 10,
"mib" => 1 << 20,
"gib" => 1 << 30,
"tib" => 1 << 40,
_ => return Err(MemoryError::InvalidBudget(input.into())),
};
let (whole, fraction) = match number.split_once('.') {
Some((whole, fraction)) if !fraction.contains('.') => (whole, fraction),
Some(_) => return Err(MemoryError::InvalidBudget(input.into())),
None => (number, ""),
};
if whole.is_empty() && fraction.is_empty()
|| !whole.bytes().all(|digit| digit.is_ascii_digit())
|| !fraction.bytes().all(|digit| digit.is_ascii_digit())
|| !whole
.bytes()
.chain(fraction.bytes())
.any(|digit| digit != b'0')
{
return Err(MemoryError::InvalidBudget(input.into()));
}
let whole = if whole.is_empty() {
0
} else {
whole
.parse::<u64>()
.map_err(|_| MemoryError::InvalidBudget(input.into()))?
};
let whole_bytes = whole
.checked_mul(multiplier)
.ok_or_else(|| MemoryError::InvalidBudget(input.into()))?;
let fractional_bytes = fraction.bytes().rev().try_fold(0_u64, |carry, digit| {
u64::from(digit - b'0')
.checked_mul(multiplier)
.and_then(|value| value.checked_add(carry))
.map(|value| value / 10)
});
whole_bytes
.checked_add(fractional_bytes.ok_or_else(|| MemoryError::InvalidBudget(input.into()))?)
.ok_or_else(|| MemoryError::InvalidBudget(input.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CapacitySource, MemoryResourceKind};
fn resource() -> MemoryResource {
MemoryResource {
id: "test".into(),
name: "Test".into(),
kind: MemoryResourceKind::Device,
total_bytes: Some(1_000),
available_bytes: Some(500),
capacity_source: CapacitySource::User,
device_identity: None,
}
}
#[test]
fn parses_and_resolves_budgets() {
assert_eq!(
"8 GiB".parse(),
Ok(MemoryBudget::Bytes(8 * 1024_u64.pow(3)))
);
assert_eq!("70% total".parse(), Ok(MemoryBudget::PercentTotal(0.7)));
assert_eq!(
"60% available".parse(),
Ok(MemoryBudget::PercentAvailable(0.6))
);
assert_eq!(MemoryBudget::Auto.resolve(&resource()), Ok(400));
assert_eq!(
MemoryBudget::PercentTotal(0.5).resolve(&resource()),
Ok(500)
);
assert_eq!(
MemoryBudget::PercentAvailable(0.5).resolve(&resource()),
Ok(250)
);
}
#[test]
fn parses_absolute_budgets_exactly_at_boundaries() {
for (unit, expected) in [
("b", 1),
("byte", 1),
("bytes", 1),
("kb", 1_000),
("mb", 1_000_000),
("gb", 1_000_000_000),
("tb", 1_000_000_000_000),
("kib", 1 << 10),
("mib", 1 << 20),
("gib", 1 << 30),
("tib", 1 << 40),
] {
assert_eq!(
format!("1 {unit}").parse(),
Ok(MemoryBudget::Bytes(expected))
);
}
assert_eq!("1.5 KiB".parse(), Ok(MemoryBudget::Bytes(1_536)));
assert_eq!(".5 kb".parse(), Ok(MemoryBudget::Bytes(500)));
assert_eq!("1.999 B".parse(), Ok(MemoryBudget::Bytes(1)));
assert_eq!("0.1 B".parse(), Ok(MemoryBudget::Bytes(0)));
assert_eq!(
"9007199254740991 B".parse(),
Ok(MemoryBudget::Bytes(9_007_199_254_740_991))
);
assert_eq!(
"9007199254740993 B".parse(),
Ok(MemoryBudget::Bytes(9_007_199_254_740_993))
);
assert_eq!(
"18446744073709551615 B".parse(),
Ok(MemoryBudget::Bytes(u64::MAX))
);
assert_eq!(
"18446744073709551.615 KB".parse(),
Ok(MemoryBudget::Bytes(u64::MAX))
);
assert_eq!(
MemoryBudget::Bytes(u64::MAX).to_string().parse(),
Ok(MemoryBudget::Bytes(u64::MAX))
);
}
#[test]
fn rejects_invalid_budgets() {
for input in [
"",
"0",
".",
"NaN",
"inf",
"1.2.3 B",
"1 XB",
"bytes",
"18446744073709551616 B",
"18446744073709551.616 KB",
] {
assert!(
input.parse::<MemoryBudget>().is_err(),
"{input:?} should be invalid"
);
}
for percent in [0.0, -1.0, 100.1, f64::NAN, f64::INFINITY] {
assert!(MemoryBudget::percent_total(percent).is_err());
assert!(MemoryBudget::percent_available(percent).is_err());
}
assert_eq!(
MemoryBudget::percent_total(100.0),
Ok(MemoryBudget::PercentTotal(1.0))
);
}
}