Skip to main content

par_term_scripting/plugin_manager/
mod.rs

1//! Window-scoped plugin host over the script runtime.
2//!
3//! [`PluginHost`] owns its own [`ScriptManager`] instance — the same registry
4//! type the per-tab script system uses, kept entirely separate so a plugin
5//! can never be confused with a tab script. Discovery, per-kind process
6//! lifecycle (one supervised process per declared kind, design D5), restart
7//! supervision (manifest-declared mode — `on_failure` default, `never`,
8//! `always` — under the host-owned delay and crash-loop cap), action
9//! invocation, and the `SetWidget` text map all live here.
10//!
11//! Land-disabled is structural (design D3): [`PluginHost::apply_enabled`]
12//! receives only the enabled set, and discovery never spawns anything by
13//! itself — a newly dropped-in plugin cannot run until the Settings layer
14//! says so.
15
16use std::collections::{HashMap, HashSet};
17use std::path::Path;
18use std::sync::Arc;
19use std::time::Instant;
20
21use super::manager::{ScriptId, ScriptManager};
22use super::manifest::{
23    DiscoveredPlugin, ENTRY_POINT_ACTION_CONTRIBUTOR, ENTRY_POINT_OVERLAY, ENTRY_POINT_PANEL,
24    ENTRY_POINT_STATUS_BAR_WIDGET, KIND_ACTION_CONTRIBUTOR, KIND_OVERLAY, KIND_PANEL,
25    KIND_STATUS_BAR_WIDGET, discover_plugins,
26};
27use super::observer::ScriptEventForwarder;
28use super::protocol::{PLUGIN_ACTION_INVOKED_KIND, ScriptEvent, ScriptEventData};
29use super::restart::ScriptRestartState;
30
31mod supervision;
32
33/// Restart delay for a plugin process, host-owned alongside the crash-loop
34/// cap (the manifest declares the restart *mode* only — parsight decision
35/// 95: a manifest must not be able to tune its way out of the cap).
36const PLUGIN_RESTART_DELAY_MS: u64 = 250;
37
38/// Settings argv marker passed to every plugin entry point (design D2).
39const SETTINGS_ARG: &str = "--par-term-settings";
40
41/// One plugin the host should be running (Task 4 maps config state to this).
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EnabledPlugin {
44    /// Manifest id; must match a discovered plugin.
45    pub id: String,
46    /// Settings values serialized as one JSON object string, validated
47    /// against the manifest schema by the caller before landing here.
48    pub settings_json: String,
49}
50
51/// One command-palette entry contributed by an enabled action plugin.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PluginActionRow {
54    /// Palette action id, `plugin-action:<plugin-id>:<action-id>` (design D1).
55    pub wire_id: String,
56    /// Palette label: the manifest action label, then `" · "`, then the
57    /// manifest plugin name (design D2).
58    pub label: String,
59}
60
61/// Extra argv the widget kind's entry point declares before the settings.
62fn widget_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
63    plugin
64        .manifest
65        .entry_points
66        .get(ENTRY_POINT_STATUS_BAR_WIDGET)
67        .map(|entry| entry.args.as_slice())
68        .unwrap_or_default()
69}
70
71/// Extra argv the action kind's entry point declares before the settings.
72fn action_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
73    plugin
74        .manifest
75        .entry_points
76        .get(ENTRY_POINT_ACTION_CONTRIBUTOR)
77        .map(|entry| entry.args.as_slice())
78        .unwrap_or_default()
79}
80
81/// Extra argv the panel kind's entry point declares before the settings.
82fn panel_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
83    plugin
84        .manifest
85        .entry_points
86        .get(ENTRY_POINT_PANEL)
87        .map(|entry| entry.args.as_slice())
88        .unwrap_or_default()
89}
90
91/// Extra argv the overlay kind's entry point declares before the settings.
92fn overlay_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
93    plugin
94        .manifest
95        .entry_points
96        .get(ENTRY_POINT_OVERLAY)
97        .map(|entry| entry.args.as_slice())
98        .unwrap_or_default()
99}
100
101/// One declared plugin kind's supervision slot. Every per-kind map pair is
102/// selected through this so the three kinds cannot drift apart — the same
103/// role the `action: bool` toggle played for two kinds.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum KindSlot {
106    /// `status-bar-widget` — owns the shared `SetWidget` text map.
107    Widget,
108    /// `action-contributor` — palette actions; also writes `SetWidget`.
109    Action,
110    /// `panel` — owns the `SetPanel`/`ClearPanel` content map.
111    Panel,
112    /// `overlay` — owns the `SetOverlay`/`ClearOverlay` map.
113    Overlay,
114}
115
116impl KindSlot {
117    /// Kind name used in log lines.
118    fn as_str(self) -> &'static str {
119        match self {
120            KindSlot::Widget => "widget",
121            KindSlot::Action => "action",
122            KindSlot::Panel => "panel",
123            KindSlot::Overlay => "overlay",
124        }
125    }
126}
127
128/// Deduplicates a repeating fault warning to once per condition episode.
129///
130/// [`PluginHost::apply_enabled`] runs every render frame, so an ungated
131/// steady-state fault (an enabled plugin that is not discovered) emits the
132/// same warn ~60×/s — measured at ~480 identical warns in one 8 s run.
133/// `should_warn` returns `true` only on the first call per key; `clear`
134/// re-arms the key when the condition resolves (successful spawn, teardown)
135/// so a recurrence warns again.
136#[derive(Debug, Default)]
137pub struct WarnOnce {
138    warned: HashSet<String>,
139}
140
141impl WarnOnce {
142    /// Whether the caller should emit the warn: `true` the first time `key`
143    /// is seen, `false` while the same key persists.
144    pub fn should_warn(&mut self, key: &str) -> bool {
145        self.warned.insert(key.to_string())
146    }
147
148    /// Re-arm `key` so a later recurrence warns again.
149    pub fn clear(&mut self, key: &str) {
150        self.warned.remove(key);
151    }
152
153    /// Re-arm every key except those in `keep` — call when the monitored
154    /// set shrinks, so an entry that left and later returns warns again.
155    pub fn clear_except(&mut self, keep: &HashSet<String>) {
156        self.warned.retain(|key| keep.contains(key));
157    }
158}
159
160/// Window-scoped plugin host: discovery cache, running processes, restart
161/// supervision, and the last `SetWidget` text per plugin.
162#[derive(Default)]
163pub struct PluginHost {
164    /// Last discovery scan's valid plugins.
165    discovered: Vec<DiscoveredPlugin>,
166    /// Running (or supervised) widget-kind plugin id → process id. An exited
167    /// process stays mapped while its restart is pending so the supervisor
168    /// keeps being polled; teardown is the only removal path.
169    running: HashMap<String, ScriptId>,
170    /// Running (or supervised) action-kind plugin id → process id — one
171    /// supervised process per (plugin, kind), so a both-kinds manifest runs
172    /// both entry points independently (design D5).
173    action_running: HashMap<String, ScriptId>,
174    /// Running (or supervised) panel-kind plugin id → process id.
175    panel_running: HashMap<String, ScriptId>,
176    /// Running (or supervised) overlay-kind plugin id → process id.
177    overlay_running: HashMap<String, ScriptId>,
178    /// The host's own script registry; never shared with tab scripts.
179    manager: ScriptManager,
180    /// Last `SetWidget` text per plugin id (last write wins). Either kind's
181    /// process writes through the same shared key; stopping the action kind
182    /// leaves its last text latched until the widget kind stops or teardown
183    /// clears it (documented asymmetry, consistent with the shared-key
184    /// contract).
185    widget_texts: HashMap<String, String>,
186    /// Last `SetPanel` (title, content) per plugin id (last write wins).
187    /// Panel-kind processes only; a `ClearPanel` (or teardown — no orphaned
188    /// surface) removes the key.
189    panel_contents: HashMap<String, (String, String)>,
190    /// Live overlays per plugin id (overlay kind, one per plugin by design):
191    /// the last upsert scene per overlay id. `ClearOverlay` (or teardown)
192    /// removes the key — a stopped plugin leaves no orphaned surface.
193    overlays: HashMap<String, crate::protocol::PluginOverlay>,
194    /// The one focused overlay's plugin id, if any (design: at most one
195    /// overlay is focused at a time). Focus never lands automatically —
196    /// only a click on the overlay's widgets or an explicit host API sets
197    /// it (O4) — and it drops when the overlay is cleared, replaced by a
198    /// display-only upsert, or its plugin stops.
199    focused_overlay: Option<String>,
200    /// SetOverlay upsert timestamps per plugin id, for the update-rate
201    /// clamp (design: ~30/sec with a warning; a runaway plugin must not
202    /// spin the renderer).
203    overlay_update_times: HashMap<String, Vec<std::time::Instant>>,
204    /// Successful [`Self::invoke_action`] stdin writes this session — the
205    /// `plugin_action_dispatched` ui-test operand's source. Monotonic for
206    /// the session; never reset.
207    actions_dispatched: u64,
208    /// Successful [`Self::send_overlay_event`] stdin writes this session —
209    /// the `plugin_overlay_event` ui-test operand's source. Monotonic.
210    overlay_events_dispatched: u64,
211    /// Per-plugin widget-kind restart supervisor (pin P3).
212    restart: HashMap<String, ScriptRestartState>,
213    /// Per-plugin action-kind restart supervisor, driven identically to
214    /// [`Self::restart`] but independently per kind.
215    action_restart: HashMap<String, ScriptRestartState>,
216    /// Per-plugin panel-kind restart supervisor, driven identically to the
217    /// other kinds.
218    panel_restart: HashMap<String, ScriptRestartState>,
219    /// Per-plugin overlay-kind restart supervisor, driven identically to the
220    /// other kinds.
221    overlay_restart: HashMap<String, ScriptRestartState>,
222    /// Settings argv each running plugin was spawned with, kept so a
223    /// supervisor restart re-spawns with the same settings. Shared by both
224    /// kinds of one plugin (same settings argv, design D2).
225    settings_json: HashMap<String, String>,
226    /// Enabled plugin ids retained from the last [`Self::apply_enabled`]
227    /// call, so the palette can enumerate enabled plugins' actions without
228    /// a config read.
229    enabled_ids: HashSet<String>,
230    /// Commands refused because v1 plugins may only send `SetWidget`.
231    ignored_lines: Vec<String>,
232    /// Per-plugin event forwarders for plugins whose manifest declares
233    /// subscriptions. Created on the plugin's first successful spawn, dropped
234    /// on teardown — the window layer registers each forwarder as an observer
235    /// on the window's tab terminals, drains it per sweep, and delivers via
236    /// [`Self::deliver_events`] (the same forwarder machinery per-tab scripts
237    /// use; per-plugin filtering at the forwarder level is what keeps a
238    /// subscription reliable under event floods).
239    subscription_forwarders: HashMap<String, Arc<ScriptEventForwarder>>,
240    /// Gate for event-delivery write failures (a process exiting
241    /// mid-delivery) — the sweep retries delivery every wake, so a dead
242    /// process must not warn per frame.
243    warned_event_delivery: WarnOnce,
244    /// Per-fault warn gates: steady faults warn once per episode, not per
245    /// frame (see [`WarnOnce`]).
246    warned_not_discovered: WarnOnce,
247    warned_spawn_failed: WarnOnce,
248    /// Gate for invoke misses on the action kind (not running, kind absent,
249    /// unknown action, failed write) — a stuck keybinding can retry a
250    /// dispatch at frame rate.
251    warned_action_not_running: WarnOnce,
252}
253
254impl PluginHost {
255    /// Create an empty host. Nothing runs until
256    /// [`refresh_discovery`](Self::refresh_discovery) +
257    /// [`apply_enabled`](Self::apply_enabled) say otherwise.
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    /// Re-scan a plugins root, replacing the discovery cache. Running
263    /// plugins are preserved — a re-scan never stops or spawns anything.
264    pub fn refresh_discovery(&mut self, root: &Path) -> &[DiscoveredPlugin] {
265        let (plugins, warnings) = discover_plugins(root);
266        for warning in &warnings {
267            log::warn!(
268                "plugin discovery skipped '{}': {}",
269                warning.dir,
270                warning.reason
271            );
272        }
273        self.discovered = plugins;
274        &self.discovered
275    }
276
277    /// The discovery-cache entry for a plugin id, if the last scan found it.
278    pub fn discovered(&self, id: &str) -> Option<&DiscoveredPlugin> {
279        self.discovered.iter().find(|d| d.manifest.id == id)
280    }
281
282    /// Number of plugins the last discovery scan accepted.
283    pub fn discovered_count(&self) -> usize {
284        self.discovered.len()
285    }
286
287    /// Reconcile running plugins with the enabled set (diff → spawn/stop).
288    ///
289    /// Land-disabled is structural: only ids present in `enabled` AND in the
290    /// discovery cache are spawned, so dropping a plugin directory into the
291    /// root does nothing until the Settings layer enables it. A multi-kind
292    /// manifest spawns every declared kind's entry in this one pass, with
293    /// the same settings argv and one supervision slot per kind (design D5).
294    pub fn apply_enabled(&mut self, enabled: &[EnabledPlugin]) {
295        let now = Instant::now();
296
297        let stale: Vec<String> = self
298            .running
299            .keys()
300            .chain(self.action_running.keys())
301            .chain(self.panel_running.keys())
302            .chain(self.overlay_running.keys())
303            .filter(|id| !enabled.iter().any(|e| e.id == **id))
304            .cloned()
305            .collect();
306        for id in &stale {
307            self.teardown(id);
308        }
309
310        // A plugin leaving the enabled set ends its fault episodes, so a
311        // disable/enable cycle of a still-missing plugin warns again.
312        let enabled_ids: HashSet<String> = enabled.iter().map(|e| e.id.clone()).collect();
313        self.enabled_ids = enabled_ids.clone();
314        self.warned_not_discovered.clear_except(&enabled_ids);
315        self.warned_spawn_failed.clear_except(&enabled_ids);
316        self.warned_action_not_running.clear_except(&enabled_ids);
317        self.warned_event_delivery.clear_except(&enabled_ids);
318
319        for plugin in enabled {
320            let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin.id) else {
321                if self.warned_not_discovered.should_warn(&plugin.id) {
322                    log::warn!(
323                        "plugin '{}' is enabled but not discovered; not spawned",
324                        plugin.id
325                    );
326                }
327                continue;
328            };
329            let has_widget = found
330                .manifest
331                .kinds
332                .iter()
333                .any(|k| k == KIND_STATUS_BAR_WIDGET);
334            let has_action = found
335                .manifest
336                .kinds
337                .iter()
338                .any(|k| k == KIND_ACTION_CONTRIBUTOR);
339            let has_panel = found.manifest.kinds.iter().any(|k| k == KIND_PANEL);
340            let has_overlay = found.manifest.kinds.iter().any(|k| k == KIND_OVERLAY);
341
342            // Extract every declared kind's spawn descriptor up front so the
343            // discovery borrow ends before the supervisor/spawn calls below.
344            // The action and panel kinds route through their own
345            // confinement-checked entries — never `entry_path`, which belongs
346            // to the widget kind.
347            let widget_entry =
348                has_widget.then(|| (found.entry_path.clone(), widget_entry_args(found).to_vec()));
349            let action_entry = found
350                .action_entry_path
351                .clone()
352                .filter(|_| has_action)
353                .map(|path| (path, action_entry_args(found).to_vec()));
354            let panel_entry = found
355                .panel_entry_path
356                .clone()
357                .filter(|_| has_panel)
358                .map(|path| (path, panel_entry_args(found).to_vec()));
359            let overlay_entry = found
360                .overlay_entry_path
361                .clone()
362                .filter(|_| has_overlay)
363                .map(|path| (path, overlay_entry_args(found).to_vec()));
364            // Plugin-level, kind-independent: whichever kind spawns first
365            // creates the shared event forwarder.
366            let subscriptions = found.manifest.subscriptions.clone();
367            // Restart mode is plugin-level too (every kind slot supervises
368            // under the same manifest policy; the delay and cap stay
369            // host-owned).
370            let restart_policy = found.manifest.restart;
371
372            self.spawn_kind_leg(
373                &plugin.id,
374                KindSlot::Widget,
375                widget_entry,
376                &plugin.settings_json,
377                &subscriptions,
378                restart_policy,
379                now,
380            );
381            self.spawn_kind_leg(
382                &plugin.id,
383                KindSlot::Action,
384                action_entry,
385                &plugin.settings_json,
386                &subscriptions,
387                restart_policy,
388                now,
389            );
390            self.spawn_kind_leg(
391                &plugin.id,
392                KindSlot::Panel,
393                panel_entry,
394                &plugin.settings_json,
395                &subscriptions,
396                restart_policy,
397                now,
398            );
399            self.spawn_kind_leg(
400                &plugin.id,
401                KindSlot::Overlay,
402                overlay_entry,
403                &plugin.settings_json,
404                &subscriptions,
405                restart_policy,
406                now,
407            );
408        }
409    }
410
411    /// Advance supervision one frame: drain each running plugin process's
412    /// commands (every kind), observe exits, and drive each kind's restart
413    /// supervisor (manifest-declared mode, capped) — one frame advance
414    /// covers the widget, action, and panel maps identically.
415    ///
416    /// Commands are drained before exit handling so a plugin's final
417    /// output before a crash still lands.
418    pub fn poll(&mut self) {
419        let now = Instant::now();
420        let widget_ids: Vec<String> = self.running.keys().cloned().collect();
421        for id in widget_ids {
422            self.poll_one(&id, KindSlot::Widget, now);
423        }
424        let action_ids: Vec<String> = self.action_running.keys().cloned().collect();
425        for id in action_ids {
426            self.poll_one(&id, KindSlot::Action, now);
427        }
428        let panel_ids: Vec<String> = self.panel_running.keys().cloned().collect();
429        for id in panel_ids {
430            self.poll_one(&id, KindSlot::Panel, now);
431        }
432        let overlay_ids: Vec<String> = self.overlay_running.keys().cloned().collect();
433        for id in overlay_ids {
434            self.poll_one(&id, KindSlot::Overlay, now);
435        }
436    }
437
438    /// Last `SetWidget` text recorded for a plugin, if any.
439    pub fn widget_text(&self, plugin_id: &str) -> Option<&str> {
440        self.widget_texts.get(plugin_id).map(String::as_str)
441    }
442
443    /// Last `SetPanel` (title, content) recorded for a panel plugin, if any.
444    pub fn panel_content(&self, plugin_id: &str) -> Option<&(String, String)> {
445        self.panel_contents.get(plugin_id)
446    }
447
448    /// Live per-plugin event forwarders (plugin id → forwarder), for the
449    /// window layer's observer registration and per-sweep drain — the pump
450    /// in `StatusBarUI::pump_plugin_events`.
451    pub fn subscription_forwarders(&self) -> &HashMap<String, Arc<ScriptEventForwarder>> {
452        &self.subscription_forwarders
453    }
454
455    /// Deliver drained events to one plugin's running processes (every kind
456    /// alike — the manifest's subscriptions are plugin-level, so every
457    /// running process of the plugin hears them).
458    ///
459    /// A plugin with no running process this sweep silently drops the events:
460    /// they are moments in time, not state, and its supervisor restarts it
461    /// for the next ones. Write failures (a process exiting mid-delivery)
462    /// warn once per episode.
463    pub fn deliver_events(&mut self, plugin_id: &str, events: &[ScriptEvent]) {
464        let targets: Vec<ScriptId> = self
465            .running
466            .get(plugin_id)
467            .into_iter()
468            .chain(self.action_running.get(plugin_id))
469            .chain(self.panel_running.get(plugin_id))
470            .copied()
471            .collect();
472        if targets.is_empty() {
473            return;
474        }
475        let mut write_failed = false;
476        for event in events {
477            for &sid in &targets {
478                if let Err(error) = self.manager.send_event(sid, event) {
479                    write_failed = true;
480                    if self.warned_event_delivery.should_warn(plugin_id) {
481                        log::warn!(
482                            "failed to deliver a {} event to plugin '{}': {}",
483                            event.kind,
484                            plugin_id,
485                            error
486                        );
487                    }
488                }
489            }
490        }
491        if !write_failed {
492            self.warned_event_delivery.clear(plugin_id);
493        }
494    }
495
496    /// All recorded widget texts (plugin id → text), for the status bar.
497    pub fn widget_texts(&self) -> &HashMap<String, String> {
498        &self.widget_texts
499    }
500
501    /// All recorded panel contents (plugin id → (title, content)), for the
502    /// settings plugins section.
503    pub fn panel_contents(&self) -> &HashMap<String, (String, String)> {
504        &self.panel_contents
505    }
506
507    /// Live plugin overlays (plugin id → overlay), for the render layer.
508    pub fn overlays(&self) -> &HashMap<String, crate::protocol::PluginOverlay> {
509        &self.overlays
510    }
511
512    /// The focused overlay's plugin id, if any — the render layer's
513    /// click-to-focus target check and the key router's focus check.
514    pub fn focused_overlay(&self) -> Option<&str> {
515        self.focused_overlay.as_deref()
516    }
517
518    /// Focus one plugin's overlay (the render layer's click path). Only an
519    /// overlay that is live AND interactive can take focus — everything
520    /// else clears focus, so the call doubles as the unfocus path.
521    pub fn focus_overlay(&mut self, plugin_id: &str) {
522        self.focused_overlay = self
523            .overlays
524            .get(plugin_id)
525            .filter(|o| o.interactive)
526            .map(|_| plugin_id.to_string());
527    }
528
529    /// Drop overlay focus entirely (Escape path; also called when the
530    /// focused overlay stops being focusable).
531    pub fn unfocus_overlay(&mut self) {
532        self.focused_overlay = None;
533    }
534
535    /// Clear focus when it names this plugin's overlay (the upsert and
536    /// teardown paths) — other plugins' focus is untouched.
537    pub(super) fn clear_focus_if(&mut self, plugin_id: &str) {
538        if self.focused_overlay.as_deref() == Some(plugin_id) {
539            self.focused_overlay = None;
540        }
541    }
542
543    /// Deliver one semantic overlay-widget event to a plugin's running
544    /// overlay process (the renderer's interaction sink). Returns false
545    /// without warning when there is nothing to deliver to — the renderer
546    /// only calls this for a focused overlay it just drew, so a miss means
547    /// the process raced an exit, which the supervisor reports.
548    pub fn send_overlay_event(
549        &mut self,
550        plugin_id: &str,
551        widget_id: &str,
552        event: crate::protocol::OverlayWidgetEvent,
553    ) -> bool {
554        let Some(&sid) = self.overlay_running.get(plugin_id) else {
555            return false;
556        };
557        let Some(overlay) = self.overlays.get(plugin_id) else {
558            return false;
559        };
560        let script_event = ScriptEvent {
561            kind: crate::protocol::OVERLAY_EVENT_KIND.to_string(),
562            data: ScriptEventData::OverlayEvent {
563                overlay: overlay.id.clone(),
564                widget: widget_id.to_string(),
565                event,
566            },
567        };
568        match self.manager.send_event(sid, &script_event) {
569            Ok(()) => {
570                self.overlay_events_dispatched += 1;
571                true
572            }
573            Err(error) => {
574                if self.warned_event_delivery.should_warn(plugin_id) {
575                    log::warn!(
576                        "failed to deliver overlay event to plugin '{}': {}",
577                        plugin_id,
578                        error
579                    );
580                }
581                false
582            }
583        }
584    }
585
586    /// Successful overlay-event stdin writes this session — the
587    /// `plugin_overlay_event` ui-test operand's source. Monotonic.
588    pub fn overlay_events_dispatched_count(&self) -> u64 {
589        self.overlay_events_dispatched
590    }
591
592    /// The SetOverlay rate clamp's budget: accepted upserts per sliding
593    /// second (design names ~30/sec with a warning).
594    const OVERLAY_UPDATE_WINDOW_SECS: u64 = 1;
595    const OVERLAY_UPDATE_MAX_PER_WINDOW: usize = 30;
596
597    /// Whether one more `SetOverlay` from this plugin lands inside the
598    /// update-rate budget; records the attempt either way. Over-budget
599    /// upserts are dropped after the first, which warns once per episode
600    /// (the episode resets after a full quiet second, so a plugin that
601    /// bursts, stops, and bursts again warns once per burst).
602    pub(super) fn overlay_update_permitted(&mut self, id: &str, now: Instant) -> bool {
603        let times = self.overlay_update_times.entry(id.to_string()).or_default();
604        times.retain(|t| now.duration_since(*t).as_secs() < Self::OVERLAY_UPDATE_WINDOW_SECS);
605        if times.len() >= Self::OVERLAY_UPDATE_MAX_PER_WINDOW {
606            if self
607                .warned_event_delivery
608                .should_warn(&format!("{id}/overlay-rate"))
609            {
610                log::warn!(
611                    "plugin '{id}' is pushing SetOverlay faster than {} per second; \
612                     dropping the excess (update-rate clamp)",
613                    Self::OVERLAY_UPDATE_MAX_PER_WINDOW
614                );
615            }
616            return false;
617        }
618        times.push(now);
619        true
620    }
621
622    /// Successful [`Self::invoke_action`] stdin writes this session.
623    /// Monotonic for the session and never reset — delivered-means-true
624    /// dispatch has no undo, so the count only grows.
625    pub fn actions_dispatched_count(&self) -> u64 {
626        self.actions_dispatched
627    }
628
629    /// Ids of plugins with any running or supervised process, of any
630    /// kind. A multi-kind plugin appears once.
631    pub fn running_plugin_ids(&self) -> Vec<String> {
632        self.running
633            .keys()
634            .chain(self.action_running.keys())
635            .chain(self.panel_running.keys())
636            .chain(self.overlay_running.keys())
637            .cloned()
638            .collect()
639    }
640
641    /// Invoke one of a plugin's contributed palette actions: write a single
642    /// [`ScriptEvent`] line carrying [`ScriptEventData::PluginActionInvoked`]
643    /// to the plugin's running action process.
644    ///
645    /// Returns `true` only when the plugin is discovered, declares the
646    /// action-contributor kind, the action id is in its manifest, a process
647    /// is running, and the stdin write succeeded. Every miss returns `false`
648    /// behind [`WarnOnce`] gates (`warned_not_discovered`, and
649    /// `warned_action_not_running` for every post-discovery miss) — a stuck
650    /// keybinding can retry this at frame rate.
651    pub fn invoke_action(&mut self, plugin_id: &str, action_id: &str) -> bool {
652        let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin_id) else {
653            if self.warned_not_discovered.should_warn(plugin_id) {
654                log::warn!(
655                    "plugin '{}' is not discovered; cannot invoke action '{}'",
656                    plugin_id,
657                    action_id
658                );
659            }
660            return false;
661        };
662        if !found
663            .manifest
664            .kinds
665            .iter()
666            .any(|k| k == KIND_ACTION_CONTRIBUTOR)
667        {
668            if self.warned_action_not_running.should_warn(plugin_id) {
669                log::warn!(
670                    "plugin '{}' does not declare kind '{}'; cannot invoke action '{}'",
671                    plugin_id,
672                    KIND_ACTION_CONTRIBUTOR,
673                    action_id
674                );
675            }
676            return false;
677        }
678        if !found.manifest.actions.iter().any(|a| a.id == action_id) {
679            if self.warned_action_not_running.should_warn(plugin_id) {
680                log::warn!(
681                    "plugin '{}' has no action '{}'; cannot invoke it",
682                    plugin_id,
683                    action_id
684                );
685            }
686            return false;
687        }
688        let Some(&sid) = self.action_running.get(plugin_id) else {
689            if self.warned_action_not_running.should_warn(plugin_id) {
690                log::warn!(
691                    "plugin '{}' has no running action process; action '{}' not invoked",
692                    plugin_id,
693                    action_id
694                );
695            }
696            return false;
697        };
698        let event = ScriptEvent {
699            kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
700            data: ScriptEventData::PluginActionInvoked {
701                action: action_id.to_string(),
702            },
703        };
704        match self.manager.send_event(sid, &event) {
705            Ok(()) => {
706                // A landed invoke starts a fresh miss episode.
707                self.warned_action_not_running.clear(plugin_id);
708                self.actions_dispatched += 1;
709                true
710            }
711            Err(error) => {
712                if self.warned_action_not_running.should_warn(plugin_id) {
713                    log::warn!(
714                        "failed to invoke action '{}' on plugin '{}': {}",
715                        action_id,
716                        plugin_id,
717                        error
718                    );
719                }
720                false
721            }
722        }
723    }
724
725    /// Palette rows for every action of every discovered, enabled,
726    /// action-contributor plugin (design D2), in discovery order.
727    pub fn palette_actions(&self) -> Vec<PluginActionRow> {
728        let mut rows = Vec::new();
729        for found in &self.discovered {
730            if !self.enabled_ids.contains(&found.manifest.id) {
731                continue;
732            }
733            if !found
734                .manifest
735                .kinds
736                .iter()
737                .any(|k| k == KIND_ACTION_CONTRIBUTOR)
738            {
739                continue;
740            }
741            for action in &found.manifest.actions {
742                rows.push(PluginActionRow {
743                    wire_id: format!("plugin-action:{}:{}", found.manifest.id, action.id),
744                    label: format!("{} · {}", action.label, found.manifest.name),
745                });
746            }
747        }
748        rows
749    }
750
751    /// Take the lines accumulated from refused plugin commands (the
752    /// read-errors-style surface for commands v1 ignores).
753    pub fn drain_ignored(&mut self) -> Vec<String> {
754        std::mem::take(&mut self.ignored_lines)
755    }
756
757    /// Stop every plugin and drop all supervision state.
758    pub fn stop_all(&mut self) {
759        self.manager.stop_all();
760        self.running.clear();
761        self.action_running.clear();
762        self.panel_running.clear();
763        self.overlay_running.clear();
764        self.restart.clear();
765        self.action_restart.clear();
766        self.panel_restart.clear();
767        self.overlay_restart.clear();
768        self.widget_texts.clear();
769        self.panel_contents.clear();
770        self.overlays.clear();
771        self.settings_json.clear();
772        self.enabled_ids.clear();
773        self.subscription_forwarders.clear();
774        self.warned_not_discovered = WarnOnce::default();
775        self.warned_spawn_failed = WarnOnce::default();
776        self.warned_action_not_running = WarnOnce::default();
777        self.warned_event_delivery = WarnOnce::default();
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use std::fs;
785    use std::thread;
786    use std::time::Duration;
787
788    /// A plugin that emits two `SetWidget` lines and then stays alive.
789    const WIDGET_SCRIPT: &str = r#"
790import json, time
791def emit(text):
792    print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
793emit("first")
794emit("second")
795while True:
796    time.sleep(0.2)
797"#;
798
799    /// A plugin that emits a `Log` command (refused in v1) and stays alive.
800    const LOG_SCRIPT: &str = r#"
801import json, time
802print(json.dumps({"type": "Log", "level": "info", "message": "hello"}), flush=True)
803while True:
804    time.sleep(0.2)
805"#;
806
807    /// A plugin that exits immediately with a failure code.
808    const CRASH_SCRIPT: &str = "import sys\nsys.exit(3)\n";
809
810    fn manifest_json(id: &str) -> String {
811        format!(
812            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"T\",\"version\":\"0.1.0\",\
813\"kinds\":[\"status-bar-widget\"],\"activation\":\"manual\",\
814\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}}}},\
815\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
816\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
817        )
818    }
819
820    /// Write one valid plugin directory under `root`; executable on unix so
821    /// discovery's permissions check accepts the entry.
822    fn write_plugin(root: &Path, id: &str, script: &str) {
823        let dir = root.join(id);
824        fs::create_dir_all(&dir).unwrap();
825        fs::write(dir.join("manifest.json"), manifest_json(id)).unwrap();
826        let entry = dir.join("widget.py");
827        fs::write(&entry, script).unwrap();
828        #[cfg(unix)]
829        {
830            use std::os::unix::fs::PermissionsExt;
831            fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
832        }
833    }
834
835    fn enabled(id: &str) -> EnabledPlugin {
836        EnabledPlugin {
837            id: id.to_string(),
838            settings_json: "{}".to_string(),
839        }
840    }
841
842    fn plugin_running(host: &PluginHost, id: &str) -> bool {
843        host.running_plugin_ids().iter().any(|i| i == id)
844    }
845
846    /// Drive `poll` until `check` holds or ~10 s elapse (the fixture is a
847    /// real subprocess; its first output line can take a moment to arrive).
848    fn wait_until(host: &mut PluginHost, mut check: impl FnMut(&mut PluginHost) -> bool) -> bool {
849        let deadline = Instant::now() + Duration::from_secs(10);
850        while Instant::now() < deadline {
851            host.poll();
852            if check(host) {
853                return true;
854            }
855            thread::sleep(Duration::from_millis(50));
856        }
857        false
858    }
859
860    fn skip_without_interpreter() -> bool {
861        if crate::manager::python_interpreter().is_some() {
862            return false;
863        }
864        eprintln!("skipping: no Python interpreter on PATH");
865        true
866    }
867
868    #[test]
869    fn discovery_never_spawns_even_for_an_absent_root() {
870        let mut host = PluginHost::new();
871        let root = std::env::temp_dir().join("par-term-plugin-test-absent-root");
872        assert!(host.refresh_discovery(&root).is_empty());
873        host.apply_enabled(&[enabled("com.test.widget")]);
874        assert!(host.running_plugin_ids().is_empty());
875        host.poll(); // must not panic on an empty host
876    }
877
878    #[test]
879    fn apply_enabled_spawns_and_last_set_widget_wins() {
880        if skip_without_interpreter() {
881            return;
882        }
883        let root = tempfile::tempdir().unwrap();
884        write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
885
886        let mut host = PluginHost::new();
887        assert_eq!(host.refresh_discovery(root.path()).len(), 1);
888        host.apply_enabled(&[enabled("com.test.widget")]);
889        // Nothing is drained until poll runs, so no widget text yet.
890        assert_eq!(host.widget_text("com.test.widget"), None);
891
892        let settled = wait_until(&mut host, |h| h.widget_text("com.test.widget").is_some());
893        assert!(settled, "fixture's SetWidget never arrived");
894        // The fixture emits "first" then "second"; both lines are typically
895        // drained by one poll, so the surviving text proves last-write-wins.
896        assert_eq!(host.widget_text("com.test.widget"), Some("second"));
897        host.stop_all();
898    }
899
900    #[test]
901    fn disabling_stops_the_plugin_and_its_widget_text() {
902        if skip_without_interpreter() {
903            return;
904        }
905        let root = tempfile::tempdir().unwrap();
906        write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
907
908        let mut host = PluginHost::new();
909        host.refresh_discovery(root.path());
910        host.apply_enabled(&[enabled("com.test.widget")]);
911        assert!(wait_until(&mut host, |h| h
912            .widget_text("com.test.widget")
913            .is_some()));
914
915        host.apply_enabled(&[]);
916        assert!(!plugin_running(&host, "com.test.widget"));
917        assert_eq!(host.widget_text("com.test.widget"), None);
918        // A disabled plugin must not be resurrected by the supervisor.
919        for _ in 0..10 {
920            host.poll();
921            thread::sleep(Duration::from_millis(50));
922        }
923        assert!(host.running_plugin_ids().is_empty());
924    }
925
926    #[test]
927    fn crash_loop_is_capped_without_panicking() {
928        if skip_without_interpreter() {
929            return;
930        }
931        let root = tempfile::tempdir().unwrap();
932        write_plugin(root.path(), "com.test.crash", CRASH_SCRIPT);
933
934        let mut host = PluginHost::new();
935        host.refresh_discovery(root.path());
936        host.apply_enabled(&[enabled("com.test.crash")]);
937
938        // 5 supervised restarts at a 250 ms delay, plus spawn overhead —
939        // generous wall-clock budget, hard deadline so a bug fails fast.
940        let gave_up = wait_until(&mut host, |h| !plugin_running(h, "com.test.crash"));
941        assert!(
942            gave_up,
943            "supervisor never gave up; still running: {:?}",
944            host.running_plugin_ids()
945        );
946        // Polling a given-up slot stays a no-op.
947        host.poll();
948        assert!(host.running_plugin_ids().is_empty());
949    }
950
951    /// A one-shot plugin: emits one widget line and exits 0.
952    const ONE_SHOT_SCRIPT: &str = r#"
953import json
954print(json.dumps({"type": "SetWidget", "text": "once"}), flush=True)
955"#;
956
957    /// A widget manifest declaring a restart `mode` (never / always).
958    fn restart_manifest_json(id: &str, mode: &str) -> String {
959        manifest_json(id).replacen(
960            "\"activation\":\"manual\",",
961            &format!("\"activation\":\"manual\",\"restart\":\"{mode}\","),
962            1,
963        )
964    }
965
966    #[test]
967    fn never_policy_plugin_is_not_restarted_after_a_crash() {
968        if skip_without_interpreter() {
969            return;
970        }
971        let root = tempfile::tempdir().unwrap();
972        write_plugin_files(
973            root.path(),
974            "com.test.never",
975            &restart_manifest_json("com.test.never", "never"),
976            &[("widget.py", CRASH_SCRIPT)],
977        );
978
979        let mut host = PluginHost::new();
980        host.refresh_discovery(root.path());
981        host.apply_enabled(&[enabled("com.test.never")]);
982
983        // The crash exits once and `never` stops it there — no capped round
984        // of retries, no supervision state left behind.
985        let stopped = wait_until(&mut host, |h| !plugin_running(h, "com.test.never"));
986        assert!(stopped, "plugin never stopped");
987        assert!(
988            !host.restart.contains_key("com.test.never"),
989            "stop_kind must drop the supervision state"
990        );
991
992        // Well past the 250 ms restart delay: no respawn may appear.
993        thread::sleep(Duration::from_millis(700));
994        host.poll();
995        assert!(
996            !plugin_running(&host, "com.test.never"),
997            "`never` must not respawn a crashed plugin"
998        );
999    }
1000
1001    #[test]
1002    fn always_policy_plugin_restarts_after_a_clean_exit() {
1003        if skip_without_interpreter() {
1004            return;
1005        }
1006        let root = tempfile::tempdir().unwrap();
1007        write_plugin_files(
1008            root.path(),
1009            "com.test.always",
1010            &restart_manifest_json("com.test.always", "always"),
1011            &[("widget.py", ONE_SHOT_SCRIPT)],
1012        );
1013
1014        let mut host = PluginHost::new();
1015        host.refresh_discovery(root.path());
1016        host.apply_enabled(&[enabled("com.test.always")]);
1017
1018        // Under `always` even a clean (exit 0) one-shot is restarted: the
1019        // first exit schedules attempt 1, the respawn's exit attempt 2, so
1020        // two consecutive failures prove at least one clean-exit restart
1021        // fired (on_failure, today's behaviour, would have stopped at the
1022        // first exit).
1023        let restarted = wait_until(&mut host, |h| {
1024            h.restart
1025                .get("com.test.always")
1026                .is_some_and(|s| s.consecutive_failures() >= 2)
1027        });
1028        assert!(
1029            restarted,
1030            "clean exit was never restarted; failures: {:?}",
1031            host.restart
1032                .get("com.test.always")
1033                .map(|s| s.consecutive_failures())
1034        );
1035        host.stop_all();
1036    }
1037
1038    #[test]
1039    fn non_widget_commands_are_refused_with_an_error_line() {
1040        if skip_without_interpreter() {
1041            return;
1042        }
1043        let root = tempfile::tempdir().unwrap();
1044        write_plugin(root.path(), "com.test.log", LOG_SCRIPT);
1045
1046        let mut host = PluginHost::new();
1047        host.refresh_discovery(root.path());
1048        host.apply_enabled(&[enabled("com.test.log")]);
1049
1050        let mut refused: Vec<String> = Vec::new();
1051        let seen = wait_until(&mut host, |h| {
1052            refused.extend(h.drain_ignored());
1053            refused.iter().any(|line| line.contains("Log"))
1054        });
1055        assert!(
1056            seen,
1057            "Log command was never refused; lines seen: {:?}, widget text: {:?}",
1058            refused,
1059            host.widget_text("com.test.log")
1060        );
1061        assert!(refused.iter().all(|line| line.starts_with("[error]")));
1062        host.stop_all();
1063    }
1064
1065    #[test]
1066    fn failing_spawn_retries_at_the_restart_delay_not_per_frame() {
1067        let root = tempfile::tempdir().unwrap();
1068        // An executable non-.py entry with a broken interpreter line:
1069        // discovery accepts it, direct exec fails — a persistently
1070        // failing spawn. (.py entries bypass the shebang via the
1071        // interpreter route in `spawn_command`, so the entry must not be
1072        // python.)
1073        let dir = root.path().join("com.test.broken");
1074        fs::create_dir_all(&dir).unwrap();
1075        fs::write(
1076            dir.join("manifest.json"),
1077            manifest_json("com.test.broken").replace("widget.py", "widget.sh"),
1078        )
1079        .unwrap();
1080        fs::write(
1081            dir.join("widget.sh"),
1082            "#!/nonexistent/par-term-test-interpreter\n",
1083        )
1084        .unwrap();
1085        #[cfg(unix)]
1086        {
1087            use std::os::unix::fs::PermissionsExt;
1088            fs::set_permissions(dir.join("widget.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1089        }
1090
1091        let mut host = PluginHost::new();
1092        host.refresh_discovery(root.path());
1093        assert_eq!(host.discovered_count(), 1, "fixture must be discovered");
1094
1095        // First reconcile: one spawn attempt, it fails, the supervisor's
1096        // backoff arms.
1097        host.apply_enabled(&[enabled("com.test.broken")]);
1098        assert!(!plugin_running(&host, "com.test.broken"));
1099        let after_first = host
1100            .restart
1101            .get("com.test.broken")
1102            .expect("supervisor state armed by a failed spawn")
1103            .consecutive_failures();
1104        assert_eq!(after_first, 1);
1105
1106        // Reconcile again immediately (well inside the 250 ms delay): no
1107        // re-attempt — a second exec would have pushed the failure count.
1108        host.apply_enabled(&[enabled("com.test.broken")]);
1109        assert_eq!(
1110            host.restart
1111                .get("com.test.broken")
1112                .unwrap()
1113                .consecutive_failures(),
1114            after_first,
1115            "spawn must not be retried inside the restart delay"
1116        );
1117
1118        // Once the delay has elapsed: exactly one retry.
1119        thread::sleep(Duration::from_millis(300));
1120        host.apply_enabled(&[enabled("com.test.broken")]);
1121        assert_eq!(
1122            host.restart
1123                .get("com.test.broken")
1124                .unwrap()
1125                .consecutive_failures(),
1126            after_first + 1,
1127            "exactly one retry after the delay elapses"
1128        );
1129    }
1130
1131    #[test]
1132    fn stop_all_drains_every_plugin() {
1133        if skip_without_interpreter() {
1134            return;
1135        }
1136        let root = tempfile::tempdir().unwrap();
1137        write_plugin(root.path(), "com.test.a", WIDGET_SCRIPT);
1138        write_plugin(root.path(), "com.test.b", WIDGET_SCRIPT);
1139
1140        let mut host = PluginHost::new();
1141        host.refresh_discovery(root.path());
1142        host.apply_enabled(&[enabled("com.test.a"), enabled("com.test.b")]);
1143        assert!(wait_until(&mut host, |h| h.running_plugin_ids().len() == 2));
1144
1145        host.stop_all();
1146        assert!(host.running_plugin_ids().is_empty());
1147        assert!(host.widget_texts().is_empty());
1148        host.poll(); // no panic after a full drain
1149    }
1150
1151    /// Records every `log::warn!` this test binary emits so tests can assert
1152    /// on warn *frequency*, not just occurrence. Installed once per process;
1153    /// this crate's tests never install a file logger, so capturing here
1154    /// changes nothing for the other tests.
1155    static WARNED: std::sync::LazyLock<std::sync::Mutex<Vec<String>>> =
1156        std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
1157
1158    struct CountingLogger;
1159
1160    impl log::Log for CountingLogger {
1161        fn enabled(&self, _metadata: &log::Metadata) -> bool {
1162            true
1163        }
1164
1165        fn log(&self, record: &log::Record) {
1166            if record.level() == log::Level::Warn {
1167                WARNED.lock().unwrap().push(record.args().to_string());
1168            }
1169        }
1170
1171        fn flush(&self) {}
1172    }
1173
1174    fn install_counting_logger() {
1175        static INSTALL: std::sync::Once = std::sync::Once::new();
1176        static COUNTING_LOGGER: CountingLogger = CountingLogger;
1177        INSTALL.call_once(|| {
1178            // An error here would mean another logger is already installed;
1179            // capture works regardless.
1180            let _ = log::set_logger(&COUNTING_LOGGER);
1181            log::set_max_level(log::LevelFilter::Warn);
1182        });
1183    }
1184
1185    fn warns_containing(fragment: &str) -> usize {
1186        WARNED
1187            .lock()
1188            .unwrap()
1189            .iter()
1190            .filter(|msg| msg.contains(fragment))
1191            .count()
1192    }
1193
1194    // Both dedup tests run in parallel against the shared warn capture, so
1195    // each filters on its own plugin id, not the shared phrase.
1196    #[test]
1197    fn fault_warns_fire_once_per_transition_not_per_frame() {
1198        install_counting_logger();
1199        let fragment = "'com.test.missing' is enabled but not discovered";
1200        let mut host = PluginHost::new();
1201        // An empty root: the enabled plugin is never discovered.
1202        let root = tempfile::tempdir().unwrap();
1203        host.refresh_discovery(root.path());
1204
1205        for _ in 0..5 {
1206            host.apply_enabled(&[enabled("com.test.missing")]);
1207        }
1208        let warns = warns_containing(fragment);
1209        assert_eq!(
1210            warns, 1,
1211            "5 frames of one steady fault must produce exactly 1 warn, got {warns}"
1212        );
1213
1214        // Disabling re-arms the warn: re-enabling the same fault warns again
1215        // rather than staying silent forever.
1216        host.apply_enabled(&[]);
1217        for _ in 0..2 {
1218            host.apply_enabled(&[enabled("com.test.missing")]);
1219        }
1220        let warns = warns_containing(fragment);
1221        assert_eq!(
1222            warns, 2,
1223            "warn must re-arm after a disable/enable, got {warns}"
1224        );
1225    }
1226
1227    #[test]
1228    fn fault_warn_rearms_after_recovery() {
1229        if skip_without_interpreter() {
1230            return;
1231        }
1232        install_counting_logger();
1233        let empty = tempfile::tempdir().unwrap();
1234        let populated = tempfile::tempdir().unwrap();
1235        write_plugin(populated.path(), "com.test.flapping", WIDGET_SCRIPT);
1236
1237        let mut host = PluginHost::new();
1238        // Fault: enabled but not discovered.
1239        let fragment = "'com.test.flapping' is enabled but not discovered";
1240        host.refresh_discovery(empty.path());
1241        host.apply_enabled(&[enabled("com.test.flapping")]);
1242        assert_eq!(warns_containing(fragment), 1);
1243
1244        // Recovery: the plugin appears and spawns, which re-arms the warn.
1245        host.refresh_discovery(populated.path());
1246        host.apply_enabled(&[enabled("com.test.flapping")]);
1247        assert!(wait_until(&mut host, |h| h
1248            .widget_text("com.test.flapping")
1249            .is_some()));
1250
1251        // The plugin is disabled, disappears, and is enabled again: the same
1252        // fault must warn a second time.
1253        host.apply_enabled(&[]);
1254        host.refresh_discovery(empty.path());
1255        host.apply_enabled(&[enabled("com.test.flapping")]);
1256        assert_eq!(
1257            warns_containing(fragment),
1258            2,
1259            "a recovered-then-failed-again plugin must warn again"
1260        );
1261    }
1262
1263    /// An action entry that echoes every stdin event back as a `SetWidget`
1264    /// naming the invoked action — the observable seam for invoke writes
1265    /// (same pattern as `WIDGET_SCRIPT` for spawned plugin stdout).
1266    const ACTION_SCRIPT: &str = r#"
1267import json, sys
1268def emit(text):
1269    print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
1270for line in iter(sys.stdin.readline, ""):
1271    line = line.strip()
1272    if not line:
1273        continue
1274    try:
1275        event = json.loads(line)
1276    except ValueError:
1277        continue
1278    emit("invoked:" + str(event.get("data", {}).get("action", "?")))
1279"#;
1280
1281    fn action_manifest_json(id: &str) -> String {
1282        format!(
1283            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Actions\",\"version\":\"0.1.0\",\
1284\"kinds\":[\"action-contributor\"],\"activation\":\"manual\",\
1285\"entryPoints\":{{\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1286\"actions\":[{{\"id\":\"say-hello\",\"label\":\"Say hello\"}}]}}"
1287        )
1288    }
1289
1290    fn both_kinds_manifest_json(id: &str) -> String {
1291        format!(
1292            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Both\",\"version\":\"0.1.0\",\
1293\"kinds\":[\"status-bar-widget\",\"action-contributor\"],\"activation\":\"manual\",\
1294\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}},\
1295\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1296\"actions\":[{{\"id\":\"ping\",\"label\":\"Ping\"}}],\
1297\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
1298\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
1299        )
1300    }
1301
1302    fn panel_manifest_json(id: &str) -> String {
1303        format!(
1304            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Panel\",\"version\":\"0.1.0\",\
1305\"kinds\":[\"panel\"],\"activation\":\"manual\",\
1306\"entryPoints\":{{\"panel\":{{\"command\":\"panel.py\",\"args\":[]}}}}}}"
1307        )
1308    }
1309
1310    /// A panel plugin that emits a `SetWidget` line (refused for the panel
1311    /// kind) and then a `SetPanel`, then stays alive.
1312    const PANEL_SCRIPT: &str = r##"
1313import json, time
1314def emit(obj):
1315    print(json.dumps(obj), flush=True)
1316emit({"type": "SetWidget", "text": "nope"})
1317emit({"type": "SetPanel", "title": "Notes", "content": "# hello\nworld"})
1318while True:
1319    time.sleep(0.2)
1320"##;
1321
1322    /// A panel plugin that pushes a panel and then clears it.
1323    const PANEL_CLEAR_SCRIPT: &str = r#"
1324import json, time
1325def emit(obj):
1326    print(json.dumps(obj), flush=True)
1327emit({"type": "SetPanel", "title": "T", "content": "c"})
1328time.sleep(0.3)
1329emit({"type": "ClearPanel"})
1330while True:
1331    time.sleep(0.2)
1332"#;
1333
1334    /// Write one plugin directory with a manifest plus named entry files,
1335    /// executable on unix so discovery's permissions check accepts them.
1336    fn write_plugin_files(root: &Path, id: &str, manifest: &str, files: &[(&str, &str)]) {
1337        let dir = root.join(id);
1338        fs::create_dir_all(&dir).unwrap();
1339        fs::write(dir.join("manifest.json"), manifest).unwrap();
1340        for (name, script) in files {
1341            let entry = dir.join(name);
1342            fs::write(&entry, script).unwrap();
1343            #[cfg(unix)]
1344            {
1345                use std::os::unix::fs::PermissionsExt;
1346                fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
1347            }
1348        }
1349    }
1350
1351    fn write_action_plugin(root: &Path, id: &str, script: &str) {
1352        write_plugin_files(
1353            root,
1354            id,
1355            &action_manifest_json(id),
1356            &[("actions.py", script)],
1357        );
1358    }
1359
1360    fn write_panel_plugin(root: &Path, id: &str, script: &str) {
1361        write_plugin_files(root, id, &panel_manifest_json(id), &[("panel.py", script)]);
1362    }
1363
1364    #[test]
1365    fn panel_plugin_pushes_content_and_refuses_set_widget() {
1366        if skip_without_interpreter() {
1367            return;
1368        }
1369        let root = tempfile::tempdir().unwrap();
1370        write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1371
1372        let mut host = PluginHost::new();
1373        assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1374        host.apply_enabled(&[enabled("com.test.panel")]);
1375        let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1376        assert!(settled, "fixture's SetPanel never arrived");
1377        assert_eq!(
1378            host.panel_content("com.test.panel"),
1379            Some(&("Notes".to_string(), "# hello\nworld".to_string()))
1380        );
1381        // The panel kind's allowlist refused the fixture's SetWidget line
1382        // with the panel-specific message.
1383        let ignored = host.drain_ignored().join("\n");
1384        assert!(ignored.contains("SetWidget"), "got: {ignored}");
1385        assert!(ignored.contains("SetPanel/ClearPanel"), "got: {ignored}");
1386        host.stop_all();
1387    }
1388
1389    #[test]
1390    fn clear_panel_from_a_panel_process_removes_the_panel() {
1391        if skip_without_interpreter() {
1392            return;
1393        }
1394        let root = tempfile::tempdir().unwrap();
1395        write_panel_plugin(root.path(), "com.test.panel", PANEL_CLEAR_SCRIPT);
1396
1397        let mut host = PluginHost::new();
1398        host.refresh_discovery(root.path());
1399        host.apply_enabled(&[enabled("com.test.panel")]);
1400        let pushed = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1401        assert!(pushed, "fixture's SetPanel never arrived");
1402        let cleared = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_none());
1403        assert!(cleared, "fixture's ClearPanel never landed");
1404        host.stop_all();
1405    }
1406
1407    #[test]
1408    fn disabling_a_panel_plugin_stops_the_process_and_clears_the_panel() {
1409        if skip_without_interpreter() {
1410            return;
1411        }
1412        let root = tempfile::tempdir().unwrap();
1413        write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1414
1415        let mut host = PluginHost::new();
1416        host.refresh_discovery(root.path());
1417        host.apply_enabled(&[enabled("com.test.panel")]);
1418        let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1419        assert!(settled, "fixture's SetPanel never arrived");
1420        assert!(plugin_running(&host, "com.test.panel"));
1421
1422        // Leaving the enabled set tears the plugin down: process stopped AND
1423        // the pushed panel dropped — no orphaned surface.
1424        host.apply_enabled(&[]);
1425        assert!(!plugin_running(&host, "com.test.panel"));
1426        assert_eq!(host.panel_content("com.test.panel"), None);
1427        host.stop_all();
1428    }
1429
1430    fn overlay_manifest_json(id: &str) -> String {
1431        format!(
1432            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1433\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1434\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1435        )
1436    }
1437
1438    /// An overlay plugin that emits a `SetWidget` line (refused for the
1439    /// overlay kind) and then a `SetOverlay`, then stays alive.
1440    const OVERLAY_SCRIPT: &str = r##"
1441import json, time
1442def emit(obj):
1443    print(json.dumps(obj), flush=True)
1444emit({"type": "SetWidget", "text": "nope"})
1445emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1446      "size": {"w": 0.2, "h": 0.1}, "opacity": 0.8,
1447      "content": {"type": "markdown", "text": "# hello"}})
1448while True:
1449    time.sleep(0.2)
1450"##;
1451
1452    /// An overlay plugin that pushes an overlay and then clears it.
1453    const OVERLAY_CLEAR_SCRIPT: &str = r#"
1454import json, time
1455def emit(obj):
1456    print(json.dumps(obj), flush=True)
1457emit({"type": "SetOverlay", "id": "hud", "position": "center",
1458      "size": {"w": 0.2, "h": 0.1},
1459      "content": {"type": "text", "text": "hi"}})
1460time.sleep(0.3)
1461emit({"type": "ClearOverlay", "id": "hud"})
1462while True:
1463    time.sleep(0.2)
1464"#;
1465
1466    fn write_overlay_plugin(root: &Path, id: &str, script: &str) {
1467        write_plugin_files(
1468            root,
1469            id,
1470            &overlay_manifest_json(id),
1471            &[("overlay.py", script)],
1472        );
1473    }
1474
1475    #[test]
1476    fn overlay_plugin_pushes_scene_and_refuses_set_widget() {
1477        if skip_without_interpreter() {
1478            return;
1479        }
1480        let root = tempfile::tempdir().unwrap();
1481        write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1482
1483        let mut host = PluginHost::new();
1484        assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1485        host.apply_enabled(&[enabled("com.test.overlay")]);
1486        let settled = wait_until(&mut host, |h| {
1487            // Both lines are typically drained by one poll; waiting on the
1488            // overlay alone could assert the refusal before it lands, so
1489            // wait until the SetWidget refusal is recorded too.
1490            h.overlays().contains_key("com.test.overlay")
1491                && h.drain_ignored().iter().any(|l| l.contains("SetWidget"))
1492        });
1493        assert!(settled, "fixture's SetOverlay never arrived");
1494
1495        let overlay = &host.overlays()["com.test.overlay"];
1496        assert_eq!(overlay.id, "hud");
1497        assert_eq!(overlay.opacity, 0.8);
1498        assert_eq!(
1499            overlay.position,
1500            crate::protocol::OverlayPosition::Anchor(crate::protocol::OverlayAnchor::TopRight)
1501        );
1502        assert_eq!(
1503            overlay.content,
1504            crate::protocol::OverlayScene::Markdown {
1505                text: "# hello".to_string()
1506            }
1507        );
1508
1509        // The overlay kind's allowlist refused the fixture's SetWidget line
1510        // with the overlay-specific message (drained inside the wait; the
1511        // remaining lines here are just belt-and-braces).
1512        let ignored = host.drain_ignored().join("\n");
1513        assert!(ignored.contains("SetOverlay/ClearOverlay") || ignored.is_empty());
1514        host.stop_all();
1515    }
1516
1517    #[test]
1518    fn clear_overlay_from_an_overlay_process_removes_it() {
1519        if skip_without_interpreter() {
1520            return;
1521        }
1522        let root = tempfile::tempdir().unwrap();
1523        write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_CLEAR_SCRIPT);
1524
1525        let mut host = PluginHost::new();
1526        host.refresh_discovery(root.path());
1527        host.apply_enabled(&[enabled("com.test.overlay")]);
1528        let pushed = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1529        assert!(pushed, "fixture's SetOverlay never arrived");
1530        let cleared = wait_until(&mut host, |h| h.overlays().is_empty());
1531        assert!(cleared, "fixture's ClearOverlay never landed");
1532        host.stop_all();
1533    }
1534
1535    #[test]
1536    fn disabling_an_overlay_plugin_stops_the_process_and_clears_the_overlay() {
1537        if skip_without_interpreter() {
1538            return;
1539        }
1540        let root = tempfile::tempdir().unwrap();
1541        write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1542
1543        let mut host = PluginHost::new();
1544        host.refresh_discovery(root.path());
1545        host.apply_enabled(&[enabled("com.test.overlay")]);
1546        let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1547        assert!(settled, "fixture's SetOverlay never arrived");
1548        assert!(plugin_running(&host, "com.test.overlay"));
1549
1550        // Leaving the enabled set tears the plugin down: process stopped AND
1551        // the pushed overlay dropped — no orphaned surface.
1552        host.apply_enabled(&[]);
1553        assert!(!plugin_running(&host, "com.test.overlay"));
1554        assert!(host.overlays().is_empty());
1555        host.stop_all();
1556    }
1557
1558    #[test]
1559    fn a_widget_plugin_cannot_send_overlay_commands() {
1560        if skip_without_interpreter() {
1561            return;
1562        }
1563        let root = tempfile::tempdir().unwrap();
1564        write_plugin(root.path(), "com.test.widget", OVERLAY_SCRIPT);
1565
1566        let mut host = PluginHost::new();
1567        host.refresh_discovery(root.path());
1568        host.apply_enabled(&[enabled("com.test.widget")]);
1569        // Kind purity: the fixture's SetOverlay is refused with an
1570        // error-style line, and no overlay ever exists.
1571        let mut saw_refusal = false;
1572        let refused = wait_until(&mut host, |h| {
1573            saw_refusal = h.drain_ignored().iter().any(|l| l.contains("SetOverlay"));
1574            saw_refusal
1575        });
1576        assert!(refused, "fixture's SetOverlay refusal never landed");
1577        assert!(host.overlays().is_empty(), "no overlay may exist");
1578        host.stop_all();
1579    }
1580
1581    fn overlay_interactive_manifest_json(id: &str) -> String {
1582        format!(
1583            "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1584\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1585\"overlay\":{{\"interactive\":true}},\
1586\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1587        )
1588    }
1589
1590    fn write_overlay_plugin_with_manifest(root: &Path, id: &str, manifest: &str, script: &str) {
1591        write_plugin_files(root, id, manifest, &[("overlay.py", script)]);
1592    }
1593
1594    /// An overlay plugin that requests interactivity and then idles. The
1595    /// capability comes from the manifest the test pairs it with.
1596    const OVERLAY_INTERACTIVE_SCRIPT: &str = r##"
1597import json, time
1598def emit(obj):
1599    print(json.dumps(obj), flush=True)
1600emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1601      "size": {"w": 0.3, "h": 0.2}, "opacity": 0.9, "interactive": True,
1602      "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1603while True:
1604    time.sleep(0.2)
1605"##;
1606
1607    /// An overlay plugin that pushes an interactive overlay and then echoes
1608    /// every stdin event back by re-pushing the overlay with the event named
1609    /// in its text — the observable seam for overlay-event writes (the
1610    /// overlay kind cannot send SetWidget, so the scene text is the echo).
1611    const OVERLAY_ECHO_SCRIPT: &str = r#"
1612import json, sys, time
1613def emit(obj):
1614    print(json.dumps(obj), flush=True)
1615emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1616      "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1617      "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1618for line in iter(sys.stdin.readline, ""):
1619    line = line.strip()
1620    if not line:
1621        continue
1622    try:
1623        event = json.loads(line)
1624    except ValueError:
1625        continue
1626    emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1627          "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1628          "content": {"type": "text", "text": "event:" + str(event.get("kind", "?")) + ":" + str(event.get("data", {}).get("widget", "?"))}})
1629"#;
1630
1631    #[test]
1632    fn interactive_requires_the_manifest_capability() {
1633        if skip_without_interpreter() {
1634            return;
1635        }
1636        let root = tempfile::tempdir().unwrap();
1637        // No capability block: the request is forced off at ingest.
1638        write_overlay_plugin(root.path(), "com.test.no-cap", OVERLAY_INTERACTIVE_SCRIPT);
1639
1640        let mut host = PluginHost::new();
1641        assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1642        host.apply_enabled(&[enabled("com.test.no-cap")]);
1643        let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.no-cap"));
1644        assert!(settled, "fixture's SetOverlay never arrived");
1645        let overlay = &host.overlays()["com.test.no-cap"];
1646        assert!(!overlay.interactive, "no capability → forced display-only");
1647
1648        // Focus on a display-only overlay never lands.
1649        host.focus_overlay("com.test.no-cap");
1650        assert_eq!(host.focused_overlay(), None);
1651        host.stop_all();
1652    }
1653
1654    #[test]
1655    fn interactive_capability_allows_focus_and_clearing_drops_it() {
1656        if skip_without_interpreter() {
1657            return;
1658        }
1659        let root = tempfile::tempdir().unwrap();
1660        write_overlay_plugin_with_manifest(
1661            root.path(),
1662            "com.test.cap",
1663            &overlay_interactive_manifest_json("com.test.cap"),
1664            OVERLAY_INTERACTIVE_SCRIPT,
1665        );
1666
1667        let mut host = PluginHost::new();
1668        assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1669        host.apply_enabled(&[enabled("com.test.cap")]);
1670        let settled = wait_until(&mut host, |h| {
1671            h.overlays()
1672                .get("com.test.cap")
1673                .is_some_and(|o| o.interactive)
1674        });
1675        assert!(settled, "fixture's interactive SetOverlay never arrived");
1676
1677        // Click-to-focus lands; a second focus call re-points (still the
1678        // only focused overlay — at most one by construction, but assert).
1679        host.focus_overlay("com.test.cap");
1680        assert_eq!(host.focused_overlay(), Some("com.test.cap"));
1681
1682        // Escape path.
1683        host.unfocus_overlay();
1684        assert_eq!(host.focused_overlay(), None);
1685
1686        // Disable clears overlay AND focus — no orphaned focus.
1687        host.focus_overlay("com.test.cap");
1688        host.apply_enabled(&[]);
1689        assert!(host.overlays().is_empty());
1690        assert_eq!(host.focused_overlay(), None);
1691        host.stop_all();
1692    }
1693
1694    #[test]
1695    fn overlay_events_reach_the_running_process() {
1696        if skip_without_interpreter() {
1697            return;
1698        }
1699        let root = tempfile::tempdir().unwrap();
1700        write_overlay_plugin_with_manifest(
1701            root.path(),
1702            "com.test.echo",
1703            &overlay_interactive_manifest_json("com.test.echo"),
1704            OVERLAY_ECHO_SCRIPT,
1705        );
1706
1707        let mut host = PluginHost::new();
1708        host.refresh_discovery(root.path());
1709        host.apply_enabled(&[enabled("com.test.echo")]);
1710        // Wait for the overlay to be live (the renderer's precondition for
1711        // sending events) and the process up.
1712        let up = wait_until(&mut host, |h| {
1713            h.overlays()
1714                .get("com.test.echo")
1715                .is_some_and(|o| o.interactive)
1716        });
1717        assert!(up, "fixture never pushed its overlay");
1718
1719        let delivered = host.send_overlay_event(
1720            "com.test.echo",
1721            "deploy",
1722            crate::protocol::OverlayWidgetEvent::Click {},
1723        );
1724        assert!(delivered, "send_overlay_event reported failure");
1725        assert_eq!(host.overlay_events_dispatched_count(), 1);
1726
1727        // The echo fixture re-pushes its overlay with the event named in
1728        // the scene text — proof the process parsed the event.
1729        let echoed = wait_until(&mut host, |h| {
1730            h.overlays().get("com.test.echo").is_some_and(|o| {
1731                matches!(
1732                    &o.content,
1733                    crate::protocol::OverlayScene::Text { text }
1734                        if text.contains("overlay_event") && text.contains("deploy")
1735                )
1736            })
1737        });
1738        assert!(echoed, "the overlay process never echoed the event");
1739        host.stop_all();
1740    }
1741
1742    #[test]
1743    fn overlay_update_rate_clamp_drops_excess_upserts() {
1744        // Pure host-side test: no interpreter needed — drive the clamp
1745        // bookkeeping directly through overlay_update_permitted.
1746        let mut host = PluginHost::new();
1747        let t0 = Instant::now();
1748        // 30 land inside the first sliding second…
1749        for _ in 0..30 {
1750            assert!(host.overlay_update_permitted("p", t0));
1751        }
1752        // …and the 31st is refused while the window is saturated.
1753        assert!(
1754            !host.overlay_update_permitted("p", t0),
1755            "clamp must refuse past the budget"
1756        );
1757        // After the window slides fully past every recorded timestamp,
1758        // upserts are permitted again.
1759        let after = t0 + Duration::from_secs(2);
1760        assert!(host.overlay_update_permitted("p", after));
1761    }
1762
1763    #[test]
1764    fn invoke_action_miss_paths_return_false_without_a_process() {
1765        let root = tempfile::tempdir().unwrap();
1766        write_plugin(root.path(), "com.test.only-widget", WIDGET_SCRIPT);
1767        write_action_plugin(root.path(), "com.test.only-action", ACTION_SCRIPT);
1768
1769        let mut host = PluginHost::new();
1770        host.refresh_discovery(root.path());
1771        host.apply_enabled(&[]); // nothing enabled, so nothing spawns
1772
1773        // Unknown plugin: not discovered.
1774        assert!(!host.invoke_action("com.test.nope", "greet"));
1775        // Discovered but disabled: the action process is not running.
1776        assert!(!host.invoke_action("com.test.only-action", "say-hello"));
1777        // Discovered, but the action id is not in the manifest.
1778        assert!(!host.invoke_action("com.test.only-action", "bogus"));
1779        // Discovered widget-only plugin: the action kind is absent.
1780        assert!(!host.invoke_action("com.test.only-widget", "say-hello"));
1781
1782        assert!(host.running_plugin_ids().is_empty());
1783        assert!(host.action_running.is_empty());
1784    }
1785
1786    #[test]
1787    fn invoke_action_not_running_warns_once_per_episode() {
1788        install_counting_logger();
1789        let root = tempfile::tempdir().unwrap();
1790        write_action_plugin(root.path(), "com.test.invoke-miss", ACTION_SCRIPT);
1791
1792        let mut host = PluginHost::new();
1793        host.refresh_discovery(root.path());
1794        host.apply_enabled(&[]);
1795
1796        // A stuck keybinding can retry dispatch at frame rate; the warn must
1797        // fire once per episode, not per call.
1798        let fragment = "'com.test.invoke-miss' has no running action process";
1799        for _ in 0..5 {
1800            assert!(!host.invoke_action("com.test.invoke-miss", "say-hello"));
1801        }
1802        assert_eq!(
1803            warns_containing(fragment),
1804            1,
1805            "5 invokes of one steady miss must produce exactly 1 warn"
1806        );
1807    }
1808
1809    #[test]
1810    fn disable_enable_cycle_rearms_the_action_warn_gate() {
1811        // A discovered+enabled plugin whose action entry never spawned holds a
1812        // set action warn gate; disabling must prune it (apply_enabled's
1813        // clear_except) so a re-enable warns again instead of staying
1814        // stale-silent across the cycle. Arms the gate directly — the same
1815        // seam every invoke miss-path uses.
1816        let mut host = PluginHost::new();
1817        assert!(host.warned_action_not_running.should_warn("com.test.gate"));
1818        assert!(
1819            !host.warned_action_not_running.should_warn("com.test.gate"),
1820            "second call in the same episode must be silent"
1821        );
1822
1823        host.apply_enabled(&[]);
1824        assert!(
1825            host.warned_action_not_running.should_warn("com.test.gate"),
1826            "a disable/enable cycle must re-arm the action warn gate"
1827        );
1828    }
1829
1830    #[test]
1831    fn palette_actions_list_enabled_action_plugins_only() {
1832        let root = tempfile::tempdir().unwrap();
1833        write_action_plugin(root.path(), "com.test.pal-on", ACTION_SCRIPT);
1834        write_action_plugin(root.path(), "com.test.pal-off", ACTION_SCRIPT);
1835
1836        let mut host = PluginHost::new();
1837        host.refresh_discovery(root.path());
1838
1839        host.apply_enabled(&[]);
1840        assert!(
1841            host.palette_actions().is_empty(),
1842            "nothing enabled -> no rows"
1843        );
1844
1845        host.apply_enabled(&[enabled("com.test.pal-on")]);
1846        let rows = host.palette_actions();
1847        assert_eq!(rows.len(), 1, "one action on the enabled plugin: {rows:?}");
1848        assert_eq!(rows[0].wire_id, "plugin-action:com.test.pal-on:say-hello");
1849        assert_eq!(rows[0].label, "Say hello · Test Actions");
1850        host.stop_all();
1851    }
1852
1853    #[test]
1854    fn both_kinds_manifest_runs_one_process_per_kind() {
1855        if skip_without_interpreter() {
1856            return;
1857        }
1858        let root = tempfile::tempdir().unwrap();
1859        write_plugin_files(
1860            root.path(),
1861            "com.test.both",
1862            &both_kinds_manifest_json("com.test.both"),
1863            &[("widget.py", WIDGET_SCRIPT), ("actions.py", ACTION_SCRIPT)],
1864        );
1865
1866        let mut host = PluginHost::new();
1867        host.refresh_discovery(root.path());
1868        host.apply_enabled(&[enabled("com.test.both")]);
1869
1870        assert!(
1871            wait_until(&mut host, |h| h.running.len() == 1
1872                && h.action_running.len() == 1),
1873            "a both-kinds manifest must run both entry points after one pass"
1874        );
1875        // Widget behavior is untouched: the widget entry's output still lands.
1876        assert!(wait_until(&mut host, |h| h
1877            .widget_text("com.test.both")
1878            .is_some()));
1879        // The action entry answers invokes.
1880        assert!(host.invoke_action("com.test.both", "ping"));
1881        host.stop_all();
1882    }
1883
1884    #[test]
1885    fn invoke_action_writes_the_event_to_the_running_action_process() {
1886        if skip_without_interpreter() {
1887            return;
1888        }
1889        let root = tempfile::tempdir().unwrap();
1890        write_action_plugin(root.path(), "com.test.invoke", ACTION_SCRIPT);
1891
1892        let mut host = PluginHost::new();
1893        host.refresh_discovery(root.path());
1894        host.apply_enabled(&[enabled("com.test.invoke")]);
1895
1896        assert!(
1897            host.invoke_action("com.test.invoke", "say-hello"),
1898            "writing to a running action process must succeed"
1899        );
1900        // The fixture echoes the invoked action id back as its widget text,
1901        // so the round-trip through stdin is observable at the spawned-
1902        // process seam the crate's SetWidget tests already use.
1903        let landed = wait_until(&mut host, |h| {
1904            h.widget_text("com.test.invoke") == Some("invoked:say-hello")
1905        });
1906        assert!(
1907            landed,
1908            "PluginActionInvoked never reached the plugin's stdin; widget text: {:?}",
1909            host.widget_text("com.test.invoke")
1910        );
1911        host.stop_all();
1912    }
1913
1914    #[test]
1915    fn actions_dispatched_count_counts_only_successful_writes() {
1916        if skip_without_interpreter() {
1917            return;
1918        }
1919        let root = tempfile::tempdir().unwrap();
1920        write_action_plugin(root.path(), "com.test.count", ACTION_SCRIPT);
1921
1922        let mut host = PluginHost::new();
1923        host.refresh_discovery(root.path());
1924
1925        // A miss (not running) must not bump the counter.
1926        host.apply_enabled(&[]);
1927        assert!(!host.invoke_action("com.test.count", "say-hello"));
1928        assert_eq!(
1929            host.actions_dispatched_count(),
1930            0,
1931            "a missed invoke is not a successful write"
1932        );
1933
1934        // Each delivered invoke bumps it exactly once.
1935        host.apply_enabled(&[enabled("com.test.count")]);
1936        assert!(wait_until(&mut host, |h| !h.action_running.is_empty()));
1937        for expected in 1..=2u64 {
1938            assert!(host.invoke_action("com.test.count", "say-hello"));
1939            assert_eq!(host.actions_dispatched_count(), expected);
1940        }
1941        host.stop_all();
1942    }
1943
1944    /// A plugin that echoes every stdin event's kind back as a `SetWidget`
1945    /// line prefixed with `tag` — the observable seam for event delivery
1946    /// (same pattern as `ACTION_SCRIPT` for invokes). `tag` distinguishes
1947    /// the two kind processes of a both-kinds plugin, which share the
1948    /// widget-text key.
1949    fn echo_script(tag: &str) -> String {
1950        format!(
1951            r#"
1952import json, sys
1953def emit(text):
1954    print(json.dumps({{"type": "SetWidget", "text": text}}), flush=True)
1955for line in iter(sys.stdin.readline, ""):
1956    line = line.strip()
1957    if not line:
1958        continue
1959    try:
1960        event = json.loads(line)
1961    except ValueError:
1962        continue
1963    emit("{tag}:" + str(event.get("kind", "?")))
1964"#
1965        )
1966    }
1967
1968    /// A widget-kind manifest declaring `subscriptions_json` (raw JSON array
1969    /// body, e.g. `"bell_rang"`).
1970    fn subscribed_manifest_json(id: &str, subscriptions_json: &str) -> String {
1971        manifest_json(id).replacen(
1972            r#""kinds":["status-bar-widget"],"#,
1973            &format!(r#""kinds":["status-bar-widget"],"subscriptions":[{subscriptions_json}],"#),
1974            1,
1975        )
1976    }
1977
1978    /// A both-kinds manifest declaring `subscriptions_json`.
1979    fn subscribed_both_kinds_manifest_json(id: &str, subscriptions_json: &str) -> String {
1980        both_kinds_manifest_json(id).replacen(
1981            r#""kinds":["status-bar-widget","action-contributor"],"#,
1982            &format!(
1983                r#""kinds":["status-bar-widget","action-contributor"],"subscriptions":[{subscriptions_json}],"#
1984            ),
1985            1,
1986        )
1987    }
1988
1989    #[test]
1990    fn subscribed_plugin_gets_a_kind_filtered_forwarder_on_spawn() {
1991        if skip_without_interpreter() {
1992            return;
1993        }
1994        let root = tempfile::tempdir().unwrap();
1995        write_plugin_files(
1996            root.path(),
1997            "com.test.sub",
1998            &subscribed_manifest_json("com.test.sub", r#""bell_rang""#),
1999            &[("widget.py", WIDGET_SCRIPT)],
2000        );
2001
2002        let mut host = PluginHost::new();
2003        host.refresh_discovery(root.path());
2004        host.apply_enabled(&[enabled("com.test.sub")]);
2005
2006        let forwarder = host
2007            .subscription_forwarders()
2008            .get("com.test.sub")
2009            .cloned()
2010            .expect("a subscribed plugin gets a forwarder on spawn");
2011
2012        // The forwarder carries the plugin's kind filter: a bell buffers, a
2013        // title change (not subscribed) never enters the buffer.
2014        use par_term_emu_core_rust::observer::TerminalObserver;
2015        use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2016        forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2017        forwarder.on_event(&TerminalEvent::TitleChanged("nope".to_string()));
2018        let drained = forwarder.drain_events();
2019        assert_eq!(drained.len(), 1, "unsubscribed kinds must not buffer");
2020        assert_eq!(drained[0].kind, "bell_rang");
2021        host.stop_all();
2022    }
2023
2024    #[test]
2025    fn unsubscribed_plugin_gets_no_forwarder() {
2026        if skip_without_interpreter() {
2027            return;
2028        }
2029        // The self-scheduled contract: a plugin with no subscriptions block
2030        // must not gain event delivery (or its buffering machinery).
2031        let root = tempfile::tempdir().unwrap();
2032        write_plugin(root.path(), "com.test.plain", WIDGET_SCRIPT);
2033
2034        let mut host = PluginHost::new();
2035        host.refresh_discovery(root.path());
2036        host.apply_enabled(&[enabled("com.test.plain")]);
2037        assert!(wait_until(&mut host, |h| !h.running.is_empty()));
2038        assert!(
2039            host.subscription_forwarders().is_empty(),
2040            "no subscriptions declared — no forwarder may exist"
2041        );
2042        host.stop_all();
2043    }
2044
2045    #[test]
2046    fn teardown_drops_the_forwarder_when_the_plugin_leaves_the_enabled_set() {
2047        if skip_without_interpreter() {
2048            return;
2049        }
2050        let root = tempfile::tempdir().unwrap();
2051        write_plugin_files(
2052            root.path(),
2053            "com.test.leave",
2054            &subscribed_manifest_json("com.test.leave", r#""bell_rang""#),
2055            &[("widget.py", WIDGET_SCRIPT)],
2056        );
2057
2058        let mut host = PluginHost::new();
2059        host.refresh_discovery(root.path());
2060        host.apply_enabled(&[enabled("com.test.leave")]);
2061        assert!(
2062            host.subscription_forwarders()
2063                .contains_key("com.test.leave")
2064        );
2065
2066        host.apply_enabled(&[]);
2067        assert!(
2068            host.subscription_forwarders().is_empty(),
2069            "teardown must drop the forwarder so its observer is unregistered"
2070        );
2071    }
2072
2073    #[test]
2074    fn delivered_events_reach_the_running_process() {
2075        if skip_without_interpreter() {
2076            return;
2077        }
2078        let root = tempfile::tempdir().unwrap();
2079        write_plugin_files(
2080            root.path(),
2081            "com.test.echo",
2082            &subscribed_manifest_json("com.test.echo", r#""bell_rang""#),
2083            &[("widget.py", &echo_script("event"))],
2084        );
2085
2086        let mut host = PluginHost::new();
2087        host.refresh_discovery(root.path());
2088        host.apply_enabled(&[enabled("com.test.echo")]);
2089
2090        // Simulate the window pump: the forwarder observes a terminal event,
2091        // is drained, and the drained events are delivered to the plugin.
2092        let forwarder = host
2093            .subscription_forwarders()
2094            .get("com.test.echo")
2095            .cloned()
2096            .expect("forwarder exists for a subscribed plugin");
2097        use par_term_emu_core_rust::observer::TerminalObserver;
2098        use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2099        forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2100        let events = forwarder.drain_events();
2101        assert_eq!(events.len(), 1);
2102        host.deliver_events("com.test.echo", &events);
2103
2104        // The fixture echoes the event kind back as its widget text, so the
2105        // round-trip through plugin stdin is observable at the same seam the
2106        // crate's SetWidget tests use.
2107        let landed = wait_until(&mut host, |h| {
2108            h.widget_text("com.test.echo") == Some("event:bell_rang")
2109        });
2110        assert!(
2111            landed,
2112            "the bell event never reached the plugin's stdin; widget text: {:?}",
2113            host.widget_text("com.test.echo")
2114        );
2115        host.stop_all();
2116    }
2117
2118    /// Widget-kind echo that delays its reply. Both kind processes of a
2119    /// plugin share one last-write-wins widget-text key, so when one poll
2120    /// drains both echoes the second masks the first before any observer can
2121    /// sample it — the delay spaces the two writes so each is seen.
2122    const WIDGET_ECHO_DELAYED_SCRIPT: &str = r#"
2123import json, sys, time
2124def emit(text):
2125    print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
2126for line in iter(sys.stdin.readline, ""):
2127    line = line.strip()
2128    if not line:
2129        continue
2130    try:
2131        event = json.loads(line)
2132    except ValueError:
2133        continue
2134    time.sleep(0.3)
2135    emit("w:" + str(event.get("kind", "?")))
2136"#;
2137
2138    #[test]
2139    fn both_kinds_processes_receive_subscribed_events() {
2140        if skip_without_interpreter() {
2141            return;
2142        }
2143        let root = tempfile::tempdir().unwrap();
2144        write_plugin_files(
2145            root.path(),
2146            "com.test.both-echo",
2147            &subscribed_both_kinds_manifest_json("com.test.both-echo", r#""bell_rang""#),
2148            &[
2149                ("widget.py", WIDGET_ECHO_DELAYED_SCRIPT),
2150                ("actions.py", &echo_script("a")),
2151            ],
2152        );
2153
2154        let mut host = PluginHost::new();
2155        host.refresh_discovery(root.path());
2156        host.apply_enabled(&[enabled("com.test.both-echo")]);
2157        assert!(wait_until(&mut host, |h| h.running.len() == 1
2158            && h.action_running.len() == 1));
2159
2160        let forwarder = host
2161            .subscription_forwarders()
2162            .get("com.test.both-echo")
2163            .cloned()
2164            .expect("forwarder exists for a subscribed plugin");
2165        use par_term_emu_core_rust::observer::TerminalObserver;
2166        use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2167        forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2168        let events = forwarder.drain_events();
2169        host.deliver_events("com.test.both-echo", &events);
2170
2171        // Both kind processes echo with their own tag into the shared
2172        // widget-text key (last write wins), so the proof is having SEEN
2173        // each tag at some point during polling.
2174        let mut seen_widget = false;
2175        let mut seen_action = false;
2176        let both_seen = wait_until(&mut host, |h| {
2177            if h.widget_text("com.test.both-echo") == Some("w:bell_rang") {
2178                seen_widget = true;
2179            }
2180            if h.widget_text("com.test.both-echo") == Some("a:bell_rang") {
2181                seen_action = true;
2182            }
2183            seen_widget && seen_action
2184        });
2185        assert!(
2186            both_seen,
2187            "both kind processes must receive the event (w seen: {seen_widget}, a seen: {seen_action})"
2188        );
2189        host.stop_all();
2190    }
2191
2192    #[test]
2193    fn deliver_events_is_silent_with_no_running_process() {
2194        // A plugin between restarts must not warn or panic when the sweep
2195        // delivers events nobody is running to receive.
2196        install_counting_logger();
2197        let mut host = PluginHost::new();
2198        let event = ScriptEvent {
2199            kind: "bell_rang".to_string(),
2200            data: ScriptEventData::Empty {},
2201        };
2202        host.deliver_events("com.test.nobody", std::slice::from_ref(&event));
2203        assert_eq!(warns_containing("com.test.nobody"), 0);
2204    }
2205}