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::path::PathBuf;
8
9use crate::action::Action;
10use crate::api::ViewTokenWire;
11use crate::{BufferId, CursorId, SplitId};
12
13/// Arguments passed to hook callbacks
14#[derive(Debug, Clone, serde::Serialize)]
15#[serde(untagged)]
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 /// The file explorer mutated the filesystem (paste, duplicate, ...)
30 /// without going through a buffer save. Plugins that surface
31 /// filesystem-derived state (git status decorations, etc.) use this
32 /// to re-scan after explorer-driven changes that wouldn't otherwise
33 /// fire `BeforeFileSave`/`AfterFileSave`. `path` is one of the
34 /// affected paths; for batch operations (multi-paste) the hook
35 /// fires once per refresh, not once per file.
36 AfterFileExplorerChange { path: PathBuf },
37
38 /// A buffer was closed
39 BufferClosed { buffer_id: BufferId },
40
41 /// Before text is inserted
42 BeforeInsert {
43 buffer_id: BufferId,
44 position: usize,
45 text: String,
46 },
47
48 /// After text was inserted
49 AfterInsert {
50 buffer_id: BufferId,
51 position: usize,
52 text: String,
53 /// Byte position where the affected range starts
54 affected_start: usize,
55 /// Byte position where the affected range ends (after the inserted text)
56 affected_end: usize,
57 /// Line number where insertion occurred (0-indexed)
58 start_line: usize,
59 /// Line number where insertion ended (0-indexed)
60 end_line: usize,
61 /// Number of lines added by this insertion
62 lines_added: usize,
63 },
64
65 /// Before text is deleted
66 BeforeDelete {
67 buffer_id: BufferId,
68 start: usize,
69 end: usize,
70 },
71
72 /// After text was deleted
73 AfterDelete {
74 buffer_id: BufferId,
75 start: usize,
76 end: usize,
77 deleted_text: String,
78 /// Byte position where the deletion occurred
79 affected_start: usize,
80 /// Length of the deleted content in bytes
81 deleted_len: usize,
82 /// Line number where deletion started (0-indexed)
83 start_line: usize,
84 /// Line number where deletion ended (0-indexed, in original buffer)
85 end_line: usize,
86 /// Number of lines removed by this deletion
87 lines_removed: usize,
88 },
89
90 /// Cursor moved to a new position
91 CursorMoved {
92 buffer_id: BufferId,
93 cursor_id: CursorId,
94 old_position: usize,
95 new_position: usize,
96 /// Line number at new position (1-indexed)
97 line: usize,
98 /// Text properties at the new cursor position
99 text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
100 },
101
102 /// Buffer became active
103 BufferActivated { buffer_id: BufferId },
104
105 /// Buffer was deactivated
106 BufferDeactivated { buffer_id: BufferId },
107
108 /// LSP diagnostics were updated for a file
109 DiagnosticsUpdated {
110 /// The URI of the file that was updated
111 uri: String,
112 /// Number of diagnostics in the update
113 count: usize,
114 },
115
116 /// Before a command/action is executed
117 PreCommand { action: Action },
118
119 /// After a command/action was executed
120 PostCommand { action: Action },
121
122 /// Editor has been idle for N milliseconds (no input)
123 Idle { milliseconds: u64 },
124
125 /// Editor is initializing
126 EditorInitialized {},
127
128 /// All plugin packages + init.ts have been loaded. Fires after the
129 /// plugin discovery loop and before session restore — the lifecycle
130 /// hook for code that wants to configure a plugin via its
131 /// getPluginApi(...) surface. See design §3.3 (phase 2).
132 PluginsLoaded {},
133
134 /// Editor has completed startup: plugins are loaded, session is
135 /// restored, and the active buffer exists. Design §3.3 (phase 3).
136 Ready {},
137
138 /// The editor's active authority changed (e.g. local → container,
139 /// container → local). Fires after the new authority is in place
140 /// and the plugin state snapshot has been refreshed, so handlers
141 /// can read the new label via `editor.getAuthorityLabel()`.
142 /// Plugins use this to re-register state-dependent commands
143 /// that should only appear in one authority mode (e.g. dev
144 /// container `Detach` only when attached). In production a
145 /// transition triggers a full editor restart that re-runs plugin
146 /// init from scratch; this hook lets plugins react inline
147 /// without that, which keeps the harness in sync too.
148 AuthorityChanged { label: String },
149
150 /// The workspace trust level changed (e.g. Restricted → Trusted) via the
151 /// trust modal, the status-bar trust pill, or a plugin-dispatched trust
152 /// action. Fires after the new level is live and persisted *and* the
153 /// plugin state snapshot has been refreshed, so handlers can read it via
154 /// `editor.workspaceTrustLevel()` (or the `level` payload). Plugins use
155 /// this to re-trigger trust-gated work inline — env-manager re-runs its
156 /// activation flow the moment a folder is trusted, instead of waiting for
157 /// the next window switch or editor restart. Deliberately a lightweight
158 /// hook, *not* an editor rebuild (which would reset other sessions).
159 TrustChanged { level: String },
160
161 /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
162 RenderStart { buffer_id: BufferId },
163
164 /// A line is being rendered (called during the rendering pass)
165 RenderLine {
166 buffer_id: BufferId,
167 line_number: usize,
168 byte_start: usize,
169 byte_end: usize,
170 content: String,
171 },
172
173 /// Lines have changed and need processing (batched for efficiency)
174 LinesChanged {
175 buffer_id: BufferId,
176 lines: Vec<LineInfo>,
177 },
178
179 /// Prompt input changed (user typed/edited)
180 PromptChanged { prompt_type: String, input: String },
181
182 /// Prompt was confirmed (user pressed Enter)
183 PromptConfirmed {
184 prompt_type: String,
185 input: String,
186 selected_index: Option<usize>,
187 },
188
189 /// Prompt was cancelled (user pressed Escape/Ctrl+G)
190 PromptCancelled { prompt_type: String, input: String },
191
192 /// Prompt suggestion selection changed (user navigated with Up/Down)
193 PromptSelectionChanged {
194 prompt_type: String,
195 selected_index: usize,
196 },
197
198 /// Request keyboard shortcuts data (key, action) for the help buffer
199 KeyboardShortcuts { bindings: Vec<(String, String)> },
200
201 /// LSP find references response received
202 LspReferences {
203 /// The symbol name being queried
204 symbol: String,
205 /// The locations where the symbol is referenced
206 locations: Vec<LspLocation>,
207 },
208
209 /// LSP go-to-implementation response received
210 LspImplementation {
211 /// The symbol name being queried
212 symbol: String,
213 /// The locations where the symbol is implemented
214 locations: Vec<LspLocation>,
215 },
216
217 /// View transform request
218 ViewTransformRequest {
219 buffer_id: BufferId,
220 split_id: SplitId,
221 /// Byte offset of the viewport start
222 viewport_start: usize,
223 /// Byte offset of the viewport end
224 viewport_end: usize,
225 /// Base tokens (Text, Newline, Space) from the source
226 tokens: Vec<ViewTokenWire>,
227 /// Byte positions of all cursors in this buffer
228 cursor_positions: Vec<usize>,
229 },
230
231 /// Mouse click event
232 MouseClick {
233 /// Column (x coordinate) in screen cells
234 column: u16,
235 /// Row (y coordinate) in screen cells
236 row: u16,
237 /// Mouse button: "left", "right", "middle"
238 button: String,
239 /// Modifier keys
240 modifiers: String,
241 /// Content area X offset
242 content_x: u16,
243 /// Content area Y offset
244 content_y: u16,
245 /// Buffer under the click (None when the click is outside any
246 /// buffer panel).
247 buffer_id: Option<u64>,
248 /// 0-indexed buffer row (line number) of the click, accounting
249 /// for scroll. None when the click is outside any buffer.
250 buffer_row: Option<u32>,
251 /// 0-indexed byte column inside the buffer row. None when the
252 /// click is outside any buffer.
253 buffer_col: Option<u32>,
254 },
255
256 /// Mouse move/hover event
257 MouseMove {
258 /// Column (x coordinate) in screen cells
259 column: u16,
260 /// Row (y coordinate) in screen cells
261 row: u16,
262 /// Content area X offset
263 content_x: u16,
264 /// Content area Y offset
265 content_y: u16,
266 },
267
268 /// LSP server request (server -> client)
269 LspServerRequest {
270 /// The language/server that sent the request
271 language: String,
272 /// The JSON-RPC method name
273 method: String,
274 /// The server command used to spawn this LSP
275 server_command: String,
276 /// The request parameters as a JSON string
277 params: Option<String>,
278 },
279
280 /// Viewport changed (scrolled or resized)
281 ViewportChanged {
282 split_id: SplitId,
283 buffer_id: BufferId,
284 top_byte: usize,
285 top_line: Option<usize>,
286 width: u16,
287 height: u16,
288 },
289
290 /// LSP server failed to start or crashed
291 LspServerError {
292 /// The language that failed
293 language: String,
294 /// The server command that failed
295 server_command: String,
296 /// Error type: "not_found", "spawn_failed", "timeout", "crash"
297 error_type: String,
298 /// Human-readable error message
299 message: String,
300 },
301
302 /// User clicked the LSP status indicator
303 LspStatusClicked {
304 /// The language of the current buffer
305 language: String,
306 /// Whether there's an active error
307 has_error: bool,
308 /// Commands of configured servers whose binaries are not on `$PATH`
309 /// (or absolute-path equivalents). Empty when every configured
310 /// server is installed. Plugins can inspect this to show tailored
311 /// install hints without waiting for a failed spawn.
312 missing_servers: Vec<String>,
313 /// Whether the user previously dismissed the LSP pill for this
314 /// language (via the popup's "Disable" action). Plugins seeing
315 /// this as `true` should offer "Enable" / "Install" rather than
316 /// "Start".
317 user_dismissed: bool,
318 },
319
320 /// User selected an action from an action popup
321 ActionPopupResult {
322 /// The popup ID
323 popup_id: String,
324 /// The action ID selected, or "dismissed"
325 action_id: String,
326 },
327
328 /// User clicked a plugin-registered status-bar token. Fires
329 /// regardless of which plugin registered it; subscribers filter
330 /// by `plugin_name` + `token_name`. Plugins typically use this
331 /// to re-open a deferred prompt or surface the relevant
332 /// settings UI for what the token represents (e.g., trust chip
333 /// click → trust elevation popup, env pill click → env activate
334 /// popup).
335 StatusBarTokenClicked {
336 /// Plugin that originally called `RegisterStatusBarElement`.
337 plugin_name: String,
338 /// Token name within that plugin's namespace.
339 token_name: String,
340 },
341
342 /// Background process output (streaming)
343 ProcessOutput {
344 /// The process ID
345 process_id: u64,
346 /// The output data
347 data: String,
348 },
349
350 /// A new editor session was created. Fires after the session is
351 /// added to `Editor.sessions`, before any UI retarget. Plugins
352 /// (like Orchestrator) use this to reconcile their per-session
353 /// bookkeeping with the editor.
354 WindowCreated {
355 /// The new session's stable id.
356 id: u64,
357 /// Resolved label (basename fallback applied).
358 label: String,
359 /// Absolute project root.
360 root: String,
361 },
362
363 /// An editor session was closed and its state dropped. The id
364 /// is still valid in the payload but is no longer present in
365 /// `editor.listWindows()`.
366 WindowClosed { id: u64 },
367
368 /// The active session changed. Fires after the editor's UI has
369 /// retargeted (file tree, working_dir, snapshot). Plugins
370 /// observing for "the editor's project root just changed" use
371 /// this rather than polling.
372 ActiveWindowChanged {
373 /// The previously active session id, or `None` only on
374 /// first switch from the initial base session — currently
375 /// always `Some` since the base session always exists.
376 previous_id: Option<u64>,
377 /// The newly active session id. Always present in the
378 /// `sessions` list.
379 active_id: u64,
380 },
381
382 /// PTY terminal received output bytes from the spawned process.
383 /// Fires for every async batch the editor reads off the PTY, so it
384 /// is hot — consumers should be cheap. The payload includes only a
385 /// snapshot of the last visible (cursor) row so plugins can detect
386 /// prompt patterns (`(Y/n)`, `Press enter`, `> `) without an extra
387 /// readback API. Plugins that need full output should tail the
388 /// terminal's backing file via the existing buffer.
389 TerminalOutput {
390 /// Stable terminal session id (matches `TerminalId.0`).
391 terminal_id: u64,
392 /// Editor window that owns this terminal (matches `WindowId.0`).
393 /// Lets a plugin attribute output to a *session* — Orchestrator
394 /// keys activity off the window, so output from ANY terminal in
395 /// the window (not just the one the plugin spawned) counts. Fires
396 /// on every PTY read, so in-place redraws and carriage-return
397 /// progress bars register as activity too, not just newlines.
398 window_id: u64,
399 /// Snapshot of the cursor row's text content. May be empty
400 /// (just-resized terminal, cleared screen). Trailing whitespace
401 /// is preserved because prompt detection often depends on it
402 /// (e.g. `"... (Y/n): "` ends in a space).
403 last_line: String,
404 },
405
406 /// PTY terminal's spawned process has ended. Fires once per
407 /// terminal lifetime, after the editor has flushed any final
408 /// scrollback to the backing file.
409 TerminalExited {
410 /// Stable terminal session id (matches `TerminalId.0`).
411 terminal_id: u64,
412 /// Editor window that owned this terminal (matches `WindowId.0`).
413 window_id: u64,
414 /// Process exit code if known. `None` when the platform did
415 /// not report a status (signal, detach, kill before wait).
416 /// Plugins that can't distinguish should treat `None` as
417 /// "errored, cause unknown" rather than "ready".
418 exit_code: Option<i32>,
419 },
420
421 /// A path under a `watchPath`-registered watcher changed.
422 /// Plugins (Orchestrator's collision radar, etc.) use this to
423 /// build path → modifying-session-set matrices. Fires once per
424 /// raw `notify` event — no debouncing in core; plugins coalesce
425 /// per their policy.
426 PathChanged {
427 /// Watch handle that delivered this event. Maps back to
428 /// the `watchPath()` call that registered it; lets plugins
429 /// route events to per-watcher state.
430 handle: u64,
431 /// Absolute path the kernel reported as changed.
432 path: String,
433 /// `"modify"` | `"create"` | `"delete"` | `"rename"` |
434 /// `"other"`. Conservative bucketing of `notify::EventKind`
435 /// — plugins that need finer detail can switch on more
436 /// specific strings the editor learns to emit later.
437 kind: String,
438 },
439
440 /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
441 LanguageChanged {
442 buffer_id: BufferId,
443 /// The new language identifier (e.g., "markdown", "rust", "text")
444 language: String,
445 },
446
447 /// Request to inspect a theme key in the theme editor
448 ThemeInspectKey {
449 /// The name of the current theme
450 theme_name: String,
451 /// The theme key to inspect (e.g. "editor.bg")
452 key: String,
453 },
454
455 /// Mouse scroll event (wheel up/down)
456 MouseScroll {
457 buffer_id: BufferId,
458 /// Scroll delta: negative = up, positive = down (typically ±3)
459 delta: i32,
460 /// Mouse column (0-based, terminal origin top-left)
461 col: u16,
462 /// Mouse row (0-based, terminal origin top-left)
463 row: u16,
464 },
465
466 /// Terminal was resized
467 Resize { width: u16, height: u16 },
468
469 /// Terminal focus was gained (e.g. user switched back to the editor)
470 FocusGained {},
471
472 /// A widget mounted via `MountWidgetPanel` emitted a semantic event.
473 /// Plugins subscribe via `editor.on("widget_event", "<handler>")`
474 /// and dispatch on `(panel_id, widget_key, event_type)`.
475 ///
476 /// `event_type` is one of: `"activate"`, `"toggle"`, `"change"`,
477 /// `"submit"`, `"hover"`, `"dismiss"`, `"focus"`. `payload` is
478 /// event-specific JSON (e.g. `{ "value": "search text" }` for
479 /// `change`, `{ "previous": "<old key>" }` for `focus`).
480 ///
481 /// At v1 only widgets that have user-driven behaviour fire this
482 /// hook. The HintBar widget is read-only and does not emit events.
483 WidgetEvent {
484 /// The plugin-allocated panel ID from the original
485 /// `MountWidgetPanel`.
486 panel_id: u64,
487 /// The stable `key` of the widget node that fired the event,
488 /// or empty when the event originates from the panel root.
489 widget_key: String,
490 /// The kind of event — see variants above.
491 event_type: String,
492 /// Event-specific JSON payload.
493 #[serde(default)]
494 payload: serde_json::Value,
495 },
496}
497
498/// Information about a single line for the LinesChanged hook
499#[derive(Debug, Clone, serde::Serialize)]
500pub struct LineInfo {
501 /// Line number (0-based)
502 pub line_number: usize,
503 /// Byte offset where the line starts in the buffer
504 pub byte_start: usize,
505 /// Byte offset where the line ends (exclusive)
506 pub byte_end: usize,
507 /// The content of the line
508 pub content: String,
509}
510
511/// Location information for LSP references
512#[derive(Debug, Clone, serde::Serialize)]
513pub struct LspLocation {
514 /// File path
515 pub file: String,
516 /// Line number (1-based)
517 pub line: u32,
518 /// Column number (1-based)
519 pub column: u32,
520}
521
522/// Type for hook callbacks
523pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
524
525/// Registry for managing hooks
526pub struct HookRegistry {
527 /// Map from hook name to list of callbacks
528 hooks: HashMap<String, Vec<HookCallback>>,
529}
530
531impl HookRegistry {
532 /// Create a new hook registry
533 pub fn new() -> Self {
534 Self {
535 hooks: HashMap::new(),
536 }
537 }
538
539 /// Add a hook callback for a specific hook name
540 pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
541 self.hooks
542 .entry(name.to_string())
543 .or_default()
544 .push(callback);
545 }
546
547 /// Remove all hooks for a specific name
548 pub fn remove_hooks(&mut self, name: &str) {
549 self.hooks.remove(name);
550 }
551
552 /// Run all hooks for a specific name
553 pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
554 if let Some(hooks) = self.hooks.get(name) {
555 for callback in hooks {
556 if !callback(args) {
557 return false;
558 }
559 }
560 }
561 true
562 }
563
564 /// Get count of registered callbacks for a hook
565 pub fn hook_count(&self, name: &str) -> usize {
566 self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
567 }
568
569 /// Get all registered hook names
570 pub fn hook_names(&self) -> Vec<String> {
571 self.hooks.keys().cloned().collect()
572 }
573}
574
575impl Default for HookRegistry {
576 fn default() -> Self {
577 Self::new()
578 }
579}
580
581/// Convert HookArgs to a serde_json::Value for plugin communication.
582///
583/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
584/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
585/// an empty JSON object rather than `null`.
586pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
587 Ok(serde_json::to_value(args)?)
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use std::sync::atomic::{AtomicUsize, Ordering};
594 use std::sync::Arc;
595
596 fn noop_true() -> HookCallback {
597 Box::new(|_| true)
598 }
599
600 /// Adding, listing, counting, and removing hooks behave consistently:
601 /// counts match the number added, names reflect the keys, and removal
602 /// purges all callbacks for that key.
603 #[test]
604 fn add_count_list_remove_round_trip() {
605 let mut reg = HookRegistry::new();
606 assert_eq!(reg.hook_count("a"), 0);
607 assert!(reg.hook_names().is_empty());
608
609 reg.add_hook("a", noop_true());
610 reg.add_hook("a", noop_true());
611 reg.add_hook("b", noop_true());
612
613 assert_eq!(reg.hook_count("a"), 2);
614 assert_eq!(reg.hook_count("b"), 1);
615 assert_eq!(reg.hook_count("missing"), 0);
616
617 let mut names = reg.hook_names();
618 names.sort();
619 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
620
621 reg.remove_hooks("a");
622 assert_eq!(reg.hook_count("a"), 0);
623 assert_eq!(reg.hook_count("b"), 1);
624 assert_eq!(reg.hook_names(), vec!["b".to_string()]);
625 }
626
627 /// `run_hooks` returns true iff every callback returned true, short-circuits
628 /// on the first `false`, and returns true for hook names with no callbacks.
629 #[test]
630 fn run_hooks_all_true_and_short_circuits_on_false() {
631 let mut reg = HookRegistry::new();
632 let args = HookArgs::EditorInitialized {};
633
634 // Unknown hook: treated as "no callbacks" → true.
635 assert!(reg.run_hooks("unknown", &args));
636
637 // All-true chain returns true and calls every callback.
638 let calls = Arc::new(AtomicUsize::new(0));
639 for _ in 0..3 {
640 let c = calls.clone();
641 reg.add_hook(
642 "all_true",
643 Box::new(move |_| {
644 c.fetch_add(1, Ordering::SeqCst);
645 true
646 }),
647 );
648 }
649 assert!(reg.run_hooks("all_true", &args));
650 assert_eq!(calls.load(Ordering::SeqCst), 3);
651
652 // Short-circuits on the first `false` — the second callback must not run.
653 let calls = Arc::new(AtomicUsize::new(0));
654 let c1 = calls.clone();
655 reg.add_hook(
656 "short",
657 Box::new(move |_| {
658 c1.fetch_add(1, Ordering::SeqCst);
659 false
660 }),
661 );
662 let c2 = calls.clone();
663 reg.add_hook(
664 "short",
665 Box::new(move |_| {
666 c2.fetch_add(1, Ordering::SeqCst);
667 true
668 }),
669 );
670 assert!(!reg.run_hooks("short", &args));
671 assert_eq!(calls.load(Ordering::SeqCst), 1);
672 }
673
674 /// `hook_args_to_json` produces an object with the expected field for
675 /// a representative variant — ensuring the function actually serializes
676 /// the payload instead of returning a default (null) value.
677 #[test]
678 fn hook_args_to_json_serializes_payload_fields() {
679 let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
680 uri: "file:///x.rs".into(),
681 count: 7,
682 })
683 .unwrap();
684 assert_eq!(json["uri"], "file:///x.rs");
685 assert_eq!(json["count"], 7);
686 }
687
688 #[test]
689 fn hook_args_to_json_empty_variants_produce_empty_object() {
690 for args in [
691 HookArgs::EditorInitialized {},
692 HookArgs::PluginsLoaded {},
693 HookArgs::Ready {},
694 HookArgs::FocusGained {},
695 ] {
696 let json = hook_args_to_json(&args).unwrap();
697 assert_eq!(
698 json,
699 serde_json::json!({}),
700 "variant should serialize as {{}}"
701 );
702 }
703 }
704
705 #[test]
706 fn hook_args_to_json_terminal_output_fields_are_flat() {
707 let json = hook_args_to_json(&HookArgs::TerminalOutput {
708 terminal_id: 7,
709 window_id: 2,
710 last_line: "Do you want me to attempt a fix? (Y/n): ".into(),
711 })
712 .unwrap();
713 assert_eq!(json["terminal_id"], 7);
714 assert_eq!(json["window_id"], 2);
715 assert_eq!(
716 json["last_line"],
717 "Do you want me to attempt a fix? (Y/n): "
718 );
719 }
720
721 #[test]
722 fn hook_args_to_json_terminal_exited_serializes_exit_code() {
723 let json_some = hook_args_to_json(&HookArgs::TerminalExited {
724 terminal_id: 3,
725 window_id: 1,
726 exit_code: Some(0),
727 })
728 .unwrap();
729 assert_eq!(json_some["terminal_id"], 3);
730 assert_eq!(json_some["window_id"], 1);
731 assert_eq!(json_some["exit_code"], 0);
732
733 let json_err = hook_args_to_json(&HookArgs::TerminalExited {
734 terminal_id: 4,
735 window_id: 1,
736 exit_code: Some(2),
737 })
738 .unwrap();
739 assert_eq!(json_err["exit_code"], 2);
740
741 let json_none = hook_args_to_json(&HookArgs::TerminalExited {
742 terminal_id: 5,
743 window_id: 1,
744 exit_code: None,
745 })
746 .unwrap();
747 assert!(
748 json_none["exit_code"].is_null(),
749 "exit_code: None should serialize as JSON null, not omitted: got {json_none}"
750 );
751 }
752
753 #[test]
754 fn hook_args_to_json_delete_fields_are_flat() {
755 let json = hook_args_to_json(&HookArgs::BeforeDelete {
756 buffer_id: crate::BufferId(1),
757 start: 10,
758 end: 20,
759 })
760 .unwrap();
761 assert_eq!(json["start"], 10);
762 assert_eq!(json["end"], 20);
763 assert!(json.get("range").is_none(), "range must not be nested");
764 }
765}