baldr 0.2.2

Convenience tool for building and running C++ code.
Documentation
use clap::Parser;
use config::Config;

use baldr::{
    Args,
    build,
    configure,
    find_files,
    format_cmd,
    read_config,
    read_input,
};

use log::*;

use std::{
    io,
    fs,
    fmt,
    os::unix::{
        fs::symlink,
        process::ExitStatusExt
    },
    path::{
        self,
        Path,
        PathBuf
    },
    process::{
        self,
        Command,
    }
};

struct BuildPath<'a> {
    project: &'a str,
    build_type: &'a str,
    compiler_path: &'a str,
    sanitizer: Option<&'a str>,
    custom_dir: Option<&'a str>
}

impl<'a> BuildPath<'a> {

    /// Create a build path with the build dir containing the following information.
    ///
    /// - build type in lowercase, e.g. `debug` or `release`
    /// - compiler name - source: CC and CXX
    /// - compiler version (if not the default is in use) - source: CC and CXX
    /// - sanitizers (if used)
    ///
    fn to_path(&self) -> PathBuf {
        let compiler = match self.compiler_path {
            "" => String::new(),
            _  => format!("-{}", Path::new(&self.compiler_path).file_name().expect("Invalid compiler path").to_string_lossy()),
        };

        let dir = match self.custom_dir {
            Some(dir) => dir.into(),
            None => {
                format!(
                    "{}{}{}",
                    self.build_type.to_lowercase(),
                    compiler,
                    match self.sanitizer {
                        Some(san) => format!("-{san}"),
                        None => String::new()
                    }
                )
            }
        };

        PathBuf::from(self.project)
            .join("build")
            .join(dir)
    }
}

impl fmt::Display for BuildPath<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_fmt(format_args!("{}", self.to_path().display()))
    }
}

fn create_compile_cmd_symlink(src: &Path, dst: &Path) -> Result<(), io::Error> {
    let file = "compile_commands.json";
    let src = path::absolute(src.join(file))?;
    let dst = dst.join(file);

    match fs::exists(&dst) {
        Ok(true) => {
            debug!("`compile_commands.json` symlink already exists and is valid.");
            Ok(())
        }
        Ok(false) => {
            match fs::remove_file(&dst) {
                Ok(()) => { debug!("Broken `compile_commands.json` symlink is removed."); },
                Err(e) => { debug!("`compile_commands.json` symlink cannot be removed: {e}"); },
            };

            debug!("Creating `compile_commands.json` symlink...");
            symlink(src, &dst)
        }
        Err(e) => Err(e),
    }
}

fn delete_build_dir(build_dir: &Path, confirm: bool) -> Result<bool, String> {
    if confirm {
        eprint!("Are you sure to remove `{}` (press 'y' to proceed): ", build_dir.to_string_lossy());

        if read_input() != "y" {
            info!("Skipping clean build.");
            return Ok(false);
        }

    }
    else {
        info!("Non-interactive mode, skipping confirmation for deleting build directory.");
    }

    fs::remove_dir_all(build_dir).map_err(|e| format!("Failed to delete build directory: {e}"))?;
    info!("Build directory deleted!");
    Ok(true)
}

/// Run the built executable with the given arguments optionall under a debugger.
///
/// Supported debuggers:
/// - `gdb`: `gdb --args <EXECUTABLE> <ARGS> ...`
/// - `lldb`: lldb <EXECUTABLE> <ARGS> ...`
///
/// # Errors
///
/// Returns an error in the following cases:
/// - Target is not specified ("all" can build multiple executables)
/// - Debugger is not configured or is unsupported when it is asked to run under debugger
/// - The built executable returns with other than exit code 0
fn run(target: &String, build_dir: &PathBuf, config: &Config, args: &Args) -> Result<(), String> {
    if target == "all" {
        return Err("Target must be specified".into());
    }

    let exes = find_files(build_dir, |filename| { filename == *target });
    match exes.len() {
        1 => {
            let mut cmd = (|| -> Result<Command, String> {
                if args.debug {
                    let debugger = config.get::<String>("debugger")
                        .map_err(|e| format!("No debugger is configured: {e}"))?;

                    let mut cmd = Command::new(&debugger);
                    if debugger == "gdb" {
                        cmd.arg("--args");
                        cmd.arg(&exes[0]);
                    } else if debugger == "lldb" {
                        cmd.arg(&exes[0]);
                    } else {
                        return Err("Unsupported debugger: `{debugger}`!".into());
                    }
                    Ok(cmd)
                } else {
                    Ok(Command::new(&exes[0]))
                }
            })()?;

            cmd.args(&args.exe_args);
            let mut process = cmd.spawn().map_err(|e| format!("Failed to run the built executable: {e}"))?;
            let cmd_str = format_cmd(&cmd);
            let result = process.wait().map_err(|e| format!("Command `{cmd_str}` did not start: {e}"))?;

            match result.code() {
                Some(0) => Ok(()),
                Some(code) => Err(format!("Process has returned with exit code: {code}")),
                None => match result.signal() {
                    Some(code) => Err(format!("Process has been made to exit with signal: {code}")),
                    None => Err("Unknown error during checking exit code".to_string()),
                }
            }

        },
        0 => Err(format!("No executable found in `{}`", build_dir.display())),
        _ => Err(format!("Multiple executables found in `{}`", build_dir.display())),
    }
}

