Skip to main content

standout_dispatch/
handler.rs

1//! Command handler types.
2//!
3//! This module provides the core types for building logic handlers - the
4//! business logic layer in the dispatch pipeline.
5//!
6//! # Design Rationale
7//!
8//! Logic handlers are responsible for business logic only. They:
9//!
10//! - Receive parsed CLI arguments (`&ArgMatches`) and execution context
11//! - Perform application logic (database queries, file operations, etc.)
12//! - Return serializable data that will be passed to the render handler
13//!
14//! Handlers explicitly do not handle:
15//! - Output formatting (that's the render handler's job)
16//! - Template selection (that's configured at the framework level)
17//! - Theme/style decisions (that's the render handler's job)
18//!
19//! This separation keeps handlers focused and testable - you can unit test
20//! a handler by checking the data it returns, without worrying about rendering.
21//!
22//! # Core Types
23//!
24//! - [`CommandContext`]: Environment information passed to handlers
25//! - [`Output`]: What a handler produces (render data, silent, or binary)
26//! - [`HandlerResult`]: The result type for handlers (`Result<Output<T>, Error>`)
27//! - [`RunResult`]: The result of running the CLI dispatcher
28//! - [`Handler`]: Trait for thread-safe command handlers (`Send + Sync`, `&self`)
29//! - [`LocalHandler`]: Trait for local command handlers (no `Send + Sync`, `&mut self`)
30
31use clap::ArgMatches;
32use serde::Serialize;
33
34/// Context passed to command handlers.
35///
36/// Provides information about the execution environment. Note that output format
37/// is deliberately not included here - format decisions are made by the
38/// render handler, not by logic handlers.
39///
40/// If handlers need format-aware behavior (e.g., skip expensive formatting for
41/// JSON output), the consuming framework can extend this context or pass format
42/// information through other means.
43#[derive(Debug, Clone, Default)]
44pub struct CommandContext {
45    /// The command path being executed (e.g., ["config", "get"])
46    pub command_path: Vec<String>,
47}
48
49/// What a handler produces.
50///
51/// This enum represents the different types of output a command handler can produce.
52#[derive(Debug)]
53pub enum Output<T: Serialize> {
54    /// Data to render with a template or serialize to JSON/YAML/etc.
55    Render(T),
56    /// Silent exit (no output produced)
57    Silent,
58    /// Binary output for file exports
59    Binary {
60        /// The binary data
61        data: Vec<u8>,
62        /// Suggested filename for the output
63        filename: String,
64    },
65}
66
67impl<T: Serialize> Output<T> {
68    /// Returns true if this is a render result.
69    pub fn is_render(&self) -> bool {
70        matches!(self, Output::Render(_))
71    }
72
73    /// Returns true if this is a silent result.
74    pub fn is_silent(&self) -> bool {
75        matches!(self, Output::Silent)
76    }
77
78    /// Returns true if this is a binary result.
79    pub fn is_binary(&self) -> bool {
80        matches!(self, Output::Binary { .. })
81    }
82}
83
84/// The result type for command handlers.
85///
86/// Enables use of the `?` operator for error propagation.
87pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>;
88
89/// Result of running the CLI dispatcher.
90///
91/// After processing arguments, the dispatcher either handles a command
92/// or falls through for manual handling.
93#[derive(Debug)]
94pub enum RunResult {
95    /// A handler processed the command; contains the rendered output
96    Handled(String),
97    /// A handler produced binary output (bytes, suggested filename)
98    Binary(Vec<u8>, String),
99    /// Silent output (handler completed but produced no output)
100    Silent,
101    /// No handler matched; contains the ArgMatches for manual handling
102    NoMatch(ArgMatches),
103}
104
105impl RunResult {
106    /// Returns true if a handler processed the command (text output).
107    pub fn is_handled(&self) -> bool {
108        matches!(self, RunResult::Handled(_))
109    }
110
111    /// Returns true if the result is binary output.
112    pub fn is_binary(&self) -> bool {
113        matches!(self, RunResult::Binary(_, _))
114    }
115
116    /// Returns true if the result is silent.
117    pub fn is_silent(&self) -> bool {
118        matches!(self, RunResult::Silent)
119    }
120
121    /// Returns the output if handled, or None otherwise.
122    pub fn output(&self) -> Option<&str> {
123        match self {
124            RunResult::Handled(s) => Some(s),
125            _ => None,
126        }
127    }
128
129    /// Returns the binary data and filename if binary, or None otherwise.
130    pub fn binary(&self) -> Option<(&[u8], &str)> {
131        match self {
132            RunResult::Binary(bytes, filename) => Some((bytes, filename)),
133            _ => None,
134        }
135    }
136
137    /// Returns the matches if unhandled, or None if handled.
138    pub fn matches(&self) -> Option<&ArgMatches> {
139        match self {
140            RunResult::NoMatch(m) => Some(m),
141            _ => None,
142        }
143    }
144}
145
146/// Trait for thread-safe command handlers.
147///
148/// Handlers must be `Send + Sync` and use immutable `&self`.
149pub trait Handler: Send + Sync {
150    /// The output type produced by this handler (must be serializable)
151    type Output: Serialize;
152
153    /// Execute the handler with the given matches and context.
154    fn handle(&self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>;
155}
156
157/// A wrapper that implements Handler for closures.
158pub struct FnHandler<F, T>
159where
160    F: Fn(&ArgMatches, &CommandContext) -> HandlerResult<T> + Send + Sync,
161    T: Serialize + Send + Sync,
162{
163    f: F,
164    _phantom: std::marker::PhantomData<fn() -> T>,
165}
166
167impl<F, T> FnHandler<F, T>
168where
169    F: Fn(&ArgMatches, &CommandContext) -> HandlerResult<T> + Send + Sync,
170    T: Serialize + Send + Sync,
171{
172    /// Creates a new FnHandler wrapping the given closure.
173    pub fn new(f: F) -> Self {
174        Self {
175            f,
176            _phantom: std::marker::PhantomData,
177        }
178    }
179}
180
181impl<F, T> Handler for FnHandler<F, T>
182where
183    F: Fn(&ArgMatches, &CommandContext) -> HandlerResult<T> + Send + Sync,
184    T: Serialize + Send + Sync,
185{
186    type Output = T;
187
188    fn handle(&self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> {
189        (self.f)(matches, ctx)
190    }
191}
192
193/// Trait for local (single-threaded) command handlers.
194///
195/// Unlike [`Handler`], this trait:
196/// - Does NOT require `Send + Sync`
197/// - Takes `&mut self` instead of `&self`
198/// - Allows handlers to mutate their internal state directly
199pub trait LocalHandler {
200    /// The output type produced by this handler (must be serializable)
201    type Output: Serialize;
202
203    /// Execute the handler with the given matches and context.
204    fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext)
205        -> HandlerResult<Self::Output>;
206}
207
208/// A wrapper that implements LocalHandler for FnMut closures.
209pub struct LocalFnHandler<F, T>
210where
211    F: FnMut(&ArgMatches, &CommandContext) -> HandlerResult<T>,
212    T: Serialize,
213{
214    f: F,
215    _phantom: std::marker::PhantomData<fn() -> T>,
216}
217
218impl<F, T> LocalFnHandler<F, T>
219where
220    F: FnMut(&ArgMatches, &CommandContext) -> HandlerResult<T>,
221    T: Serialize,
222{
223    /// Creates a new LocalFnHandler wrapping the given FnMut closure.
224    pub fn new(f: F) -> Self {
225        Self {
226            f,
227            _phantom: std::marker::PhantomData,
228        }
229    }
230}
231
232impl<F, T> LocalHandler for LocalFnHandler<F, T>
233where
234    F: FnMut(&ArgMatches, &CommandContext) -> HandlerResult<T>,
235    T: Serialize,
236{
237    type Output = T;
238
239    fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> {
240        (self.f)(matches, ctx)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use serde_json::json;
248
249    #[test]
250    fn test_command_context_creation() {
251        let ctx = CommandContext {
252            command_path: vec!["config".into(), "get".into()],
253        };
254        assert_eq!(ctx.command_path, vec!["config", "get"]);
255    }
256
257    #[test]
258    fn test_command_context_default() {
259        let ctx = CommandContext::default();
260        assert!(ctx.command_path.is_empty());
261    }
262
263    #[test]
264    fn test_output_render() {
265        let output: Output<String> = Output::Render("success".into());
266        assert!(output.is_render());
267        assert!(!output.is_silent());
268        assert!(!output.is_binary());
269    }
270
271    #[test]
272    fn test_output_silent() {
273        let output: Output<String> = Output::Silent;
274        assert!(!output.is_render());
275        assert!(output.is_silent());
276        assert!(!output.is_binary());
277    }
278
279    #[test]
280    fn test_output_binary() {
281        let output: Output<String> = Output::Binary {
282            data: vec![0x25, 0x50, 0x44, 0x46],
283            filename: "report.pdf".into(),
284        };
285        assert!(!output.is_render());
286        assert!(!output.is_silent());
287        assert!(output.is_binary());
288    }
289
290    #[test]
291    fn test_run_result_handled() {
292        let result = RunResult::Handled("output".into());
293        assert!(result.is_handled());
294        assert!(!result.is_binary());
295        assert!(!result.is_silent());
296        assert_eq!(result.output(), Some("output"));
297        assert!(result.matches().is_none());
298    }
299
300    #[test]
301    fn test_run_result_silent() {
302        let result = RunResult::Silent;
303        assert!(!result.is_handled());
304        assert!(!result.is_binary());
305        assert!(result.is_silent());
306    }
307
308    #[test]
309    fn test_run_result_binary() {
310        let bytes = vec![0x25, 0x50, 0x44, 0x46];
311        let result = RunResult::Binary(bytes.clone(), "report.pdf".into());
312        assert!(!result.is_handled());
313        assert!(result.is_binary());
314        assert!(!result.is_silent());
315
316        let (data, filename) = result.binary().unwrap();
317        assert_eq!(data, &bytes);
318        assert_eq!(filename, "report.pdf");
319    }
320
321    #[test]
322    fn test_run_result_no_match() {
323        let matches = clap::Command::new("test").get_matches_from(vec!["test"]);
324        let result = RunResult::NoMatch(matches);
325        assert!(!result.is_handled());
326        assert!(!result.is_binary());
327        assert!(result.matches().is_some());
328    }
329
330    #[test]
331    fn test_fn_handler() {
332        let handler = FnHandler::new(|_m: &ArgMatches, _ctx: &CommandContext| {
333            Ok(Output::Render(json!({"status": "ok"})))
334        });
335
336        let ctx = CommandContext::default();
337        let matches = clap::Command::new("test").get_matches_from(vec!["test"]);
338
339        let result = handler.handle(&matches, &ctx);
340        assert!(result.is_ok());
341    }
342
343    #[test]
344    fn test_local_fn_handler_mutation() {
345        let mut counter = 0u32;
346
347        let mut handler = LocalFnHandler::new(|_m: &ArgMatches, _ctx: &CommandContext| {
348            counter += 1;
349            Ok(Output::Render(counter))
350        });
351
352        let ctx = CommandContext::default();
353        let matches = clap::Command::new("test").get_matches_from(vec!["test"]);
354
355        let _ = handler.handle(&matches, &ctx);
356        let _ = handler.handle(&matches, &ctx);
357        let result = handler.handle(&matches, &ctx);
358
359        assert!(result.is_ok());
360        if let Ok(Output::Render(count)) = result {
361            assert_eq!(count, 3);
362        }
363    }
364}