mod config;
mod init;
mod runner;
mod templates;
mod utils;
use anyhow::{Result, bail};
use clap::{CommandFactory, Parser, Subcommand};
use std::env;
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "plz",
about = "A simple task runner. Define tasks in plz.toml and run them with `plz <task>`.",
after_help = "\x1b[34mRun \x1b[1mplz\x1b[22m to choose a task\nRun \x1b[1mplz init\x1b[22m to create a new config\n\x1b[0m"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(trailing_var_arg = true)]
task: Vec<String>,
#[arg(long)]
no_interactive: bool,
}
#[derive(Subcommand)]
enum Command {
Plz {
#[command(subcommand)]
plz_command: Option<PlzCommand>,
},
Init,
Add {
name: Option<String>,
},
Example,
}
#[derive(Subcommand)]
enum PlzCommand {
Schema,
}
fn is_interactive(cli: &Cli) -> bool {
!cli.no_interactive && !is_ci::cached()
}
const CONFIG_NAMES: &[&str] = &["plz.toml", ".plz.toml"];
fn find_config() -> Option<PathBuf> {
let cwd = env::current_dir().ok()?;
for name in CONFIG_NAMES {
let path = cwd.join(name);
if path.exists() {
return Some(path);
}
}
None
}
fn is_git_repo() -> bool {
let Ok(cwd) = env::current_dir() else {
return false;
};
let mut dir = cwd.as_path();
loop {
if dir.join(".git").exists() {
return true;
}
match dir.parent() {
Some(parent) => dir = parent,
None => return false,
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Command::Init) => return init::run(),
Some(Command::Example) => return init::help_templates(),
Some(Command::Add { name }) => return init::add_task(name),
Some(Command::Plz { ref plz_command }) => match plz_command {
Some(PlzCommand::Schema) => {
let schema = schemars::schema_for!(config::PlzConfig);
println!("{}", serde_json::to_string_pretty(&schema)?);
return Ok(());
}
None => return init::setup(),
},
None => {}
}
let interactive = is_interactive(&cli);
let config_path = match find_config() {
Some(path) => path,
None => {
if interactive && is_git_repo() {
let create: bool = cliclack::confirm("No plz.toml found. Create one?")
.initial_value(true)
.interact()?;
if create {
return init::run();
}
}
Cli::command().print_help()?;
return Ok(());
}
};
let config = config::load(&config_path)?;
let base_dir = config_path.parent().unwrap().to_path_buf();
if cli.task.is_empty() {
if !interactive {
bail!("No task specified (running in non-interactive mode)");
}
let mut names: Vec<&String> = config.tasks.keys().collect();
names.sort();
if names.is_empty() {
bail!("No tasks defined in plz.toml");
}
let items: Vec<utils::PickItem> = names
.iter()
.map(|name| utils::PickItem {
label: name.to_string(),
description: config.tasks[*name].description.clone().unwrap_or_default(),
preview: None,
})
.collect();
match utils::pick_from_list(&items, "Enter to run · Esc to cancel")? {
Some(idx) => {
return runner::run_task(&config, names[idx], &base_dir, interactive);
}
None => {
println!("\x1b[2m✕ Cancelled\x1b[0m");
return Ok(());
}
}
}
let input = &cli.task[0];
if input == "add" && !config.tasks.contains_key("add") {
let name = cli.task.get(1).cloned();
return init::add_task(name);
}
let task_name = resolve_task(&config, input, interactive)?;
runner::run_task(&config, &task_name, &base_dir, interactive)?;
Ok(())
}
fn resolve_task(config: &config::PlzConfig, input: &str, interactive: bool) -> Result<String> {
if config.tasks.contains_key(input) {
return Ok(input.to_string());
}
if !interactive {
bail!("Unknown task: {input}");
}
let mut matches: Vec<&String> = config
.tasks
.keys()
.filter(|k| utils::fuzzy_match(input, k))
.collect();
matches.sort();
match matches.len() {
0 => bail!("Unknown task: {input}"),
1 => {
let confirmed: bool = cliclack::confirm(format!("Did you mean \"{}\"?", matches[0]))
.initial_value(true)
.interact()?;
if confirmed {
Ok(matches[0].clone())
} else {
bail!("Cancelled");
}
}
_ => {
let selected: &&String = cliclack::select("Did you mean...".to_string())
.items(
&matches
.iter()
.map(|n| (n, n.as_str(), ""))
.collect::<Vec<_>>(),
)
.interact()?;
Ok(selected.to_string())
}
}
}