use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::bail;
use color_eyre::Result;
use directories::ProjectDirs;
use env_logger::Env;
use log::debug;
use log::info;
use log::Level;
use log::{error, warn};
use owo_colors::OwoColorize;
use std::env;
use std::fmt;
use std::fmt::Display;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
use tokio_stream::StreamExt;
use xcommand::StdioType;
use xcommand::XCommand;
use xcommand::XStatus;
mod config;
use config::Config;
const INDENT: &str = " ";
const USER_CONFIG_NAME: &str = "config.toml";
const REPO_CONFIG_NAME: &str = ".cbtr.toml";
#[derive(Debug, Args, Clone)]
struct CommandArgs {
#[arg(short, long)]
dry_run: bool,
#[arg(short, long)]
no_searchback: bool,
}
#[derive(Subcommand, Debug, Clone)]
enum Command {
#[command(id = "f")]
Format {
#[clap(flatten)]
args: CommandArgs,
},
#[command(id = "c")]
Check {
#[clap(flatten)]
args: CommandArgs,
},
#[command(id = "b")]
Build {
#[clap(flatten)]
args: CommandArgs,
},
#[command(id = "t")]
Test {
#[clap(flatten)]
args: CommandArgs,
},
#[command(id = "r")]
Run {
#[clap(flatten)]
args: CommandArgs,
},
}
impl Command {
fn args(&self) -> &CommandArgs {
match self {
Command::Format { args } => args,
Command::Check { args } => args,
Command::Build { args } => args,
Command::Test { args } => args,
Command::Run { args } => args,
}
}
}
impl Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Command::Format { args: _ } => "format",
Command::Check { args: _ } => "check",
Command::Build { args: _ } => "build",
Command::Test { args: _ } => "test",
Command::Run { args: _ } => "run",
};
write!(f, "{}", s)
}
}
#[derive(Subcommand, Debug)]
enum Multicall {
#[command(flatten)]
Multicall(Command),
Cbtr {
#[command(subcommand)]
command: Command,
},
}
#[derive(Debug, Error)]
#[error("unexpected command name")]
struct CommandError {}
#[derive(Debug, Parser)]
#[command(multicall(true))]
struct Cli {
#[command(subcommand)]
multicall: Multicall,
}
fn repo_root(cwd: &Path) -> Result<PathBuf> {
let repo = gix::discover(cwd)?;
let git_dir = repo.path();
let root = git_dir.parent().unwrap();
Ok(root.to_path_buf())
}
async fn run(cmd: &str, args: &[&str]) -> Result<i32> {
let bin = which::which(cmd)?;
let command = XCommand::builder(&bin)?.args(args)?.build();
let Ok(mut child) = command.spawn() else {
bail!("Unable to run '{}'", bin.display());
};
let mut streamer = child.streamer();
let mut stream = streamer.stream();
while let Some(item) = stream.next().await {
let (message_type, message) = item?;
match message_type {
StdioType::Stdout => {
println!("{}{}", INDENT, message);
}
StdioType::Stderr => {
eprintln!("{}{}", INDENT, message);
}
}
}
let XStatus::Exited(code) = child.status().await? else {
bail!("Process was expected to have finished");
};
Ok(code)
}
fn user_config() -> Result<Option<Config>> {
let Some(proj_dirs) = ProjectDirs::from("", "", "cbtr") else {
bail!("Couldn't find proj dirs");
};
let config_dir = proj_dirs.config_dir();
if !config_dir.is_dir() {
fs::create_dir_all(config_dir)?;
}
let config_file = config_dir.join(USER_CONFIG_NAME);
if !config_file.is_file() {
warn!("Please create a cbtr config at {}", config_file.display());
return Ok(None);
};
let contents = fs::read_to_string(&config_file)?;
let config: Config = toml::from_str(&contents)?;
Ok(Some(config))
}
#[tokio::main]
async fn main() -> Result<()> {
let log_level = Env::default().default_filter_or("info");
env_logger::Builder::from_env(log_level)
.format(|buf, record| {
let level_value = record.level();
let level = format!("[{}]", level_value);
let level = match level_value {
Level::Error => format!("{}", level.red()),
Level::Warn => format!("{}", level.yellow()),
Level::Info => format!("{}", level.green()),
Level::Debug => format!("{}", level.blue()),
Level::Trace => format!("{}", level.cyan()),
};
writeln!(buf, "{} {}", level.bold(), record.args())
})
.init();
let args = Cli::parse();
let command = match &args.multicall {
Multicall::Multicall(c) => c,
Multicall::Cbtr { command } => command,
};
debug!("args: {:?}", args);
let args = command.args();
let cwd = env::current_dir()?;
let root = if args.no_searchback {
cwd.clone()
} else {
match repo_root(&cwd) {
Ok(root) => root,
Err(_) => {
warn!("Current dir is not within a git repo. Using CWD as repo root");
cwd.clone()
}
}
};
let repo_config_file = root.join(REPO_CONFIG_NAME);
let repo_config = if repo_config_file.is_file() {
let contents = fs::read_to_string(&repo_config_file)?;
let config: Config = toml::from_str(&contents)?;
Some(config)
} else {
None
};
let user_config = match user_config() {
Ok(config) => config,
Err(e) => {
bail!("{}", e)
}
};
let config = match (repo_config, user_config) {
(Some(mut repo_config), Some(user_config)) => {
repo_config.append(user_config);
repo_config
}
(Some(config), None) => config,
(None, Some(config)) => config,
(None, None) => {
error!("Could not find config file");
std::process::exit(1);
}
};
let mut tools = None;
for entry in &config.entries {
let name = &entry.name;
debug!("Checking conditions for {}", name);
if entry.matches(&cwd, &root) {
match command {
Command::Format { args: _ } => {
tools = entry.tools.format.as_ref();
}
Command::Check { args: _ } => {
tools = entry.tools.check.as_ref();
}
Command::Build { args: _ } => {
tools = entry.tools.build.as_ref();
}
Command::Test { args: _ } => {
tools = entry.tools.test.as_ref();
}
Command::Run { args: _ } => {
tools = entry.tools.run.as_ref();
}
}
if tools.is_some() {
break;
}
}
}
let Some(tools) = tools else {
error!("No {} tool matched config rules", command);
std::process::exit(1);
};
for tool in tools.to_vec() {
info!("Running '{}'", tool.bold());
if args.dry_run {
println!("[dryrun] Would run '{}'", tool)
} else {
let parts: Vec<&str> = tool.split_whitespace().collect();
let cmd = parts[0];
let cmd_args = &parts[1..];
let code = run(cmd, cmd_args).await?;
debug!("cmd: {}, args: {:?}", cmd, cmd_args);
if code != 0 {
error!("Subprocess '{}' failed with exit code {}", tool, code);
std::process::exit(code)
};
}
}
Ok(())
}