dors 0.0.8

No-fuss workspace-friendly task runner for cargo
Documentation
#![deny(clippy::print_stdout)]
mod dorsfile;
mod error;
mod take_while_ext;

pub use crate::error::{DorsError, Error};

use cargo_metadata::MetadataCommand;
use colored::Colorize;
use dorsfile::{Dorsfile, MemberModifiers, Run};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::ExitStatus;
use take_while_ext::TakeWhileLastExt;

#[derive(Debug)]
struct DorsfileGetter {
    workspace_root: PathBuf,
    workspace_dorsfile: Option<Dorsfile>,
}
impl DorsfileGetter {
    pub fn new<P: AsRef<Path>>(workspace_root: P) -> Result<DorsfileGetter, Box<dyn Error>> {
        let workspace_dorsfile_path = workspace_root.as_ref().join("./Dorsfile.toml");
        Ok(DorsfileGetter {
            workspace_root: workspace_root.as_ref().into(),
            workspace_dorsfile: if workspace_dorsfile_path.exists() {
                Some(Dorsfile::load(&workspace_dorsfile_path)?)
            } else {
                None
            },
        })
    }

    pub fn get<P: AsRef<Path>>(&self, crate_path: P) -> Result<Dorsfile, Box<dyn Error>> {
        let crate_path = crate_path.as_ref();
        if crate_path.canonicalize().unwrap() == self.workspace_root.canonicalize().unwrap() {
            return Ok(self
                .workspace_dorsfile
                .as_ref()
                .cloned()
                .ok_or(DorsError::NoDorsfile)?);
        }
        let local = crate_path.join("./Dorsfile.toml");

        let mut dorsfile = match (local.exists(), self.workspace_dorsfile.is_some()) {
            (true, true) => {
                // Extend local dorsfile with workspace dorsfile
                let mut curr = Dorsfile::load(local)?;
                let workspace_dorsfile = self.workspace_dorsfile.as_ref().unwrap();
                let mut env = workspace_dorsfile.env.clone();
                let mut task = workspace_dorsfile.task.clone();

                task.values_mut().for_each(|task| {
                    // Clear all befores and afters from member task
                    // so that they are not ran on both member and workspace root
                    task.before = None;
                    task.after = None;

                    // Clear any 'run-from = "member"' from the workspace, as we ARE running
                    // from the member
                    if let Run::Members = task.run_from {
                        task.run_from = Run::Here;
                    }
                });

                env.extend(curr.env.drain(..));
                task.extend(curr.task.drain());
                curr.env = env;
                curr.task = task;
                curr
            }
            (true, false) => Dorsfile::load(local)?,
            (false, true) => {
                let mut curr = self.workspace_dorsfile.as_ref().cloned().unwrap();

                let mut task = curr.task.clone();
                task.values_mut().for_each(|task| {
                    // Clear all befores and afters from member task
                    // so that they are not ran on both member and workspace root
                    task.before = None;
                    task.after = None;

                    // Clear any 'run-from = "member"' from the workspace, as we ARE running
                    // from the member
                    if let Run::Members = task.run_from {
                        task.run_from = Run::Here;
                    }
                });

                curr.task = task;
                curr
            }
            (false, false) => return Err(DorsError::NoMemberDorsfile.into()),
        };

        // extend environment
        let builtins: HashMap<_, _> = [(
            "CARGO_WORKSPACE_ROOT",
            self.workspace_root.to_str().unwrap(),
        )]
        .iter()
        .cloned()
        .map(|(key, value)| (key.to_string(), value.to_string()))
        .collect();
        let mut env = vec![builtins];
        env.extend(dorsfile.env.drain(..));
        dorsfile.env = env;
        Ok(dorsfile)
    }
}

struct CargoWorkspaceInfo {
    members: HashMap<String, PathBuf>,
    root: PathBuf,
}

impl CargoWorkspaceInfo {
    fn new(dir: &Path) -> CargoWorkspaceInfo {
        let metadata = MetadataCommand::new().current_dir(&dir).exec().unwrap();
        let root = metadata.workspace_root;
        // allow O(1) referencing of package information
        let packages: HashMap<_, _> = metadata
            .packages
            .iter()
            .map(|package| (package.id.clone(), package))
            .collect();
        let members = metadata
            .workspace_members
            .into_iter()
            .map(|member| {
                let package = packages[&member];
                (
                    package.name.clone(),
                    package.manifest_path.parent().unwrap().into(),
                )
            })
            .collect();
        CargoWorkspaceInfo { members, root }
    }
}

