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::path::PathBuf;
34use std::rc::Rc;
35use thiserror::Error;
36
37use crate::handler::{CommandContext, ExternalFailure};
38use clap::ArgMatches;
39
40/// Text output with both formatted and raw versions.
41///
42/// This struct carries both the terminal-formatted output (with ANSI codes)
43/// and the raw output (with style tags but no ANSI codes). This allows
44/// post-output hooks like piping to choose the appropriate version.
45#[derive(Debug, Clone)]
46pub struct TextOutput {
47    /// The formatted output with ANSI codes applied (for terminal display)
48    pub formatted: String,
49    /// The raw output with `[tag]...[/tag]` markers but no ANSI codes.
50    /// This is the intermediate output after template rendering but before
51    /// style tag processing. Piping uses this by default.
52    pub raw: String,
53}
54
55impl TextOutput {
56    /// Creates a new TextOutput with both formatted and raw versions.
57    pub fn new(formatted: String, raw: String) -> Self {
58        Self { formatted, raw }
59    }
60
61    /// Creates a TextOutput where formatted and raw are the same.
62    /// Use this for output that doesn't go through style tag processing
63    /// (e.g., JSON output, error messages).
64    pub fn plain(text: String) -> Self {
65        Self {
66            formatted: text.clone(),
67            raw: text,
68        }
69    }
70}
71
72/// Artifact bytes and report as post-output hooks see them.
73///
74/// Post-output hooks observe this *before* the framework selects a destination
75/// and performs the final write, so a hook can still transform the bytes or
76/// enrich the report. Hooks deliberately cannot perform the write themselves:
77/// the framework owns it, which is what keeps the success report truthful and
78/// the failure path single.
79///
80/// The fields are public so hooks can mutate them, mirroring [`TextOutput`].
81#[derive(Debug, Clone)]
82pub struct ArtifactOutput {
83    /// The artifact bytes, byte-for-byte as the handler produced them.
84    pub bytes: Vec<u8>,
85    /// The destination the application suggested, if it opted in. `Some`
86    /// authorizes the framework to write there absent an explicit override.
87    pub suggested_destination: Option<PathBuf>,
88    /// Whether the application authorized the stdout fallback.
89    pub stdout_allowed: bool,
90    /// The serialized application-owned report, after post-dispatch hooks.
91    pub report: Option<serde_json::Value>,
92}
93
94/// Output from a command, used in post-output hooks.
95///
96/// This represents the final output from a command handler after rendering.
97#[derive(Debug, Clone)]
98pub enum RenderedOutput {
99    /// Text output with both formatted (ANSI) and raw versions.
100    /// The `formatted` field contains ANSI codes for terminal display.
101    /// The `raw` field contains the intermediate output for piping.
102    Text(TextOutput),
103    /// Binary output with suggested filename
104    Binary(Vec<u8>, String),
105    /// A compound artifact awaiting the framework-owned write.
106    Artifact(ArtifactOutput),
107    /// No output (silent command)
108    Silent,
109}
110
111impl RenderedOutput {
112    /// Returns true if this is text output.
113    pub fn is_text(&self) -> bool {
114        matches!(self, RenderedOutput::Text(_))
115    }
116
117    /// Returns true if this is binary output.
118    pub fn is_binary(&self) -> bool {
119        matches!(self, RenderedOutput::Binary(_, _))
120    }
121
122    /// Returns true if this is a compound artifact awaiting the final write.
123    pub fn is_artifact(&self) -> bool {
124        matches!(self, RenderedOutput::Artifact(_))
125    }
126
127    /// Returns true if this is silent (no output).
128    pub fn is_silent(&self) -> bool {
129        matches!(self, RenderedOutput::Silent)
130    }
131
132    /// Returns the formatted text content (with ANSI codes) if this is text output.
133    pub fn as_text(&self) -> Option<&str> {
134        match self {
135            RenderedOutput::Text(t) => Some(&t.formatted),
136            _ => None,
137        }
138    }
139
140    /// Returns the raw text content (without ANSI codes) if this is text output.
141    /// This is the intermediate output suitable for piping.
142    pub fn as_raw_text(&self) -> Option<&str> {
143        match self {
144            RenderedOutput::Text(t) => Some(&t.raw),
145            _ => None,
146        }
147    }
148
149    /// Returns the full TextOutput if this is text output.
150    pub fn as_text_output(&self) -> Option<&TextOutput> {
151        match self {
152            RenderedOutput::Text(t) => Some(t),
153            _ => None,
154        }
155    }
156
157    /// Returns the binary content and filename if this is binary output.
158    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    /// Returns the pending artifact if this is artifact output.
166    pub fn as_artifact(&self) -> Option<&ArtifactOutput> {
167        match self {
168            RenderedOutput::Artifact(artifact) => Some(artifact),
169            _ => None,
170        }
171    }
172
173    /// Returns the pending artifact mutably, for hooks that transform bytes or
174    /// enrich the report before the framework-owned write.
175    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/// The phase at which a hook error occurred.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185pub enum HookPhase {
186    /// Error occurred during pre-dispatch phase
187    PreDispatch,
188    /// Error occurred during post-dispatch phase
189    PostDispatch,
190    /// Error occurred during post-output phase
191    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/// Error returned by a hook.
205#[derive(Debug, Error)]
206#[error("hook error ({phase}): {message}")]
207pub struct HookError {
208    /// Human-readable error message
209    pub message: String,
210    /// The hook phase where the error occurred
211    pub phase: HookPhase,
212    /// The underlying error source, if any
213    #[source]
214    pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
215}
216
217impl HookError {
218    /// Creates a new hook error for the pre-dispatch phase.
219    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    /// Declares an authoritative external failure before handler dispatch.
228    ///
229    /// Standout preserves the failure's exact nonzero status and diagnostic.
230    /// This constructor is intentionally limited to the pre-dispatch seam;
231    /// ordinary hook failures retain status `1`.
232    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    /// Creates a new hook error for the post-dispatch phase.
241    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    /// Creates a new hook error for the post-output phase.
250    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    /// Sets the source error.
259    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
268/// Type alias for pre-dispatch hook functions.
269///
270/// Pre-dispatch hooks receive mutable access to [`CommandContext`], allowing them
271/// to inject state into `ctx.extensions` that handlers can retrieve.
272pub type PreDispatchFn = Rc<dyn Fn(&ArgMatches, &mut CommandContext) -> Result<(), HookError>>;
273
274/// Type alias for post-dispatch hook functions.
275pub type PostDispatchFn = Rc<
276    dyn Fn(&ArgMatches, &CommandContext, serde_json::Value) -> Result<serde_json::Value, HookError>,
277>;
278
279/// Type alias for post-output hook functions.
280pub type PostOutputFn =
281    Rc<dyn Fn(&ArgMatches, &CommandContext, RenderedOutput) -> Result<RenderedOutput, HookError>>;
282
283/// Per-command hook configuration.
284///
285/// Hooks are registered per-command path and executed in order.
286#[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    /// Creates a new empty hooks configuration.
295    pub fn new() -> Self {
296        Self::default()
297    }
298
299    /// Returns true if no hooks are registered.
300    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    /// Adds a pre-dispatch hook.
305    ///
306    /// Pre-dispatch hooks receive mutable access to [`CommandContext`], allowing
307    /// state injection via `ctx.extensions`. Handlers can then retrieve this state.
308    ///
309    /// # Example
310    ///
311    /// ```rust
312    /// use standout_dispatch::{Hooks, HookError};
313    ///
314    /// struct ApiClient { base_url: String }
315    ///
316    /// let hooks = Hooks::new()
317    ///     .pre_dispatch(|_matches, ctx| {
318    ///         ctx.extensions.insert(ApiClient {
319    ///             base_url: "https://api.example.com".into()
320    ///         });
321    ///         Ok(())
322    ///     });
323    /// ```
324    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    /// Adds a post-dispatch hook.
333    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    /// Adds a post-output hook.
347    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    /// Runs all pre-dispatch hooks.
357    ///
358    /// Hooks receive mutable access to the context, allowing state injection.
359    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    /// Runs all post-dispatch hooks, chaining transformations.
371    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    /// Runs all post-output hooks, chaining transformations.
385    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        // Test plain constructor (formatted == raw)
434        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        // Before hook runs, extension is not present
509        assert!(!ctx.extensions.contains::<TestState>());
510
511        hooks.run_pre_dispatch(&matches, &mut ctx).unwrap();
512
513        // After hook runs, extension is available
514        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                // Second hook can read and modify what first hook inserted
531                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}