plzplz 0.0.3

A simple cross-platform task runner with helpful defaults
Documentation
use crate::config::{FailHook, PlzConfig};
use anyhow::{Result, bail};
use std::path::Path;
use std::process::Command;

pub fn run_task(
    config: &PlzConfig,
    task_name: &str,
    base_dir: &Path,
    interactive: bool,
) -> Result<()> {
    run_task_impl(config, task_name, base_dir, interactive, true)
}

fn run_task_impl(
    config: &PlzConfig,
    task_name: &str,
    base_dir: &Path,
    interactive: bool,
    run_hooks: bool,
) -> Result<()> {
    let task = config
        .tasks
        .get(task_name)
        .ok_or_else(|| anyhow::anyhow!("Unknown task: {task_name}"))?;

    let work_dir = match &task.dir {
        Some(d) => base_dir.join(d),
        None => base_dir.to_path_buf(),
    };

    let wrap = |cmd: &str| -> String {
        match task.tool_env.as_deref() {
            Some("uv") => format!("uv run {cmd}"),
            Some("pnpm") => format!("pnpm exec {cmd}"),
            _ => cmd.to_string(),
        }
    };

    let result: Result<()> = (|| {
        if let Some(ref cmd) = task.run {
            run_command_or_ref(config, &wrap(cmd), &work_dir, base_dir, interactive)?;
        }

        if let Some(ref cmds) = task.run_serial {
            run_serial_commands(config, cmds, &wrap, &work_dir, base_dir, interactive)?;
        }

        if let Some(ref cmds) = task.run_parallel {
            run_parallel_commands(config, cmds, &wrap, &work_dir, base_dir, interactive)?;
        }

        Ok(())
    })();

    if run_hooks {
        if let Err(ref e) = result
            && let Some(ref hook) = task.fail_hook
        {
            if handle_fail_hook(hook, e, &work_dir, interactive)? {
                return Ok(());
            }
        }
    }

    result
}

fn run_command_or_ref(
    config: &PlzConfig,
    cmd: &str,
    work_dir: &Path,
    base_dir: &Path,
    interactive: bool,
) -> Result<()> {
    if let Some(ref_name) = cmd.strip_prefix("plz:") {
        return run_task(config, ref_name, base_dir, interactive);
    }
    exec_shell(cmd, work_dir)
}

fn exec_shell(cmd: &str, work_dir: &Path) -> Result<()> {
    eprintln!("{cmd}");
    let status = Command::new("/bin/sh")
        .arg("-c")
        .arg(cmd)
        .current_dir(work_dir)
        .status()?;

    if !status.success() {
        bail!(
            "Command failed with exit code {}: {cmd}",
            status.code().unwrap_or(-1)
        );
    }
    Ok(())
}

struct DeferredFailure {
    name: String,
    error: anyhow::Error,
}

fn print_summary(results: &[(String, bool)]) {
    let total = results.len();
    let parts: Vec<String> = results
        .iter()
        .map(|(name, ok)| {
            if *ok {
                format!("\x1b[32m✓ {name}\x1b[0m")
            } else {
                format!("\x1b[31m✗ {name}\x1b[0m")
            }
        })
        .collect();
    eprintln!("\nRan {total} tasks: {}", parts.join("  "));
}

/// Process deferred failures: run each task's fail_hook in succession,
/// asking "continue?" between unresolved ones.
fn handle_deferred_failures(
    config: &PlzConfig,
    failures: Vec<DeferredFailure>,
    base_dir: &Path,
    interactive: bool,
) -> Result<()> {
    for (i, failure) in failures.iter().enumerate() {
        let task = config.tasks.get(&failure.name);
        let hook = task.and_then(|t| t.fail_hook.as_ref());

        if let Some(hook) = hook {
            let task_work_dir = task
                .and_then(|t| t.dir.as_ref())
                .map(|d| base_dir.join(d))
                .unwrap_or_else(|| base_dir.to_path_buf());

            if handle_fail_hook(hook, &failure.error, &task_work_dir, interactive)? {
                continue;
            }
        } else {
            eprintln!(
                "\n\x1b[31mTask failed:\x1b[0m {}: {}",
                failure.name, failure.error
            );
        }

        let has_more = i + 1 < failures.len();
        if interactive && has_more {
            let cont = cliclack::confirm("Continue to next task?")
                .initial_value(true)
                .interact()
                .unwrap_or(false);
            if !cont {
                bail!("Aborted");
            }
        }
    }

    bail!("One or more tasks failed");
}

