Skip to main content

halter_hooks/
merge.rs

1// pattern: Functional Core
2
3use halter_protocol::{HookOutputEntry, HookOutputKind};
4use serde::Deserialize;
5use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum HandlerPriorityGroup {
9    SdkBeforePlugins,
10    PluginFiles,
11    SdkAfterPlugins,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub struct HandlerPriority {
16    pub group: HandlerPriorityGroup,
17    pub plugin_load_order: usize,
18    pub event_declaration_index: usize,
19    pub matcher_group_index: usize,
20    pub hook_index_within_group: usize,
21}
22
23#[derive(Debug, Clone)]
24pub struct MergeInput {
25    pub handler_id: String,
26    pub priority: HandlerPriority,
27    pub output: HookOutput,
28}
29
30#[derive(Debug, Clone, Default, PartialEq)]
31pub struct HookMergedOutcome {
32    pub stop_reason: Option<String>,
33    pub block_reason: Option<String>,
34    pub permission_decision: Option<PermissionDecision>,
35    pub permission_decision_reason: Option<String>,
36    pub updated_input: Option<Value>,
37    pub updated_output: Option<Value>,
38    pub additional_context: Vec<String>,
39    pub system_messages: Vec<String>,
40    pub suppress_output: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct MergeConflict {
45    pub field: &'static str,
46    pub winner: String,
47    pub loser: String,
48}
49
50#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
51pub struct HookOutput {
52    #[serde(default, rename = "continue")]
53    pub continue_execution: Option<bool>,
54    #[serde(default, rename = "suppressOutput")]
55    pub suppress_output: Option<bool>,
56    #[serde(default)]
57    pub decision: Option<HookDecision>,
58    #[serde(default)]
59    pub reason: Option<String>,
60    #[serde(default, rename = "stopReason")]
61    pub stop_reason: Option<String>,
62    #[serde(default, rename = "systemMessage")]
63    pub system_message: Option<String>,
64    #[serde(default, rename = "hookSpecificOutput")]
65    pub hook_specific_output: Option<HookSpecificOutput>,
66}
67
68#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum HookDecision {
71    Approve,
72    Block,
73}
74
75#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
76pub struct HookSpecificOutput {
77    #[serde(default, rename = "hookEventName")]
78    pub hook_event_name: Option<String>,
79    #[serde(default, rename = "permissionDecision")]
80    pub permission_decision: Option<PermissionDecision>,
81    #[serde(default, rename = "permissionDecisionReason")]
82    pub permission_decision_reason: Option<String>,
83    #[serde(default, rename = "updatedInput")]
84    pub updated_input: Option<Value>,
85    #[serde(default, rename = "updatedMCPToolOutput")]
86    pub updated_mcp_tool_output: Option<Value>,
87    #[serde(default, rename = "additionalContext")]
88    pub additional_context: Option<String>,
89}
90
91/// Variants ordered **least-restrictive first** so the derived `Ord` matches
92/// the semantic "strength" of the decision: `Passthrough < Allow < Ask < Deny`.
93/// Merging two outputs picks the stronger decision with `.max(...)` rather
94/// than a hand-rolled rank table (finding L16). Serde uses variant names
95/// (snake_case), not declaration position, so the reordering is safe.
96#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
97#[serde(rename_all = "snake_case")]
98pub enum PermissionDecision {
99    Passthrough,
100    Allow,
101    Ask,
102    Deny,
103}
104
105pub fn merge_outputs(inputs: &[MergeInput]) -> (HookMergedOutcome, Vec<MergeConflict>) {
106    // Sort references, not values — `HookOutput` carries `serde_json::Value`
107    // payloads whose clones can be large. (M24)
108    let mut ordered: Vec<&MergeInput> = inputs.iter().collect();
109    ordered.sort_by(|left, right| left.priority.cmp(&right.priority));
110
111    let mut merged = HookMergedOutcome::default();
112    let mut conflicts = Vec::new();
113    let mut winning_updated_input: Option<String> = None;
114    let mut winning_updated_output: Option<String> = None;
115    let mut winning_permission: Option<(PermissionDecision, String, Option<String>)> = None;
116
117    for input in &ordered {
118        let reason = non_empty(input.output.reason.clone());
119        let stop_reason = non_empty(input.output.stop_reason.clone());
120
121        if matches!(input.output.continue_execution, Some(false)) && merged.stop_reason.is_none() {
122            merged.stop_reason = stop_reason
123                .clone()
124                .or(reason.clone())
125                .or_else(|| Some(default_stop_reason().to_owned()));
126        }
127
128        if matches!(input.output.decision, Some(HookDecision::Block))
129            && merged.block_reason.is_none()
130        {
131            merged.block_reason = Some(
132                reason
133                    .clone()
134                    .unwrap_or_else(|| default_block_reason().to_owned()),
135            );
136        }
137
138        if let Some(permission_decision) = input
139            .output
140            .hook_specific_output
141            .as_ref()
142            .and_then(|output| output.permission_decision)
143        {
144            let permission_reason = input
145                .output
146                .hook_specific_output
147                .as_ref()
148                .and_then(|output| non_empty(output.permission_decision_reason.clone()));
149            match &winning_permission {
150                Some((current, _, _)) if *current >= permission_decision => {}
151                _ => {
152                    winning_permission = Some((
153                        permission_decision,
154                        input.handler_id.clone(),
155                        permission_reason.clone(),
156                    ));
157                    merged.permission_decision = Some(permission_decision);
158                    merged.permission_decision_reason = permission_reason;
159                }
160            }
161        }
162
163        if let Some(updated_input) = input
164            .output
165            .hook_specific_output
166            .as_ref()
167            .and_then(|output| output.updated_input.clone())
168        {
169            if merged.updated_input.is_none() {
170                merged.updated_input = Some(updated_input);
171                winning_updated_input = Some(input.handler_id.clone());
172            } else if let Some(winner) = winning_updated_input.as_ref() {
173                conflicts.push(MergeConflict {
174                    field: "updated_input",
175                    winner: winner.clone(),
176                    loser: input.handler_id.clone(),
177                });
178            }
179        }
180
181        if let Some(updated_output) = input
182            .output
183            .hook_specific_output
184            .as_ref()
185            .and_then(|output| output.updated_mcp_tool_output.clone())
186        {
187            if merged.updated_output.is_none() {
188                merged.updated_output = Some(updated_output);
189                winning_updated_output = Some(input.handler_id.clone());
190            } else if let Some(winner) = winning_updated_output.as_ref() {
191                conflicts.push(MergeConflict {
192                    field: "updated_output",
193                    winner: winner.clone(),
194                    loser: input.handler_id.clone(),
195                });
196            }
197        }
198
199        if let Some(context) = input
200            .output
201            .hook_specific_output
202            .as_ref()
203            .and_then(|output| output.additional_context.clone())
204            .filter(|value| !value.trim().is_empty())
205        {
206            merged.additional_context.push(context);
207        }
208
209        if let Some(message) = input
210            .output
211            .system_message
212            .clone()
213            .filter(|value| !value.trim().is_empty())
214        {
215            merged.system_messages.push(message);
216        }
217
218        if matches!(input.output.suppress_output, Some(true)) {
219            merged.suppress_output = true;
220        }
221    }
222
223    if let Some((decision, _, reason)) = winning_permission
224        && matches!(decision, PermissionDecision::Deny | PermissionDecision::Ask)
225        && merged.block_reason.is_none()
226    {
227        merged.block_reason =
228            Some(reason.unwrap_or_else(|| default_permission_block_reason(decision).to_owned()));
229    }
230
231    (merged, conflicts)
232}
233
234pub fn summary_entries(output: &HookOutput) -> Vec<HookOutputEntry> {
235    let mut entries = Vec::new();
236    if let Some(reason) = output
237        .reason
238        .clone()
239        .filter(|value| !value.trim().is_empty())
240    {
241        let kind = if matches!(output.decision, Some(HookDecision::Block)) {
242            HookOutputKind::Stop
243        } else {
244            HookOutputKind::Warning
245        };
246        entries.push(HookOutputEntry { kind, text: reason });
247    }
248    if let Some(stop_reason) = output
249        .stop_reason
250        .clone()
251        .filter(|value| !value.trim().is_empty())
252    {
253        entries.push(HookOutputEntry {
254            kind: HookOutputKind::Stop,
255            text: stop_reason,
256        });
257    }
258    if let Some(system_message) = output
259        .system_message
260        .clone()
261        .filter(|value| !value.trim().is_empty())
262    {
263        entries.push(HookOutputEntry {
264            kind: HookOutputKind::Feedback,
265            text: system_message,
266        });
267    }
268    if let Some(context) = output
269        .hook_specific_output
270        .as_ref()
271        .and_then(|value| value.additional_context.clone())
272        .filter(|value| !value.trim().is_empty())
273    {
274        entries.push(HookOutputEntry {
275            kind: HookOutputKind::Context,
276            text: context,
277        });
278    }
279    entries
280}
281
282fn non_empty(value: Option<String>) -> Option<String> {
283    value.and_then(|value| {
284        let trimmed = value.trim();
285        (!trimmed.is_empty()).then(|| trimmed.to_owned())
286    })
287}
288
289fn default_stop_reason() -> &'static str {
290    "hook requested stop"
291}
292
293fn default_block_reason() -> &'static str {
294    "hook blocked without explanation"
295}
296
297fn default_permission_block_reason(decision: PermissionDecision) -> &'static str {
298    match decision {
299        PermissionDecision::Deny => "hook denied permission without explanation",
300        PermissionDecision::Ask => "hook requested permission confirmation without explanation",
301        PermissionDecision::Allow | PermissionDecision::Passthrough => {
302            "hook blocked without explanation"
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use serde_json::json;
310
311    use super::*;
312
313    fn priority(
314        group: HandlerPriorityGroup,
315        plugin_load_order: usize,
316        event_declaration_index: usize,
317        matcher_group_index: usize,
318        hook_index_within_group: usize,
319    ) -> HandlerPriority {
320        HandlerPriority {
321            group,
322            plugin_load_order,
323            event_declaration_index,
324            matcher_group_index,
325            hook_index_within_group,
326        }
327    }
328
329    fn merge_input(handler_id: &str, priority: HandlerPriority, output: HookOutput) -> MergeInput {
330        MergeInput {
331            handler_id: handler_id.to_owned(),
332            priority,
333            output,
334        }
335    }
336
337    #[test]
338    fn merge_prefers_highest_priority_updated_input() {
339        let (merged, conflicts) = merge_outputs(&[
340            merge_input(
341                "plugin-a",
342                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
343                HookOutput {
344                    hook_specific_output: Some(HookSpecificOutput {
345                        updated_input: Some(json!({"command": "echo a"})),
346                        ..HookSpecificOutput::default()
347                    }),
348                    ..HookOutput::default()
349                },
350            ),
351            merge_input(
352                "plugin-b",
353                priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
354                HookOutput {
355                    hook_specific_output: Some(HookSpecificOutput {
356                        updated_input: Some(json!({"command": "echo b"})),
357                        ..HookSpecificOutput::default()
358                    }),
359                    ..HookOutput::default()
360                },
361            ),
362        ]);
363
364        assert_eq!(merged.updated_input, Some(json!({"command": "echo a"})));
365        assert_eq!(conflicts.len(), 1);
366        assert_eq!(conflicts[0].winner, "plugin-a");
367        assert_eq!(conflicts[0].loser, "plugin-b");
368    }
369
370    #[test]
371    fn merge_synthesizes_block_reason_when_reason_is_missing() {
372        let (merged, conflicts) = merge_outputs(&[merge_input(
373            "plugin-a",
374            priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
375            HookOutput {
376                decision: Some(HookDecision::Block),
377                ..HookOutput::default()
378            },
379        )]);
380
381        assert_eq!(
382            merged.block_reason.as_deref(),
383            Some("hook blocked without explanation")
384        );
385        assert!(conflicts.is_empty());
386    }
387
388    #[test]
389    fn merge_uses_default_stop_reason_when_continue_stops_without_reason() {
390        let (merged, conflicts) = merge_outputs(&[merge_input(
391            "plugin-a",
392            priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
393            HookOutput {
394                continue_execution: Some(false),
395                ..HookOutput::default()
396            },
397        )]);
398
399        assert_eq!(merged.stop_reason.as_deref(), Some("hook requested stop"));
400        assert!(conflicts.is_empty());
401    }
402
403    #[test]
404    fn merge_prefers_strongest_permission_decision() {
405        let (merged, conflicts) = merge_outputs(&[
406            merge_input(
407                "allow",
408                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
409                HookOutput {
410                    hook_specific_output: Some(HookSpecificOutput {
411                        permission_decision: Some(PermissionDecision::Allow),
412                        permission_decision_reason: Some("allow".to_owned()),
413                        ..HookSpecificOutput::default()
414                    }),
415                    ..HookOutput::default()
416                },
417            ),
418            merge_input(
419                "deny",
420                priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
421                HookOutput {
422                    hook_specific_output: Some(HookSpecificOutput {
423                        permission_decision: Some(PermissionDecision::Deny),
424                        permission_decision_reason: Some("deny".to_owned()),
425                        ..HookSpecificOutput::default()
426                    }),
427                    ..HookOutput::default()
428                },
429            ),
430        ]);
431
432        assert_eq!(merged.permission_decision, Some(PermissionDecision::Deny));
433        assert_eq!(merged.permission_decision_reason.as_deref(), Some("deny"));
434        assert_eq!(merged.block_reason.as_deref(), Some("deny"));
435        assert!(conflicts.is_empty());
436    }
437
438    #[test]
439    fn merge_synthesizes_permission_block_reason_when_missing() {
440        let (merged, conflicts) = merge_outputs(&[merge_input(
441            "deny",
442            priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
443            HookOutput {
444                hook_specific_output: Some(HookSpecificOutput {
445                    permission_decision: Some(PermissionDecision::Deny),
446                    ..HookSpecificOutput::default()
447                }),
448                ..HookOutput::default()
449            },
450        )]);
451
452        assert_eq!(merged.permission_decision, Some(PermissionDecision::Deny));
453        assert_eq!(
454            merged.block_reason.as_deref(),
455            Some("hook denied permission without explanation")
456        );
457        assert!(conflicts.is_empty());
458    }
459
460    #[test]
461    fn merge_orders_context_and_system_messages_by_priority() {
462        let (merged, conflicts) = merge_outputs(&[
463            merge_input(
464                "sdk-before",
465                priority(HandlerPriorityGroup::SdkBeforePlugins, 0, 0, 0, 0),
466                HookOutput {
467                    system_message: Some("sdk-before-message".to_owned()),
468                    hook_specific_output: Some(HookSpecificOutput {
469                        additional_context: Some("sdk-before-context".to_owned()),
470                        ..HookSpecificOutput::default()
471                    }),
472                    ..HookOutput::default()
473                },
474            ),
475            merge_input(
476                "plugin",
477                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
478                HookOutput {
479                    system_message: Some("plugin-message".to_owned()),
480                    hook_specific_output: Some(HookSpecificOutput {
481                        additional_context: Some("plugin-context".to_owned()),
482                        ..HookSpecificOutput::default()
483                    }),
484                    ..HookOutput::default()
485                },
486            ),
487            merge_input(
488                "sdk-after",
489                priority(HandlerPriorityGroup::SdkAfterPlugins, 0, 0, 0, 0),
490                HookOutput {
491                    system_message: Some("sdk-after-message".to_owned()),
492                    hook_specific_output: Some(HookSpecificOutput {
493                        additional_context: Some("sdk-after-context".to_owned()),
494                        ..HookSpecificOutput::default()
495                    }),
496                    ..HookOutput::default()
497                },
498            ),
499        ]);
500
501        assert_eq!(
502            merged.additional_context,
503            vec![
504                "sdk-before-context".to_owned(),
505                "plugin-context".to_owned(),
506                "sdk-after-context".to_owned(),
507            ]
508        );
509        assert_eq!(
510            merged.system_messages,
511            vec![
512                "sdk-before-message".to_owned(),
513                "plugin-message".to_owned(),
514                "sdk-after-message".to_owned(),
515            ]
516        );
517        assert!(conflicts.is_empty());
518    }
519
520    #[test]
521    fn merge_uses_full_priority_tuple_for_tie_breaks() {
522        let (merged, conflicts) = merge_outputs(&[
523            merge_input(
524                "later-matcher",
525                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 1, 0),
526                HookOutput {
527                    hook_specific_output: Some(HookSpecificOutput {
528                        updated_input: Some(json!({"command": "echo later"})),
529                        ..HookSpecificOutput::default()
530                    }),
531                    ..HookOutput::default()
532                },
533            ),
534            merge_input(
535                "earlier-matcher",
536                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 1),
537                HookOutput {
538                    hook_specific_output: Some(HookSpecificOutput {
539                        updated_input: Some(json!({"command": "echo earlier"})),
540                        ..HookSpecificOutput::default()
541                    }),
542                    ..HookOutput::default()
543                },
544            ),
545        ]);
546
547        assert_eq!(
548            merged.updated_input,
549            Some(json!({"command": "echo earlier"}))
550        );
551        assert_eq!(conflicts.len(), 1);
552        assert_eq!(conflicts[0].winner, "earlier-matcher");
553        assert_eq!(conflicts[0].loser, "later-matcher");
554    }
555
556    #[test]
557    fn merge_prefers_earlier_event_declaration_index() {
558        let (merged, conflicts) = merge_outputs(&[
559            merge_input(
560                "later-event",
561                priority(HandlerPriorityGroup::PluginFiles, 0, 1, 0, 0),
562                HookOutput {
563                    hook_specific_output: Some(HookSpecificOutput {
564                        updated_input: Some(json!({"command": "echo later-event"})),
565                        ..HookSpecificOutput::default()
566                    }),
567                    ..HookOutput::default()
568                },
569            ),
570            merge_input(
571                "earlier-event",
572                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
573                HookOutput {
574                    hook_specific_output: Some(HookSpecificOutput {
575                        updated_input: Some(json!({"command": "echo earlier-event"})),
576                        ..HookSpecificOutput::default()
577                    }),
578                    ..HookOutput::default()
579                },
580            ),
581        ]);
582
583        assert_eq!(
584            merged.updated_input,
585            Some(json!({"command": "echo earlier-event"}))
586        );
587        assert_eq!(conflicts.len(), 1);
588        assert_eq!(conflicts[0].winner, "earlier-event");
589    }
590
591    #[test]
592    fn merge_prefers_earlier_hook_index_within_group() {
593        let (merged, conflicts) = merge_outputs(&[
594            merge_input(
595                "later-hook",
596                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 1),
597                HookOutput {
598                    hook_specific_output: Some(HookSpecificOutput {
599                        updated_input: Some(json!({"command": "echo later-hook"})),
600                        ..HookSpecificOutput::default()
601                    }),
602                    ..HookOutput::default()
603                },
604            ),
605            merge_input(
606                "earlier-hook",
607                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
608                HookOutput {
609                    hook_specific_output: Some(HookSpecificOutput {
610                        updated_input: Some(json!({"command": "echo earlier-hook"})),
611                        ..HookSpecificOutput::default()
612                    }),
613                    ..HookOutput::default()
614                },
615            ),
616        ]);
617
618        assert_eq!(
619            merged.updated_input,
620            Some(json!({"command": "echo earlier-hook"}))
621        );
622        assert_eq!(conflicts.len(), 1);
623        assert_eq!(conflicts[0].winner, "earlier-hook");
624    }
625
626    #[test]
627    fn merge_prefers_earlier_priority_for_same_permission_strength() {
628        let (merged, conflicts) = merge_outputs(&[
629            merge_input(
630                "earlier",
631                priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
632                HookOutput {
633                    hook_specific_output: Some(HookSpecificOutput {
634                        permission_decision: Some(PermissionDecision::Ask),
635                        permission_decision_reason: Some("earlier".to_owned()),
636                        ..HookSpecificOutput::default()
637                    }),
638                    ..HookOutput::default()
639                },
640            ),
641            merge_input(
642                "later",
643                priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
644                HookOutput {
645                    hook_specific_output: Some(HookSpecificOutput {
646                        permission_decision: Some(PermissionDecision::Ask),
647                        permission_decision_reason: Some("later".to_owned()),
648                        ..HookSpecificOutput::default()
649                    }),
650                    ..HookOutput::default()
651                },
652            ),
653        ]);
654
655        assert_eq!(merged.permission_decision, Some(PermissionDecision::Ask));
656        assert_eq!(
657            merged.permission_decision_reason.as_deref(),
658            Some("earlier")
659        );
660        assert_eq!(merged.block_reason.as_deref(), Some("earlier"));
661        assert!(conflicts.is_empty());
662    }
663}