#[derive(Debug, Clone)]
pub struct Mount {
access_mode: AccessMode,
mount_type: MountType,
source: Option<String>,
target: Option<String>,
tmpfs_options: Option<MountTmpfsOptions>,
}
#[derive(Debug, Clone, Default)]
pub struct MountTmpfsOptions {
pub size_bytes: Option<i64>,
pub mode: Option<i64>,
}
#[derive(parse_display::Display, Debug, Copy, Clone, PartialEq)]
#[display(style = "snake_case")]
pub enum MountType {
Bind,
Volume,
Tmpfs,
}
#[derive(parse_display::Display, Debug, Copy, Clone, PartialEq)]
pub enum AccessMode {
#[display("ro")]
ReadOnly,
#[display("rw")]
ReadWrite,
}
impl Mount {
pub fn bind_mount(host_path: impl Into<String>, container_path: impl Into<String>) -> Self {
Self {
access_mode: AccessMode::ReadWrite,
mount_type: MountType::Bind,
source: Some(host_path.into()),
target: Some(container_path.into()),
tmpfs_options: None,
}
}
pub fn volume_mount(name: impl Into<String>, container_path: impl Into<String>) -> Self {
Self {
access_mode: AccessMode::ReadWrite,
mount_type: MountType::Volume,
source: Some(name.into()),
target: Some(container_path.into()),
tmpfs_options: None,
}
}
pub fn tmpfs_mount(container_path: impl Into<String>) -> Self {
Self {
access_mode: AccessMode::ReadWrite,
mount_type: MountType::Tmpfs,
source: None,
target: Some(container_path.into()),
tmpfs_options: None,
}
}
pub fn with_access_mode(mut self, access_mode: AccessMode) -> Self {
self.access_mode = access_mode;
self
}
pub fn access_mode(&self) -> AccessMode {
self.access_mode
}
pub fn mount_type(&self) -> MountType {
self.mount_type
}
pub fn source(&self) -> Option<&str> {
self.source.as_deref()
}
pub fn target(&self) -> Option<&str> {
self.target.as_deref()
}
pub fn with_size_bytes(mut self, size: i64) -> Self {
let opts = self
.tmpfs_options
.get_or_insert_with(MountTmpfsOptions::default);
opts.size_bytes = Some(size);
self
}
pub fn with_size(self, size: &str) -> Self {
let bytes = parse_size(size).expect("Invalid size format");
self.with_size_bytes(bytes)
}
pub fn with_mode(mut self, mode: i64) -> Self {
let opts = self
.tmpfs_options
.get_or_insert_with(MountTmpfsOptions::default);
opts.mode = Some(mode);
self
}
pub fn tmpfs_options(&self) -> Option<&MountTmpfsOptions> {
self.tmpfs_options.as_ref()
}
}
fn parse_size(size: &str) -> Result<i64, String> {
let size = size.trim();
if size.is_empty() {
return Err("Size string is empty".to_string());
}
let (number_part, unit) = if let Some(stripped) = size.strip_suffix(['k', 'K']) {
(stripped, 1_000)
} else if let Some(stripped) = size.strip_suffix(['m', 'M']) {
(stripped, 1_000_000)
} else if let Some(stripped) = size.strip_suffix(['g', 'G']) {
(stripped, 1_000_000_000)
} else {
(size, 1)
};
let number: i64 = number_part
.trim()
.parse()
.map_err(|e| format!("Failed to parse number '{}': {}", number_part, e))?;
if number < 0 {
return Err("Size cannot be negative".to_string());
}
number
.checked_mul(unit)
.ok_or_else(|| "Size value overflows i64".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_size_kilobytes() {
assert_eq!(parse_size("100k").unwrap(), 100_000);
assert_eq!(parse_size("100K").unwrap(), 100_000);
assert_eq!(parse_size("1k").unwrap(), 1_000);
assert_eq!(parse_size("500k").unwrap(), 500_000);
}
#[test]
fn test_parse_size_megabytes() {
assert_eq!(parse_size("100m").unwrap(), 100_000_000);
assert_eq!(parse_size("100M").unwrap(), 100_000_000);
assert_eq!(parse_size("1m").unwrap(), 1_000_000);
assert_eq!(parse_size("500m").unwrap(), 500_000_000);
}
#[test]
fn test_parse_size_gigabytes() {
assert_eq!(parse_size("1g").unwrap(), 1_000_000_000);
assert_eq!(parse_size("1G").unwrap(), 1_000_000_000);
assert_eq!(parse_size("20g").unwrap(), 20_000_000_000);
assert_eq!(parse_size("100G").unwrap(), 100_000_000_000);
}
#[test]
fn test_parse_size_bytes() {
assert_eq!(parse_size("1000").unwrap(), 1000);
assert_eq!(parse_size("12345").unwrap(), 12345);
assert_eq!(parse_size("0").unwrap(), 0);
}
#[test]
fn test_parse_size_with_whitespace() {
assert_eq!(parse_size(" 100m ").unwrap(), 100_000_000);
assert_eq!(parse_size("1g ").unwrap(), 1_000_000_000);
assert_eq!(parse_size(" 500k").unwrap(), 500_000);
}
#[test]
fn test_parse_size_empty_string() {
assert!(parse_size("").is_err());
assert!(parse_size(" ").is_err());
}
#[test]
fn test_parse_size_invalid_number() {
assert!(parse_size("abc").is_err());
assert!(parse_size("12.5g").is_err());
assert!(parse_size("1.5m").is_err());
assert!(parse_size("k").is_err());
assert!(parse_size("m").is_err());
}
#[test]
fn test_parse_size_negative() {
assert!(parse_size("-100m").is_err());
assert!(parse_size("-1g").is_err());
}
#[test]
fn test_parse_size_overflow() {
assert!(parse_size("10000000000g").is_err());
}
#[test]
fn test_tmpfs_mount_with_size_bytes() {
let mount = Mount::tmpfs_mount("/tmp").with_size_bytes(1_000_000_000);
assert_eq!(mount.mount_type(), MountType::Tmpfs);
assert_eq!(mount.target(), Some("/tmp"));
assert!(mount.tmpfs_options().is_some());
assert_eq!(
mount.tmpfs_options().unwrap().size_bytes,
Some(1_000_000_000)
);
}
#[test]
fn test_tmpfs_mount_with_size_string() {
let mount = Mount::tmpfs_mount("/tmp").with_size("20g");
assert_eq!(mount.mount_type(), MountType::Tmpfs);
assert!(mount.tmpfs_options().is_some());
assert_eq!(
mount.tmpfs_options().unwrap().size_bytes,
Some(20_000_000_000)
);
}
#[test]
fn test_tmpfs_mount_with_mode() {
let mount = Mount::tmpfs_mount("/tmp").with_mode(0o1777);
assert_eq!(mount.mount_type(), MountType::Tmpfs);
assert!(mount.tmpfs_options().is_some());
assert_eq!(mount.tmpfs_options().unwrap().mode, Some(0o1777));
}
#[test]
fn test_tmpfs_mount_with_size_and_mode() {
let mount = Mount::tmpfs_mount("/tmp")
.with_size("10g")
.with_mode(0o1777);
assert_eq!(mount.mount_type(), MountType::Tmpfs);
let opts = mount.tmpfs_options().unwrap();
assert_eq!(opts.size_bytes, Some(10_000_000_000));
assert_eq!(opts.mode, Some(0o1777));
}
#[test]
fn test_tmpfs_mount_without_options() {
let mount = Mount::tmpfs_mount("/tmp");
assert_eq!(mount.mount_type(), MountType::Tmpfs);
assert_eq!(mount.target(), Some("/tmp"));
assert!(mount.tmpfs_options().is_none());
}
#[test]
fn test_bind_mount_has_no_tmpfs_options() {
let mount = Mount::bind_mount("/host", "/container");
assert_eq!(mount.mount_type(), MountType::Bind);
assert!(mount.tmpfs_options().is_none());
}
#[test]
fn test_volume_mount_has_no_tmpfs_options() {
let mount = Mount::volume_mount("my-vol", "/container");
assert_eq!(mount.mount_type(), MountType::Volume);
assert!(mount.tmpfs_options().is_none());
}
#[test]
#[should_panic(expected = "Invalid size format")]
fn test_with_size_panics_on_invalid_format() {
Mount::tmpfs_mount("/tmp").with_size("invalid");
}
#[test]
fn test_tmpfs_mount_builder_chain() {
let mount = Mount::tmpfs_mount("/var/lib/data")
.with_size_bytes(5_000_000_000)
.with_mode(0o755)
.with_access_mode(AccessMode::ReadOnly);
assert_eq!(mount.mount_type(), MountType::Tmpfs);
assert_eq!(mount.access_mode(), AccessMode::ReadOnly);
let opts = mount.tmpfs_options().unwrap();
assert_eq!(opts.size_bytes, Some(5_000_000_000));
assert_eq!(opts.mode, Some(0o755));
}
#[test]
fn test_tmpfs_options_can_be_overwritten() {
let mount = Mount::tmpfs_mount("/tmp").with_size("1g").with_size("2g");
let opts = mount.tmpfs_options().unwrap();
assert_eq!(opts.size_bytes, Some(2_000_000_000));
}
}