fn entrypoint() -> Result<(), String> {
    let args = Args::parse();
    let config = read_config(&args.config).map_err(|e| e.to_string())?;

    let build_dir = BuildPath{
        project: args.project.as_str(),
        build_type: args.build_type.as_str(),
        compiler_path: &config.get_string("compiler.cxx").unwrap_or_default(),
        sanitizer: None,
        custom_dir: args.build_dir.as_deref()
    }.to_path();

    info!("Using build directory: {}", build_dir.to_string_lossy());

    let mut build_exists = match fs::exists(&build_dir) {
        Ok(true) => {
            info!("Build directory already exists.");
            Ok(true)
        },
        Ok(false) => Ok(false),
        Err(x) => Err(format!("{x}")),
    }?;

    if args.delete {
        if build_exists {
            build_exists = !delete_build_dir(&build_dir, !args.no_confirm)?;
        } else {
            warn!("Build directory does not exist, there is nothing to delete!");
        }

        if !build_exists {
            fs::create_dir_all(&build_dir).map_err(|e| format!("Failed to create build directory: {e}"))?;
            info!("Build directory has been created.");
        }
    }

    if (!build_exists || !args.no_configure) &&
        !configure(build_dir.as_path(), &args, &config)?.success() {
            return Err("Configuring failed".into());
    }

    if !build(build_dir.as_path(), &args)?.success() {
        return Err("Build failed".into());
    }

    create_compile_cmd_symlink(build_dir.as_path(), Path::new(&args.project))
        .map_err(|e| format!("Failed to create a symlink for `compile_commands.json`: {e}"))?;

    if args.run {
        run(&args.target, &build_dir, &config, &args)?;
        info!("Built exectuable has been successfully run.");
    }

    Ok(())
}

fn main() {
    env_logger::builder()
        .format_timestamp_millis()
        .init();

    match entrypoint() {
        Ok(()) => {},
        Err(e) => {
            log::error!("Fatal error encountered: {e}");
            process::exit(1);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_dir_default() {
        assert_eq!(
            BuildPath{
                project: "project".into(),
                build_type: "Debug".into(),
                compiler_path: "".into(),
                sanitizer: None,
                custom_dir: None
            }.to_path().to_string_lossy(),
            "project/build/debug"
        );
    }

    #[test]
    fn build_dir_custom() {
        assert_eq!(
            BuildPath{
                project: "project".into(),
                build_type: "Debug".into(),
                compiler_path: "".into(),
                sanitizer: None,
                custom_dir: "custom".into()
            }.to_path().to_string_lossy(),
            "project/build/custom"
        );
    }

    #[test]
    fn build_dir_sanitizer() {
        assert_eq!(
            BuildPath{
                project: "project".into(),
                build_type: "Debug".into(),
                compiler_path: "".into(),
                sanitizer: Some("asan"),
                custom_dir: None
            }.to_path().to_string_lossy(),
            "project/build/debug-asan"
        );
    }

    #[test]
    fn build_dir_compiler() {
        assert_eq!(
            BuildPath{
                project: "project".into(),
                build_type: "Debug".into(),
                compiler_path: "gcc".into(),
                sanitizer: None,
                custom_dir: None
            }.to_path().to_string_lossy(),
            "project/build/debug-gcc"
        );
    }

    #[test]
    fn build_dir_both() {
        assert_eq!(
            BuildPath{
                project: "project".into(),
                build_type: "Debug".into(),
                compiler_path: "gcc".into(),
                sanitizer: Some("asan"),
                custom_dir: None
            }.to_path().to_string_lossy(),
            "project/build/debug-gcc-asan"
        );
    }
}