1use std::fmt;
33use std::rc::Rc;
34use thiserror::Error;
35
36use crate::handler::{CommandContext, ExternalFailure};
37use clap::ArgMatches;
38
39#[derive(Debug, Clone)]
45pub struct TextOutput {
46 pub formatted: String,
48 pub raw: String,
52}
53
54impl TextOutput {
55 pub fn new(formatted: String, raw: String) -> Self {
57 Self { formatted, raw }
58 }
59
60 pub fn plain(text: String) -> Self {
64 Self {
65 formatted: text.clone(),
66 raw: text,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
75pub enum RenderedOutput {
76 Text(TextOutput),
80 Binary(Vec<u8>, String),
82 Silent,
84}
85
86impl RenderedOutput {
87 pub fn is_text(&self) -> bool {
89 matches!(self, RenderedOutput::Text(_))
90 }
91
92 pub fn is_binary(&self) -> bool {
94 matches!(self, RenderedOutput::Binary(_, _))
95 }
96
97 pub fn is_silent(&self) -> bool {
99 matches!(self, RenderedOutput::Silent)
100 }
101
102 pub fn as_text(&self) -> Option<&str> {
104 match self {
105 RenderedOutput::Text(t) => Some(&t.formatted),
106 _ => None,
107 }
108 }
109
110 pub fn as_raw_text(&self) -> Option<&str> {
113 match self {
114 RenderedOutput::Text(t) => Some(&t.raw),
115 _ => None,
116 }
117 }
118
119 pub fn as_text_output(&self) -> Option<&TextOutput> {
121 match self {
122 RenderedOutput::Text(t) => Some(t),
123 _ => None,
124 }
125 }
126
127 pub fn as_binary(&self) -> Option<(&[u8], &str)> {
129 match self {
130 RenderedOutput::Binary(bytes, filename) => Some((bytes, filename)),
131 _ => None,
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub enum HookPhase {
139 PreDispatch,
141 PostDispatch,
143 PostOutput,
145}
146
147impl fmt::Display for HookPhase {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 HookPhase::PreDispatch => write!(f, "pre-dispatch"),
151 HookPhase::PostDispatch => write!(f, "post-dispatch"),
152 HookPhase::PostOutput => write!(f, "post-output"),
153 }
154 }
155}
156
157#[derive(Debug, Error)]
159#[error("hook error ({phase}): {message}")]
160pub struct HookError {
161 pub message: String,
163 pub phase: HookPhase,
165 #[source]
167 pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
168}
169
170impl HookError {
171 pub fn pre_dispatch(message: impl Into<String>) -> Self {
173 Self {
174 message: message.into(),
175 phase: HookPhase::PreDispatch,
176 source: None,
177 }
178 }
179
180 pub fn pre_dispatch_external(failure: ExternalFailure) -> Self {
186 Self {
187 message: failure.diagnostic().to_owned(),
188 phase: HookPhase::PreDispatch,
189 source: Some(Box::new(failure)),
190 }
191 }
192
193 pub fn post_dispatch(message: impl Into<String>) -> Self {
195 Self {
196 message: message.into(),
197 phase: HookPhase::PostDispatch,
198 source: None,
199 }
200 }
201
202 pub fn post_output(message: impl Into<String>) -> Self {
204 Self {
205 message: message.into(),
206 phase: HookPhase::PostOutput,
207 source: None,
208 }
209 }
210
211 pub fn with_source<E>(mut self, source: E) -> Self
213 where
214 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
215 {
216 self.source = Some(source.into());
217 self
218 }
219}
220
221pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
226
227pub type PostDispatchFn = Rc<
229 dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
230>;
231
232pub type PostOutputFn =
234 Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
235
236#[derive(Clone, Default)]
240pub struct Hooks {
241 pre_dispatch: Vec<PreDispatchFn>,
242 post_dispatch: Vec<PostDispatchFn>,
243 post_output: Vec<PostOutputFn>,
244}
245
246impl Hooks {
247 pub fn new() -> Self {
249 Self::default()
250 }
251
252 pub fn is_empty(&self) -> bool {
254 self.pre_dispatch.is_empty() && self.post_dispatch.is_empty() && self.post_output.is_empty()
255 }
256
257 pub fn pre_dispatch<F>(mut self, f: F) -> Self
278 where
279 F: Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError> + 'static,
280 {
281 self.pre_dispatch.push(Rc::new(f));
282 self
283 }
284
285 pub fn post_dispatch<F>(mut self, f: F) -> Self
287 where
288 F: Fn(
289 &ArgMatches,
290 &CommandContext,
291 serde_json::Value,
292 ) -> Result<serde_json::Value, HookError>
293 + 'static,
294 {
295 self.post_dispatch.push(Rc::new(f));
296 self
297 }
298
299 pub fn post_output<F>(mut self, f: F) -> Self
301 where
302 F: Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>
303 + 'static,
304 {
305 self.post_output.push(Rc::new(f));
306 self
307 }
308
309 pub fn run_pre_dispatch(
313 &self,
314 matches: &ArgMatches,
315 ctx: &mut CommandContext,
316 ) -> Result<(), HookError> {
317 for hook in &self.pre_dispatch {
318 hook(matches, ctx)?;
319 }
320 Ok(())
321 }
322
323 pub fn run_post_dispatch(
325 &self,
326 matches: &ArgMatches,
327 ctx: &CommandContext,
328 data: serde_json::Value,
329 ) -> Result<serde_json::Value, HookError> {
330 let mut current = data;
331 for hook in &self.post_dispatch {
332 current = hook(matches, ctx, current)?;
333 }
334 Ok(current)
335 }
336
337 pub fn run_post_output(
339 &self,
340 matches: &ArgMatches,
341 ctx: &CommandContext,
342 output: RenderedOutput,
343 ) -> Result<RenderedOutput, HookError> {
344 let mut current = output;
345 for hook in &self.post_output {
346 current = hook(matches, ctx, current)?;
347 }
348 Ok(current)
349 }
350}
351
352impl fmt::Debug for Hooks {
353 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354 f.debug_struct("Hooks")
355 .field("pre_dispatch_count", &self.pre_dispatch.len())
356 .field("post_dispatch_count", &self.post_dispatch.len())
357 .field("post_output_count", &self.post_output.len())
358 .finish()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn test_context() -> CommandContext {
367 CommandContext {
368 command_path: vec!["test".into()],
369 ..Default::default()
370 }
371 }
372
373 fn test_matches() -> ArgMatches {
374 clap::Command::new("test").get_matches_from(vec!["test"])
375 }
376
377 #[test]
378 fn test_rendered_output_variants() {
379 let text = RenderedOutput::Text(TextOutput::new("formatted".into(), "raw".into()));
380 assert!(text.is_text());
381 assert!(!text.is_binary());
382 assert!(!text.is_silent());
383 assert_eq!(text.as_text(), Some("formatted"));
384 assert_eq!(text.as_raw_text(), Some("raw"));
385
386 let plain = RenderedOutput::Text(TextOutput::plain("hello".into()));
388 assert_eq!(plain.as_text(), Some("hello"));
389 assert_eq!(plain.as_raw_text(), Some("hello"));
390
391 let binary = RenderedOutput::Binary(vec![1, 2, 3], "file.bin".into());
392 assert!(!binary.is_text());
393 assert!(binary.is_binary());
394 assert_eq!(binary.as_binary(), Some((&[1u8, 2, 3][..], "file.bin")));
395
396 let silent = RenderedOutput::Silent;
397 assert!(silent.is_silent());
398 }
399
400 #[test]
401 fn test_hook_error_creation() {
402 let err = HookError::pre_dispatch("test error");
403 assert_eq!(err.phase, HookPhase::PreDispatch);
404 assert_eq!(err.message, "test error");
405 }
406
407 #[test]
408 fn test_hooks_empty() {
409 let hooks = Hooks::new();
410 assert!(hooks.is_empty());
411 }
412
413 #[test]
414 fn test_pre_dispatch_success() {
415 use std::cell::Cell;
416 use std::rc::Rc;
417
418 let called = Rc::new(Cell::new(false));
419 let called_clone = called.clone();
420
421 let hooks = Hooks::new().pre_dispatch(move |_, _| {
422 called_clone.set(true);
423 Ok(())
424 });
425
426 let mut ctx = test_context();
427 let matches = test_matches();
428 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
429
430 assert!(result.is_ok());
431 assert!(called.get());
432 }
433
434 #[test]
435 fn test_pre_dispatch_error_aborts() {
436 let hooks = Hooks::new()
437 .pre_dispatch(|_, _| Err(HookError::pre_dispatch("first fails")))
438 .pre_dispatch(|_, _| panic!("should not be called"));
439
440 let mut ctx = test_context();
441 let matches = test_matches();
442 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
443
444 assert!(result.is_err());
445 }
446
447 #[test]
448 fn test_pre_dispatch_injects_extensions() {
449 struct TestState {
450 value: i32,
451 }
452
453 let hooks = Hooks::new().pre_dispatch(|_, ctx| {
454 ctx.extensions.insert(TestState { value: 42 });
455 Ok(())
456 });
457
458 let mut ctx = test_context();
459 let matches = test_matches();
460
461 assert!(!ctx.extensions.contains::<TestState>());
463
464 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
465
466 let state = ctx.extensions.get::<TestState>().unwrap();
468 assert_eq!(state.value, 42);
469 }
470
471 #[test]
472 fn test_pre_dispatch_multiple_hooks_share_context() {
473 struct Counter {
474 count: i32,
475 }
476
477 let hooks = Hooks::new()
478 .pre_dispatch(|_, ctx| {
479 ctx.extensions.insert(Counter { count: 1 });
480 Ok(())
481 })
482 .pre_dispatch(|_, ctx| {
483 if let Some(counter) = ctx.extensions.get_mut::<Counter>() {
485 counter.count += 10;
486 }
487 Ok(())
488 });
489
490 let mut ctx = test_context();
491 let matches = test_matches();
492 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
493
494 let counter = ctx.extensions.get::<Counter>().unwrap();
495 assert_eq!(counter.count, 11);
496 }
497
498 #[test]
499 fn test_post_dispatch_transformation() {
500 use serde_json::json;
501
502 let hooks = Hooks::new().post_dispatch(|_, _, mut data| {
503 if let Some(obj) = data.as_object_mut() {
504 obj.insert("modified".into(), json!(true));
505 }
506 Ok(data)
507 });
508
509 let ctx = test_context();
510 let matches = test_matches();
511 let data = json!({"value": 42});
512 let result = hooks.run_post_dispatch(&matches, &ctx, data);
513
514 assert!(result.is_ok());
515 let output = result.unwrap();
516 assert_eq!(output["value"], 42);
517 assert_eq!(output["modified"], true);
518 }
519
520 #[test]
521 fn test_post_output_transformation() {
522 let hooks = Hooks::new().post_output(|_, _, output| {
523 if let RenderedOutput::Text(text_output) = output {
524 Ok(RenderedOutput::Text(TextOutput::new(
525 text_output.formatted.to_uppercase(),
526 text_output.raw.to_uppercase(),
527 )))
528 } else {
529 Ok(output)
530 }
531 });
532
533 let ctx = test_context();
534 let matches = test_matches();
535 let input = RenderedOutput::Text(TextOutput::plain("hello".into()));
536 let result = hooks.run_post_output(&matches, &ctx, input);
537
538 assert!(result.is_ok());
539 assert_eq!(result.unwrap().as_text(), Some("HELLO"));
540 }
541}