1use std::fmt;
33use std::path::PathBuf;
34use std::rc::Rc;
35use thiserror::Error;
36
37use crate::handler::{CommandContext, ExternalFailure};
38use clap::ArgMatches;
39
40#[derive(Debug, Clone)]
46pub struct TextOutput {
47 pub formatted: String,
49 pub raw: String,
53}
54
55impl TextOutput {
56 pub fn new(formatted: String, raw: String) -> Self {
58 Self { formatted, raw }
59 }
60
61 pub fn plain(text: String) -> Self {
65 Self {
66 formatted: text.clone(),
67 raw: text,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
82pub struct ArtifactOutput {
83 pub bytes: Vec<u8>,
85 pub suggested_destination: Option<PathBuf>,
88 pub stdout_allowed: bool,
90 pub report: Option<serde_json::Value>,
92}
93
94#[derive(Debug, Clone)]
98pub enum RenderedOutput {
99 Text(TextOutput),
103 Binary(Vec<u8>, String),
105 Artifact(ArtifactOutput),
107 Silent,
109}
110
111impl RenderedOutput {
112 pub fn is_text(&self) -> bool {
114 matches!(self, RenderedOutput::Text(_))
115 }
116
117 pub fn is_binary(&self) -> bool {
119 matches!(self, RenderedOutput::Binary(_, _))
120 }
121
122 pub fn is_artifact(&self) -> bool {
124 matches!(self, RenderedOutput::Artifact(_))
125 }
126
127 pub fn is_silent(&self) -> bool {
129 matches!(self, RenderedOutput::Silent)
130 }
131
132 pub fn as_text(&self) -> Option<&str> {
134 match self {
135 RenderedOutput::Text(t) => Some(&t.formatted),
136 _ => None,
137 }
138 }
139
140 pub fn as_raw_text(&self) -> Option<&str> {
143 match self {
144 RenderedOutput::Text(t) => Some(&t.raw),
145 _ => None,
146 }
147 }
148
149 pub fn as_text_output(&self) -> Option<&TextOutput> {
151 match self {
152 RenderedOutput::Text(t) => Some(t),
153 _ => None,
154 }
155 }
156
157 pub fn as_binary(&self) -> Option<(&[u8], &str)> {
159 match self {
160 RenderedOutput::Binary(bytes, filename) => Some((bytes, filename)),
161 _ => None,
162 }
163 }
164
165 pub fn as_artifact(&self) -> Option<&ArtifactOutput> {
167 match self {
168 RenderedOutput::Artifact(artifact) => Some(artifact),
169 _ => None,
170 }
171 }
172
173 pub fn as_artifact_mut(&mut self) -> Option<&mut ArtifactOutput> {
176 match self {
177 RenderedOutput::Artifact(artifact) => Some(artifact),
178 _ => None,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185pub enum HookPhase {
186 PreDispatch,
188 PostDispatch,
190 PostOutput,
192}
193
194impl fmt::Display for HookPhase {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 match self {
197 HookPhase::PreDispatch => write!(f, "pre-dispatch"),
198 HookPhase::PostDispatch => write!(f, "post-dispatch"),
199 HookPhase::PostOutput => write!(f, "post-output"),
200 }
201 }
202}
203
204#[derive(Debug, Error)]
206#[error("hook error ({phase}): {message}")]
207pub struct HookError {
208 pub message: String,
210 pub phase: HookPhase,
212 #[source]
214 pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
215}
216
217impl HookError {
218 pub fn pre_dispatch(message: impl Into<String>) -> Self {
220 Self {
221 message: message.into(),
222 phase: HookPhase::PreDispatch,
223 source: None,
224 }
225 }
226
227 pub fn pre_dispatch_external(failure: ExternalFailure) -> Self {
233 Self {
234 message: failure.diagnostic().to_owned(),
235 phase: HookPhase::PreDispatch,
236 source: Some(Box::new(failure)),
237 }
238 }
239
240 pub fn post_dispatch(message: impl Into<String>) -> Self {
242 Self {
243 message: message.into(),
244 phase: HookPhase::PostDispatch,
245 source: None,
246 }
247 }
248
249 pub fn post_output(message: impl Into<String>) -> Self {
251 Self {
252 message: message.into(),
253 phase: HookPhase::PostOutput,
254 source: None,
255 }
256 }
257
258 pub fn with_source<E>(mut self, source: E) -> Self
260 where
261 E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
262 {
263 self.source = Some(source.into());
264 self
265 }
266}
267
268pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
273
274pub type PostDispatchFn = Rc<
276 dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
277>;
278
279pub type PostOutputFn =
281 Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
282
283#[derive(Clone, Default)]
287pub struct Hooks {
288 pre_dispatch: Vec<PreDispatchFn>,
289 post_dispatch: Vec<PostDispatchFn>,
290 post_output: Vec<PostOutputFn>,
291}
292
293impl Hooks {
294 pub fn new() -> Self {
296 Self::default()
297 }
298
299 pub fn is_empty(&self) -> bool {
301 self.pre_dispatch.is_empty() && self.post_dispatch.is_empty() && self.post_output.is_empty()
302 }
303
304 pub fn pre_dispatch<F>(mut self, f: F) -> Self
325 where
326 F: Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError> + 'static,
327 {
328 self.pre_dispatch.push(Rc::new(f));
329 self
330 }
331
332 pub fn post_dispatch<F>(mut self, f: F) -> Self
334 where
335 F: Fn(
336 &ArgMatches,
337 &CommandContext,
338 serde_json::Value,
339 ) -> Result<serde_json::Value, HookError>
340 + 'static,
341 {
342 self.post_dispatch.push(Rc::new(f));
343 self
344 }
345
346 pub fn post_output<F>(mut self, f: F) -> Self
348 where
349 F: Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>
350 + 'static,
351 {
352 self.post_output.push(Rc::new(f));
353 self
354 }
355
356 pub fn run_pre_dispatch(
360 &self,
361 matches: &ArgMatches,
362 ctx: &mut CommandContext,
363 ) -> Result<(), HookError> {
364 for hook in &self.pre_dispatch {
365 hook(matches, ctx)?;
366 }
367 Ok(())
368 }
369
370 pub fn run_post_dispatch(
372 &self,
373 matches: &ArgMatches,
374 ctx: &CommandContext,
375 data: serde_json::Value,
376 ) -> Result<serde_json::Value, HookError> {
377 let mut current = data;
378 for hook in &self.post_dispatch {
379 current = hook(matches, ctx, current)?;
380 }
381 Ok(current)
382 }
383
384 pub fn run_post_output(
386 &self,
387 matches: &ArgMatches,
388 ctx: &CommandContext,
389 output: RenderedOutput,
390 ) -> Result<RenderedOutput, HookError> {
391 let mut current = output;
392 for hook in &self.post_output {
393 current = hook(matches, ctx, current)?;
394 }
395 Ok(current)
396 }
397}
398
399impl fmt::Debug for Hooks {
400 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401 f.debug_struct("Hooks")
402 .field("pre_dispatch_count", &self.pre_dispatch.len())
403 .field("post_dispatch_count", &self.post_dispatch.len())
404 .field("post_output_count", &self.post_output.len())
405 .finish()
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn test_context() -> CommandContext {
414 CommandContext {
415 command_path: vec!["test".into()],
416 ..Default::default()
417 }
418 }
419
420 fn test_matches() -> ArgMatches {
421 clap::Command::new("test").get_matches_from(vec!["test"])
422 }
423
424 #[test]
425 fn test_rendered_output_variants() {
426 let text = RenderedOutput::Text(TextOutput::new("formatted".into(), "raw".into()));
427 assert!(text.is_text());
428 assert!(!text.is_binary());
429 assert!(!text.is_silent());
430 assert_eq!(text.as_text(), Some("formatted"));
431 assert_eq!(text.as_raw_text(), Some("raw"));
432
433 let plain = RenderedOutput::Text(TextOutput::plain("hello".into()));
435 assert_eq!(plain.as_text(), Some("hello"));
436 assert_eq!(plain.as_raw_text(), Some("hello"));
437
438 let binary = RenderedOutput::Binary(vec![1, 2, 3], "file.bin".into());
439 assert!(!binary.is_text());
440 assert!(binary.is_binary());
441 assert_eq!(binary.as_binary(), Some((&[1u8, 2, 3][..], "file.bin")));
442
443 let silent = RenderedOutput::Silent;
444 assert!(silent.is_silent());
445 }
446
447 #[test]
448 fn test_hook_error_creation() {
449 let err = HookError::pre_dispatch("test error");
450 assert_eq!(err.phase, HookPhase::PreDispatch);
451 assert_eq!(err.message, "test error");
452 }
453
454 #[test]
455 fn test_hooks_empty() {
456 let hooks = Hooks::new();
457 assert!(hooks.is_empty());
458 }
459
460 #[test]
461 fn test_pre_dispatch_success() {
462 use std::cell::Cell;
463 use std::rc::Rc;
464
465 let called = Rc::new(Cell::new(false));
466 let called_clone = called.clone();
467
468 let hooks = Hooks::new().pre_dispatch(move |_, _| {
469 called_clone.set(true);
470 Ok(())
471 });
472
473 let mut ctx = test_context();
474 let matches = test_matches();
475 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
476
477 assert!(result.is_ok());
478 assert!(called.get());
479 }
480
481 #[test]
482 fn test_pre_dispatch_error_aborts() {
483 let hooks = Hooks::new()
484 .pre_dispatch(|_, _| Err(HookError::pre_dispatch("first fails")))
485 .pre_dispatch(|_, _| panic!("should not be called"));
486
487 let mut ctx = test_context();
488 let matches = test_matches();
489 let result = hooks.run_pre_dispatch(&matches, &mut ctx);
490
491 assert!(result.is_err());
492 }
493
494 #[test]
495 fn test_pre_dispatch_injects_extensions() {
496 struct TestState {
497 value: i32,
498 }
499
500 let hooks = Hooks::new().pre_dispatch(|_, ctx| {
501 ctx.extensions.insert(TestState { value: 42 });
502 Ok(())
503 });
504
505 let mut ctx = test_context();
506 let matches = test_matches();
507
508 assert!(!ctx.extensions.contains::<TestState>());
510
511 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
512
513 let state = ctx.extensions.get::<TestState>().unwrap();
515 assert_eq!(state.value, 42);
516 }
517
518 #[test]
519 fn test_pre_dispatch_multiple_hooks_share_context() {
520 struct Counter {
521 count: i32,
522 }
523
524 let hooks = Hooks::new()
525 .pre_dispatch(|_, ctx| {
526 ctx.extensions.insert(Counter { count: 1 });
527 Ok(())
528 })
529 .pre_dispatch(|_, ctx| {
530 if let Some(counter) = ctx.extensions.get_mut::<Counter>() {
532 counter.count += 10;
533 }
534 Ok(())
535 });
536
537 let mut ctx = test_context();
538 let matches = test_matches();
539 hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
540
541 let counter = ctx.extensions.get::<Counter>().unwrap();
542 assert_eq!(counter.count, 11);
543 }
544
545 #[test]
546 fn test_post_dispatch_transformation() {
547 use serde_json::json;
548
549 let hooks = Hooks::new().post_dispatch(|_, _, mut data| {
550 if let Some(obj) = data.as_object_mut() {
551 obj.insert("modified".into(), json!(true));
552 }
553 Ok(data)
554 });
555
556 let ctx = test_context();
557 let matches = test_matches();
558 let data = json!({"value": 42});
559 let result = hooks.run_post_dispatch(&matches, &ctx, data);
560
561 assert!(result.is_ok());
562 let output = result.unwrap();
563 assert_eq!(output["value"], 42);
564 assert_eq!(output["modified"], true);
565 }
566
567 #[test]
568 fn test_post_output_transformation() {
569 let hooks = Hooks::new().post_output(|_, _, output| {
570 if let RenderedOutput::Text(text_output) = output {
571 Ok(RenderedOutput::Text(TextOutput::new(
572 text_output.formatted.to_uppercase(),
573 text_output.raw.to_uppercase(),
574 )))
575 } else {
576 Ok(output)
577 }
578 });
579
580 let ctx = test_context();
581 let matches = test_matches();
582 let input = RenderedOutput::Text(TextOutput::plain("hello".into()));
583 let result = hooks.run_post_output(&matches, &ctx, input);
584
585 assert!(result.is_ok());
586 assert_eq!(result.unwrap().as_text(), Some("HELLO"));
587 }
588}