Skip to main content

standout_dispatch/
hooks.rs

1//! Hook system for pre/post command execution.
2//!
3//! Hooks allow you to run custom code at specific points in the dispatch pipeline.
4//! They enable cross-cutting concerns (logging, validation, transformation) without
5//! polluting handler logic.
6//!
7//! # Pipeline Position
8//!
9//! Hooks fit into the dispatch flow as follows:
10//!
11//! ```text
12//! parsed CLI args
13//!   → PRE-DISPATCH HOOK ← (validation, auth checks, setup)
14//!   → logic handler
15//!   → POST-DISPATCH HOOK ← (data transformation, enrichment)
16//!   → render handler
17//!   → POST-OUTPUT HOOK ← (output transformation, logging)
18//! ```
19//!
20//! # Hook Points
21//!
22//! - Pre-dispatch: Runs before the command handler. Can abort execution.
23//!   Use for: authentication, input validation, resource acquisition.
24//!
25//! - Post-dispatch: Runs after the handler but before rendering. Receives the raw
26//!   handler data as `serde_json::Value`. Can inspect, modify, or replace the data.
27//!   Use for: adding metadata, data transformation, caching.
28//!
29//! - Post-output: Runs after output is generated. Can transform output or abort.
30//!   Use for: logging, clipboard copy, output filtering.
31
32use std::fmt;
33use std::rc::Rc;
34use thiserror::Error;
35
36use crate::handler::{CommandContext, ExternalFailure};
37use clap::ArgMatches;
38
39/// Text output with both formatted and raw versions.
40///
41/// This struct carries both the terminal-formatted output (with ANSI codes)
42/// and the raw output (with style tags but no ANSI codes). This allows
43/// post-output hooks like piping to choose the appropriate version.
44#[derive(Debug, Clone)]
45pub struct TextOutput {
46    /// The formatted output with ANSI codes applied (for terminal display)
47    pub formatted: String,
48    /// The raw output with `[tag]...[/tag]` markers but no ANSI codes.
49    /// This is the intermediate output after template rendering but before
50    /// style tag processing. Piping uses this by default.
51    pub raw: String,
52}
53
54impl TextOutput {
55    /// Creates a new TextOutput with both formatted and raw versions.
56    pub fn new(formatted: String, raw: String) -> Self {
57        Self { formatted, raw }
58    }
59
60    /// Creates a TextOutput where formatted and raw are the same.
61    /// Use this for output that doesn't go through style tag processing
62    /// (e.g., JSON output, error messages).
63    pub fn plain(text: String) -> Self {
64        Self {
65            formatted: text.clone(),
66            raw: text,
67        }
68    }
69}
70
71/// Output from a command, used in post-output hooks.
72///
73/// This represents the final output from a command handler after rendering.
74#[derive(Debug, Clone)]
75pub enum RenderedOutput {
76    /// Text output with both formatted (ANSI) and raw versions.
77    /// The `formatted` field contains ANSI codes for terminal display.
78    /// The `raw` field contains the intermediate output for piping.
79    Text(TextOutput),
80    /// Binary output with suggested filename
81    Binary(Vec<u8>, String),
82    /// No output (silent command)
83    Silent,
84}
85
86impl RenderedOutput {
87    /// Returns true if this is text output.
88    pub fn is_text(&self) -> bool {
89        matches!(self, RenderedOutput::Text(_))
90    }
91
92    /// Returns true if this is binary output.
93    pub fn is_binary(&self) -> bool {
94        matches!(self, RenderedOutput::Binary(_, _))
95    }
96
97    /// Returns true if this is silent (no output).
98    pub fn is_silent(&self) -> bool {
99        matches!(self, RenderedOutput::Silent)
100    }
101
102    /// Returns the formatted text content (with ANSI codes) if this is text output.
103    pub fn as_text(&self) -> Option<&str> {
104        match self {
105            RenderedOutput::Text(t) => Some(&t.formatted),
106            _ => None,
107        }
108    }
109
110    /// Returns the raw text content (without ANSI codes) if this is text output.
111    /// This is the intermediate output suitable for piping.
112    pub fn as_raw_text(&self) -> Option<&str> {
113        match self {
114            RenderedOutput::Text(t) => Some(&t.raw),
115            _ => None,
116        }
117    }
118
119    /// Returns the full TextOutput if this is text output.
120    pub fn as_text_output(&self) -> Option<&TextOutput> {
121        match self {
122            RenderedOutput::Text(t) => Some(t),
123            _ => None,
124        }
125    }
126
127    /// Returns the binary content and filename if this is binary output.
128    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/// The phase at which a hook error occurred.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub enum HookPhase {
139    /// Error occurred during pre-dispatch phase
140    PreDispatch,
141    /// Error occurred during post-dispatch phase
142    PostDispatch,
143    /// Error occurred during post-output phase
144    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/// Error returned by a hook.
158#[derive(Debug, Error)]
159#[error("hook error ({phase}): {message}")]
160pub struct HookError {
161    /// Human-readable error message
162    pub message: String,
163    /// The hook phase where the error occurred
164    pub phase: HookPhase,
165    /// The underlying error source, if any
166    #[source]
167    pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
168}
169
170impl HookError {
171    /// Creates a new hook error for the pre-dispatch phase.
172    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    /// Declares an authoritative external failure before handler dispatch.
181    ///
182    /// Standout preserves the failure's exact nonzero status and diagnostic.
183    /// This constructor is intentionally limited to the pre-dispatch seam;
184    /// ordinary hook failures retain status `1`.
185    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    /// Creates a new hook error for the post-dispatch phase.
194    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    /// Creates a new hook error for the post-output phase.
203    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    /// Sets the source error.
212    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
221/// Type alias for pre-dispatch hook functions.
222///
223/// Pre-dispatch hooks receive mutable access to [`CommandContext`], allowing them
224/// to inject state into `ctx.extensions` that handlers can retrieve.
225pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
226
227/// Type alias for post-dispatch hook functions.
228pub type PostDispatchFn = Rc<
229    dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
230>;
231
232/// Type alias for post-output hook functions.
233pub type PostOutputFn =
234    Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
235
236/// Per-command hook configuration.
237///
238/// Hooks are registered per-command path and executed in order.
239#[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    /// Creates a new empty hooks configuration.
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    /// Returns true if no hooks are registered.
253    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    /// Adds a pre-dispatch hook.
258    ///
259    /// Pre-dispatch hooks receive mutable access to [`CommandContext`], allowing
260    /// state injection via `ctx.extensions`. Handlers can then retrieve this state.
261    ///
262    /// # Example
263    ///
264    /// ```rust
265    /// use standout_dispatch::{Hooks, HookError};
266    ///
267    /// struct ApiClient { base_url: String }
268    ///
269    /// let hooks = Hooks::new()
270    ///     .pre_dispatch(|_matches, ctx| {
271    ///         ctx.extensions.insert(ApiClient {
272    ///             base_url: "https://api.example.com".into()
273    ///         });
274    ///         Ok(())
275    ///     });
276    /// ```
277    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    /// Adds a post-dispatch hook.
286    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    /// Adds a post-output hook.
300    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    /// Runs all pre-dispatch hooks.
310    ///
311    /// Hooks receive mutable access to the context, allowing state injection.
312    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    /// Runs all post-dispatch hooks, chaining transformations.
324    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    /// Runs all post-output hooks, chaining transformations.
338    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        // Test plain constructor (formatted == raw)
387        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        // Before hook runs, extension is not present
462        assert!(!ctx.extensions.contains::<TestState>());
463
464        hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
465
466        // After hook runs, extension is available
467        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                // Second hook can read and modify what first hook inserted
484                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}