Skip to main content

standout_dispatch/
hooks.rs

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}