fn run_command(
    command: &str,
    workdir: &Path,
    env: &[HashMap<String, String>],
    args: &[String],
) -> ExitStatus {
    use rand::distributions::Alphanumeric;
    use rand::{thread_rng, Rng};
    use std::iter;
    let mut rng = thread_rng();
    let chars: String = iter::repeat(())
        .map(|()| rng.sample(Alphanumeric))
        .take(10)
        .collect();
    let file = Path::new("./")
        .canonicalize()
        .unwrap()
        .join(format!("tmp-{}.sh", chars));
    let mut script = env
        .iter()
        .flatten()
        .fold("".to_string(), |mut acc, (k, v)| {
            acc.push_str(&format!("export {}={}\n", k, v));
            acc
        });
    script.push_str(command);
    script.push_str("\n");
    std::fs::write(&file, &script).unwrap();
    let exit_status = Command::new("bash")
        .arg("-e")
        .arg(file.to_str().unwrap())
        .args(args)
        .current_dir(workdir)
        .spawn()
        .unwrap()
        .wait()
        .unwrap();
    std::fs::remove_file(file).unwrap();
    exit_status
}

pub fn all_tasks<P: AsRef<Path>>(dir: P) -> Result<Vec<String>, Box<dyn Error>> {
    let workspace = CargoWorkspaceInfo::new(dir.as_ref());
    let dorsfiles = DorsfileGetter::new(&workspace.root)?;
    Ok(dorsfiles
        .get(dir.as_ref())?
        .task
        .keys()
        .cloned()
        .collect::<Vec<_>>())
}

fn print_task(task_name: &str, path: &Path) {
    // TODO convert absolute path to relative
    eprintln!(
        "      {} Running {} from `{}`",
        "[Dors]".yellow().bold(),
        task_name.bold(),
        path.to_str().unwrap().bold()
    );
}

pub fn run<P: AsRef<Path>>(task: &str, dir: P) -> Result<ExitStatus, Box<dyn Error>> {
    run_with_args(task, dir, &[])
}

struct TaskRunner {
    workspace: CargoWorkspaceInfo,
    dorsfiles: DorsfileGetter,
}

pub fn run_with_args<P: AsRef<Path>>(
    task: &str,
    dir: P,
    args: &[String],
) -> Result<ExitStatus, Box<dyn Error>> {
    let dir = dir.as_ref();
    let workspace = CargoWorkspaceInfo::new(&dir);
    let dorsfiles = DorsfileGetter::new(&workspace.root)?;
    let dorsfile = dorsfiles.get(&dir)?;

    TaskRunner {
        workspace,
        dorsfiles,
    }
    // seed recursion
    .run_task(
        task,
        &dorsfile,
        &dir,
        args,
        &mut HashSet::new(),
        &mut HashSet::new(),
    )
}

impl TaskRunner {
    fn run_task(
        &self,
        task_name: &str,
        dorsfile: &Dorsfile,
        dir: &Path,
        args: &[String],
        already_ran_befores: &mut HashSet<String>,
        already_ran_afters: &mut HashSet<String>,
    ) -> Result<ExitStatus, Box<dyn Error>> {
        let task = dorsfile
            .task
            .get(task_name)
            .ok_or_else(|| DorsError::NoTask(task_name.to_string()))?;

        // Handle befores
        if let Some(ref befores) = task.before {
            if let Some(befores_result) = befores
                .iter()
                .filter_map(|before_task_name| {
                    if !already_ran_befores.contains(before_task_name) {
                        already_ran_befores.insert(before_task_name.into());
                        Some(self.run_task(
                            before_task_name,
                            dorsfile,
                            dir,
                            &[],
                            already_ran_befores,
                            &mut HashSet::new(),
                        ))
                    } else {
                        None
                    }
                })
                .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
                .last()
            {
                if befores_result.is_err() || !befores_result.as_ref().unwrap().success() {
                    return befores_result;
                }
            }
        }

        // run command
        let result = match task.run_from {
            Run::Here => {
                print_task(task_name, &dir);
                run_command(&task.command, dir, &dorsfile.env, args)
            }
            Run::WorkspaceRoot => {
                // TODO error gracefully when someone messes this up
                let path = &self.workspace.root;
                print_task(task_name, path);
                run_command(&task.command, path, &dorsfile.env, args)
            }
            Run::Members => {
                if dir.canonicalize().unwrap() != self.workspace.root.canonicalize().unwrap() {
                    panic!("cannot run from members from outside workspace root");
                }
                self.workspace
                    .members
                    .iter()
                    .filter_map(|(name, path)| {
                        let short_path = if path.is_relative() {
                            path
                        } else {
                            path.strip_prefix(&self.workspace.root).unwrap()
                        };
                        match task.member_modifiers {
                            Some(ref modifiers) => match modifiers {
                                MemberModifiers::SkipMembers(skips) => {
                                    if skips.contains(name)
                                        || skips.contains(&short_path.to_str().unwrap().to_string())
                                    {
                                        None
                                    } else {
                                        Some(path)
                                    }
                                }
                                MemberModifiers::OnlyMembers(onlys) => {
                                    if onlys.contains(name)
                                        || onlys.contains(&short_path.to_str().unwrap().to_string())
                                    {
                                        Some(path)
                                    } else {
                                        None
                                    }
                                }
                            },
                            None => Some(path),
                        }
                    })
                    .map(|path| {
                        let dorsfile = self.dorsfiles.get(&path)?;
                        self.run_task(
                            task_name,
                            &dorsfile,
                            &path,
                            args,
                            &mut HashSet::new(),
                            &mut HashSet::new(),
                        )
                    })
                    .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
                    .last()
                    .unwrap()?
            }
            Run::Path(ref target_path) => {
                print_task(task_name, &target_path);
                run_command(&task.command, &dir.join(target_path), &dorsfile.env, args)
            }
        };

        if !result.success() {
            return Ok(result);
        }

        // handle afters
        if let Some(ref afters) = task.after {
            if let Some(afters_result) = afters
                .iter()
                .filter_map(|after_task_name| {
                    if !already_ran_afters.contains(after_task_name) {
                        already_ran_afters.insert(after_task_name.into());
                        Some(self.run_task(
                            after_task_name,
                            dorsfile,
                            dir,
                            &[],
                            &mut HashSet::new(),
                            already_ran_afters,
                        ))
                    } else {
                        None
                    }
                })
                .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
                .last()
            {
                if afters_result.is_err() || !afters_result.as_ref().unwrap().success() {
                    return afters_result;
                }
            }
        }

        Ok(result)
    }
}

