1use crate::handler::{CommandContext, ExternalFailure};
2use clap::ArgMatches;
3use std::fmt;
4use std::path::PathBuf;
5use std::rc::Rc;
6use thiserror::Error;
7#[derive(Debug, Clone)]
8pub struct TextOutput {
9 pub formatted: String,
10 pub raw: String,
11}
12impl TextOutput {
13 pub fn new(formatted: String, raw: String) -> Self {
14 Self { formatted, raw }
15 }
16 pub fn plain(text: String) -> Self {
17 Self {
18 formatted: text.clone(),
19 raw: text,
20 }
21 }
22}
23#[derive(Debug, Clone)]
24pub struct ArtifactOutput {
25 pub bytes: Vec<u8>,
26 pub suggested_destination: Option<PathBuf>,
27 pub stdout_allowed: bool,
28 pub report: Option<serde_json::Value>,
29}
30#[derive(Debug, Clone)]
31pub enum RenderedOutput {
32 Text(TextOutput),
33 Binary(Vec<u8>, String),
34 Artifact(ArtifactOutput),
35 Silent,
36}
37impl RenderedOutput {
38 pub fn is_text(&self) -> bool {
39 matches!(self, RenderedOutput::Text(_))
40 }
41 pub fn is_binary(&self) -> bool {
42 matches!(self, RenderedOutput::Binary(_, _))
43 }
44 pub fn is_artifact(&self) -> bool {
45 matches!(self, RenderedOutput::Artifact(_))
46 }
47 pub fn is_silent(&self) -> bool {
48 matches!(self, RenderedOutput::Silent)
49 }
50 pub fn as_text(&self) -> Option<&str> {
51 match self {
52 RenderedOutput::Text(t) => Some(&t.formatted),
53 _ => None,
54 }
55 }
56 pub fn as_raw_text(&self) -> Option<&str> {
57 match self {
58 RenderedOutput::Text(t) => Some(&t.raw),
59 _ => None,
60 }
61 }
62 pub fn as_text_output(&self) -> Option<&TextOutput> {
63 match self {
64 RenderedOutput::Text(t) => Some(t),
65 _ => None,
66 }
67 }
68 pub fn as_binary(&self) -> Option<(&[u8], &str)> {
69 match self {
70 RenderedOutput::Binary(bytes, filename) => Some((bytes, filename)),
71 _ => None,
72 }
73 }
74 pub fn as_artifact(&self) -> Option<&ArtifactOutput> {
75 match self {
76 RenderedOutput::Artifact(artifact) => Some(artifact),
77 _ => None,
78 }
79 }
80 pub fn as_artifact_mut(&mut self) -> Option<&mut ArtifactOutput> {
81 match self {
82 RenderedOutput::Artifact(artifact) => Some(artifact),
83 _ => None,
84 }
85 }
86}
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum HookPhase {
89 PreDispatch,
90 PostDispatch,
91 PostOutput,
92}
93impl fmt::Display for HookPhase {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 HookPhase::PreDispatch => write!(f, "pre-dispatch"),
97 HookPhase::PostDispatch => write!(f, "post-dispatch"),
98 HookPhase::PostOutput => write!(f, "post-output"),
99 }
100 }
101}
102#[derive(Debug, Error)]
103#[error("hook error ({phase}): {message}")]
104pub struct HookError {
105 pub message: String,
106 pub phase: HookPhase,
107 #[source]
108 pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
109}
110impl HookError {
111 pub fn pre_dispatch(message: impl Into<String>) -> Self {
112 Self {
113 message: message.into(),
114 phase: HookPhase::PreDispatch,
115 source: None,
116 }
117 }
118 pub fn pre_dispatch_external(failure: ExternalFailure) -> Self {
119 Self {
120 message: failure.diagnostic().to_owned(),
121 phase: HookPhase::PreDispatch,
122 source: Some(Box::new(failure)),
123 }
124 }
125 pub fn post_dispatch(message: impl Into<String>) -> Self {
126 Self {
127 message: message.into(),
128 phase: HookPhase::PostDispatch,
129 source: None,
130 }
131 }
132 pub fn post_output(message: impl Into<String>) -> Self {
133 Self {
134 message: message.into(),
135 phase: HookPhase::PostOutput,
136 source: None,
137 }
138 }
139 pub fn with_source<E>(mut self, source: E) -> Self
140 where
141 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
142 {
143 self.source = Some(source.into());
144 self
145 }
146}
147pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
148pub type PostDispatchFn = Rc<
149 dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
150>;
151pub type PostOutputFn =
152 Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
153#[derive(Clone, Default)]
154pub struct Hooks {
155 pre_dispatch: Vec<PreDispatchFn>,
156 post_dispatch: Vec<PostDispatchFn>,
157 post_output: Vec<PostOutputFn>,
158}
159impl Hooks {
160 pub fn new() -> Self {
161 Self::default()
162 }
163 pub fn is_empty(&self) -> bool {
164 self.pre_dispatch.is_empty() && self.post_dispatch.is_empty() && self.post_output.is_empty()
165 }
166 pub fn has_phase(&self, phase: HookPhase) -> bool {
167 match phase {
168 HookPhase::PreDispatch => !self.pre_dispatch.is_empty(),
169 HookPhase::PostDispatch => !self.post_dispatch.is_empty(),
170 HookPhase::PostOutput => !self.post_output.is_empty(),
171 }
172 }
173 pub fn phases(&self) -> impl Iterator<Item = HookPhase> + '_ {
174 [
175 HookPhase::PreDispatch,
176 HookPhase::PostDispatch,
177 HookPhase::PostOutput,
178 ]
179 .into_iter()
180 .filter(|phase| self.has_phase(*phase))
181 }
182 pub fn append(mut self, mut other: Hooks) -> Self {
183 self.pre_dispatch.append(&mut other.pre_dispatch);
184 self.post_dispatch.append(&mut other.post_dispatch);
185 self.post_output.append(&mut other.post_output);
186 self
187 }
188 pub fn pre_dispatch<F>(mut self, f: F) -> Self
189 where
190 F: Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError> + 'static,
191 {
192 self.pre_dispatch.push(Rc::new(f));
193 self
194 }
195 pub fn post_dispatch<F>(mut self, f: F) -> Self
196 where
197 F: Fn(
198 &ArgMatches,
199 &CommandContext,
200 serde_json::Value,
201 ) -> Result<serde_json::Value, HookError>
202 + 'static,
203 {
204 self.post_dispatch.push(Rc::new(f));
205 self
206 }
207 pub fn post_output<F>(mut self, f: F) -> Self
208 where
209 F: Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>
210 + 'static,
211 {
212 self.post_output.push(Rc::new(f));
213 self
214 }
215 pub fn run_pre_dispatch(
216 &self,
217 matches: &ArgMatches,
218 ctx: &mut CommandContext,
219 ) -> Result<(), HookError> {
220 for hook in &self.pre_dispatch {
221 hook(matches, ctx)?;
222 }
223 Ok(())
224 }
225 pub fn run_post_dispatch(
226 &self,
227 matches: &ArgMatches,
228 ctx: &CommandContext,
229 data: serde_json::Value,
230 ) -> Result<serde_json::Value, HookError> {
231 let mut current = data;
232 for hook in &self.post_dispatch {
233 current = hook(matches, ctx, current)?;
234 }
235 Ok(current)
236 }
237 pub fn run_post_output(
238 &self,
239 matches: &ArgMatches,
240 ctx: &CommandContext,
241 output: RenderedOutput,
242 ) -> Result<RenderedOutput, HookError> {
243 let mut current = output;
244 for hook in &self.post_output {
245 current = hook(matches, ctx, current)?;
246 }
247 Ok(current)
248 }
249}
250impl fmt::Debug for Hooks {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 f.debug_struct("Hooks")
253 .field("pre_dispatch_count", &self.pre_dispatch.len())
254 .field("post_dispatch_count", &self.post_dispatch.len())
255 .field("post_output_count", &self.post_output.len())
256 .finish()
257 }
258}
259#[cfg(test)]
260mod tests {
261 use super::*;
262 fn test_context() -> CommandContext {
263 CommandContext {
264 command_path: vec!["test".into()],
265 ..Default::default()
266 }
267 }
268 fn test_matches() -> ArgMatches {
269 clap::Command::new("test").get_matches_from(vec!["test"])
270 }
271 #[test]
272 fn test_rendered_output_variants() {
273 let text = RenderedOutput::Text(TextOutput::new("formatted".into(), "raw".into()));
274 assert!(text.is_text());
275 assert!(!text.is_binary());
276 assert!(!text.is_silent());
277 assert_eq!(text.as_text(), Some("formatted"));
278 assert_eq!(text.as_raw_text(), Some("raw"));
279 let plain = RenderedOutput::Text(TextOutput::plain("hello".into()));
280 assert_eq!(plain.as_text(), Some("hello"));
281 assert_eq!(plain.as_raw_text(), Some("hello"));
282 let binary = RenderedOutput::Binary(vec![1, 2, 3], "file.bin".into());
283 assert!(!binary.is_text());
284 assert!(binary.is_binary());
285 assert_eq!(binary.as_binary(), Some((&[1u8, 2, 3][..], "file.bin")));
286 let silent = RenderedOutput::Silent;
287 assert!(silent.is_silent());
288 }
289 #[test]
290 fn test_hook_error_creation() {
291 let err = HookError::pre_dispatch("test error");
292 assert_eq!(err.phase, HookPhase::PreDispatch);
293 assert_eq!(err.message, "test error");
294 }
295 #[test]
296 fn test_hooks_empty() {
297 let hooks = Hooks::new();
298 assert!(hooks.is_empty());
299 }
300 #[test]
301 fn test_hooks_report_registered_phases() {
302 let hooks = Hooks::new()
303 .pre_dispatch(|_, _| Ok(()))
304 .post_output(|_, _, output| Ok(output));
305 let phases: Vec<_> = hooks.phases().collect();
306 assert_eq!(phases, vec![HookPhase::PreDispatch, HookPhase::PostOutput]);
307 assert!(hooks.has_phase(HookPhase::PreDispatch));
308 assert!(!hooks.has_phase(HookPhase::PostDispatch));
309 }
310 #[test]
311 fn test_hooks_append_preserves_phase_order() {
312 use std::cell::RefCell;
313 let calls = Rc::new(RefCell::new(Vec::new()));
314 let first_calls = calls.clone();
315 let second_calls = calls.clone();
316 let hooks = Hooks::new()
317 .pre_dispatch(move |_, _| {
318 first_calls.borrow_mut().push("first");
319 Ok(())
320 })
321 .append(Hooks::new().pre_dispatch(move |_, _| {
322 second_calls.borrow_mut().push("second");
323 Ok(())
324 }));
325 let mut ctx = test_context();
326 let matches = test_matches();
327 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
328 assert_eq!(&*calls.borrow(), &["first", "second"]);
329 }
330 #[test]
331 fn test_pre_dispatch_success() {
332 use std::cell::Cell;
333 use std::rc::Rc;
334 let called = Rc::new(Cell::new(false));
335 let called_clone = called.clone();
336 let hooks = Hooks::new().pre_dispatch(move |_, _| {
337 called_clone.set(true);
338 Ok(())
339 });
340 let mut ctx = test_context();
341 let matches = test_matches();
342 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
343 assert!(result.is_ok());
344 assert!(called.get());
345 }
346 #[test]
347 fn test_pre_dispatch_error_aborts() {
348 let hooks = Hooks::new()
349 .pre_dispatch(|_, _| Err(HookError::pre_dispatch("first fails")))
350 .pre_dispatch(|_, _| panic!("should not be called"));
351 let mut ctx = test_context();
352 let matches = test_matches();
353 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
354 assert!(result.is_err());
355 }
356 #[test]
357 fn test_pre_dispatch_injects_extensions() {
358 struct TestState {
359 value: i32,
360 }
361 let hooks = Hooks::new().pre_dispatch(|_, ctx| {
362 ctx.extensions.insert(TestState { value: 42 });
363 Ok(())
364 });
365 let mut ctx = test_context();
366 let matches = test_matches();
367 assert!(!ctx.extensions.contains::<TestState>());
368 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
369 let state = ctx.extensions.get::<TestState>().unwrap();
370 assert_eq!(state.value, 42);
371 }
372 #[test]
373 fn test_pre_dispatch_multiple_hooks_share_context() {
374 struct Counter {
375 count: i32,
376 }
377 let hooks = Hooks::new()
378 .pre_dispatch(|_, ctx| {
379 ctx.extensions.insert(Counter { count: 1 });
380 Ok(())
381 })
382 .pre_dispatch(|_, ctx| {
383 if let Some(counter) = ctx.extensions.get_mut::<Counter>() {
384 counter.count += 10;
385 }
386 Ok(())
387 });
388 let mut ctx = test_context();
389 let matches = test_matches();
390 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
391 let counter = ctx.extensions.get::<Counter>().unwrap();
392 assert_eq!(counter.count, 11);
393 }
394 #[test]
395 fn test_post_dispatch_transformation() {
396 use serde_json::json;
397 let hooks = Hooks::new().post_dispatch(|_, _, mut data| {
398 if let Some(obj) = data.as_object_mut() {
399 obj.insert("modified".into(), json!(true));
400 }
401 Ok(data)
402 });
403 let ctx = test_context();
404 let matches = test_matches();
405 let data = json!({"value": 42});
406 let result = hooks.run_post_dispatch(&matches, &ctx, data);
407 assert!(result.is_ok());
408 let output = result.unwrap();
409 assert_eq!(output["value"], 42);
410 assert_eq!(output["modified"], true);
411 }
412 #[test]
413 fn test_post_output_transformation() {
414 let hooks = Hooks::new().post_output(|_, _, output| {
415 if let RenderedOutput::Text(text_output) = output {
416 Ok(RenderedOutput::Text(TextOutput::new(
417 text_output.formatted.to_uppercase(),
418 text_output.raw.to_uppercase(),
419 )))
420 } else {
421 Ok(output)
422 }
423 });
424 let ctx = test_context();
425 let matches = test_matches();
426 let input = RenderedOutput::Text(TextOutput::plain("hello".into()));
427 let result = hooks.run_post_output(&matches, &ctx, input);
428 assert!(result.is_ok());
429 assert_eq!(result.unwrap().as_text(), Some("HELLO"));
430 }
431}