use std::{fmt::Display, net::Ipv4Addr, path::PathBuf, str::FromStr, time::Duration};

use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use tracing::Level;

use crate::utils::{VmexecDirs, escape_path};
use crate::vms::{VmId, VmState, get_vms};

/// The operating system to run
#[derive(Debug, Clone, PartialEq, ValueEnum)]
pub enum OsType {
    Archlinux,
}

#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize)]
pub enum Interactive {
    Always,
    Never,
    Auto,
}

#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize)]
pub enum Tty {
    Always,
    Never,
    Auto,
}

#[derive(Debug, Clone, PartialEq)]
pub enum OsTypeOrImagePath {
    OsType(OsType),
    ImagePath(PathBuf),
}

impl FromStr for OsTypeOrImagePath {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        // First we'll try to parse the input as a known OS.
        if let Ok(os_type) = OsType::from_str(src, true) {
            return Ok(Self::OsType(os_type));
        } else {
            // If we get here we'll try to parse the input as a path to a file (which is hopefully
            // a valid VM image).
            let path = PathBuf::from(src)
                .canonicalize()
                .map_err(|e| format!("Failed to canonicalize path '{src}': {e}"))?;
            if path.is_file() {
                return Ok(Self::ImagePath(path));
            }
        }
        let mut err = format!("Could not parse '{src}' as OS type or as an existing file path\n");
        let os_types = format!("{:?}", OsType::value_variants());
        err.push_str(&format!("Valid OS types are: {}", os_types.to_lowercase()));
        Err(err)
    }
}

/// Parse a string into a Duration
fn parse_seconds_to_duration(src: &str) -> Result<Duration, String> {
    let sec_int = src
        .parse()
        .map_err(|_e| format!("Failed to parse '{src}' as an integer"))?;
    Ok(Duration::from_secs(sec_int))
}

/// Parse and validate that a given `vmid` exists
fn parse_existing_vmid(vmid: &str) -> Result<VmId, String> {
    let dirs = VmexecDirs::new().unwrap();

    let vms = get_vms(&dirs.vms_dir).unwrap();

    let vmid = VmId::new(vmid.to_string())?;
    if vms.contains_key(&vmid) {
        return Ok(vmid);
    }

    Err("No virtual machine with provided ID found".to_string())
}

/// Parse and validate that a given `vmid` exists and is live
fn parse_existing_and_live_vmid(vmid: &str) -> Result<VmId, String> {
    let dirs = VmexecDirs::new().unwrap();

    let vms = get_vms(&dirs.vms_dir).unwrap();

    let vmid = VmId::new(vmid.to_string())?;
    if let Some(vm) = vms.get(&vmid) {
        let vm_is_live = vm.state() == VmState::Running;

        if vm_is_live {
            return Ok(vmid);
        } else {
            return Err(
                "A virtual machine with the provided ID was found but it has already exited"
                    .to_string(),
            );
        }
    }

    Err("No running virtual machine with provided ID found".to_string())
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PublishPort {
    pub host_ip: Ipv4Addr,
    pub host_port: u32,
    pub vm_port: u32,
}

impl Display for PublishPort {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}:{}->{}/tcp",
            self.host_ip, self.host_port, self.vm_port
        )
    }
}

impl FromStr for PublishPort {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = src.split(':').collect();

        if parts[0].is_empty() {
            return Err("Expected format: [[hostip:][hostport]:]vmport".to_string());
        }

        let (host_ip, host_port, vm_port) = match parts.len() {
            // If there's only a single part, it has to be the `vm_port`.
            1 => {
                let host_ip = Ipv4Addr::UNSPECIFIED;
                let host_port = parts[0]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid port", parts[0]))?;
                let vm_port = parts[0]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid port", parts[0]))?;

                (host_ip, host_port, vm_port)
            }
            2 => {
                let host_ip = Ipv4Addr::UNSPECIFIED;
                let host_port = parts[0]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid port", parts[0]))?;
                let vm_port = parts[1]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid port", parts[1]))?;
                (host_ip, host_port, vm_port)
            }
            3 => {
                let host_ip = parts[0]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid IPv4", parts[0]))?;
                let vm_port = parts[2]
                    .parse()
                    .map_err(|_| format!("'{}' is not a valid port", parts[2]))?;
                let host_port = if !parts[1].is_empty() {
                    parts[1]
                        .parse()
                        .map_err(|_| format!("'{}' is not a valid port", parts[1]))?
                } else {
                    vm_port
                };
                (host_ip, host_port, vm_port)
            }
            _ => return Err("Expected format: [[hostip:][hostport]:]vmport".to_string()),
        };