#[allow(clippy::print_stdout)]
pub fn process_cmd<'a>(matches: &clap::ArgMatches<'a>) -> i32 {
    let directory = match matches.value_of("subdirectory") {
        Some(directory) => directory.into(),
        None => std::env::current_dir().unwrap(),
    };
    if matches.is_present("list") {
        let mut tasks = match all_tasks(directory) {
            Ok(tasks) => tasks,
            Err(e) => {
                println!("{}", e);
                return 1;
            }
        };
        tasks.sort();
        tasks.iter().for_each(|task| println!("{}", task));
        return 0;
    }

    if matches.is_present("completions") {
        println!(r#"complete -C "cargo dors -l" cargo dors"#);
        println!(r#"complete -C "cargo dors -l" dors"#);
        return 0;
    }

    if let Some(task) = matches.value_of("TASK") {
        let args = match matches.values_of("TASK_ARGS") {
            Some(values) => values.map(|s| s.to_string()).collect(),
            None => vec![],
        };
        match run_with_args(&task, directory, &args) {
            Ok(resp) => return resp.code().unwrap(),
            Err(e) => {
                println!("{}", e);
                return 1;
            }
        }
    }

    let mut tasks = match all_tasks(directory) {
        Ok(tasks) => tasks,
        Err(e) => {
            println!("{}", e);
            return 1;
        }
    };
    tasks.sort();

    if matches.is_present("list") {
        tasks.iter().for_each(|task| println!("{}", task));
        return 0;
    }

    println!("{}: Please select a task to run:", "Error".red());
    tasks.iter().for_each(|task| println!("{}", task.bold()));
    1
}

pub fn get_about() -> &'static str {
    "No-fuss workspace-aware task runner for rust"
}

pub fn set_app_options<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> {
    app.version(env!("CARGO_PKG_VERSION"))
        .author("Andrew Klitzke <andrewknpe@gmail.com>")
        .setting(clap::AppSettings::TrailingVarArg)
        .setting(clap::AppSettings::ColoredHelp)
        .setting(clap::AppSettings::DontCollapseArgsInUsage)
        .about(get_about())
        .arg(
            clap::Arg::with_name("subdirectory")
                .short("d")
                .long("subdirectory")
                .conflicts_with_all(&["completions"])
                .display_order(0)
                .takes_value(true)
                .help("only run task on a specified subdirectory"),
        )
        .arg(
            clap::Arg::with_name("list")
                .short("l")
                .long("list")
                .conflicts_with_all(&["TASK", "TASK_ARGS", "completions"])
                .display_order(1)
                .help("list all the available tasks"),
        )
        .arg(
            clap::Arg::with_name("completions")
                .long("completions")
                .help(
                    "Generate bash/zsh completions. Install once, and will automatically \
                        update when Dorsfiles are modified. Usually added to \
                        .bashrc or similar file to take effect. See `rustup completions`\
                        for a detailed explanation of how to install the output of this
                        command",
                ),
        )
        .arg(clap::Arg::with_name("TASK").help("the name of the task to run"))
        .arg(
            clap::Arg::with_name("TASK_ARGS")
                .help("arguments to pass to the task")
                .requires("TASK")
                .multiple(true),
        )
}