fn run_serial_commands(
    config: &PlzConfig,
    cmds: &[String],
    wrap: &dyn Fn(&str) -> String,
    work_dir: &Path,
    base_dir: &Path,
    interactive: bool,
) -> Result<()> {
    let mut task_results: Vec<(String, bool)> = Vec::new();
    let mut failures: Vec<DeferredFailure> = Vec::new();

    for cmd in cmds {
        let wrapped = wrap(cmd);
        if let Some(ref_name) = wrapped.strip_prefix("plz:") {
            match run_task_impl(config, ref_name, base_dir, interactive, false) {
                Ok(()) => task_results.push((ref_name.to_string(), true)),
                Err(e) => {
                    task_results.push((ref_name.to_string(), false));
                    failures.push(DeferredFailure {
                        name: ref_name.to_string(),
                        error: e,
                    });
                }
            }
        } else {
            exec_shell(&wrapped, work_dir)?;
        }
    }

    if !failures.is_empty() {
        if task_results.len() > 1 {
            print_summary(&task_results);
        }
        return handle_deferred_failures(config, failures, base_dir, interactive);
    }

    Ok(())
}

fn run_parallel_commands(
    config: &PlzConfig,
    cmds: &[String],
    wrap: &dyn Fn(&str) -> String,
    work_dir: &Path,
    base_dir: &Path,
    interactive: bool,
) -> Result<()> {
    let mut children = Vec::new();
    let mut plz_refs = Vec::new();

    for cmd in cmds {
        let wrapped = wrap(cmd);
        if let Some(ref_name) = wrapped.strip_prefix("plz:") {
            plz_refs.push(ref_name.to_string());
        } else {
            eprintln!("{wrapped} &");
            let child = Command::new("/bin/sh")
                .arg("-c")
                .arg(&wrapped)
                .current_dir(work_dir)
                .spawn()?;
            children.push((wrapped, child));
        }
    }

    let mut task_results: Vec<(String, bool)> = Vec::new();
    let mut failures: Vec<DeferredFailure> = Vec::new();

    for ref_name in &plz_refs {
        match run_task_impl(config, ref_name, base_dir, interactive, false) {
            Ok(()) => task_results.push((ref_name.clone(), true)),
            Err(e) => {
                task_results.push((ref_name.clone(), false));
                failures.push(DeferredFailure {
                    name: ref_name.clone(),
                    error: e,
                });
            }
        }
    }

    for (cmd, mut child) in children {
        let status = child.wait()?;
        if !status.success() {
            task_results.push((cmd.clone(), false));
            failures.push(DeferredFailure {
                name: cmd.clone(),
                error: anyhow::anyhow!(
                    "Command failed with exit code {}: {cmd}",
                    status.code().unwrap_or(-1)
                ),
            });
        } else {
            task_results.push((cmd, true));
        }
    }

    if !failures.is_empty() {
        if task_results.len() > 1 {
            print_summary(&task_results);
        }
        return handle_deferred_failures(config, failures, base_dir, interactive);
    }

    Ok(())
}

/// Returns true if the fail hook resolved the failure (e.g. suggestion was taken and succeeded).
fn handle_fail_hook(
    hook: &FailHook,
    error: &anyhow::Error,
    work_dir: &Path,
    interactive: bool,
) -> Result<bool> {
    match hook {
        FailHook::Command(cmd) => {
            eprintln!("\n\x1b[31mTask failed:\x1b[0m {error}");
            eprintln!("Running fail hook: {cmd}");
            let _ = exec_shell(cmd, work_dir);
        }
        FailHook::Message(msg) => {
            eprintln!("\n\x1b[31mTask failed:\x1b[0m {error}");
            eprintln!("⚠️  {msg}");
        }
        FailHook::Suggest { suggest_command } => {
            eprintln!("\n\x1b[31mTask failed:\x1b[0m {error}");
            if !interactive {
                eprintln!("\x1b[33mSuggestion:\x1b[0m try running \x1b[1m{suggest_command}\x1b[0m");
            } else {
                let run_it: bool = cliclack::confirm(format!("Run `{suggest_command}`?"))
                    .initial_value(true)
                    .interact()
                    .unwrap_or(false);
                if run_it {
                    if exec_shell(suggest_command, work_dir).is_ok() {
                        return Ok(true);
                    }
                    eprintln!("\x1b[31mFix command failed.\x1b[0m");
                }
            }
        }
    }
    Ok(false)
}