        Ok(Self {
            host_ip,
            host_port,
            vm_port,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BindMount {
    pub source: PathBuf,
    pub dest: PathBuf,
    pub read_only: bool,
}

impl BindMount {
    /// Safely printable/escaped path
    pub fn tag(&self) -> String {
        let mut escaped = escape_path(&self.dest.to_string_lossy());

        // Truncate because QEMU limits how long tags may be.
        escaped.truncate(30);

        escaped
    }

    pub fn socket_name(&self) -> String {
        format!("{}.sock", self.tag())
    }
}

impl Display for BindMount {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let source = self.source.to_string_lossy();
        let dest = self.dest.to_string_lossy();
        if self.read_only {
            write!(f, "{source}:{dest}:ro")
        } else {
            write!(f, "{source}:{dest}")
        }
    }
}

/// Parse a string the format `source:dest`
impl FromStr for BindMount {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = src.split(':').collect();
        if parts.len() != 2 && parts.len() != 3 {
            return Err("Expected format: source:dest[:ro]".to_string());
        }

        let source = PathBuf::from(parts[0]);
        if !source.is_absolute() {
            return Err("source must be an absolute path".to_string());
        }
        if !source.is_dir() {
            return Err("source doesn't exist or isn't a directory".to_string());
        }

        let dest = PathBuf::from(parts[1]);
        if !dest.is_absolute() {
            return Err("dest must be an absolute path".to_string());
        }

        // Last part (ro) is optional so we have to check for that.
        if parts.len() == 3 {
            let options = parts[2];
            if options == "ro" {
                return Ok(BindMount {
                    source,
                    dest,
                    read_only: true,
                });
            } else {
                return Err("Expected format: source:dest[:ro]".to_string());
            }
        }

        Ok(BindMount {
            source,
            dest,
            read_only: false,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PmemMount {
    pub dest: PathBuf,
    pub size: u64,
}

impl FromStr for PmemMount {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = src.split(':').collect();
        if parts.len() != 2 {
            return Err("Expected format: dest:<size>".to_string());
        }

        let dest = PathBuf::from(parts[0]);
        if !dest.is_absolute() {
            return Err("dest must be an absolute path".to_string());
        }

        let size = if let Ok(size) = parts[1].parse() {
            size
        } else {
            return Err("Couldn't parse size as integer".to_string());
        };

        Ok(PmemMount { dest, size })
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Memory {
    pub gb: u64,
}

impl Default for Memory {
    fn default() -> Self {
        let sysinfo_system = sysinfo::System::new_with_specifics(
            sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()),
        );
        let total_memory_gb = sysinfo_system.total_memory() / 1024 / 1024 / 1024;
        Memory {
            gb: total_memory_gb,
        }
    }
}

impl FromStr for Memory {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let gb = src
            .parse::<u64>()
            .map_err(|_| "Expected an integer in GBs".to_string())?;

        if gb < 1 {
            return Err("Memory must be at least 1 GB".to_string());
        }

        Ok(Memory { gb })
    }
}

#[derive(Clone, Debug, PartialEq, ValueEnum)]
pub enum Pull {
    Missing,
    Never,
    Newer,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EnvVar {
    pub key: String,
    pub value: String,
}

impl FromStr for EnvVar {
    type Err = String;

    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = src.split('=').collect();

        if parts.len() != 2 {
            return Err("Expected format: KEY=VALUE".to_string());
        }
        Ok(Self {
            key: parts[0].to_string(),
            value: parts[1].to_string(),
        })
    }
}

#[derive(Debug, Clone, Subcommand)]
pub enum Command {
    /// List virtual machines
    Ps(PsCommand),

    /// Display detailed information on a virtual machine
    Inspect(InspectCommand),

    /// List all available OS images
    Images(ImagesCommand),

    /// Show terminal log of an existing virtual machine
    Logs(LogsCommand),

    /// Stop a virtual machine by sending a SIGTERM signal
    Stop(StopCommand),

    /// Run a command in an existing virtual machine
    Exec(ExecCommand),

    /// Pull an image without running a virtual machine
    Pull(PullCommand),

    /// Run a command in a new virtual machine
    Run(RunCommand),

    /// Check and change KSM status
    ///
    /// Without flags, this prints the current KSM state and some stats.
    Ksm(KsmCommand),

    /// Clean up old virtual machines
    Clean(CleanCommand),

    /// Delete all overlay images from the cache
    Prune(PruneCommand),

    /// Check if a virtual machine is ready to accept SSH connections
    ///
    /// Without --wait, this checks the current state and exits immediately.
    /// With --wait, this polls until the virtual machine is ready or the timeout is reached.
    Ready(ReadyCommand),

    /// Generate completion file for a shell
    Completions { shell: clap_complete::Shell },

    /// Print man page
    Manpage { out_dir: PathBuf },
}

#[derive(Debug, Clone, Args)]
pub struct PsCommand {
    /// Show all the virtual machines, default is only running virtual machines
    #[arg(short, long)]
    pub all: bool,
}

#[derive(Debug, Clone, Args)]
pub struct InspectCommand {
    /// Identifier of a virtual machine
    #[arg(value_parser = parse_existing_vmid)]
    pub vmid: VmId,
}

#[derive(Debug, Clone, Args)]
pub struct ImagesCommand {}

#[derive(Debug, Clone, Args)]
pub struct LogsCommand {
    /// Identifier of a virtual machine
    #[arg(value_parser = parse_existing_vmid)]
    pub vmid: VmId,

    /// Follow log output
    #[arg(short, long)]
    pub follow: bool,
}

#[derive(Debug, Clone, Args)]
pub struct StopCommand {
    /// Identifier of a running virtual machine
    #[arg(value_parser = parse_existing_and_live_vmid)]
    pub vmid: VmId,
}

#[derive(Debug, Clone, Args)]
pub struct ExecCommand {
    /// Set environment variables for the process inside the virtual machine
    ///
    /// Can be provided multiple times.
    ///
    /// Expected format: KEY=VALUE
    #[arg(short, long, value_parser = EnvVar::from_str)]
    pub env: Vec<EnvVar>,

    /// Working directory inside the virtual machine
    ///
    /// The default wokring directory for running binaries within a virtual machine is dependent on
    /// the image. Most commonly, it'll be /root.
    #[arg(short, long)]
    pub workdir: Option<PathBuf>,

    /// SSH connection timeout
    ///
    /// Try for this long (in seconds) to connect to the virtual machine's SSH server.
    #[arg(
        short,
        long,
        default_value = "20",
        value_parser = parse_seconds_to_duration,
    )]
    pub ssh_timeout: Duration,

    /// Make STDIN available to the virtual machine's process
    ///
    /// If 'auto', this will try to read from stdin if it is available, and do nothing when
    /// stdin is not available.
    /// If 'always', this will try to read from stdin and abort when stdin is not available.
    #[arg(short, long, default_value = "auto")]
    pub interactive: Interactive,

    /// Allocate a pseudo-TTY for the virtual machine
    ///
    /// If 'auto', this will be enabled in case vmexec is run from an interactive terminal.
    #[arg(short, long, default_value = "auto")]
    pub tty: Tty,

    /// Identifier of a running virtual machine
    #[arg(value_parser = parse_existing_and_live_vmid)]
    pub vmid: VmId,

    /// Arguments to run in the virtual machine
    pub args: Vec<String>,
}

#[derive(Debug, Clone, Args)]
pub struct PullCommand {
    /// The operating system image to pull
    pub os_type: OsType,

    /// Additionally prepare the image so that the first run is fast
    ///
    /// This extracts the kernel, creates the overlay image and performs the warmup boot that
    /// would otherwise happen on the first run.
    #[arg(long)]
    pub prepare: bool,

    /// Run the warmup virtual machine with TCG instead of KVM
    ///
    /// This would be mostly useful in environments where KVM is not available. It will be a lot
    /// slower but might be acceptable in some contexts.
    #[arg(long, requires = "prepare")]
    pub disable_kvm: bool,

    /// Memory limit for the warmup virtual machine in GBs
    ///
    /// Must be an integer of at least 1.
    ///
    /// If not provided, all available host memory is used.
    #[arg(short, long, requires = "prepare", value_parser = Memory::from_str)]
    pub memory: Option<Memory>,

    /// Show a window with the warmup virtual machine running in it
    ///
    /// This is mostly useful for debugging boot failures.
    #[arg(long, requires = "prepare")]
    pub show_vm_window: bool,

    /// SSH connection timeout during warmup
    ///
    /// Try for this long (in seconds) to connect to the virtual machine's SSH server.
    #[arg(
        short,
        long,
        default_value = "20",
        requires = "prepare",
        value_parser = parse_seconds_to_duration,
    )]
    pub ssh_timeout: Duration,
}

