use super::config_dir;
use anyhow::{Context, Result};
use clap::ValueEnum;
use config::{Config, File};
use minus::{
input::{HashedEventRegister, InputEvent},
page_all, ExitStrategy, LineNumbers, Pager,
};
use run_script::{run_script, ScriptOptions};
use serde::Deserialize;
use std::{
collections::HashMap,
fs::read_to_string,
hash::RandomState,
path::{Path, PathBuf},
sync::atomic::{AtomicBool, Ordering},
sync::Arc,
};
use tracing::{debug, info, trace, warn};
#[derive(Debug)]
pub struct HookRunner {
entries: CommandHooks,
action: HookAction,
pager: HookPager,
}
impl HookRunner {
pub fn new() -> Result<Self> {
trace!("Load hook configurations");
let path = config_dir()?.join("hooks.toml");
debug!("Load hooks at {path:?}");
let entries: CommandHooks = Config::builder()
.add_source(File::from(path).required(false))
.build()?
.try_deserialize()?;
Ok(Self { entries, action: HookAction::default(), pager: HookPager::default() })
}
pub fn set_action(&mut self, action: HookAction) {
self.action = action;
}
pub fn run(
&self,
cmd: impl AsRef<str>,
kind: HookKind,
repos: Option<&Vec<String>>,
) -> Result<()> {
if self.action == HookAction::Never {
return Ok(());
}
if self.entries.hooks.is_none() {
return Ok(());
}
if let Some(hooks) = self.entries.hooks.as_ref().unwrap().get(cmd.as_ref()) {
for hook in hooks {
let name = match kind {
HookKind::Pre => hook.pre.as_ref(),
HookKind::Post => hook.post.as_ref(),
};
let name = match name {
Some(name) => name,
None => continue,
};
if let Some(repos) = repos {
if let Some(repo) = &hook.target {
if !repos.contains(repo) {
continue;
}
}
} else if hook.target.is_some() {
warn!(
"Command {:?} cannot operate on targets, skipping {hook:?}",
cmd.as_ref()
);
continue;
}
let path = config_dir()?.join("hooks").join(name);
let data = read_to_string(&path).with_context(|| "Script {path:?} undefined")?;
let work_dir = if let Some(work_dir) = &hook.work_dir {
let path: PathBuf =
shellexpand::full(work_dir.to_string_lossy().as_ref())?.into_owned().into();
if !path.exists() {
warn!("Work directory {path:?} does not exist, skipping {hook:?}");
continue;
}
Some(path)
} else {
None
};
if self.action == HookAction::Prompt {
self.pager.page_and_prompt(&path, &work_dir, &data)?;
if !self.pager.choice() {
continue;
}
}
let mut opts = ScriptOptions::new();
opts.working_directory = work_dir;
let (code, out, err) = run_script!(data, opts)?;
info!("[{code}] {name:?}\nstdout: {out}\nstderr: {err}");
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
pub struct CommandHooks {
hooks: Option<HashMap<String, Vec<HookEntry>>>,
}
#[derive(Debug, Deserialize)]
pub struct HookEntry {
pub pre: Option<String>,
pub post: Option<String>,
pub work_dir: Option<PathBuf>,
pub target: Option<String>,
}
#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum HookAction {
Always,
#[default]
Prompt,
Never,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub enum HookKind {
#[default]
Pre,
Post,
}
#[derive(Debug, Default)]
pub(crate) struct HookPager {
choice: Arc<AtomicBool>,
}
impl HookPager {
pub(crate) fn new() -> Self {
HookPager::default()
}
pub(crate) fn choice(&self) -> bool {
self.choice.load(Ordering::Relaxed)
}
pub(crate) fn page_and_prompt(
&self,
name: impl AsRef<Path>,
work_dir: &Option<PathBuf>,
data: impl AsRef<str>,
) -> Result<()> {
let pager = Pager::new();
let work_dir = match work_dir {
Some(path) => path.clone(),
None => PathBuf::from("./"),
};
pager.set_prompt(format!("Run {:?} at {:?}? [A]ccept/[D]eny", name.as_ref(), work_dir,))?;
pager.show_prompt(true)?;
pager.set_run_no_overflow(true)?;
pager.set_line_numbers(LineNumbers::Enabled)?;
pager.push_str(data.as_ref())?;
pager.set_input_classifier(self.generate_key_bindings())?;
pager.set_exit_strategy(ExitStrategy::PagerQuit)?;
page_all(pager)?;
Ok(())
}
fn generate_key_bindings(&self) -> Box<HashedEventRegister<RandomState>> {
let mut input = HashedEventRegister::default();
let response = self.choice.clone();
input.add_key_events(&["a"], move |_, _| {
response.store(true, Ordering::Relaxed);
InputEvent::Exit
});
let response = self.choice.clone();
input.add_key_events(&["d"], move |_, _| {
response.store(false, Ordering::Relaxed);
InputEvent::Exit
});
Box::new(input)
}
}