Skip to main content

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}