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