iforgor 0.3.0

The CLI tool for all those commands you forget about
Documentation
use {
    anyhow::Context,
    std::{fs::File, io::Write, path::Path, process},
};

use crate::command::{PredefinedShell, Shell, UserCommand};

/// Resolve the working directory for a command.
/// If `working_dir` is set, it's resolved relative to the project root
/// (parent of `.iforgor/`). Returns None if unset (use CWD).
fn resolve_working_dir(cmd: &UserCommand) -> Option<std::path::PathBuf> {
    let working_dir = cmd.working_dir.as_ref()?;
    let project_dir = cmd.project_dir.as_ref()?;
    Some(project_dir.join(working_dir))
}

pub fn execute_command(cmd: &UserCommand, args: &[String]) -> anyhow::Result<process::ExitStatus> {
    let default_shell = Shell::default();
    let shell = cmd.shell.as_ref().unwrap_or(&default_shell);

    // Build named env vars from ArgDef names + collected values.
    let env_vars: Vec<(String, String)> = cmd
        .args
        .iter()
        .zip(args.iter())
        .map(|(def, val)| (def.name.clone(), val.clone()))
        .collect();

    execute_script(
        &cmd.script,
        args,
        shell,
        resolve_working_dir(cmd).as_deref(),
        &env_vars,
    )
}

pub fn execute_script(
    script: &str,
    args: &[String],
    shell: &Shell,
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) -> anyhow::Result<process::ExitStatus> {
    match shell {
        Shell::Predefined(p) => match p {
            PredefinedShell::Sh => {
                execute_script_unix(script, args, "script", "/bin/sh", work_dir, env_vars)
            }
            PredefinedShell::Bash => {
                execute_script_unix(script, args, "script", "/bin/bash", work_dir, env_vars)
            }
            PredefinedShell::Zsh => {
                execute_script_unix(script, args, "script", "/bin/zsh", work_dir, env_vars)
            }
            PredefinedShell::Fish => execute_script_unix(
                script,
                args,
                "script",
                "/usr/bin/env fish",
                work_dir,
                env_vars,
            ),
            PredefinedShell::Cmd => execute_script_cmd(script, args, work_dir, env_vars),
            PredefinedShell::Powershell => {
                execute_script_powershell(script, args, work_dir, env_vars)
            }
        },
        Shell::Custom {
            command,
            extension,
            shebang,
        } => {
            if let Some(shebang) = shebang {
                execute_script_unix(
                    script,
                    args,
                    &format!("script{extension}"),
                    shebang,
                    work_dir,
                    env_vars,
                )
            } else {
                execute_script_custom_command(script, args, command, extension, work_dir, env_vars)
            }
        }
    }
}

/// Configure a Command builder with working dir and env vars.
fn configure_cmd(
    cmd: &mut process::Command,
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) {
    if let Some(dir) = work_dir {
        cmd.current_dir(dir);
    }
    for (key, value) in env_vars {
        cmd.env(key, value);
    }
}

fn execute_script_unix(
    script: &str,
    args: &[String],
    filename: &str,
    shebang: &str,
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) -> anyhow::Result<process::ExitStatus> {
    let tmp_dir = tempfile::tempdir()?;
    let file_path = tmp_dir.path().join(filename);

    {
        let mut tmp_file = File::create(&file_path)?;
        writeln!(tmp_file, "#!{shebang}")?;
        tmp_file.write_all(script.as_bytes())?;
        tmp_file.flush()?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut permissions = tmp_file.metadata()?.permissions();
            permissions.set_mode(0o500);
            tmp_file.set_permissions(permissions)?;
        }
    }

    let mut cmd = process::Command::new(file_path);
    cmd.args(args);
    configure_cmd(&mut cmd, work_dir, env_vars);
    let mut child = cmd.spawn().context("script command failed to start")?;

    let status = child.wait()?;
    tmp_dir.close()?;
    Ok(status)
}

fn execute_script_custom_command(
    script: &str,
    args: &[String],
    command: &str,
    extension: &str,
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) -> anyhow::Result<process::ExitStatus> {
    let tmp_dir = tempfile::tempdir()?;
    let file_path = tmp_dir.path().join(format!("script{extension}"));

    {
        let mut tmp_file = File::create(&file_path)?;
        tmp_file.write_all(script.as_bytes())?;
        tmp_file.flush()?;
    }

    let mut cmd = process::Command::new(command);
    cmd.arg(&file_path).args(args);
    configure_cmd(&mut cmd, work_dir, env_vars);
    let mut child = cmd
        .spawn()
        .context("custom shell command failed to start")?;

    let status = child.wait()?;
    tmp_dir.close()?;
    Ok(status)
}