#[derive(Debug, Clone, Args)]
pub struct RunCommand {
    /// Run virtual machine in background and print virtual machine ID
    #[arg(short, long, conflicts_with_all = ["interactive", "tty"])]
    pub detach: bool,

    /// Remove virtual machine runtime data after exit
    #[arg(long)]
    pub rm: bool,

    /// Virtual machine identifier
    ///
    /// If not provided, a random identifier will be generated.
    #[arg(long)]
    pub vmid: Option<VmId>,

    /// Run virtual machine with TCG instead of KVM
    ///
    /// This would be mostly useful in environments where KVM is not available. It will be a lot
    /// slower but might be acceptable in some contexts.
    #[arg(long)]
    pub disable_kvm: bool,

    /// Memory limit for the virtual machine in GBs
    ///
    /// Must be an integer of at least 1.
    ///
    /// If not provided, all available host memory is used.
    #[arg(short, long, value_parser = Memory::from_str)]
    pub memory: Option<Memory>,

    /// Set environment variables for the process inside the virtual machine
    ///
    /// Can be provided multiple times.
    ///
    /// Expected format: KEY=VALUE
    #[arg(short, long, value_parser = EnvVar::from_str)]
    pub env: Vec<EnvVar>,

    /// Working directory inside the virtual machine
    ///
    /// The default wokring directory for running binaries within a virtual machine is dependent on
    /// the image. Most commonly, it'll be /root.
    #[arg(short, long)]
    pub workdir: Option<PathBuf>,

