Skip to main content

standout_dispatch/
hooks.rs

1use crate::handler::{AppFailure, 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 pre_dispatch_app(failure: AppFailure) -> Self {
126        Self {
127            message: failure.diagnostic().to_owned(),
128            phase: HookPhase::PreDispatch,
129            source: Some(Box::new(failure)),
130        }
131    }
132    pub fn post_dispatch(message: impl Into<String>) -> Self {
133        Self {
134            message: message.into(),
135            phase: HookPhase::PostDispatch,
136            source: None,
137        }
138    }
139    pub fn post_output(message: impl Into<String>) -> Self {
140        Self {
141            message: message.into(),
142            phase: HookPhase::PostOutput,
143            source: None,
144        }
145    }
146    pub fn with_source<E>(mut self, source: E) -> Self
147    where
148        E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
149    {
150        self.source = Some(source.into());
151        self
152    }
153}
154pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
155pub type PostDispatchFn = Rc<
156    dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
157>;
158pub type PostOutputFn =
159    Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
160#[derive(Clone, Default)]
161pub struct Hooks {
162    pre_dispatch: Vec<PreDispatchFn>,
163    post_dispatch: Vec<PostDispatchFn>,
164    post_output: Vec<PostOutputFn>,
165}
166impl Hooks {
167    pub fn new() -> Self {
168        Self::default()
169    }
170    pub fn is_empty(&self) -> bool {
171        self.pre_dispatch.is_empty() && self.post_dispatch.is_empty() && self.post_output.is_empty()
172    }
173    pub fn has_phase(&self, phase: HookPhase) -> bool {
174        match phase {
175            HookPhase::PreDispatch => !self.pre_dispatch.is_empty(),
176            HookPhase::PostDispatch => !self.post_dispatch.is_empty(),
177            HookPhase::PostOutput => !self.post_output.is_empty(),
178        }
179    }
180    pub fn phases(&self) -> impl Iterator<Item = HookPhase> + '_ {
181        [
182            HookPhase::PreDispatch,
183            HookPhase::PostDispatch,
184            HookPhase::PostOutput,
185        ]
186        .into_iter()
187        .filter(|phase| self.has_phase(*phase))
188    }
189    pub fn append(mut self, mut other: Hooks) -> Self {
190        self.pre_dispatch.append(&mut other.pre_dispatch);
191        self.post_dispatch.append(&mut other.post_dispatch);
192        self.post_output.append(&mut other.post_output);
193        self
194    }
195    pub fn pre_dispatch<F>(mut self, f: F) -> Self
196    where
197        F: Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError> + 'static,
198    {
199        self.pre_dispatch.push(Rc::new(f));
200        self
201    }
202    pub fn post_dispatch<F>(mut self, f: F) -> Self
203    where
204        F: Fn(
205                &ArgMatches,
206                &CommandContext,
207                serde_json::Value,
208            ) -> Result<serde_json::Value, HookError>
209            + 'static,
210    {
211        self.post_dispatch.push(Rc::new(f));
212        self
213    }
214    pub fn post_output<F>(mut self, f: F) -> Self
215    where
216        F: Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>
217            + 'static,
218    {
219        self.post_output.push(Rc::new(f));
220        self
221    }
222    pub fn run_pre_dispatch(
223        &self,
224        matches: &ArgMatches,
225        ctx: &mut CommandContext,
226    ) -> Result<(), HookError> {
227        for hook in &self.pre_dispatch {
228            hook(matches, ctx)?;
229        }
230        Ok(())
231    }
232    pub fn run_post_dispatch(
233        &self,
234        matches: &ArgMatches,
235        ctx: &CommandContext,
236        data: serde_json::Value,
237    ) -> Result<serde_json::Value, HookError> {
238        let mut current = data;
239        for hook in &self.post_dispatch {
240            current = hook(matches, ctx, current)?;
241        }
242        Ok(current)
243    }
244    pub fn run_post_output(
245        &self,
246        matches: &ArgMatches,
247        ctx: &CommandContext,
248        output: RenderedOutput,
249    ) -> Result<RenderedOutput, HookError> {
250        let mut current = output;
251        for hook in &self.post_output {
252            current = hook(matches, ctx, current)?;
253        }
254        Ok(current)
255    }
256}
257impl fmt::Debug for Hooks {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("Hooks")
260            .field("pre_dispatch_count", &self.pre_dispatch.len())
261            .field("post_dispatch_count", &self.post_dispatch.len())
262            .field("post_output_count", &self.post_output.len())
263            .finish()
264    }
265}
266#[cfg(test)]
267mod tests {
268    use super::*;
269    fn test_context() -> CommandContext {
270        CommandContext {
271            command_path: vec!["test".into()],
272            ..Default::default()
273        }
274    }
275    fn test_matches() -> ArgMatches {
276        clap::Command::new("test").get_matches_from(vec!["test"])
277    }
278    #[test]
279    fn test_rendered_output_variants() {
280        let text = RenderedOutput::Text(TextOutput::new("formatted".into(), "raw".into()));
281        assert!(text.is_text());
282        assert!(!text.is_binary());
283        assert!(!text.is_silent());
284        assert_eq!(text.as_text(), Some("formatted"));
285        assert_eq!(text.as_raw_text(), Some("raw"));
286        let plain = RenderedOutput::Text(TextOutput::plain("hello".into()));
287        assert_eq!(plain.as_text(), Some("hello"));
288        assert_eq!(plain.as_raw_text(), Some("hello"));
289        let binary = RenderedOutput::Binary(vec![1, 2, 3], "file.bin".into());
290        assert!(!binary.is_text());
291        assert!(binary.is_binary());
292        assert_eq!(binary.as_binary(), Some((&[1u8, 2, 3][..], "file.bin")));
293        let silent = RenderedOutput::Silent;
294        assert!(silent.is_silent());
295    }
296    #[test]
297    fn test_hook_error_creation() {
298        let err = HookError::pre_dispatch("test error");
299        assert_eq!(err.phase, HookPhase::PreDispatch);
300        assert_eq!(err.message, "test error");
301    }
302    #[test]
303    fn test_hooks_empty() {
304        let hooks = Hooks::new();
305        assert!(hooks.is_empty());
306    }
307    #[test]
308    fn test_hooks_report_registered_phases() {
309        let hooks = Hooks::new()
310            .pre_dispatch(|_, _| Ok(()))
311            .post_output(|_, _, output| Ok(output));
312        let phases: Vec<_> = hooks.phases().collect();
313        assert_eq!(phases, vec![HookPhase::PreDispatch, HookPhase::PostOutput]);
314        assert!(hooks.has_phase(HookPhase::PreDispatch));
315        assert!(!hooks.has_phase(HookPhase::PostDispatch));
316    }
317    #[test]
318    fn test_hooks_append_preserves_phase_order() {
319        use std::cell::RefCell;
320        let calls = Rc::new(RefCell::new(Vec::new()));
321        let first_calls = calls.clone();
322        let second_calls = calls.clone();
323        let hooks = Hooks::new()
324            .pre_dispatch(move |_, _| {
325                first_calls.borrow_mut().push("first");
326                Ok(())
327            })
328            .append(Hooks::new().pre_dispatch(move |_, _| {
329                second_calls.borrow_mut().push("second");
330                Ok(())
331            }));
332        let mut ctx = test_context();
333        let matches = test_matches();
334        hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
335        assert_eq!(&*calls.borrow(), &["first", "second"]);
336    }
337    #[test]
338    fn test_pre_dispatch_success() {
339        use std::cell::Cell;
340        use std::rc::Rc;
341        let called = Rc::new(Cell::new(false));
342        let called_clone = called.clone();
343        let hooks = Hooks::new().pre_dispatch(move |_, _| {
344            called_clone.set(true);
345            Ok(())
346        });
347        let mut ctx = test_context();
348        let matches = test_matches();
349        let result = hooks.run_pre_dispatch(&matches, &mut ctx);
350        assert!(result.is_ok());
351        assert!(called.get());
352    }
353    #[test]
354    fn test_pre_dispatch_error_aborts() {
355        let hooks = Hooks::new()
356            .pre_dispatch(|_, _| Err(HookError::pre_dispatch("first fails")))
357            .pre_dispatch(|_, _| panic!("should not be called"));
358        let mut ctx = test_context();
359        let matches = test_matches();
360        let result = hooks.run_pre_dispatch(&matches, &mut ctx);
361        assert!(result.is_err());
362    }
363    #[test]
364    fn test_pre_dispatch_injects_extensions() {
365        struct TestState {
366            value: i32,
367        }
368        let hooks = Hooks::new().pre_dispatch(|_, ctx| {
369            ctx.extensions.insert(TestState { value: 42 });
370            Ok(())
371        });
372        let mut ctx = test_context();
373        let matches = test_matches();
374        assert!(!ctx.extensions.contains::<TestState>());
375        hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
376        let state = ctx.extensions.get::<TestState>().unwrap();
377        assert_eq!(state.value, 42);
378    }
379    #[test]
380    fn test_pre_dispatch_multiple_hooks_share_context() {
381        struct Counter {
382            count: i32,
383        }
384        let hooks = Hooks::new()
385            .pre_dispatch(|_, ctx| {
386                ctx.extensions.insert(Counter { count: 1 });
387                Ok(())
388            })
389            .pre_dispatch(|_, ctx| {
390                if let Some(counter) = ctx.extensions.get_mut::<Counter>() {
391                    counter.count += 10;
392                }
393                Ok(())
394            });
395        let mut ctx = test_context();
396        let matches = test_matches();
397        hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
398        let counter = ctx.extensions.get::<Counter>().unwrap();
399        assert_eq!(counter.count, 11);
400    }
401    #[test]
402    fn test_post_dispatch_transformation() {
403        use serde_json::json;
404        let hooks = Hooks::new().post_dispatch(|_, _, mut data| {
405            if let Some(obj) = data.as_object_mut() {
406                obj.insert("modified".into(), json!(true));
407            }
408            Ok(data)
409        });
410        let ctx = test_context();
411        let matches = test_matches();
412        let data = json!({"value": 42});
413        let result = hooks.run_post_dispatch(&matches, &ctx, data);
414        assert!(result.is_ok());
415        let output = result.unwrap();
416        assert_eq!(output["value"], 42);
417        assert_eq!(output["modified"], true);
418    }
419    #[test]
420    fn test_post_output_transformation() {
421        let hooks = Hooks::new().post_output(|_, _, output| {
422            if let RenderedOutput::Text(text_output) = output {
423                Ok(RenderedOutput::Text(TextOutput::new(
424                    text_output.formatted.to_uppercase(),
425                    text_output.raw.to_uppercase(),
426                )))
427            } else {
428                Ok(output)
429            }
430        });
431        let ctx = test_context();
432        let matches = test_matches();
433        let input = RenderedOutput::Text(TextOutput::plain("hello".into()));
434        let result = hooks.run_post_output(&matches, &ctx, input);
435        assert!(result.is_ok());
436        assert_eq!(result.unwrap().as_text(), Some("HELLO"));
437    }
438}