hbox 0.7.1

CLI tool that leverages container technology to manage packages.
Documentation
use crate::configs::context::Context;
use crate::configs::index::Binary;
use crate::configs::user::UserConfig;
use crate::packages::Package;
use log::{debug, error, info, warn};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use std::io::{stdin, BufRead, BufReader, IsTerminal, Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread;

pub fn build(package: &Package) -> bool {
    let config = UserConfig::load().unwrap_or_default();
    let context = Context::from(package);
    let image_name = context.apply(package.index.image.name.clone());
    let image = format!("{}:{}", image_name, package.versions.current);

    let mut args = vec!["build".to_string(), "-t".to_string(), image];
    if let Some(build) = &package.index.image.build {
        args.push("-f".to_string());
        args.push(build.dockerfile.clone());
        if let Some(build_args) = &build.args {
            for (key, value) in build_args {
                args.push("--build-arg".to_string());
                args.push(format!("{}={}", key, context.apply(value.clone())));
            }
        }
        args.push(build.context.clone());
    }

    run_command_with_args(config.engine.as_str(), &args, None)
}

pub fn pull(package: &Package) -> bool {
    let config = UserConfig::load().unwrap_or_default();
    let image_name = Context::from(package).apply(package.index.image.name.clone());
    let image = format!("{}:{}", image_name, package.versions.current);
    run_command_with_args(config.engine.as_str(), &["pull".to_string(), image], None)
}

pub fn run(package: &Package, binary: Option<String>, params: &Vec<String>) -> bool {
    let config = UserConfig::load().unwrap_or_default();

    let interactive = !stdin().is_terminal();
    let mut buffer = Vec::new();
    if interactive {
        stdin()
            .read_to_end(&mut buffer)
            .expect("Failed to read stdin");
    }

    let mut args = vec!["run".to_string()];
    args.push(if interactive {
        "-i".to_string()
    } else {
        "-it".to_string()
    });

    let binary = get_binary(package, &binary);

    add_default_flags(package, &mut args);
    add_ports(package, &mut args);
    add_volumes(package, &mut args);
    add_current_directory(package, &mut args);
    add_environment_variables(package, &mut args);
    add_binary_entrypoint(binary, &mut args);
    add_container_image(package, &mut args);
    add_binary_cmd(binary, &mut args);

    if should_wrap_args(binary) {
        debug!("Wrapping params in quotes");
        let escaped_params: Vec<String> = params
            .iter()
            .map(|param| param.replace("\"", "\\\""))
            .collect();
        args.push(escaped_params.join(" "));
    } else {
        args.extend(params.iter().cloned());
    }

    run_command_with_args(config.engine.as_str(), &args, Some(buffer))
}

fn should_wrap_args(binary: Option<&Binary>) -> bool {
    binary.map_or(false, |bin| bin.wrap_args)
}

fn generate_random_name(package: &Package) -> String {
    let id: String = thread_rng()
        .sample_iter(&Alphanumeric)
        .take(10)
        .map(char::from)
        .collect();
    format!("hbox-{}-{}-{}", package.name, package.versions.current, id)
}

fn add_default_flags(package: &Package, args: &mut Vec<String>) {
    args.push("--rm".to_string());
    args.push("--name".to_string());
    args.push(generate_random_name(package));
}

fn add_container_image(package: &Package, args: &mut Vec<String>) {
    let context = Context::from(&package);
    args.push(format!(
        "{}:{}",
        context.apply(package.index.image.name.clone()),
        package.versions.current
    ));
}

fn add_volumes(package: &Package, args: &mut Vec<String>) {
    if let Some(volumes) = &package.index.volumes {
        for volume in volumes {
            let source = shellexpand::full(&volume.source).unwrap();
            if Path::new(&source.to_string()).exists() {
                args.push("-v".to_string());
                args.push(format!("{}:{}", &source, volume.target));
            } else {
                warn!("Volume source '{}' not found. Skipping.", source);
            }
        }
    }
}

fn add_ports(package: &Package, args: &mut Vec<String>) {
    if let Some(ports) = &package.index.ports {
        for port in ports {
            args.push("-p".to_string());
            args.push(format!("{}:{}", &port.host, port.container));
        }
    }
}

fn add_current_directory(package: &Package, args: &mut Vec<String>) {
    if let Some(current_directory) = &package.index.current_directory {
        args.push("-w".to_string());
        args.push(current_directory.clone());
    }
}

fn add_environment_variables(package: &Package, args: &mut Vec<String>) {
    if let Some(environment_variables) = &package.index.environment_variables {
        for env_var in environment_variables {
            let expanded_value = shellexpand::full(&env_var.value).unwrap_or_default();
            args.push("-e".to_string());
            args.push(format!("{}={}", env_var.name, expanded_value));
        }
    }
}

fn add_binary_entrypoint(binary: Option<&Binary>, args: &mut Vec<String>) {
    if let Some(binary) = binary {
        args.push("--entrypoint".to_string());
        args.push(binary.path.to_string());
    }
}

fn add_binary_cmd(binary: Option<&Binary>, args: &mut Vec<String>) {
    if let Some(binary) = binary {
        if let Some(cmd) = &binary.cmd {
            args.extend(cmd.iter().cloned());
        }
    }
}

fn get_binary<'a>(package: &'a Package, binary: &Option<String>) -> Option<&'a Binary> {
    binary.as_ref().and_then(|b| {
        package
            .index
            .binaries
            .as_ref()
            .and_then(|binaries| binaries.iter().find(|binary| binary.name == *b))
    })
}