    /// Bind mount a volume into the virtual machine
    ///
    /// Can be provided multiple times.
    ///
    /// Expected format: source:dest[:ro]
    ///
    /// `ro` can optionally be provided to mark the mount as read-only.
    ///
    /// Example: $PWD/src:/mnt:ro
    #[arg(short, long = "volume", value_parser = BindMount::from_str)]
    pub volumes: Vec<BindMount>,

    /// Mount a virtio-pmem device file into the virtual machine
    ///
    /// You might want to do this to bypass the guest page cache. This is important if you're
    /// overprovisioning your host (i.e. giving VMs more combined memory than the host actually
    /// has) and have a write-heavy workload.
    /// For more info, see: https://www.qemu.org/docs/master/system/devices/virtio-pmem.html
    ///
    /// Can be provided multiple times.
    ///
    /// Size is in gigabytes.
    ///
    /// Expected format: dest:<size>
    ///
    /// Example: /var/lib:20
    #[arg(long = "pmem", value_parser = PmemMount::from_str)]
    pub pmems: Vec<PmemMount>,

    /// Publish a port on the virtual machine to the host
    ///
    /// Can be provided multiple times.
    ///
    /// Expected format: [[hostip:][hostport]:]vmport
    ///
    /// `hostip` is optional and if not provided, the port will be bound on all host IPs.
    ///
    /// `hostport` is optional and if not provided, the same value of `vmport` will be used for the
    /// host port.
    ///
    /// Currently only IPv4 is supported for `hostip`.
    #[arg(short, long = "publish", value_parser = PublishPort::from_str)]
    pub published_ports: Vec<PublishPort>,

