mod activity;
mod catalog;
mod command;
mod config;
mod diagnostics;
mod dispatch;
mod environment;
mod matcher;
mod protocol;
mod supervisor;
use std::sync::Arc;
pub use catalog::{HookCatalog, ProjectTrust, TRUST_PROJECT_HOOKS_ENV};
pub use config::HookConfigError;
pub use diagnostics::{contract_views, HookInspector, HookReport};
pub use dispatch::HookEngine;
pub const IN_HOOK_ENV: &str = "RHO_IN_HOOK";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum HookSource {
User,
Project,
}
impl HookSource {
pub const fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
pub struct HookPipeline {
engine: Arc<HookEngine>,
gate: Option<Arc<dispatch::CommandHookGate>>,
observer: Option<Arc<dispatch::QueuedHookObserver>>,
worker: Option<dispatch::ObservationalWorker>,
}
impl HookPipeline {
pub fn start(catalog: HookCatalog, cancellation: rho_sdk::CancellationToken) -> Option<Self> {
if catalog.is_empty() {
return None;
}
let has_blocking = catalog.has_blocking_hooks();
let has_observational = catalog.has_observational_hooks();
let engine = Arc::new(HookEngine::new(
catalog,
rho_sdk::hooks::HookPayloadBounds::default(),
));
let (observer, worker) = if has_observational {
let (observer, worker) =
dispatch::observational_channel(Arc::clone(&engine), cancellation);
(Some(Arc::new(observer)), Some(worker))
} else {
(None, None)
};
let gate =
has_blocking.then(|| Arc::new(dispatch::CommandHookGate::new(Arc::clone(&engine))));
Some(Self {
gate,
observer,
engine,
worker,
})
}
pub fn attach(&self, mut builder: rho_sdk::RhoBuilder) -> rho_sdk::RhoBuilder {
if let Some(gate) = &self.gate {
builder = builder
.pre_tool_gate_shared(Arc::clone(gate) as Arc<dyn rho_sdk::hooks::PreToolUseGate>);
}
if let Some(observer) = &self.observer {
builder =
builder.hook_observer_shared(
Arc::clone(observer) as Arc<dyn rho_sdk::hooks::HookObserver>
);
}
builder
}
pub fn engine(&self) -> &Arc<HookEngine> {
&self.engine
}
pub fn reload_for_cwd(&self, cwd: &std::path::Path) -> Result<(), HookConfigError> {
let mut discard = |_message: String| {};
let catalog = discover_for_cwd(cwd, &mut discard)?;
if catalog.has_blocking_hooks() && self.gate.is_none() {
return Err(HookConfigError::at_file(
cwd,
"blocking hooks were added since this session started; restart Rho to load them",
));
}
if catalog.has_observational_hooks() && self.observer.is_none() {
return Err(HookConfigError::at_file(
cwd,
"observational hooks were added since this session started; restart Rho to load them",
));
}
self.engine.reload(catalog);
Ok(())
}
pub async fn shutdown(mut self, grace: std::time::Duration) {
drop(self.observer.take());
if let Some(worker) = self.worker.take() {
worker.drain(grace).await;
}
}
}
pub fn running_inside_hook() -> bool {
std::env::var_os(IN_HOOK_ENV).is_some()
}
pub const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
pub fn start_for_cwd(cwd: &std::path::Path) -> Option<HookPipeline> {
let mut report = |message: String| tracing::info!(target: "rho::hooks", "{message}");
match discover_for_cwd(cwd, &mut report) {
Ok(catalog) => HookPipeline::start(catalog, rho_sdk::CancellationToken::new()),
Err(error) => {
tracing::warn!(target: "rho::hooks", "hooks are disabled: {error}");
None
}
}
}
pub fn discover_for_cwd(
cwd: &std::path::Path,
report: &mut dyn FnMut(String),
) -> Result<HookCatalog, HookConfigError> {
if running_inside_hook() {
return Ok(HookCatalog::default());
}
let project_root = crate::workspace::project_ancestor_dirs(cwd)
.into_iter()
.next();
let rho_home = crate::paths::rho_dir().ok();
let trust = ProjectTrust::from_env(std::env::var(TRUST_PROJECT_HOOKS_ENV).ok().as_deref());
let catalog = HookCatalog::discover(rho_home.as_deref(), project_root.as_deref(), trust)?;
if let Some(skipped) = catalog.skipped_untrusted() {
report(format!(
"ignoring {} because this workspace is not trusted; set {TRUST_PROJECT_HOOKS_ENV}=1 to load it",
crate::paths::display(&skipped.path)
));
}
Ok(catalog)
}
#[cfg(test)]
#[path = "hooks/hooks_tests.rs"]
mod tests;