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}