    /// SSH connection timeout
    ///
    /// Try for this long (in seconds) to connect to the virtual machine's SSH server.
    #[arg(
        short,
        long,
        default_value = "20",
        value_parser = parse_seconds_to_duration,
    )]
    pub ssh_timeout: Duration,

    /// Make STDIN available to the virtual machine's process
    ///
    /// If 'auto', this will try to read from stdin if it is available, and do nothing when
    /// stdin is not available.
    /// If 'always', this will try to read from stdin and abort when stdin is not available.
    #[arg(short, long, default_value = "auto")]
    pub interactive: Interactive,

    /// Allocate a pseudo-TTY for the virtual machine
    ///
    /// If 'auto', this will be enabled in case vmexec is run from an interactive terminal.
    #[arg(short, long, default_value = "auto")]
    pub tty: Tty,

    /// Show a window with the virtual machine running in it
    ///
    /// This is mostly useful for debugging boot failures.
    #[arg(long)]
    pub show_vm_window: bool,

    /// When to pull a new image
    #[arg(long, default_value = "missing")]
    pub pull: Pull,

    /// Either an operating system (e.g. archlinux) or a path to an image
    ///
    /// Possible OS types: archlinux
    #[arg(value_parser = OsTypeOrImagePath::from_str)]
    pub image_source: OsTypeOrImagePath,

    /// Arguments to run in the virtual machine
    pub args: Vec<String>,
}

#[derive(Debug, Clone, Args)]
pub struct KsmEnableDisable {
    /// Persistently enable KSM by writing settings to /etc/tmpfiles.d/ksm.conf
    #[arg(short, long)]
    pub enable: bool,

    /// Persistently disable KSM by deleting /etc/tmpfiles.d/ksm.conf
    #[arg(short, long)]
    pub disable: bool,
}

#[derive(Debug, Clone, Args)]
pub struct KsmCommand {
    #[command(flatten)]
    pub ksm_enable_disable: Option<KsmEnableDisable>,
}

#[derive(Debug, Clone, Args)]
pub struct CleanCommand {}

#[derive(Debug, Clone, Args)]
pub struct PruneCommand {}

#[derive(Debug, Clone, Args)]
pub struct ReadyCommand {
    /// Wait for the virtual machine to become ready
    ///
    /// Without this flag, the command checks the current state and exits immediately.
    #[arg(long)]
    pub wait: bool,

    /// Timeout in seconds when waiting for the virtual machine to become ready
    ///
    /// Only used with --wait flag. Defaults to 60 seconds.
    #[arg(
        long,
        default_value = "60",
        requires = "wait",
        value_parser = parse_seconds_to_duration,
    )]
    pub timeout: Duration,

    /// Identifier of a virtual machine
    #[arg(value_parser = parse_existing_vmid)]
    pub vmid: VmId,
}

/// Run a command in a new virtual machine
#[derive(Debug, Clone, Parser)]
#[command(name = "vmexec", author, about, version)]
pub struct Cli {
    /// Log messages above specified level (error, warn, info, debug, trace)
    #[arg(long, default_value = "warn")]
    pub log_level: Level,

    // Subcommand to run
    #[clap(subcommand)]
    pub command: Command,
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;
    use rstest::rstest;

    use super::*;

