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