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 /// View transform request
210 ViewTransformRequest {
211 buffer_id: BufferId,
212 split_id: SplitId,
213 /// Byte offset of the viewport start
214 viewport_start: usize,
215 /// Byte offset of the viewport end
216 viewport_end: usize,
217 /// Base tokens (Text, Newline, Space) from the source
218 tokens: Vec<ViewTokenWire>,
219 /// Byte positions of all cursors in this buffer
220 cursor_positions: Vec<usize>,
221 },
222
223 /// Mouse click event
224 MouseClick {
225 /// Column (x coordinate) in screen cells
226 column: u16,
227 /// Row (y coordinate) in screen cells
228 row: u16,
229 /// Mouse button: "left", "right", "middle"
230 button: String,
231 /// Modifier keys
232 modifiers: String,
233 /// Content area X offset
234 content_x: u16,
235 /// Content area Y offset
236 content_y: u16,
237 /// Buffer under the click (None when the click is outside any
238 /// buffer panel).
239 buffer_id: Option<u64>,
240 /// 0-indexed buffer row (line number) of the click, accounting
241 /// for scroll. None when the click is outside any buffer.
242 buffer_row: Option<u32>,
243 /// 0-indexed byte column inside the buffer row. None when the
244 /// click is outside any buffer.
245 buffer_col: Option<u32>,
246 },
247
248 /// Mouse move/hover event
249 MouseMove {
250 /// Column (x coordinate) in screen cells
251 column: u16,
252 /// Row (y coordinate) in screen cells
253 row: u16,
254 /// Content area X offset
255 content_x: u16,
256 /// Content area Y offset
257 content_y: u16,
258 },
259
260 /// LSP server request (server -> client)
261 LspServerRequest {
262 /// The language/server that sent the request
263 language: String,
264 /// The JSON-RPC method name
265 method: String,
266 /// The server command used to spawn this LSP
267 server_command: String,
268 /// The request parameters as a JSON string
269 params: Option<String>,
270 },
271
272 /// Viewport changed (scrolled or resized)
273 ViewportChanged {
274 split_id: SplitId,
275 buffer_id: BufferId,
276 top_byte: usize,
277 top_line: Option<usize>,
278 width: u16,
279 height: u16,
280 },
281
282 /// LSP server failed to start or crashed
283 LspServerError {
284 /// The language that failed
285 language: String,
286 /// The server command that failed
287 server_command: String,
288 /// Error type: "not_found", "spawn_failed", "timeout", "crash"
289 error_type: String,
290 /// Human-readable error message
291 message: String,
292 },
293
294 /// User clicked the LSP status indicator
295 LspStatusClicked {
296 /// The language of the current buffer
297 language: String,
298 /// Whether there's an active error
299 has_error: bool,
300 /// Commands of configured servers whose binaries are not on `$PATH`
301 /// (or absolute-path equivalents). Empty when every configured
302 /// server is installed. Plugins can inspect this to show tailored
303 /// install hints without waiting for a failed spawn.
304 missing_servers: Vec<String>,
305 /// Whether the user previously dismissed the LSP pill for this
306 /// language (via the popup's "Disable" action). Plugins seeing
307 /// this as `true` should offer "Enable" / "Install" rather than
308 /// "Start".
309 user_dismissed: bool,
310 },
311
312 /// User selected an action from an action popup
313 ActionPopupResult {
314 /// The popup ID
315 popup_id: String,
316 /// The action ID selected, or "dismissed"
317 action_id: String,
318 },
319
320 /// User clicked a plugin-registered status-bar token. Fires
321 /// regardless of which plugin registered it; subscribers filter
322 /// by `plugin_name` + `token_name`. Plugins typically use this
323 /// to re-open a deferred prompt or surface the relevant
324 /// settings UI for what the token represents (e.g., trust chip
325 /// click → trust elevation popup, env pill click → env activate
326 /// popup).
327 StatusBarTokenClicked {
328 /// Plugin that originally called `RegisterStatusBarElement`.
329 plugin_name: String,
330 /// Token name within that plugin's namespace.
331 token_name: String,
332 },
333
334 /// Background process output (streaming)
335 ProcessOutput {
336 /// The process ID
337 process_id: u64,
338 /// The output data
339 data: String,
340 },
341
342 /// A new editor session was created. Fires after the session is
343 /// added to `Editor.sessions`, before any UI retarget. Plugins
344 /// (like Orchestrator) use this to reconcile their per-session
345 /// bookkeeping with the editor.
346 WindowCreated {
347 /// The new session's stable id.
348 id: u64,
349 /// Resolved label (basename fallback applied).
350 label: String,
351 /// Absolute project root.
352 root: String,
353 },
354
355 /// An editor session was closed and its state dropped. The id
356 /// is still valid in the payload but is no longer present in
357 /// `editor.listWindows()`.
358 WindowClosed { id: u64 },
359
360 /// The active session changed. Fires after the editor's UI has
361 /// retargeted (file tree, working_dir, snapshot). Plugins
362 /// observing for "the editor's project root just changed" use
363 /// this rather than polling.
364 ActiveWindowChanged {
365 /// The previously active session id, or `None` only on
366 /// first switch from the initial base session — currently
367 /// always `Some` since the base session always exists.
368 previous_id: Option<u64>,
369 /// The newly active session id. Always present in the
370 /// `sessions` list.
371 active_id: u64,
372 },
373
374 /// PTY terminal received output bytes from the spawned process.
375 /// Fires for every async batch the editor reads off the PTY, so it
376 /// is hot — consumers should be cheap. The payload includes only a
377 /// snapshot of the last visible (cursor) row so plugins can detect
378 /// prompt patterns (`(Y/n)`, `Press enter`, `> `) without an extra
379 /// readback API. Plugins that need full output should tail the
380 /// terminal's backing file via the existing buffer.
381 TerminalOutput {
382 /// Stable terminal session id (matches `TerminalId.0`).
383 terminal_id: u64,
384 /// Editor window that owns this terminal (matches `WindowId.0`).
385 /// Lets a plugin attribute output to a *session* — Orchestrator
386 /// keys activity off the window, so output from ANY terminal in
387 /// the window (not just the one the plugin spawned) counts. Fires
388 /// on every PTY read, so in-place redraws and carriage-return
389 /// progress bars register as activity too, not just newlines.
390 window_id: u64,
391 /// Snapshot of the cursor row's text content. May be empty
392 /// (just-resized terminal, cleared screen). Trailing whitespace
393 /// is preserved because prompt detection often depends on it
394 /// (e.g. `"... (Y/n): "` ends in a space).
395 last_line: String,
396 },
397
398 /// PTY terminal's spawned process has ended. Fires once per
399 /// terminal lifetime, after the editor has flushed any final
400 /// scrollback to the backing file.
401 TerminalExited {
402 /// Stable terminal session id (matches `TerminalId.0`).
403 terminal_id: u64,
404 /// Editor window that owned this terminal (matches `WindowId.0`).
405 window_id: u64,
406 /// Process exit code if known. `None` when the platform did
407 /// not report a status (signal, detach, kill before wait).
408 /// Plugins that can't distinguish should treat `None` as
409 /// "errored, cause unknown" rather than "ready".
410 exit_code: Option<i32>,
411 },
412
413 /// A path under a `watchPath`-registered watcher changed.
414 /// Plugins (Orchestrator's collision radar, etc.) use this to
415 /// build path → modifying-session-set matrices. Fires once per
416 /// raw `notify` event — no debouncing in core; plugins coalesce
417 /// per their policy.
418 PathChanged {
419 /// Watch handle that delivered this event. Maps back to
420 /// the `watchPath()` call that registered it; lets plugins
421 /// route events to per-watcher state.
422 handle: u64,
423 /// Absolute path the kernel reported as changed.
424 path: String,
425 /// `"modify"` | `"create"` | `"delete"` | `"rename"` |
426 /// `"other"`. Conservative bucketing of `notify::EventKind`
427 /// — plugins that need finer detail can switch on more
428 /// specific strings the editor learns to emit later.
429 kind: String,
430 },
431
432 /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
433 LanguageChanged {
434 buffer_id: BufferId,
435 /// The new language identifier (e.g., "markdown", "rust", "text")
436 language: String,
437 },
438
439 /// Request to inspect a theme key in the theme editor
440 ThemeInspectKey {
441 /// The name of the current theme
442 theme_name: String,
443 /// The theme key to inspect (e.g. "editor.bg")
444 key: String,
445 },
446
447 /// Mouse scroll event (wheel up/down)
448 MouseScroll {
449 buffer_id: BufferId,
450 /// Scroll delta: negative = up, positive = down (typically ±3)
451 delta: i32,
452 /// Mouse column (0-based, terminal origin top-left)
453 col: u16,
454 /// Mouse row (0-based, terminal origin top-left)
455 row: u16,
456 },
457
458 /// Terminal was resized
459 Resize { width: u16, height: u16 },
460
461 /// Terminal focus was gained (e.g. user switched back to the editor)
462 FocusGained {},
463
464 /// A widget mounted via `MountWidgetPanel` emitted a semantic event.
465 /// Plugins subscribe via `editor.on("widget_event", "<handler>")`
466 /// and dispatch on `(panel_id, widget_key, event_type)`.
467 ///
468 /// `event_type` is one of: `"activate"`, `"toggle"`, `"change"`,
469 /// `"submit"`, `"hover"`, `"dismiss"`, `"focus"`. `payload` is
470 /// event-specific JSON (e.g. `{ "value": "search text" }` for
471 /// `change`, `{ "previous": "<old key>" }` for `focus`).
472 ///
473 /// At v1 only widgets that have user-driven behaviour fire this
474 /// hook. The HintBar widget is read-only and does not emit events.
475 WidgetEvent {
476 /// The plugin-allocated panel ID from the original
477 /// `MountWidgetPanel`.
478 panel_id: u64,
479 /// The stable `key` of the widget node that fired the event,
480 /// or empty when the event originates from the panel root.
481 widget_key: String,
482 /// The kind of event — see variants above.
483 event_type: String,
484 /// Event-specific JSON payload.
485 #[serde(default)]
486 payload: serde_json::Value,
487 },
488}
489
490/// Information about a single line for the LinesChanged hook
491#[derive(Debug, Clone, serde::Serialize)]
492pub struct LineInfo {
493 /// Line number (0-based)
494 pub line_number: usize,
495 /// Byte offset where the line starts in the buffer
496 pub byte_start: usize,
497 /// Byte offset where the line ends (exclusive)
498 pub byte_end: usize,
499 /// The content of the line
500 pub content: String,
501}
502
503/// Location information for LSP references
504#[derive(Debug, Clone, serde::Serialize)]
505pub struct LspLocation {
506 /// File path
507 pub file: String,
508 /// Line number (1-based)
509 pub line: u32,
510 /// Column number (1-based)
511 pub column: u32,
512}
513
514/// Type for hook callbacks
515pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
516
517/// Registry for managing hooks
518pub struct HookRegistry {
519 /// Map from hook name to list of callbacks
520 hooks: HashMap<String, Vec<HookCallback>>,
521}
522
523impl HookRegistry {
524 /// Create a new hook registry
525 pub fn new() -> Self {
526 Self {
527 hooks: HashMap::new(),
528 }
529 }
530
531 /// Add a hook callback for a specific hook name
532 pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
533 self.hooks
534 .entry(name.to_string())
535 .or_default()
536 .push(callback);
537 }
538
539 /// Remove all hooks for a specific name
540 pub fn remove_hooks(&mut self, name: &str) {
541 self.hooks.remove(name);
542 }
543
544 /// Run all hooks for a specific name
545 pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
546 if let Some(hooks) = self.hooks.get(name) {
547 for callback in hooks {
548 if !callback(args) {
549 return false;
550 }
551 }
552 }
553 true
554 }
555
556 /// Get count of registered callbacks for a hook
557 pub fn hook_count(&self, name: &str) -> usize {
558 self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
559 }
560
561 /// Get all registered hook names
562 pub fn hook_names(&self) -> Vec<String> {
563 self.hooks.keys().cloned().collect()
564 }
565}
566
567impl Default for HookRegistry {
568 fn default() -> Self {
569 Self::new()
570 }
571}
572
573/// Convert HookArgs to a serde_json::Value for plugin communication.
574///
575/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
576/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
577/// an empty JSON object rather than `null`.
578pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
579 Ok(serde_json::to_value(args)?)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::sync::atomic::{AtomicUsize, Ordering};
586 use std::sync::Arc;
587
588 fn noop_true() -> HookCallback {
589 Box::new(|_| true)
590 }
591
592 /// Adding, listing, counting, and removing hooks behave consistently:
593 /// counts match the number added, names reflect the keys, and removal
594 /// purges all callbacks for that key.
595 #[test]
596 fn add_count_list_remove_round_trip() {
597 let mut reg = HookRegistry::new();
598 assert_eq!(reg.hook_count("a"), 0);
599 assert!(reg.hook_names().is_empty());
600
601 reg.add_hook("a", noop_true());
602 reg.add_hook("a", noop_true());
603 reg.add_hook("b", noop_true());
604
605 assert_eq!(reg.hook_count("a"), 2);
606 assert_eq!(reg.hook_count("b"), 1);
607 assert_eq!(reg.hook_count("missing"), 0);
608
609 let mut names = reg.hook_names();
610 names.sort();
611 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
612
613 reg.remove_hooks("a");
614 assert_eq!(reg.hook_count("a"), 0);
615 assert_eq!(reg.hook_count("b"), 1);
616 assert_eq!(reg.hook_names(), vec!["b".to_string()]);
617 }
618
619 /// `run_hooks` returns true iff every callback returned true, short-circuits
620 /// on the first `false`, and returns true for hook names with no callbacks.
621 #[test]
622 fn run_hooks_all_true_and_short_circuits_on_false() {
623 let mut reg = HookRegistry::new();
624 let args = HookArgs::EditorInitialized {};
625
626 // Unknown hook: treated as "no callbacks" → true.
627 assert!(reg.run_hooks("unknown", &args));
628
629 // All-true chain returns true and calls every callback.
630 let calls = Arc::new(AtomicUsize::new(0));
631 for _ in 0..3 {
632 let c = calls.clone();
633 reg.add_hook(
634 "all_true",
635 Box::new(move |_| {
636 c.fetch_add(1, Ordering::SeqCst);
637 true
638 }),
639 );
640 }
641 assert!(reg.run_hooks("all_true", &args));
642 assert_eq!(calls.load(Ordering::SeqCst), 3);
643
644 // Short-circuits on the first `false` — the second callback must not run.
645 let calls = Arc::new(AtomicUsize::new(0));
646 let c1 = calls.clone();
647 reg.add_hook(
648 "short",
649 Box::new(move |_| {
650 c1.fetch_add(1, Ordering::SeqCst);
651 false
652 }),
653 );
654 let c2 = calls.clone();
655 reg.add_hook(
656 "short",
657 Box::new(move |_| {
658 c2.fetch_add(1, Ordering::SeqCst);
659 true
660 }),
661 );
662 assert!(!reg.run_hooks("short", &args));
663 assert_eq!(calls.load(Ordering::SeqCst), 1);
664 }
665
666 /// `hook_args_to_json` produces an object with the expected field for
667 /// a representative variant — ensuring the function actually serializes
668 /// the payload instead of returning a default (null) value.
669 #[test]
670 fn hook_args_to_json_serializes_payload_fields() {
671 let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
672 uri: "file:///x.rs".into(),
673 count: 7,
674 })
675 .unwrap();
676 assert_eq!(json["uri"], "file:///x.rs");
677 assert_eq!(json["count"], 7);
678 }
679
680 #[test]
681 fn hook_args_to_json_empty_variants_produce_empty_object() {
682 for args in [
683 HookArgs::EditorInitialized {},
684 HookArgs::PluginsLoaded {},
685 HookArgs::Ready {},
686 HookArgs::FocusGained {},
687 ] {
688 let json = hook_args_to_json(&args).unwrap();
689 assert_eq!(
690 json,
691 serde_json::json!({}),
692 "variant should serialize as {{}}"
693 );
694 }
695 }
696
697 #[test]
698 fn hook_args_to_json_terminal_output_fields_are_flat() {
699 let json = hook_args_to_json(&HookArgs::TerminalOutput {
700 terminal_id: 7,
701 window_id: 2,
702 last_line: "Do you want me to attempt a fix? (Y/n): ".into(),
703 })
704 .unwrap();
705 assert_eq!(json["terminal_id"], 7);
706 assert_eq!(json["window_id"], 2);
707 assert_eq!(
708 json["last_line"],
709 "Do you want me to attempt a fix? (Y/n): "
710 );
711 }
712
713 #[test]
714 fn hook_args_to_json_terminal_exited_serializes_exit_code() {
715 let json_some = hook_args_to_json(&HookArgs::TerminalExited {
716 terminal_id: 3,
717 window_id: 1,
718 exit_code: Some(0),
719 })
720 .unwrap();
721 assert_eq!(json_some["terminal_id"], 3);
722 assert_eq!(json_some["window_id"], 1);
723 assert_eq!(json_some["exit_code"], 0);
724
725 let json_err = hook_args_to_json(&HookArgs::TerminalExited {
726 terminal_id: 4,
727 window_id: 1,
728 exit_code: Some(2),
729 })
730 .unwrap();
731 assert_eq!(json_err["exit_code"], 2);
732
733 let json_none = hook_args_to_json(&HookArgs::TerminalExited {
734 terminal_id: 5,
735 window_id: 1,
736 exit_code: None,
737 })
738 .unwrap();
739 assert!(
740 json_none["exit_code"].is_null(),
741 "exit_code: None should serialize as JSON null, not omitted: got {json_none}"
742 );
743 }
744
745 #[test]
746 fn hook_args_to_json_delete_fields_are_flat() {
747 let json = hook_args_to_json(&HookArgs::BeforeDelete {
748 buffer_id: crate::BufferId(1),
749 start: 10,
750 end: 20,
751 })
752 .unwrap();
753 assert_eq!(json["start"], 10);
754 assert_eq!(json["end"], 20);
755 assert!(json.get("range").is_none(), "range must not be nested");
756 }
757}