fn execute_script_cmd(
    script: &str,
    args: &[String],
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) -> anyhow::Result<process::ExitStatus> {
    let tmp_dir = tempfile::tempdir()?;
    let file_path = tmp_dir.path().join("script.bat");

    {
        let mut tmp_file = File::create(&file_path)?;
        tmp_file.write_all(b"@echo off\n")?;
        tmp_file.write_all(script.as_bytes())?;
        tmp_file.flush()?;
    }

    let mut cmd = process::Command::new(file_path);
    cmd.args(args);
    configure_cmd(&mut cmd, work_dir, env_vars);
    let mut child = cmd.spawn().context("script command failed to start")?;

    let status = child.wait()?;
    tmp_dir.close()?;
    Ok(status)
}

fn execute_script_powershell(
    script: &str,
    args: &[String],
    work_dir: Option<&Path>,
    env_vars: &[(String, String)],
) -> anyhow::Result<process::ExitStatus> {
    let tmp_dir = tempfile::tempdir()?;
    let file_path = tmp_dir.path().join("script.ps1");

    {
        let mut tmp_file = File::create(&file_path)?;
        tmp_file.write_all(script.as_bytes())?;
        tmp_file.flush()?;
    }

    let powershell_cmd: Vec<_> = ["&".to_string(), format!("{}", file_path.display())]
        .into_iter()
        .chain(args.iter().map(|arg| snailquote::escape(arg).into_owned()))
        .collect();

    let powershell_cmd = powershell_cmd.join(" ");

    let args = [
        "-NoProfile",
        "-ExecutionPolicy",
        "Bypass",
        "-Command",
        &powershell_cmd,
    ];

    let mut cmd = process::Command::new("PowerShell");
    cmd.args(args);
    configure_cmd(&mut cmd, work_dir, env_vars);
    let mut child = cmd.spawn().context("PowerShell command failed to start")?;

    let status = child.wait()?;
    tmp_dir.close()?;
    Ok(status)
}

#[cfg(test)]
mod tests {
    use {super::*, std::path::PathBuf};

    fn base_cmd() -> UserCommand {
        UserCommand {
            id: None,
            name: "test".into(),
            description: None,
            script: "echo hi".into(),
            args: vec![],
            tags: vec![],
            only_on: None,
            shell: None,
            only_in_dir: vec![],
            risky: false,
            after_run: None,
            working_dir: None,
            source_path: None,
            project_dir: None,
            domain: None,
        }
    }

    #[test]
    fn working_dir_relative_to_project_root() {
        let cmd = UserCommand {
            working_dir: Some("src".into()),
            project_dir: Some(PathBuf::from("/project")),
            ..base_cmd()
        };
        assert_eq!(
            resolve_working_dir(&cmd),
            Some(PathBuf::from("/project/src"))
        );
    }

    #[test]
    fn working_dir_dot_resolves_to_project_root() {
        let cmd = UserCommand {
            working_dir: Some(".".into()),
            project_dir: Some(PathBuf::from("/project")),
            ..base_cmd()
        };
        assert_eq!(resolve_working_dir(&cmd), Some(PathBuf::from("/project/.")));
    }

    #[test]
    fn working_dir_nested_subdir() {
        let cmd = UserCommand {
            working_dir: Some("src/lib".into()),
            project_dir: Some(PathBuf::from("/project")),
            ..base_cmd()
        };
        assert_eq!(
            resolve_working_dir(&cmd),
            Some(PathBuf::from("/project/src/lib"))
        );
    }

    #[test]
    fn working_dir_none_without_field() {
        let cmd = UserCommand {
            project_dir: Some(PathBuf::from("/project")),
            ..base_cmd()
        };
        assert!(resolve_working_dir(&cmd).is_none());
    }

    #[test]
    fn working_dir_none_without_project_dir() {
        let cmd = UserCommand {
            working_dir: Some("subdir".into()),
            ..base_cmd()
        };
        assert!(resolve_working_dir(&cmd).is_none());
    }

    #[test]
    fn working_dir_none_both_unset() {
        assert!(resolve_working_dir(&base_cmd()).is_none());
    }
}