par_term_scripting/protocol.rs
1//! JSON protocol types for communication between the terminal and script subprocesses.
2//!
3//! Scripts read [`ScriptEvent`] objects from stdin (one JSON object per line) and write
4//! [`ScriptCommand`] objects to stdout (one JSON object per line).
5//!
6//! # Security Model
7//!
8//! ## Trust Assumptions
9//!
10//! Scripts are user-configured subprocesses launched from `ScriptConfig` entries in
11//! `~/.config/par-term/config.yaml`. The script binary is implicitly trusted (it was
12//! placed there by the user). However, this trust must be bounded because:
13//!
14//! 1. **Supply-chain attacks**: A malicious package could replace a trusted script
15//! with one that emits dangerous command payloads.
16//! 2. **Injection through event data**: Malicious terminal sequences could produce
17//! events whose payloads are forwarded to the script, which could reflect them
18//! back in commands (terminal injection risk).
19//! 3. **Compromised scripts**: A script may be modified after initial deployment.
20//!
21//! ## Command Categories
22//!
23//! Script commands fall into three security categories:
24//!
25//! ### Safe Commands (no permission required)
26//! - `Log`: Write to the script's output buffer (UI only)
27//! - `SetPanel` / `ClearPanel`: Display markdown content in a panel
28//! - `Notify`: Show a desktop notification
29//! - `SetBadge`: Set the tab badge text
30//! - `SetVariable`: Set a user variable
31//! - `SetWidget`: Plugin-only status-bar text; a tab *script* sending it gets
32//! an error line (see the variant docs below)
33//!
34//! ### Restricted Commands (require permission flags)
35//! These commands require explicit opt-in via `ScriptConfig` permission fields:
36//! - `WriteText`: Inject text into the PTY (requires `allow_write_text: true`)
37//! - Must strip VT/ANSI escape sequences before writing
38//! - Subject to rate limiting
39//! - Queued for user confirmation unless `prompt_before_write_text: false`
40//! (see [`crate::confirm`]) — stripping escapes leaves the printable text
41//! and newline that make up a command line
42//! - `RunCommand`: Spawn an external process (requires `allow_run_command: true`)
43//! - Must check against `check_command_denylist()` from par-term-config
44//! - Must use shell tokenization (not `/bin/sh -c`) to prevent metacharacter injection
45//! - Subject to rate limiting
46//! - `ChangeConfig`: Modify terminal configuration (requires `allow_change_config: true`)
47//! - Must validate config keys against an allowlist
48//!
49//! ## Implementation Status
50//!
51//! All commands are implemented:
52//! - `Log`, `SetPanel`, `ClearPanel`: Safe, always allowed
53//! - `Notify`, `SetBadge`, `SetVariable`: Safe, always allowed
54//! - `WriteText`: Requires `allow_write_text`, rate-limited, VT sequences stripped,
55//! and confirmed by the user unless `prompt_before_write_text` is off
56//! - `RunCommand`: Requires `allow_run_command`, rate-limited, denylist-checked,
57//! tokenised without shell invocation
58//! - `ChangeConfig`: Requires `allow_change_config`, allowlisted keys only
59//!
60//! ## Dispatcher Responsibility
61//!
62//! The command dispatcher in `src/app/window_manager/scripting/mod.rs` is responsible for:
63//! 1. Checking `command.requires_permission()` before executing restricted commands
64//! 2. Verifying the corresponding `ScriptConfig.allow_*` flag is set
65//! 3. Applying rate limits, denylists, and input sanitization
66//! 4. Routing `WriteText` through the confirmation dialog when it is not
67//! pre-approved
68//!
69//! See `par-term-scripting/SECURITY.md` for the complete security model.
70
71use serde::{Deserialize, Serialize};
72use std::collections::HashMap;
73
74/// Event kind for [`ScriptEventData::PluginActionInvoked`]: a user invoked a
75/// plugin-contributed palette action.
76pub const PLUGIN_ACTION_INVOKED_KIND: &str = "plugin_action_invoked";
77
78/// Event kind for [`ScriptEventData::OverlayEvent`]: a focused overlay's
79/// widget reported a semantic interaction (click / text_changed / select).
80pub const OVERLAY_EVENT_KIND: &str = "overlay_event";
81
82/// An event sent from the terminal to a script subprocess (via stdin).
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct ScriptEvent {
85 /// Event kind name (e.g., "bell_rang", "cwd_changed", "command_complete").
86 pub kind: String,
87 /// Event-specific payload.
88 pub data: ScriptEventData,
89}
90
91/// Event-specific payload data.
92///
93/// Tagged with `data_type` so the JSON includes a discriminant field for Python scripts
94/// to easily dispatch on.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96#[serde(tag = "data_type")]
97pub enum ScriptEventData {
98 /// Empty payload for events that carry no additional data (e.g., BellRang).
99 Empty {},
100
101 /// The current working directory changed.
102 CwdChanged {
103 /// New working directory path.
104 cwd: String,
105 },
106
107 /// A command completed execution.
108 CommandComplete {
109 /// The command that completed.
110 command: String,
111 /// Exit code, if available.
112 exit_code: Option<i32>,
113 },
114
115 /// The terminal title changed.
116 TitleChanged {
117 /// New terminal title.
118 title: String,
119 },
120
121 /// The terminal size changed.
122 SizeChanged {
123 /// Number of columns.
124 cols: usize,
125 /// Number of rows.
126 rows: usize,
127 },
128
129 /// A user variable changed.
130 VariableChanged {
131 /// Variable name.
132 name: String,
133 /// New value.
134 value: String,
135 /// Previous value, if any.
136 old_value: Option<String>,
137 },
138
139 /// An environment variable changed.
140 EnvironmentChanged {
141 /// Environment variable key.
142 key: String,
143 /// New value.
144 value: String,
145 /// Previous value, if any.
146 old_value: Option<String>,
147 },
148
149 /// The badge text changed.
150 BadgeChanged {
151 /// New badge text, or None if cleared.
152 text: Option<String>,
153 },
154
155 /// A trigger pattern was matched.
156 TriggerMatched {
157 /// The trigger pattern that matched.
158 pattern: String,
159 /// The text that matched.
160 matched_text: String,
161 /// Line number where the match occurred.
162 line: usize,
163 },
164
165 /// A semantic zone event occurred.
166 ZoneEvent {
167 /// Zone identifier.
168 zone_id: u64,
169 /// Type of zone.
170 zone_type: String,
171 /// Event type (e.g., "enter", "exit").
172 event: String,
173 },
174
175 /// A plugin-contributed palette action was invoked.
176 ///
177 /// Tab scripts never receive this variant: it is written directly to the
178 /// contributing plugin process's stdin when the host invokes one of its
179 /// actions, and is never routed through the observer fan-out that
180 /// carries other events to scripts.
181 PluginActionInvoked {
182 /// Manifest id of the invoked action.
183 action: String,
184 },
185
186 /// Fallback for unmapped events. Carries arbitrary key-value fields.
187 Generic {
188 /// Arbitrary event fields.
189 fields: HashMap<String, serde_json::Value>,
190 },
191
192 /// A semantic event from a focused interactive overlay's own widget
193 /// (overlay kind only). Written directly to the overlay process's
194 /// stdin when the host's renderer reports a widget interaction —
195 /// never raw keys (design: input & focus routing).
196 OverlayEvent {
197 /// Overlay id the widget belongs to.
198 overlay: String,
199 /// The widget's scene id.
200 widget: String,
201 /// What happened on the widget.
202 event: OverlayWidgetEvent,
203 },
204}
205
206/// What a focused overlay widget reports to the plugin process.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208#[serde(tag = "type")]
209pub enum OverlayWidgetEvent {
210 /// A button was clicked.
211 Click {},
212 /// A text input's value changed.
213 TextChanged {
214 /// Current value of the input.
215 value: String,
216 },
217 /// A list row was selected.
218 Select {
219 /// Index of the selected row.
220 index: usize,
221 },
222}
223
224/// A command sent from a script subprocess to the terminal (via stdout).
225///
226/// Tagged with `type` for easy JSON dispatch.
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228#[serde(tag = "type")]
229pub enum ScriptCommand {
230 /// Write text to the PTY.
231 WriteText {
232 /// Text to write.
233 text: String,
234 },
235
236 /// Show a desktop notification.
237 Notify {
238 /// Notification title.
239 title: String,
240 /// Notification body.
241 body: String,
242 },
243
244 /// Set the tab badge text.
245 SetBadge {
246 /// Badge text to display.
247 text: String,
248 },
249
250 /// Set a user variable.
251 SetVariable {
252 /// Variable name.
253 name: String,
254 /// Variable value.
255 value: String,
256 },
257
258 /// Execute a shell command.
259 RunCommand {
260 /// Command to execute.
261 command: String,
262 },
263
264 /// Change a configuration value.
265 ChangeConfig {
266 /// Configuration key.
267 key: String,
268 /// New value.
269 value: serde_json::Value,
270 },
271
272 /// Log a message.
273 Log {
274 /// Log level (e.g., "info", "warn", "error", "debug").
275 level: String,
276 /// Log message.
277 message: String,
278 },
279
280 /// Set a markdown panel.
281 SetPanel {
282 /// Panel title.
283 title: String,
284 /// Markdown content.
285 content: String,
286 },
287
288 /// Clear the markdown panel.
289 ClearPanel {},
290
291 /// Upsert a plugin-owned overlay surface (overlay kind only). Idempotent
292 /// by id: same id replaces the whole overlay. Phase 1 is display-only —
293 /// the host forces `interactive` off pending the manifest capability.
294 SetOverlay {
295 /// Overlay id; unique within the plugin.
296 id: String,
297 /// Position: a named anchor or a free rect (fractions of window).
298 position: OverlayPosition,
299 /// Size as fractions of the window (0.0–1.0).
300 size: OverlaySize,
301 /// Opacity (0.0–1.0, default 1.0).
302 #[serde(default = "overlay_default_opacity")]
303 opacity: f32,
304 /// Interactive request; forced off without the manifest capability.
305 #[serde(default)]
306 interactive: bool,
307 /// Scene to render (declarative tree).
308 content: OverlayScene,
309 },
310
311 /// Clear a plugin-owned overlay by id (overlay kind only).
312 ClearOverlay {
313 /// Overlay id to remove.
314 id: String,
315 },
316
317 /// Set the text of a plugin-provided status-bar widget.
318 ///
319 /// Display-only and plugin-sourced: no permission flag exists for it
320 /// because a widget's text is strictly less powerful than `SetBadge`
321 /// (which scripts already send unrestricted). A tab *script* emitting
322 /// this command gets an error line in its output pane — the plugin host
323 /// is the only consumer.
324 SetWidget {
325 /// Widget text to display (last write wins; empty text hides the widget).
326 text: String,
327 },
328}
329
330fn overlay_default_opacity() -> f32 {
331 1.0
332}
333
334/// Where a plugin overlay sits: a named anchor or a free rect as window
335/// fractions. Free rects are clamped on-screen by the host.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "kebab-case", untagged)]
338pub enum OverlayPosition {
339 /// One of nine named positions (corners, edge midpoints, center) or an
340 /// edge strip (`top-strip`, `bottom-strip`, `left-strip`, `right-strip`).
341 Anchor(OverlayAnchor),
342 /// Free rect: x/y/width/height as fractions of the window (0.0–1.0).
343 Free {
344 #[serde(default)]
345 x: f32,
346 #[serde(default)]
347 y: f32,
348 },
349}
350
351/// Named overlay anchors (kebab-case on the wire).
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "kebab-case")]
354pub enum OverlayAnchor {
355 TopLeft,
356 Top,
357 TopRight,
358 Left,
359 Center,
360 Right,
361 BottomLeft,
362 Bottom,
363 BottomRight,
364 /// Top edge strip spanning the window width.
365 TopStrip,
366 /// Bottom edge strip spanning the window width.
367 BottomStrip,
368 /// Left edge strip spanning the window height.
369 LeftStrip,
370 /// Right edge strip spanning the window height.
371 RightStrip,
372}
373
374/// Overlay size as fractions of the window, clamped by the host (half the
375/// window per axis).
376#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
377pub struct OverlaySize {
378 /// Width fraction (0.0–1.0).
379 pub w: f32,
380 /// Height fraction (0.0–1.0).
381 pub h: f32,
382}
383
384/// A declarative scene tree the host renders through egui. Phase 1
385/// vocabulary: text, row, markdown; phase 2 adds the interactive
386/// button / text_input / list (rendered only while the overlay may
387/// focus — see `PluginOverlay::interactive`). No images (deferred by
388/// design).
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case", tag = "type")]
391pub enum OverlayScene {
392 /// Plain text.
393 Text { text: String },
394 /// Children laid out horizontally.
395 Row { children: Vec<OverlayScene> },
396 /// Markdown block.
397 Markdown { text: String },
398 /// Clickable button (interactive vocabulary, O3).
399 Button {
400 /// Scene-unique widget id the click event names.
401 id: String,
402 /// Button label.
403 label: String,
404 },
405 /// Single-line text input (interactive vocabulary, O3).
406 TextInput {
407 /// Scene-unique widget id events name.
408 id: String,
409 /// Current value; full-scene replace semantics (the plugin owns
410 /// state).
411 value: String,
412 /// Placeholder shown when empty.
413 #[serde(default)]
414 placeholder: String,
415 },
416 /// Selectable row list (interactive vocabulary, O3).
417 List {
418 /// Scene-unique widget id events name.
419 id: String,
420 /// Row labels.
421 items: Vec<String>,
422 /// Currently selected row index, if any.
423 #[serde(default)]
424 selected: Option<usize>,
425 },
426}
427
428/// The host-side stored form of a live overlay. `interactive` is true only
429/// when the request asked for it AND the plugin's manifest carries the
430/// `overlay.interactive` capability (phase 2) — the ingest path forces it
431/// off otherwise.
432#[derive(Debug, Clone, PartialEq)]
433pub struct PluginOverlay {
434 /// Overlay id (unique within the plugin).
435 pub id: String,
436 /// Anchor or free-rect position.
437 pub position: OverlayPosition,
438 /// Size as window fractions.
439 pub size: OverlaySize,
440 /// Opacity (0.0–1.0).
441 pub opacity: f32,
442 /// Whether the overlay may take focus and render interactive widgets.
443 pub interactive: bool,
444 /// Scene to render.
445 pub content: OverlayScene,
446}
447
448/// Strip VT/ANSI escape sequences from text before PTY injection.
449///
450/// Removes CSI (`ESC[`), OSC (`ESC]`), DCS (`ESC P`), APC (`ESC _`),
451/// PM (`ESC ^`), SOS (`ESC X`) sequences, and bare two-byte `ESC x`
452/// sequences. Printable characters and newlines are passed through.
453///
454/// This is required for safe `WriteText` dispatch: a script must not be
455/// able to embed control sequences that reposition the cursor, exfiltrate
456/// data, or otherwise corrupt the terminal state.
457pub fn strip_vt_sequences(text: &str) -> String {
458 let mut result = String::with_capacity(text.len());
459 let mut chars = text.chars().peekable();
460
461 while let Some(c) = chars.next() {
462 if c != '\x1b' {
463 result.push(c);
464 continue;
465 }
466 // ESC seen — classify and skip the sequence
467 match chars.peek().copied() {
468 Some('[') => {
469 // CSI: ESC [ ... <final-byte>
470 chars.next(); // consume '['
471 while let Some(&ch) = chars.peek() {
472 chars.next();
473 if ch.is_ascii_alphabetic() || ch == '@' || ch == '`' {
474 break;
475 }
476 }
477 }
478 Some(']') => {
479 // OSC: ESC ] ... BEL or ST (ESC \)
480 chars.next(); // consume ']'
481 while let Some(ch) = chars.next() {
482 if ch == '\x07' {
483 break;
484 }
485 if ch == '\x1b' && chars.peek() == Some(&'\\') {
486 chars.next();
487 break;
488 }
489 }
490 }
491 Some('P') | Some('_') | Some('^') | Some('X') => {
492 // DCS / APC / PM / SOS: ESC <type> ... ST (ESC \)
493 chars.next(); // consume the type byte
494 while let Some(ch) = chars.next() {
495 if ch == '\x1b' && chars.peek() == Some(&'\\') {
496 chars.next();
497 break;
498 }
499 }
500 }
501 Some('(') | Some(')') | Some('*') | Some('+') => {
502 // Character-set designation: ESC ( x — skip two bytes
503 chars.next();
504 chars.next();
505 }
506 Some(_) => {
507 // Generic two-byte ESC sequence — skip one byte
508 chars.next();
509 }
510 None => {}
511 }
512 }
513 result
514}
515
516impl ScriptCommand {
517 /// Returns `true` if this command requires explicit permission in the script config.
518 ///
519 /// Commands that return `true` must have their corresponding `allow_*` flag set
520 /// in `ScriptConfig` before the dispatcher will execute them.
521 ///
522 /// # Security Classification
523 ///
524 /// | Command | Requires Permission | Risk Level |
525 /// |---------|--------------------| -----------|
526 /// | `Log` | No | Low (UI output only) |
527 /// | `SetPanel` / `ClearPanel` | No | Low (UI display only) |
528 /// | `Notify` | No | Low (desktop notification) |
529 /// | `SetBadge` | No | Low (tab badge display) |
530 /// | `SetVariable` | No | Low (user variable storage) |
531 /// | `WriteText` | **Yes** | High (PTY injection, command execution) |
532 /// | `RunCommand` | **Yes** | Critical (arbitrary process spawn) |
533 /// | `ChangeConfig` | **Yes** | High (config modification) |
534 pub fn requires_permission(&self) -> bool {
535 matches!(
536 self,
537 ScriptCommand::RunCommand { .. }
538 | ScriptCommand::WriteText { .. }
539 | ScriptCommand::ChangeConfig { .. }
540 )
541 }
542
543 /// Returns the name of the permission flag required to execute this command.
544 ///
545 /// Returns `None` for commands that don't require permission.
546 /// The returned string corresponds to a field in `ScriptConfig`:
547 /// - `"allow_run_command"` for `RunCommand`
548 /// - `"allow_write_text"` for `WriteText`
549 /// - `"allow_change_config"` for `ChangeConfig`
550 pub fn permission_flag_name(&self) -> Option<&'static str> {
551 match self {
552 ScriptCommand::RunCommand { .. } => Some("allow_run_command"),
553 ScriptCommand::WriteText { .. } => Some("allow_write_text"),
554 ScriptCommand::ChangeConfig { .. } => Some("allow_change_config"),
555 _ => None,
556 }
557 }
558
559 /// Returns `true` if this command can safely be executed without rate limiting.
560 ///
561 /// Commands that may be emitted frequently (like `Log`) should not be rate-limited
562 /// to avoid dropping important debug output. High-impact commands (`WriteText`,
563 /// `RunCommand`) must be rate-limited to prevent abuse.
564 pub fn is_rate_limited(&self) -> bool {
565 matches!(
566 self,
567 ScriptCommand::RunCommand { .. } | ScriptCommand::WriteText { .. }
568 )
569 }
570
571 /// Returns a human-readable name for this command type (for logging/errors).
572 pub fn command_name(&self) -> &'static str {
573 match self {
574 ScriptCommand::WriteText { .. } => "WriteText",
575 ScriptCommand::Notify { .. } => "Notify",
576 ScriptCommand::SetBadge { .. } => "SetBadge",
577 ScriptCommand::SetVariable { .. } => "SetVariable",
578 ScriptCommand::RunCommand { .. } => "RunCommand",
579 ScriptCommand::ChangeConfig { .. } => "ChangeConfig",
580 ScriptCommand::Log { .. } => "Log",
581 ScriptCommand::SetPanel { .. } => "SetPanel",
582 ScriptCommand::ClearPanel {} => "ClearPanel",
583 ScriptCommand::SetOverlay { .. } => "SetOverlay",
584 ScriptCommand::ClearOverlay { .. } => "ClearOverlay",
585 ScriptCommand::SetWidget { .. } => "SetWidget",
586 }
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn set_overlay_round_trips_through_serde() {
596 let line = r##"{"type":"SetOverlay","id":"hud","position":"top-right","size":{"w":0.2,"h":0.1},"opacity":0.8,"content":{"type":"markdown","text":"# hello"}}"##;
597 let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetOverlay line");
598 match &cmd {
599 ScriptCommand::SetOverlay {
600 id,
601 position,
602 size,
603 opacity,
604 interactive,
605 content,
606 } => {
607 assert_eq!(id, "hud");
608 assert_eq!(position, &OverlayPosition::Anchor(OverlayAnchor::TopRight));
609 assert_eq!((size.w, size.h), (0.2, 0.1));
610 assert_eq!(*opacity, 0.8);
611 assert!(!interactive, "interactive defaults off");
612 assert_eq!(
613 content,
614 &OverlayScene::Markdown {
615 text: "# hello".to_string()
616 }
617 );
618 }
619 other => panic!("wrong variant: {other:?}"),
620 }
621 let re = serde_json::to_string(&cmd).expect("serialize SetOverlay");
622 let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
623 assert_eq!(back, cmd);
624 }
625
626 #[test]
627 fn clear_overlay_parses() {
628 let line = r#"{"type":"ClearOverlay","id":"hud"}"#;
629 let cmd: ScriptCommand = serde_json::from_str(line).expect("parse ClearOverlay line");
630 assert_eq!(
631 cmd,
632 ScriptCommand::ClearOverlay {
633 id: "hud".to_string()
634 }
635 );
636 }
637
638 #[test]
639 fn set_overlay_free_rect_position_parses() {
640 let line = r#"{"type":"SetOverlay","id":"f","position":{"x":0.1,"y":0.2},"size":{"w":0.3,"h":0.3},"content":{"type":"text","text":"t"}}"#;
641 let cmd: ScriptCommand = serde_json::from_str(line).expect("parse free-rect SetOverlay");
642 match cmd {
643 ScriptCommand::SetOverlay { position, .. } => {
644 assert_eq!(position, OverlayPosition::Free { x: 0.1, y: 0.2 });
645 }
646 other => panic!("wrong variant: {other:?}"),
647 }
648 }
649
650 #[test]
651 fn set_widget_round_trips_through_serde() {
652 let line = r#"{"type":"SetWidget","text":"🕒 14:32"}"#;
653 let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetWidget line");
654 assert_eq!(
655 cmd,
656 ScriptCommand::SetWidget {
657 text: "🕒 14:32".into()
658 }
659 );
660 let re = serde_json::to_string(&cmd).expect("serialize SetWidget");
661 let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
662 assert_eq!(back, cmd);
663 }
664
665 #[test]
666 fn set_widget_requires_no_permission_and_is_not_rate_limited() {
667 let cmd = ScriptCommand::SetWidget {
668 text: String::new(),
669 };
670 assert!(!cmd.requires_permission());
671 assert!(cmd.permission_flag_name().is_none());
672 assert!(!cmd.is_rate_limited());
673 assert_eq!(cmd.command_name(), "SetWidget");
674 }
675
676 #[test]
677 fn interactive_scene_nodes_round_trip_through_serde() {
678 let scene = OverlayScene::Row {
679 children: vec![
680 OverlayScene::Button {
681 id: "deploy".to_string(),
682 label: "Deploy".to_string(),
683 },
684 OverlayScene::TextInput {
685 id: "filter".to_string(),
686 value: "par".to_string(),
687 placeholder: "filter…".to_string(),
688 },
689 OverlayScene::List {
690 id: "jobs".to_string(),
691 items: vec!["one".to_string(), "two".to_string()],
692 selected: Some(1),
693 },
694 ],
695 };
696 let json = serde_json::to_string(&scene).expect("serialize scene");
697 let back: OverlayScene = serde_json::from_str(&json).expect("parse scene back");
698 assert_eq!(back, scene);
699
700 // Wire shapes stay snake_case with placeholder/selected optional.
701 assert!(json.contains(r#""type":"button""#), "got: {json}");
702 assert!(json.contains(r#""type":"text_input""#), "got: {json}");
703 assert!(json.contains(r#""type":"list""#), "got: {json}");
704 let no_optional: OverlayScene =
705 serde_json::from_str(r#"{"type":"text_input","id":"f","value":""}"#)
706 .expect("optional fields default");
707 assert_eq!(
708 no_optional,
709 OverlayScene::TextInput {
710 id: "f".to_string(),
711 value: String::new(),
712 placeholder: String::new(),
713 }
714 );
715 }
716
717 #[test]
718 fn overlay_event_round_trips_through_serde() {
719 for data in [
720 ScriptEventData::OverlayEvent {
721 overlay: "hud".to_string(),
722 widget: "deploy".to_string(),
723 event: OverlayWidgetEvent::Click {},
724 },
725 ScriptEventData::OverlayEvent {
726 overlay: "hud".to_string(),
727 widget: "filter".to_string(),
728 event: OverlayWidgetEvent::TextChanged {
729 value: "par".to_string(),
730 },
731 },
732 ScriptEventData::OverlayEvent {
733 overlay: "hud".to_string(),
734 widget: "jobs".to_string(),
735 event: OverlayWidgetEvent::Select { index: 2 },
736 },
737 ] {
738 let json = serde_json::to_string(&data).expect("serialize event data");
739 let back: ScriptEventData = serde_json::from_str(&json).expect("parse back");
740 assert_eq!(back, data);
741 }
742 // One exact wire shape: nested tag + flat fields, plugin-parseable.
743 let json = serde_json::to_string(&ScriptEventData::OverlayEvent {
744 overlay: "hud".to_string(),
745 widget: "deploy".to_string(),
746 event: OverlayWidgetEvent::Click {},
747 })
748 .expect("serialize");
749 assert_eq!(
750 json,
751 r#"{"data_type":"OverlayEvent","overlay":"hud","widget":"deploy","event":{"type":"Click"}}"#
752 );
753 }
754
755 #[test]
756 fn plugin_action_invoked_round_trips_through_serde() {
757 let data = ScriptEventData::PluginActionInvoked {
758 action: "greet".to_string(),
759 };
760 let json = serde_json::to_string(&data).expect("serialize event data");
761 assert_eq!(
762 json, r#"{"data_type":"PluginActionInvoked","action":"greet"}"#,
763 "the wire shape plugin processes parse must stay exact"
764 );
765 let back: ScriptEventData = serde_json::from_str(&json).expect("parse event data back");
766 assert_eq!(back, data);
767
768 let event = ScriptEvent {
769 kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
770 data,
771 };
772 let line = serde_json::to_string(&event).expect("serialize event");
773 assert_eq!(
774 line,
775 r#"{"kind":"plugin_action_invoked","data":{"data_type":"PluginActionInvoked","action":"greet"}}"#
776 );
777 let parsed: ScriptEvent = serde_json::from_str(&line).expect("parse event line back");
778 assert_eq!(parsed, event);
779 }
780}