fn get_stdio(
    config: &crate::configs::user::Root,
    stdin_buffer: &Option<Vec<u8>>,
) -> (Stdio, Stdio, Stdio) {
    let stdin = if let Some(b) = stdin_buffer {
        if b.is_empty() {
            Stdio::inherit()
        } else {
            Stdio::piped()
        }
    } else {
        Stdio::inherit()
    };

    let stdout = if config.experimental.capture_stdout {
        Stdio::piped()
    } else {
        Stdio::inherit()
    };
    let stderr = if config.experimental.capture_stderr {
        Stdio::piped()
    } else {
        Stdio::inherit()
    };

    (stdin, stdout, stderr)
}

fn run_command_with_args(command: &str, args: &[String], stdin_buffer: Option<Vec<u8>>) -> bool {
    debug!("Running command: {} {}", command, args.join(" "));

    let config = UserConfig::load().unwrap_or_default();
    let (stdin, stdout, stderr) = get_stdio(&config, &stdin_buffer);

    let mut child = Command::new(command)
        .args(args)
        .stdout(stdout)
        .stderr(stderr)
        .stdin(stdin)
        .spawn()
        .expect("Failed to spawn command");

    if let Some(buffer) = stdin_buffer {
        if !buffer.is_empty() {
            let child_stdin = child.stdin.as_mut().expect("Failed to open stdin");
            child_stdin
                .write_all(&buffer)
                .expect("Failed to write to stdin");
        }
    }

    let stdout_thread = spawn_log_thread(
        child.stdout.take(),
        |line| info!("{}", line),
        config.experimental.capture_stdout,
    );
    let stderr_thread = spawn_log_thread(
        child.stderr.take(),
        |line| error!("{}", line),
        config.experimental.capture_stderr,
    );

    let status = child.wait().expect("Failed to wait on child process");

    if let Some(thread) = stdout_thread {
        let _ = thread.join();
    }

    if let Some(thread) = stderr_thread {
        let _ = thread.join();
    }

    status.success()
}

fn spawn_log_thread<R: Read + Send + 'static>(
    reader: Option<R>,
    log_fn: impl Fn(&str) + Send + 'static,
    capture: bool,
) -> Option<thread::JoinHandle<()>> {
    if !capture {
        return None;
    }
    let reader = reader.expect("Failed to open reader");
    Some(thread::spawn(move || {
        let reader = BufReader::new(reader);
        for line in reader.split(b'\n') {
            match line {
                Ok(line) => match std::str::from_utf8(&line) {
                    Ok(line) => log_fn(line),
                    Err(_) => error!(
                        "Failed to read line from output: stream did not contain valid UTF-8"
                    ),
                },
                Err(e) => error!("Failed to read line from output: {}", e),
            }
        }
    }))
}