    #[rstest]
    #[case("archlinux", OsTypeOrImagePath::OsType(OsType::Archlinux))]
    #[case(env!("CARGO_MANIFEST_PATH"), OsTypeOrImagePath::ImagePath(PathBuf::from(env!("CARGO_MANIFEST_PATH"))))]
    fn test_parse_os_type_or_image_path(#[case] input: &str, #[case] expected: OsTypeOrImagePath) {
        let actual = OsTypeOrImagePath::from_str(input).unwrap();
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("just something", "Failed to canonicalize path")]
    fn test_parse_os_type_or_image_path_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = OsTypeOrImagePath::from_str(input).unwrap_err();
        assert!(actual.starts_with(expected));
    }

    #[rstest]
    #[case("127.0.0.1:8080:80", "127.0.0.1", "8080", "80")]
    #[case("80", "0.0.0.0", "80", "80")]
    #[case("8080:80", "0.0.0.0", "8080", "80")]
    #[case("127.0.0.1::80", "127.0.0.1", "80", "80")]
    fn test_parse_publish_port_valid(
        #[case] input: &str,
        #[case] host_ip: Ipv4Addr,
        #[case] host_port: u32,
        #[case] vm_port: u32,
    ) {
        let actual = PublishPort::from_str(input).unwrap();
        let expected = PublishPort {
            host_ip,
            host_port,
            vm_port,
        };
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("foo", "'foo' is not a valid port")]
    #[case("foo::", "'foo' is not a valid IPv4")]
    #[case("::", "Expected format: [[hostip:][hostport]:]vmport")]
    #[case("1:2:3:4", "Expected format: [[hostip:][hostport]:]vmport")]
    #[case(":80:", "Expected format: [[hostip:][hostport]:]vmport")]
    fn test_parse_publish_port_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = PublishPort::from_str(input).unwrap_err();
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("/tmp:/tmp", "/tmp", "/tmp", "tmp", false)]
    #[case(
        "/usr/bin:/somewhere/else",
        "/usr/bin",
        "/somewhere/else",
        "somewhere-else",
        false
    )]
    #[case(
        "/usr/bin:/somewhere/else:ro",
        "/usr/bin",
        "/somewhere/else",
        "somewhere-else",
        true
    )]
    #[case(
        "/usr/bin:/somewhere/else/super/long/path/that/is/truncated:ro",
        "/usr/bin",
        "/somewhere/else/super/long/path/that/is/truncated",
        "somewhere-else-super-long-path",
        true
    )]
    fn test_parse_bind_volume_valid(
        #[case] input: &str,
        #[case] source: PathBuf,
        #[case] dest: PathBuf,
        #[case] tag: String,
        #[case] read_only: bool,
    ) {
        let actual = BindMount::from_str(input).unwrap();
        let expected = BindMount {
            source,
            dest,
            read_only,
        };
        assert_eq!(actual, expected);
        assert_eq!(actual.tag(), tag);
    }

    #[rstest]
    #[case("tmp:/tmp", "source must be an absolute path")]
    #[case("/nowhere:/tmp", "source doesn't exist or isn't a directory")]
    #[case("/tmp:tmp", "dest must be an absolute path")]
    #[case("/tmp", "Expected format: source:dest[:ro]")]
    #[case("/tmp:/tmp:something", "Expected format: source:dest[:ro]")]
    fn test_parse_bind_volume_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = BindMount::from_str(input).unwrap_err();
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("/tmp:2", "/tmp", 2)]
    #[case("/tmp:200", "/tmp", 200)]
    fn test_parse_pmem_valid(#[case] input: &str, #[case] dest: PathBuf, #[case] size: u64) {
        let actual = PmemMount::from_str(input).unwrap();
        let expected = PmemMount { dest, size };
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("tmp:2", "dest must be an absolute path")]
    fn test_parse_pmem_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = PmemMount::from_str(input).unwrap_err();
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("key=value", "key", "value")]
    #[case("KEY=VALUE", "KEY", "VALUE")]
    fn test_parse_env_var_valid(#[case] input: &str, #[case] key: String, #[case] value: String) {
        let actual = EnvVar::from_str(input).unwrap();
        let expected = EnvVar { key, value };

        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("keyvalue", "Expected format: KEY=VALUE")]
    #[case("=key=value", "Expected format: KEY=VALUE")]
    #[case("key=value=", "Expected format: KEY=VALUE")]
    fn test_parse_env_var_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = EnvVar::from_str(input).unwrap_err();
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("1", 1)]
    #[case("4", 4)]
    #[case("16", 16)]
    #[case("100", 100)]
    fn test_parse_memory_valid(#[case] input: &str, #[case] gb: u64) {
        let actual = Memory::from_str(input).unwrap();
        let expected = Memory { gb };
        assert_eq!(actual, expected);
    }

    #[rstest]
    #[case("0", "Memory must be at least 1 GB")]
    #[case("abc", "Expected an integer in GBs")]
    #[case("1.5", "Expected an integer in GBs")]
    #[case("-1", "Expected an integer in GBs")]
    #[case("", "Expected an integer in GBs")]
    fn test_parse_memory_invalid(#[case] input: &str, #[case] expected: &str) {
        let actual = Memory::from_str(input).unwrap_err();
        assert!(actual.contains(expected));
    }
}
