Skip to main content

fresh_core/
hooks.rs

1//! Hook System: Event subscription and notification for plugins
2//!
3//! Hooks allow plugins to subscribe to editor events and react to them.
4
5use anyhow::Result;
6use std::collections::HashMap;
7use std::ops::Range;
8use std::path::PathBuf;
9
10use crate::action::Action;
11use crate::api::{ViewTokenWire, ViewTokenWireKind};
12use crate::{BufferId, CursorId, SplitId};
13
14/// Arguments passed to hook callbacks
15#[derive(Debug, Clone, serde::Serialize)]
16pub enum HookArgs {
17    /// Before a file is opened
18    BeforeFileOpen { path: PathBuf },
19
20    /// After a file is successfully opened
21    AfterFileOpen { buffer_id: BufferId, path: PathBuf },
22
23    /// Before a buffer is saved to disk
24    BeforeFileSave { buffer_id: BufferId, path: PathBuf },
25
26    /// After a buffer is successfully saved
27    AfterFileSave { buffer_id: BufferId, path: PathBuf },
28
29    /// A buffer was closed
30    BufferClosed { buffer_id: BufferId },
31
32    /// Before text is inserted
33    BeforeInsert {
34        buffer_id: BufferId,
35        position: usize,
36        text: String,
37    },
38
39    /// After text was inserted
40    AfterInsert {
41        buffer_id: BufferId,
42        position: usize,
43        text: String,
44        /// Byte position where the affected range starts
45        affected_start: usize,
46        /// Byte position where the affected range ends (after the inserted text)
47        affected_end: usize,
48        /// Line number where insertion occurred (0-indexed)
49        start_line: usize,
50        /// Line number where insertion ended (0-indexed)
51        end_line: usize,
52        /// Number of lines added by this insertion
53        lines_added: usize,
54    },
55
56    /// Before text is deleted
57    BeforeDelete {
58        buffer_id: BufferId,
59        range: Range<usize>,
60    },
61
62    /// After text was deleted
63    AfterDelete {
64        buffer_id: BufferId,
65        range: Range<usize>,
66        deleted_text: String,
67        /// Byte position where the deletion occurred
68        affected_start: usize,
69        /// Length of the deleted content in bytes
70        deleted_len: usize,
71        /// Line number where deletion started (0-indexed)
72        start_line: usize,
73        /// Line number where deletion ended (0-indexed, in original buffer)
74        end_line: usize,
75        /// Number of lines removed by this deletion
76        lines_removed: usize,
77    },
78
79    /// Cursor moved to a new position
80    CursorMoved {
81        buffer_id: BufferId,
82        cursor_id: CursorId,
83        old_position: usize,
84        new_position: usize,
85        /// Line number at new position (1-indexed)
86        line: usize,
87        /// Text properties at the new cursor position
88        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
89    },
90
91    /// Buffer became active
92    BufferActivated { buffer_id: BufferId },
93
94    /// Buffer was deactivated
95    BufferDeactivated { buffer_id: BufferId },
96
97    /// LSP diagnostics were updated for a file
98    DiagnosticsUpdated {
99        /// The URI of the file that was updated
100        uri: String,
101        /// Number of diagnostics in the update
102        count: usize,
103    },
104
105    /// Before a command/action is executed
106    PreCommand { action: Action },
107
108    /// After a command/action was executed
109    PostCommand { action: Action },
110
111    /// Editor has been idle for N milliseconds (no input)
112    Idle { milliseconds: u64 },
113
114    /// Editor is initializing
115    EditorInitialized,
116
117    /// All plugin packages + init.ts have been loaded. Fires after the
118    /// plugin discovery loop and before session restore — the lifecycle
119    /// hook for code that wants to configure a plugin via its
120    /// getPluginApi(...) surface. See design §3.3 (phase 2).
121    PluginsLoaded,
122
123    /// Editor has completed startup: plugins are loaded, session is
124    /// restored, and the active buffer exists. Design §3.3 (phase 3).
125    Ready,
126
127    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
128    RenderStart { buffer_id: BufferId },
129
130    /// A line is being rendered (called during the rendering pass)
131    RenderLine {
132        buffer_id: BufferId,
133        line_number: usize,
134        byte_start: usize,
135        byte_end: usize,
136        content: String,
137    },
138
139    /// Lines have changed and need processing (batched for efficiency)
140    LinesChanged {
141        buffer_id: BufferId,
142        lines: Vec<LineInfo>,
143    },
144
145    /// Prompt input changed (user typed/edited)
146    PromptChanged { prompt_type: String, input: String },
147
148    /// Prompt was confirmed (user pressed Enter)
149    PromptConfirmed {
150        prompt_type: String,
151        input: String,
152        selected_index: Option<usize>,
153    },
154
155    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
156    PromptCancelled { prompt_type: String, input: String },
157
158    /// Prompt suggestion selection changed (user navigated with Up/Down)
159    PromptSelectionChanged {
160        prompt_type: String,
161        selected_index: usize,
162    },
163
164    /// Request keyboard shortcuts data (key, action) for the help buffer
165    KeyboardShortcuts { bindings: Vec<(String, String)> },
166
167    /// LSP find references response received
168    LspReferences {
169        /// The symbol name being queried
170        symbol: String,
171        /// The locations where the symbol is referenced
172        locations: Vec<LspLocation>,
173    },
174
175    /// View transform request
176    ViewTransformRequest {
177        buffer_id: BufferId,
178        split_id: SplitId,
179        /// Byte offset of the viewport start
180        viewport_start: usize,
181        /// Byte offset of the viewport end
182        viewport_end: usize,
183        /// Base tokens (Text, Newline, Space) from the source
184        tokens: Vec<ViewTokenWire>,
185        /// Byte positions of all cursors in this buffer
186        cursor_positions: Vec<usize>,
187    },
188
189    /// Mouse click event
190    MouseClick {
191        /// Column (x coordinate) in screen cells
192        column: u16,
193        /// Row (y coordinate) in screen cells
194        row: u16,
195        /// Mouse button: "left", "right", "middle"
196        button: String,
197        /// Modifier keys
198        modifiers: String,
199        /// Content area X offset
200        content_x: u16,
201        /// Content area Y offset
202        content_y: u16,
203        /// Buffer under the click (None when the click is outside any
204        /// buffer panel).
205        buffer_id: Option<u64>,
206        /// 0-indexed buffer row (line number) of the click, accounting
207        /// for scroll. None when the click is outside any buffer.
208        buffer_row: Option<u32>,
209        /// 0-indexed byte column inside the buffer row. None when the
210        /// click is outside any buffer.
211        buffer_col: Option<u32>,
212    },
213
214    /// Mouse move/hover event
215    MouseMove {
216        /// Column (x coordinate) in screen cells
217        column: u16,
218        /// Row (y coordinate) in screen cells
219        row: u16,
220        /// Content area X offset
221        content_x: u16,
222        /// Content area Y offset
223        content_y: u16,
224    },
225
226    /// LSP server request (server -> client)
227    LspServerRequest {
228        /// The language/server that sent the request
229        language: String,
230        /// The JSON-RPC method name
231        method: String,
232        /// The server command used to spawn this LSP
233        server_command: String,
234        /// The request parameters as a JSON string
235        params: Option<String>,
236    },
237
238    /// Viewport changed (scrolled or resized)
239    ViewportChanged {
240        split_id: SplitId,
241        buffer_id: BufferId,
242        top_byte: usize,
243        top_line: Option<usize>,
244        width: u16,
245        height: u16,
246    },
247
248    /// LSP server failed to start or crashed
249    LspServerError {
250        /// The language that failed
251        language: String,
252        /// The server command that failed
253        server_command: String,
254        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
255        error_type: String,
256        /// Human-readable error message
257        message: String,
258    },
259
260    /// User clicked the LSP status indicator
261    LspStatusClicked {
262        /// The language of the current buffer
263        language: String,
264        /// Whether there's an active error
265        has_error: bool,
266        /// Commands of configured servers whose binaries are not on `$PATH`
267        /// (or absolute-path equivalents). Empty when every configured
268        /// server is installed. Plugins can inspect this to show tailored
269        /// install hints without waiting for a failed spawn.
270        missing_servers: Vec<String>,
271        /// Whether the user previously dismissed the LSP pill for this
272        /// language (via the popup's "Disable" action). Plugins seeing
273        /// this as `true` should offer "Enable" / "Install" rather than
274        /// "Start".
275        user_dismissed: bool,
276    },
277
278    /// User selected an action from an action popup
279    ActionPopupResult {
280        /// The popup ID
281        popup_id: String,
282        /// The action ID selected, or "dismissed"
283        action_id: String,
284    },
285
286    /// Background process output (streaming)
287    ProcessOutput {
288        /// The process ID
289        process_id: u64,
290        /// The output data
291        data: String,
292    },
293
294    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
295    LanguageChanged {
296        buffer_id: BufferId,
297        /// The new language identifier (e.g., "markdown", "rust", "text")
298        language: String,
299    },
300
301    /// Request to inspect a theme key in the theme editor
302    ThemeInspectKey {
303        /// The name of the current theme
304        theme_name: String,
305        /// The theme key to inspect (e.g. "editor.bg")
306        key: String,
307    },
308
309    /// Mouse scroll event (wheel up/down)
310    MouseScroll {
311        buffer_id: BufferId,
312        /// Scroll delta: negative = up, positive = down (typically ±3)
313        delta: i32,
314        /// Mouse column (0-based, terminal origin top-left)
315        col: u16,
316        /// Mouse row (0-based, terminal origin top-left)
317        row: u16,
318    },
319
320    /// Terminal was resized
321    Resize { width: u16, height: u16 },
322
323    /// Terminal focus was gained (e.g. user switched back to the editor)
324    FocusGained,
325}
326
327/// Information about a single line for the LinesChanged hook
328#[derive(Debug, Clone, serde::Serialize)]
329pub struct LineInfo {
330    /// Line number (0-based)
331    pub line_number: usize,
332    /// Byte offset where the line starts in the buffer
333    pub byte_start: usize,
334    /// Byte offset where the line ends (exclusive)
335    pub byte_end: usize,
336    /// The content of the line
337    pub content: String,
338}
339
340/// Location information for LSP references
341#[derive(Debug, Clone, serde::Serialize)]
342pub struct LspLocation {
343    /// File path
344    pub file: String,
345    /// Line number (1-based)
346    pub line: u32,
347    /// Column number (1-based)
348    pub column: u32,
349}
350
351/// Type for hook callbacks
352pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
353
354/// Registry for managing hooks
355pub struct HookRegistry {
356    /// Map from hook name to list of callbacks
357    hooks: HashMap<String, Vec<HookCallback>>,
358}
359
360impl HookRegistry {
361    /// Create a new hook registry
362    pub fn new() -> Self {
363        Self {
364            hooks: HashMap::new(),
365        }
366    }
367
368    /// Add a hook callback for a specific hook name
369    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
370        self.hooks
371            .entry(name.to_string())
372            .or_default()
373            .push(callback);
374    }
375
376    /// Remove all hooks for a specific name
377    pub fn remove_hooks(&mut self, name: &str) {
378        self.hooks.remove(name);
379    }
380
381    /// Run all hooks for a specific name
382    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
383        if let Some(hooks) = self.hooks.get(name) {
384            for callback in hooks {
385                if !callback(args) {
386                    return false;
387                }
388            }
389        }
390        true
391    }
392
393    /// Get count of registered callbacks for a hook
394    pub fn hook_count(&self, name: &str) -> usize {
395        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
396    }
397
398    /// Get all registered hook names
399    pub fn hook_names(&self) -> Vec<String> {
400        self.hooks.keys().cloned().collect()
401    }
402}
403
404impl Default for HookRegistry {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410/// Convert HookArgs to a serde_json::Value for plugin communication
411pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
412    let json_value = match args {
413        HookArgs::RenderStart { buffer_id } => {
414            serde_json::json!({
415                "buffer_id": buffer_id.0,
416            })
417        }
418        HookArgs::RenderLine {
419            buffer_id,
420            line_number,
421            byte_start,
422            byte_end,
423            content,
424        } => {
425            serde_json::json!({
426                "buffer_id": buffer_id.0,
427                "line_number": line_number,
428                "byte_start": byte_start,
429                "byte_end": byte_end,
430                "content": content,
431            })
432        }
433        HookArgs::BufferActivated { buffer_id } => {
434            serde_json::json!({ "buffer_id": buffer_id.0 })
435        }
436        HookArgs::BufferDeactivated { buffer_id } => {
437            serde_json::json!({ "buffer_id": buffer_id.0 })
438        }
439        HookArgs::DiagnosticsUpdated { uri, count } => {
440            serde_json::json!({
441                "uri": uri,
442                "count": count,
443            })
444        }
445        HookArgs::BufferClosed { buffer_id } => {
446            serde_json::json!({ "buffer_id": buffer_id.0 })
447        }
448        HookArgs::CursorMoved {
449            buffer_id,
450            cursor_id,
451            old_position,
452            new_position,
453            line,
454            text_properties,
455        } => {
456            serde_json::json!({
457                "buffer_id": buffer_id.0,
458                "cursor_id": cursor_id.0,
459                "old_position": old_position,
460                "new_position": new_position,
461                "line": line,
462                "text_properties": text_properties,
463            })
464        }
465        HookArgs::BeforeInsert {
466            buffer_id,
467            position,
468            text,
469        } => {
470            serde_json::json!({
471                "buffer_id": buffer_id.0,
472                "position": position,
473                "text": text,
474            })
475        }
476        HookArgs::AfterInsert {
477            buffer_id,
478            position,
479            text,
480            affected_start,
481            affected_end,
482            start_line,
483            end_line,
484            lines_added,
485        } => {
486            serde_json::json!({
487                "buffer_id": buffer_id.0,
488                "position": position,
489                "text": text,
490                "affected_start": affected_start,
491                "affected_end": affected_end,
492                "start_line": start_line,
493                "end_line": end_line,
494                "lines_added": lines_added,
495            })
496        }
497        HookArgs::BeforeDelete { buffer_id, range } => {
498            serde_json::json!({
499                "buffer_id": buffer_id.0,
500                "start": range.start,
501                "end": range.end,
502            })
503        }
504        HookArgs::AfterDelete {
505            buffer_id,
506            range,
507            deleted_text,
508            affected_start,
509            deleted_len,
510            start_line,
511            end_line,
512            lines_removed,
513        } => {
514            serde_json::json!({
515                "buffer_id": buffer_id.0,
516                "start": range.start,
517                "end": range.end,
518                "deleted_text": deleted_text,
519                "affected_start": affected_start,
520                "deleted_len": deleted_len,
521                "start_line": start_line,
522                "end_line": end_line,
523                "lines_removed": lines_removed,
524            })
525        }
526        HookArgs::BeforeFileOpen { path } => {
527            serde_json::json!({ "path": path.to_string_lossy() })
528        }
529        HookArgs::AfterFileOpen { path, buffer_id } => {
530            serde_json::json!({
531                "path": path.to_string_lossy(),
532                "buffer_id": buffer_id.0,
533            })
534        }
535        HookArgs::BeforeFileSave { path, buffer_id } => {
536            serde_json::json!({
537                "path": path.to_string_lossy(),
538                "buffer_id": buffer_id.0,
539            })
540        }
541        HookArgs::AfterFileSave { path, buffer_id } => {
542            serde_json::json!({
543                "path": path.to_string_lossy(),
544                "buffer_id": buffer_id.0,
545            })
546        }
547        HookArgs::PreCommand { action } => {
548            serde_json::json!({ "action": format!("{:?}", action) })
549        }
550        HookArgs::PostCommand { action } => {
551            serde_json::json!({ "action": format!("{:?}", action) })
552        }
553        HookArgs::Idle { milliseconds } => {
554            serde_json::json!({ "milliseconds": milliseconds })
555        }
556        HookArgs::EditorInitialized => {
557            serde_json::json!({})
558        }
559        HookArgs::PluginsLoaded => {
560            serde_json::json!({})
561        }
562        HookArgs::Ready => {
563            serde_json::json!({})
564        }
565        HookArgs::PromptChanged { prompt_type, input } => {
566            serde_json::json!({
567                "prompt_type": prompt_type,
568                "input": input,
569            })
570        }
571        HookArgs::PromptConfirmed {
572            prompt_type,
573            input,
574            selected_index,
575        } => {
576            serde_json::json!({
577                "prompt_type": prompt_type,
578                "input": input,
579                "selected_index": selected_index,
580            })
581        }
582        HookArgs::PromptCancelled { prompt_type, input } => {
583            serde_json::json!({
584                "prompt_type": prompt_type,
585                "input": input,
586            })
587        }
588        HookArgs::PromptSelectionChanged {
589            prompt_type,
590            selected_index,
591        } => {
592            serde_json::json!({
593                "prompt_type": prompt_type,
594                "selected_index": selected_index,
595            })
596        }
597        HookArgs::KeyboardShortcuts { bindings } => {
598            let entries: Vec<serde_json::Value> = bindings
599                .iter()
600                .map(|(key, action)| serde_json::json!({ "key": key, "action": action }))
601                .collect();
602            serde_json::json!({ "bindings": entries })
603        }
604        HookArgs::LspReferences { symbol, locations } => {
605            let locs: Vec<serde_json::Value> = locations
606                .iter()
607                .map(|loc| {
608                    serde_json::json!({
609                        "file": loc.file,
610                        "line": loc.line,
611                        "column": loc.column,
612                    })
613                })
614                .collect();
615            serde_json::json!({ "symbol": symbol, "locations": locs })
616        }
617        HookArgs::LinesChanged { buffer_id, lines } => {
618            let lines_json: Vec<serde_json::Value> = lines
619                .iter()
620                .map(|line| {
621                    serde_json::json!({
622                        "line_number": line.line_number,
623                        "byte_start": line.byte_start,
624                        "byte_end": line.byte_end,
625                        "content": line.content,
626                    })
627                })
628                .collect();
629            serde_json::json!({
630                "buffer_id": buffer_id.0,
631                "lines": lines_json,
632            })
633        }
634        HookArgs::ViewTransformRequest {
635            buffer_id,
636            split_id,
637            viewport_start,
638            viewport_end,
639            tokens,
640            cursor_positions,
641        } => {
642            let tokens_json: Vec<serde_json::Value> = tokens
643                .iter()
644                .map(|token| {
645                    let kind_json = match &token.kind {
646                        ViewTokenWireKind::Text(s) => serde_json::json!({ "Text": s }),
647                        ViewTokenWireKind::Newline => serde_json::json!("Newline"),
648                        ViewTokenWireKind::Space => serde_json::json!("Space"),
649                        ViewTokenWireKind::Break => serde_json::json!("Break"),
650                        ViewTokenWireKind::BinaryByte(b) => serde_json::json!({ "BinaryByte": b }),
651                    };
652                    serde_json::json!({
653                        "source_offset": token.source_offset,
654                        "kind": kind_json,
655                    })
656                })
657                .collect();
658            serde_json::json!({
659                "buffer_id": buffer_id.0,
660                "split_id": split_id.0,
661                "viewport_start": viewport_start,
662                "viewport_end": viewport_end,
663                "tokens": tokens_json,
664                "cursor_positions": cursor_positions,
665            })
666        }
667        HookArgs::MouseClick {
668            column,
669            row,
670            button,
671            modifiers,
672            content_x,
673            content_y,
674            buffer_id,
675            buffer_row,
676            buffer_col,
677        } => {
678            serde_json::json!({
679                "column": column,
680                "row": row,
681                "button": button,
682                "modifiers": modifiers,
683                "content_x": content_x,
684                "content_y": content_y,
685                "buffer_id": buffer_id,
686                "buffer_row": buffer_row,
687                "buffer_col": buffer_col,
688            })
689        }
690        HookArgs::MouseMove {
691            column,
692            row,
693            content_x,
694            content_y,
695        } => {
696            serde_json::json!({
697                "column": column,
698                "row": row,
699                "content_x": content_x,
700                "content_y": content_y,
701            })
702        }
703        HookArgs::LspServerRequest {
704            language,
705            method,
706            server_command,
707            params,
708        } => {
709            serde_json::json!({
710                "language": language,
711                "method": method,
712                "server_command": server_command,
713                "params": params,
714            })
715        }
716        HookArgs::ViewportChanged {
717            split_id,
718            buffer_id,
719            top_byte,
720            top_line,
721            width,
722            height,
723        } => {
724            serde_json::json!({
725                "split_id": split_id.0,
726                "buffer_id": buffer_id.0,
727                "top_byte": top_byte,
728                "top_line": top_line,
729                "width": width,
730                "height": height,
731            })
732        }
733        HookArgs::LspServerError {
734            language,
735            server_command,
736            error_type,
737            message,
738        } => {
739            serde_json::json!({
740                "language": language,
741                "server_command": server_command,
742                "error_type": error_type,
743                "message": message,
744            })
745        }
746        HookArgs::LspStatusClicked {
747            language,
748            has_error,
749            missing_servers,
750            user_dismissed,
751        } => {
752            serde_json::json!({
753                "language": language,
754                "has_error": has_error,
755                "missing_servers": missing_servers,
756                "user_dismissed": user_dismissed,
757            })
758        }
759        HookArgs::ActionPopupResult {
760            popup_id,
761            action_id,
762        } => {
763            serde_json::json!({
764                "popup_id": popup_id,
765                "action_id": action_id,
766            })
767        }
768        HookArgs::ProcessOutput { process_id, data } => {
769            serde_json::json!({
770                "process_id": process_id,
771                "data": data,
772            })
773        }
774        HookArgs::LanguageChanged {
775            buffer_id,
776            language,
777        } => {
778            serde_json::json!({
779                "buffer_id": buffer_id.0,
780                "language": language,
781            })
782        }
783        HookArgs::ThemeInspectKey { theme_name, key } => {
784            serde_json::json!({
785                "theme_name": theme_name,
786                "key": key,
787            })
788        }
789        HookArgs::MouseScroll {
790            buffer_id,
791            delta,
792            col,
793            row,
794        } => {
795            serde_json::json!({
796                "buffer_id": buffer_id.0,
797                "delta": delta,
798                "col": col,
799                "row": row,
800            })
801        }
802        HookArgs::Resize { width, height } => {
803            serde_json::json!({
804                "width": width,
805                "height": height,
806            })
807        }
808        HookArgs::FocusGained => {
809            serde_json::json!({})
810        }
811    };
812
813    Ok(json_value)
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use std::sync::atomic::{AtomicUsize, Ordering};
820    use std::sync::Arc;
821
822    fn noop_true() -> HookCallback {
823        Box::new(|_| true)
824    }
825
826    /// Adding, listing, counting, and removing hooks behave consistently:
827    /// counts match the number added, names reflect the keys, and removal
828    /// purges all callbacks for that key.
829    #[test]
830    fn add_count_list_remove_round_trip() {
831        let mut reg = HookRegistry::new();
832        assert_eq!(reg.hook_count("a"), 0);
833        assert!(reg.hook_names().is_empty());
834
835        reg.add_hook("a", noop_true());
836        reg.add_hook("a", noop_true());
837        reg.add_hook("b", noop_true());
838
839        assert_eq!(reg.hook_count("a"), 2);
840        assert_eq!(reg.hook_count("b"), 1);
841        assert_eq!(reg.hook_count("missing"), 0);
842
843        let mut names = reg.hook_names();
844        names.sort();
845        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
846
847        reg.remove_hooks("a");
848        assert_eq!(reg.hook_count("a"), 0);
849        assert_eq!(reg.hook_count("b"), 1);
850        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
851    }
852
853    /// `run_hooks` returns true iff every callback returned true, short-circuits
854    /// on the first `false`, and returns true for hook names with no callbacks.
855    #[test]
856    fn run_hooks_all_true_and_short_circuits_on_false() {
857        let mut reg = HookRegistry::new();
858        let args = HookArgs::EditorInitialized;
859
860        // Unknown hook: treated as "no callbacks" → true.
861        assert!(reg.run_hooks("unknown", &args));
862
863        // All-true chain returns true and calls every callback.
864        let calls = Arc::new(AtomicUsize::new(0));
865        for _ in 0..3 {
866            let c = calls.clone();
867            reg.add_hook(
868                "all_true",
869                Box::new(move |_| {
870                    c.fetch_add(1, Ordering::SeqCst);
871                    true
872                }),
873            );
874        }
875        assert!(reg.run_hooks("all_true", &args));
876        assert_eq!(calls.load(Ordering::SeqCst), 3);
877
878        // Short-circuits on the first `false` — the second callback must not run.
879        let calls = Arc::new(AtomicUsize::new(0));
880        let c1 = calls.clone();
881        reg.add_hook(
882            "short",
883            Box::new(move |_| {
884                c1.fetch_add(1, Ordering::SeqCst);
885                false
886            }),
887        );
888        let c2 = calls.clone();
889        reg.add_hook(
890            "short",
891            Box::new(move |_| {
892                c2.fetch_add(1, Ordering::SeqCst);
893                true
894            }),
895        );
896        assert!(!reg.run_hooks("short", &args));
897        assert_eq!(calls.load(Ordering::SeqCst), 1);
898    }
899
900    /// `hook_args_to_json` produces an object with the expected field for
901    /// a representative variant — ensuring the function actually serializes
902    /// the payload instead of returning a default (null) value.
903    #[test]
904    fn hook_args_to_json_serializes_payload_fields() {
905        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
906            uri: "file:///x.rs".into(),
907            count: 7,
908        })
909        .unwrap();
910        assert_eq!(json["uri"], "file:///x.rs");
911        assert_eq!(json["count"], 7);
912    }
913}