Skip to main content

par_term_scripting/
manifest.rs

1//! Plugin manifest types, discovery, and validation.
2//!
3//! A plugin is a directory under the plugins root whose name equals its manifest
4//! `id`, containing a `manifest.json` and the executable its entry points name.
5//! Discovery is deliberately forgiving: one broken manifest produces a warning
6//! and is skipped, never blocking the discovery of its neighbours. A plugin is
7//! never spawned as a side effect of discovery — the host spawns only what the
8//! enabled set names, which is what makes "lands disabled" structural.
9
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13use par_term_config::{RestartPolicy, StatusBarSection};
14use serde::{Deserialize, Serialize};
15
16/// The status-bar-widget plugin kind. Unknown kinds in a manifest skip the
17/// whole plugin with a visible warning rather than loading partially.
18pub const KIND_STATUS_BAR_WIDGET: &str = "status-bar-widget";
19
20/// The action-contributor plugin kind: the plugin contributes entries to
21/// the command palette.
22pub const KIND_ACTION_CONTRIBUTOR: &str = "action-contributor";
23
24/// The panel plugin kind: the plugin pushes markdown panel content through
25/// the `SetPanel`/`ClearPanel` protocol commands (the same surface a tab
26/// script's `SetPanel` drives), rendered in the Settings plugins section.
27pub const KIND_PANEL: &str = "panel";
28
29/// The overlay plugin kind: the plugin owns a persistent surface drawn over
30/// the terminal through the `SetOverlay`/`ClearOverlay` protocol commands
31/// (design: docs/plans/2026-09-24-overlay-plugin-design.md). Phase 1 is
32/// display-only; `interactive` requires the `overlay.interactive` capability
33/// and is forced off without it.
34pub const KIND_OVERLAY: &str = "overlay";
35
36/// Entry-point map key for the [`KIND_STATUS_BAR_WIDGET`] kind.
37pub const ENTRY_POINT_STATUS_BAR_WIDGET: &str = "statusBarWidget";
38
39/// Entry-point map key for the [`KIND_ACTION_CONTRIBUTOR`] kind.
40pub const ENTRY_POINT_ACTION_CONTRIBUTOR: &str = "actionContributor";
41
42/// Entry-point map key for the [`KIND_PANEL`] kind.
43pub const ENTRY_POINT_PANEL: &str = "panel";
44
45/// Entry-point map key for the [`KIND_OVERLAY`] kind.
46pub const ENTRY_POINT_OVERLAY: &str = "overlay";
47
48/// Activation policy declared by the manifest.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PluginActivation {
52    /// Discovered but never auto-started; the user enables it in Settings.
53    #[default]
54    Manual,
55    /// Starts with the host once enabled (still gated by the enabled set).
56    OnStartup,
57}
58
59/// A setting's value type, as declared in a manifest's settings schema.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SettingType {
63    /// Free-form text.
64    String,
65    /// Whole number; JSON floats with a fraction are rejected.
66    Integer,
67    /// Any JSON number.
68    Number,
69    /// True/false.
70    Boolean,
71}
72
73impl SettingType {
74    /// Human name used in validation error messages.
75    pub fn as_str(&self) -> &'static str {
76        match self {
77            SettingType::String => "string",
78            SettingType::Integer => "integer",
79            SettingType::Number => "number",
80            SettingType::Boolean => "boolean",
81        }
82    }
83}
84
85/// One typed setting declared by a plugin's settings schema.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct SettingSchemaEntry {
89    /// Setting key; must be non-empty and unique within the schema.
90    pub key: String,
91    /// Value type (serialized as `type`).
92    #[serde(rename = "type")]
93    pub setting_type: SettingType,
94    /// Settings-editor label.
95    pub label: String,
96    /// Inclusive lower bound for numeric settings.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub min: Option<f64>,
99    /// Inclusive upper bound for numeric settings.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub max: Option<f64>,
102    /// Drag step for numeric settings.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub step: Option<f64>,
105    /// Value used when the persisted settings omit the key.
106    pub default_value: serde_json::Value,
107}
108
109/// The `statusBarWidget` manifest block: how a status-bar-widget plugin
110/// presents itself and what the user can configure.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct StatusBarWidgetKind {
114    /// Name shown in the status-bar Settings list.
115    pub display_name: String,
116    /// Section the widget entry lands in when first enabled; the user can
117    /// move it afterwards. Defaults to the right section.
118    #[serde(default = "default_widget_section")]
119    pub section: StatusBarSection,
120    /// Settings values used when the user has configured nothing.
121    #[serde(default)]
122    pub defaults: HashMap<String, serde_json::Value>,
123    /// Typed settings the host renders an editor for.
124    pub schema: Vec<SettingSchemaEntry>,
125}
126
127fn default_widget_section() -> StatusBarSection {
128    StatusBarSection::Right
129}
130
131/// One action a plugin contributes to the command palette when it declares
132/// the [`KIND_ACTION_CONTRIBUTOR`] kind.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ActionContribution {
136    /// Action id; must match `[a-zA-Z0-9_-]+` and be unique within the
137    /// manifest.
138    pub id: String,
139    /// Text shown in the palette entry.
140    pub label: String,
141    /// Longer explanation shown alongside the label.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub description: Option<String>,
144}
145
146/// One named executable entry point. `command` is relative to the plugin
147/// directory only — never a PATH lookup, never allowed to escape the
148/// directory (enforced by [`discover_plugins`]).
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct PluginEntryPoint {
152    /// Executable file name relative to the plugin directory.
153    pub command: String,
154    /// Extra argv passed before the host's own settings argument.
155    #[serde(default)]
156    pub args: Vec<String>,
157}
158
159/// A parsed `manifest.json`.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct PluginManifest {
163    /// Manifest format version; only 1 is understood.
164    pub schema_version: u32,
165    /// Reverse-DNS-ish plugin id; must equal the directory name.
166    pub id: String,
167    /// Human-readable name.
168    pub name: String,
169    /// Plugin version, displayed at the enable toggle.
170    pub version: String,
171    /// Author, displayed at the enable toggle (trust surface).
172    #[serde(default)]
173    pub author: Option<String>,
174    /// License, displayed at the enable toggle (trust surface).
175    #[serde(default)]
176    pub license: Option<String>,
177    /// What the plugin does, shown in Settings.
178    #[serde(default)]
179    pub description: Option<String>,
180    /// Kinds this plugin provides; each must be known to this host.
181    #[serde(default)]
182    pub kinds: Vec<String>,
183    /// When the plugin starts once enabled.
184    #[serde(default)]
185    pub activation: PluginActivation,
186    /// Named entry points; the key selects the kind's executable.
187    #[serde(default)]
188    pub entry_points: HashMap<String, PluginEntryPoint>,
189    /// Required iff `kinds` contains [`KIND_STATUS_BAR_WIDGET`].
190    #[serde(default)]
191    pub status_bar_widget: Option<StatusBarWidgetKind>,
192    /// Actions contributed to the command palette; required non-empty iff
193    /// `kinds` contains [`KIND_ACTION_CONTRIBUTOR`], ignored otherwise.
194    #[serde(default)]
195    pub actions: Vec<ActionContribution>,
196    /// Terminal event kinds the plugin wants delivered to its stdin. Empty or
197    /// absent means self-scheduled: the plugin receives no terminal events
198    /// (unlike tab scripts, where an empty subscription list means all
199    /// events). Each name must be one of
200    /// [`crate::observer::EVENT_KINDS`]; an unknown or duplicate name skips
201    /// the plugin at discovery so a typo'd subscription can never silently
202    /// never-fire.
203    #[serde(default)]
204    pub subscriptions: Vec<String>,
205    /// When the host restarts the plugin's exited process: `on_failure`
206    /// (the default and the only behaviour before the field existed),
207    /// `never`, or `always` — the same policy enum tab scripts configure.
208    /// Mode-only by design: backoff parameters (restart delay, crash-loop
209    /// cap) are host-owned safety policy a manifest cannot tune its way
210    /// out of (parsight decision 95).
211    #[serde(default = "default_plugin_restart")]
212    pub restart: RestartPolicy,
213    /// Overlay-kind capability block (phase 2). Absent means display-only:
214    /// `interactive: true` requests are forced off at ingest. Present with
215    /// `interactive: true` is required before an overlay may focus and
216    /// render interactive widgets.
217    #[serde(default)]
218    pub overlay: Option<OverlayCapability>,
219}
220
221/// Capabilities an `overlay`-kind manifest may claim.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct OverlayCapability {
225    /// Whether the plugin's overlays may request interactivity (focus,
226    /// semantic events). Defaults to false — a manifest that declares the
227    /// block without the flag still gets display-only overlays.
228    #[serde(default)]
229    pub interactive: bool,
230}
231
232/// The manifest default for [`PluginManifest::restart`] — today's
233/// hardcoded behaviour, NOT the config enum's derived default (`Never` is
234/// the scripts-config default; a plugin without the field must keep
235/// restarting on failure exactly as it always has).
236fn default_plugin_restart() -> RestartPolicy {
237    RestartPolicy::OnFailure
238}
239
240/// A plugin that passed validation, with confinement-checked paths.
241#[derive(Debug, Clone, PartialEq)]
242pub struct DiscoveredPlugin {
243    /// The parsed manifest.
244    pub manifest: PluginManifest,
245    /// Canonicalized plugin directory.
246    pub dir: PathBuf,
247    /// Canonicalized, confinement-checked entry executable for the widget
248    /// kind. A plugin declaring only the action-contributor kind has no
249    /// widget entry and carries its action entry here.
250    pub entry_path: PathBuf,
251    /// Canonicalized, confinement-checked entry executable for the
252    /// action-contributor kind; `None` when the plugin does not declare
253    /// that kind.
254    pub action_entry_path: Option<PathBuf>,
255    /// Canonicalized, confinement-checked entry executable for the panel
256    /// kind; `None` when the plugin does not declare that kind.
257    pub panel_entry_path: Option<PathBuf>,
258    /// Canonicalized, confinement-checked entry executable for the overlay
259    /// kind; `None` when the plugin does not declare that kind.
260    pub overlay_entry_path: Option<PathBuf>,
261}
262
263/// Why a candidate plugin directory was skipped.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct DiscoveryWarning {
266    /// Directory name under the plugins root.
267    pub dir: String,
268    /// Human-readable skip reason.
269    pub reason: String,
270}
271
272/// Scan a plugins root for valid plugin directories.
273///
274/// An absent root is an empty result, not a warning — a machine with no
275/// plugins installed yet has not done anything wrong. Directories are visited
276/// in name order so discovery output is deterministic.
277pub fn discover_plugins(root: &Path) -> (Vec<DiscoveredPlugin>, Vec<DiscoveryWarning>) {
278    let mut plugins = Vec::new();
279    let mut warnings = Vec::new();
280
281    let Ok(entries) = std::fs::read_dir(root) else {
282        return (plugins, warnings);
283    };
284
285    let mut dirs: Vec<PathBuf> = entries
286        .filter_map(|e| e.ok())
287        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
288        .map(|e| e.path())
289        .collect();
290    dirs.sort();
291
292    for dir in dirs {
293        let name = dir
294            .file_name()
295            .map(|n| n.to_string_lossy().into_owned())
296            .unwrap_or_default();
297        match validate_plugin_dir(&dir) {
298            Ok(plugin) => plugins.push(plugin),
299            Err(reason) => warnings.push(DiscoveryWarning { dir: name, reason }),
300        }
301    }
302
303    (plugins, warnings)
304}
305
306/// Parse and fully validate one candidate plugin directory.
307pub(crate) fn validate_plugin_dir(dir: &Path) -> Result<DiscoveredPlugin, String> {
308    let manifest_path = dir.join("manifest.json");
309    let raw = std::fs::read_to_string(&manifest_path)
310        .map_err(|e| format!("missing or unreadable manifest.json: {e}"))?;
311    let manifest: PluginManifest =
312        serde_json::from_str(&raw).map_err(|e| format!("invalid manifest JSON: {e}"))?;
313
314    if manifest.schema_version != 1 {
315        return Err(format!(
316            "unsupported schemaVersion {} (expected 1)",
317            manifest.schema_version
318        ));
319    }
320
321    let dir_name = dir
322        .file_name()
323        .map(|n| n.to_string_lossy().into_owned())
324        .unwrap_or_default();
325    if manifest.id.is_empty() || manifest.id != dir_name {
326        return Err(format!(
327            "manifest id `{}` does not match directory name `{dir_name}`",
328            manifest.id
329        ));
330    }
331
332    if manifest.kinds.is_empty() {
333        return Err("no kinds declared".to_string());
334    }
335    let known_kinds = [
336        KIND_STATUS_BAR_WIDGET,
337        KIND_ACTION_CONTRIBUTOR,
338        KIND_PANEL,
339        KIND_OVERLAY,
340    ];
341    let unknown: Vec<&str> = manifest
342        .kinds
343        .iter()
344        .map(String::as_str)
345        .filter(|k| !known_kinds.contains(k))
346        .collect();
347    if !unknown.is_empty() {
348        return Err(format!(
349            "unknown kinds: {} (known: {})",
350            unknown.join(", "),
351            known_kinds.join(", ")
352        ));
353    }
354    let has_widget = manifest.kinds.iter().any(|k| k == KIND_STATUS_BAR_WIDGET);
355    let has_action = manifest.kinds.iter().any(|k| k == KIND_ACTION_CONTRIBUTOR);
356    let has_panel = manifest.kinds.iter().any(|k| k == KIND_PANEL);
357    let has_overlay = manifest.kinds.iter().any(|k| k == KIND_OVERLAY);
358
359    // A subscription naming a kind the forwarder can never produce would sit
360    // inert for the plugin's whole life, so it is rejected at discovery like
361    // every other manifest fault. Duplicates are rejected for the same
362    // reason as duplicate action ids: an authoring error, not a filter.
363    let mut seen_subscriptions: HashSet<&str> = HashSet::new();
364    for kind in &manifest.subscriptions {
365        if !super::observer::EVENT_KINDS.contains(&kind.as_str()) {
366            return Err(format!(
367                "unknown subscription kind `{kind}` (valid kinds are the script event \
368                 vocabulary, e.g. bell_rang, cwd_changed, command_complete)"
369            ));
370        }
371        if !seen_subscriptions.insert(kind.as_str()) {
372            return Err(format!("duplicate subscription kind `{kind}`"));
373        }
374    }
375
376    // Each declared kind's requirements run only when that kind is present:
377    // a both-kinds manifest must satisfy both pairs, an action-only manifest
378    // owes no widget block, and vice versa.
379    if has_widget {
380        let widget = manifest.status_bar_widget.as_ref().ok_or_else(|| {
381            format!("{KIND_STATUS_BAR_WIDGET} kind requires a statusBarWidget block")
382        })?;
383        if widget.schema.is_empty() {
384            return Err("statusBarWidget.schema must declare at least one setting".to_string());
385        }
386        if widget.schema.iter().any(|e| e.key.is_empty()) {
387            return Err("statusBarWidget.schema entries must have non-empty keys".to_string());
388        }
389    }
390
391    if has_action {
392        if manifest.actions.is_empty() {
393            return Err(format!(
394                "{KIND_ACTION_CONTRIBUTOR} kind requires at least one action in `actions`"
395            ));
396        }
397        let mut seen: HashSet<&str> = HashSet::new();
398        for action in &manifest.actions {
399            // A colon would collide with the wire format's id separator
400            // (design D1); ids stay `[a-zA-Z0-9_-]+`.
401            if action.id.is_empty()
402                || !action
403                    .id
404                    .chars()
405                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
406            {
407                return Err(format!(
408                    "action id `{}` must match [a-zA-Z0-9_-]+",
409                    action.id
410                ));
411            }
412            if !seen.insert(action.id.as_str()) {
413                return Err(format!("duplicate action id `{}`", action.id));
414            }
415            if action.label.is_empty() {
416                return Err(format!(
417                    "action `{}` must have a non-empty label",
418                    action.id
419                ));
420            }
421        }
422    }
423
424    // The security line: each declared kind's entry command resolves inside
425    // the plugin directory only — canonicalize both sides and require
426    // containment, so a `..` or symlink escape cannot point execution
427    // outside the plugin.
428    let dir_canon =
429        std::fs::canonicalize(dir).map_err(|e| format!("cannot read plugin dir: {e}"))?;
430    let widget_entry = if has_widget {
431        let entry = manifest.entry_points.get(ENTRY_POINT_STATUS_BAR_WIDGET).ok_or_else(|| {
432            format!(
433                "{KIND_STATUS_BAR_WIDGET} kind requires entryPoints.{ENTRY_POINT_STATUS_BAR_WIDGET}"
434            )
435        })?;
436        Some(confinement_check(&dir_canon, &entry.command)?)
437    } else {
438        None
439    };
440    let action_entry = if has_action {
441        let entry = manifest.entry_points.get(ENTRY_POINT_ACTION_CONTRIBUTOR).ok_or_else(|| {
442            format!(
443                "{KIND_ACTION_CONTRIBUTOR} kind requires entryPoints.{ENTRY_POINT_ACTION_CONTRIBUTOR}"
444            )
445        })?;
446        Some(confinement_check(&dir_canon, &entry.command)?)
447    } else {
448        None
449    };
450    // The panel kind owes no manifest block beyond its entry point — the
451    // pushed content is entirely the process's decision at runtime.
452    let panel_entry = if has_panel {
453        let entry = manifest
454            .entry_points
455            .get(ENTRY_POINT_PANEL)
456            .ok_or_else(|| format!("{KIND_PANEL} kind requires entryPoints.{ENTRY_POINT_PANEL}"))?;
457        Some(confinement_check(&dir_canon, &entry.command)?)
458    } else {
459        None
460    };
461    // The overlay kind owes no manifest block beyond its entry point — the
462    // pushed scene is entirely the process's decision at runtime (same
463    // shape as the panel kind).
464    let overlay_entry = if has_overlay {
465        let entry = manifest
466            .entry_points
467            .get(ENTRY_POINT_OVERLAY)
468            .ok_or_else(|| {
469                format!("{KIND_OVERLAY} kind requires entryPoints.{ENTRY_POINT_OVERLAY}")
470            })?;
471        Some(confinement_check(&dir_canon, &entry.command)?)
472    } else {
473        None
474    };
475
476    // `kinds` is non-empty and every kind is known, so at least one entry
477    // resolved; the widget entry is the primary entry when several kinds
478    // are declared.
479    let entry_path = widget_entry
480        .or(action_entry.clone())
481        .or(panel_entry.clone())
482        .or(overlay_entry.clone())
483        .expect("a validated manifest resolves at least one entry point");
484
485    Ok(DiscoveredPlugin {
486        manifest,
487        dir: dir_canon,
488        entry_path,
489        action_entry_path: action_entry,
490        panel_entry_path: panel_entry,
491        overlay_entry_path: overlay_entry,
492    })
493}
494
495/// Canonicalize one entry command and enforce the confinement rules every
496/// kind's entry shares: the resolved path must stay inside the plugin
497/// directory (no `..` or symlink escape) and, on Unix, carry the exec bit
498/// unless it is a `.py` entry.
499///
500/// `.py` entries run through the resolved Python interpreter (the same
501/// `spawn_command` routing), so the file itself never needs the exec bit —
502/// requiring it would silently reject every non-chmodded script plugin on
503/// Unix while Windows (no exec bit) accepts it.
504fn confinement_check(dir_canon: &Path, command: &str) -> Result<PathBuf, String> {
505    let entry_path = dir_canon.join(command);
506    let entry_canon = std::fs::canonicalize(&entry_path)
507        .map_err(|_| format!("entry point `{command}` not found"))?;
508    if !entry_canon.starts_with(dir_canon) {
509        return Err(format!(
510            "entry point `{command}` escapes the plugin directory"
511        ));
512    }
513    #[cfg(unix)]
514    {
515        use std::os::unix::fs::PermissionsExt;
516        if !command.ends_with(".py") {
517            let mode = std::fs::metadata(&entry_canon)
518                .map_err(|e| format!("entry point unreadable: {e}"))?
519                .permissions()
520                .mode();
521            if mode & 0o111 == 0 {
522                return Err(format!("entry point `{command}` is not executable"));
523            }
524        }
525    }
526    Ok(entry_canon)
527}
528
529/// Validate persisted settings against a manifest schema.
530///
531/// Absent keys are filled from the schema defaults; keys the schema no longer
532/// declares are dropped (a plugin update must not strand a user's config);
533/// values of the wrong type or outside `min`/`max` are errors the caller
534/// surfaces instead of spawning with garbage.
535pub fn validate_settings(
536    schema: &[SettingSchemaEntry],
537    values: &HashMap<String, serde_json::Value>,
538) -> Result<HashMap<String, serde_json::Value>, String> {
539    let mut out: HashMap<String, serde_json::Value> = schema
540        .iter()
541        .map(|e| (e.key.clone(), e.default_value.clone()))
542        .collect();
543
544    for (key, value) in values {
545        let Some(entry) = schema.iter().find(|e| &e.key == key) else {
546            continue;
547        };
548        if !value_matches_type(value, entry.setting_type) {
549            return Err(format!(
550                "setting `{key}` expects {}, got {value}",
551                entry.setting_type.as_str()
552            ));
553        }
554        if matches!(
555            entry.setting_type,
556            SettingType::Integer | SettingType::Number
557        ) {
558            if let Some(n) = value.as_f64()
559                && let Some(min) = entry.min
560                && n < min
561            {
562                return Err(format!(
563                    "setting `{key}` value {n} is below the minimum {min}"
564                ));
565            }
566            if let Some(n) = value.as_f64()
567                && let Some(max) = entry.max
568                && n > max
569            {
570                return Err(format!(
571                    "setting `{key}` value {n} is above the maximum {max}"
572                ));
573            }
574        }
575        out.insert(key.clone(), value.clone());
576    }
577
578    Ok(out)
579}
580
581/// Whether a JSON value fits a declared setting type.
582fn value_matches_type(value: &serde_json::Value, setting_type: SettingType) -> bool {
583    match setting_type {
584        SettingType::String => value.is_string(),
585        SettingType::Integer => value.is_u64() || value.is_i64(),
586        SettingType::Number => value.is_number(),
587        SettingType::Boolean => value.is_boolean(),
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use std::fs;
595    use tempfile::TempDir;
596
597    /// The design doc's D1 example manifest (§3), with real strings for the
598    /// ellipsis fields, as written to a fixture plugin directory.
599    const DESIGN_D1_MANIFEST: &str = r#"{
600        "schemaVersion": 1,
601        "id": "com.example.agents-usage",
602        "name": "Agent Usage",
603        "version": "0.1.0",
604        "author": "A Plugin Author",
605        "license": "MIT",
606        "description": "Shows agent usage in the status bar",
607        "kinds": ["status-bar-widget"],
608        "activation": "manual",
609        "entryPoints": { "statusBarWidget": { "command": "agent-widget", "args": [] } },
610        "statusBarWidget": {
611            "displayName": "Agents",
612            "section": "right",
613            "defaults": { "refreshIntervalSec": 900 },
614            "schema": [
615                { "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)",
616                  "min": 30, "max": 3600, "step": 30, "defaultValue": 900 }
617            ]
618        }
619    }"#;
620
621    /// Minimal valid manifest, used as the base most mutations start from.
622    /// `{id}` is replaced with the fixture directory name by [`write_plugin`].
623    const MINIMAL_MANIFEST: &str = r#"{
624        "schemaVersion": 1,
625        "id": "{id}",
626        "name": "Test",
627        "version": "0.1.0",
628        "kinds": ["status-bar-widget"],
629        "entryPoints": { "statusBarWidget": { "command": "widget.py", "args": [] } },
630        "statusBarWidget": {
631            "displayName": "Test",
632            "section": "right",
633            "defaults": {},
634            "schema": [
635                { "key": "format24h", "type": "boolean", "label": "24-hour clock",
636                  "defaultValue": true }
637            ]
638        }
639    }"#;
640
641    /// Minimal valid action-contributor manifest, the action-kind counterpart
642    /// of [`MINIMAL_MANIFEST`]. `{id}` is replaced by [`write_plugin`].
643    const MINIMAL_ACTION_MANIFEST: &str = r#"{
644        "schemaVersion": 1,
645        "id": "{id}",
646        "name": "Test Actions",
647        "version": "0.1.0",
648        "kinds": ["action-contributor"],
649        "entryPoints": { "actionContributor": { "command": "actions.py", "args": [] } },
650        "actions": [ { "id": "say-hello", "label": "Say hello" } ]
651    }"#;
652
653    /// Minimal valid panel manifest: the kind owes no block beyond its entry
654    /// point. `{id}` is replaced by [`write_plugin`].
655    const MINIMAL_PANEL_MANIFEST: &str = r#"{
656        "schemaVersion": 1,
657        "id": "{id}",
658        "name": "Test Panel",
659        "version": "0.1.0",
660        "kinds": ["panel"],
661        "entryPoints": { "panel": { "command": "panel.py", "args": [] } }
662    }"#;
663
664    /// Both-kinds manifest: one plugin contributing a widget and an action.
665    /// `{id}` is replaced by [`write_plugin`].
666    const BOTH_KINDS_MANIFEST: &str = r#"{
667        "schemaVersion": 1,
668        "id": "{id}",
669        "name": "Test Both",
670        "version": "0.1.0",
671        "kinds": ["status-bar-widget", "action-contributor"],
672        "entryPoints": {
673            "statusBarWidget": { "command": "widget.py", "args": [] },
674            "actionContributor": { "command": "actions.py", "args": [] }
675        },
676        "actions": [ { "id": "ping", "label": "Ping" } ],
677        "statusBarWidget": {
678            "displayName": "Test",
679            "section": "right",
680            "defaults": {},
681            "schema": [
682                { "key": "format24h", "type": "boolean", "label": "24-hour clock",
683                  "defaultValue": true }
684            ]
685        }
686    }"#;
687
688    /// Write a plugin directory: manifest plus an executable entry file.
689    /// `manifest` may use `{id}` placeholders for the directory name so tests
690    /// can keep manifest id and dir name in sync.
691    fn write_plugin(root: &Path, dir_name: &str, manifest: &str, entry: Option<&str>) {
692        let dir = root.join(dir_name);
693        fs::create_dir_all(&dir).expect("create plugin dir");
694        let manifest = manifest.replace("{id}", dir_name);
695        fs::write(dir.join("manifest.json"), manifest).expect("write manifest");
696        if let Some(entry_name) = entry {
697            write_exec_file(&dir.join(entry_name));
698        }
699    }
700
701    /// Write one executable entry file, with the exec bit on Unix.
702    fn write_exec_file(path: &Path) {
703        fs::write(path, "#!/bin/sh\nsleep 30\n").expect("write entry");
704        #[cfg(unix)]
705        {
706            use std::os::unix::fs::PermissionsExt;
707            let mut perms = fs::metadata(path).expect("entry metadata").permissions();
708            perms.set_mode(0o755);
709            fs::set_permissions(path, perms).expect("chmod entry");
710        }
711    }
712
713    #[test]
714    fn design_d1_example_parses_with_every_field_populated() {
715        let tmp = TempDir::new().unwrap();
716        write_plugin(
717            tmp.path(),
718            "com.example.agents-usage",
719            DESIGN_D1_MANIFEST,
720            Some("agent-widget"),
721        );
722        let (plugins, warnings) = discover_plugins(tmp.path());
723        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
724        let p = &plugins[0];
725        assert_eq!(p.manifest.id, "com.example.agents-usage");
726        assert_eq!(p.manifest.author.as_deref(), Some("A Plugin Author"));
727        assert_eq!(p.manifest.license.as_deref(), Some("MIT"));
728        assert_eq!(p.manifest.activation, PluginActivation::Manual);
729        assert_eq!(p.manifest.kinds, vec![KIND_STATUS_BAR_WIDGET.to_string()]);
730        let widget = p.manifest.status_bar_widget.as_ref().unwrap();
731        assert_eq!(widget.display_name, "Agents");
732        assert_eq!(widget.section, StatusBarSection::Right);
733        assert_eq!(
734            widget
735                .defaults
736                .get("refreshIntervalSec")
737                .and_then(serde_json::Value::as_u64),
738            Some(900)
739        );
740        assert_eq!(widget.schema.len(), 1);
741        assert_eq!(widget.schema[0].setting_type, SettingType::Integer);
742        assert_eq!(widget.schema[0].min, Some(30.0));
743        assert_eq!(widget.schema[0].max, Some(3600.0));
744        assert_eq!(widget.schema[0].step, Some(30.0));
745        assert!(p.entry_path.ends_with("agent-widget"));
746    }
747
748    #[cfg(unix)]
749    #[test]
750    fn py_entry_without_exec_bit_is_discovered() {
751        // `.py` entries run via the resolved Python interpreter, so
752        // validation must not require the exec bit. Regression: a plugin
753        // copied in without the bit (fresh `cp -r` of a script plugin) was
754        // silently undiscoverable on Unix while Windows accepted it.
755        let tmp = TempDir::new().unwrap();
756        write_plugin(tmp.path(), "com.example.py-noexec", MINIMAL_MANIFEST, None);
757        let entry = tmp.path().join("com.example.py-noexec/widget.py");
758        fs::write(&entry, "#!/usr/bin/env python3\n").expect("write entry");
759        // Deliberately no chmod: the interpreter routing makes it runnable.
760        let (plugins, warnings) = discover_plugins(tmp.path());
761        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
762        assert_eq!(plugins.len(), 1);
763        assert!(plugins[0].entry_path.ends_with("widget.py"));
764    }
765
766    #[test]
767    fn unknown_extra_keys_parse_for_forward_compatibility() {
768        let tmp = TempDir::new().unwrap();
769        let manifest = MINIMAL_MANIFEST.replacen(
770            "\"version\": \"0.1.0\",",
771            "\"version\": \"0.1.0\", \"futureField\": {\"nested\": true},",
772            1,
773        );
774        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
775        let (plugins, warnings) = discover_plugins(tmp.path());
776        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
777        assert_eq!(plugins.len(), 1);
778    }
779
780    #[test]
781    fn id_mismatching_directory_name_is_skipped_with_a_warning() {
782        let tmp = TempDir::new().unwrap();
783        write_plugin(
784            tmp.path(),
785            "com.example.test",
786            DESIGN_D1_MANIFEST,
787            Some("agent-widget"),
788        );
789        let (plugins, warnings) = discover_plugins(tmp.path());
790        assert!(plugins.is_empty());
791        assert_eq!(warnings.len(), 1);
792        assert!(warnings[0].reason.contains("does not match directory name"));
793    }
794
795    #[test]
796    fn unknown_kind_skips_the_plugin_with_a_warning() {
797        let tmp = TempDir::new().unwrap();
798        let manifest = MINIMAL_MANIFEST.replace("status-bar-widget", "some-future-kind");
799        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
800        let (plugins, warnings) = discover_plugins(tmp.path());
801        assert!(plugins.is_empty());
802        assert_eq!(warnings.len(), 1);
803        assert!(
804            warnings[0]
805                .reason
806                .contains("unknown kinds: some-future-kind")
807        );
808    }
809
810    #[test]
811    fn escaping_entry_command_is_rejected() {
812        let tmp = TempDir::new().unwrap();
813        // A real executable OUTSIDE the plugin dir the escape targets.
814        let outside = tmp.path().join("outside.sh");
815        fs::write(&outside, "#!/bin/sh\nsleep 30\n").unwrap();
816        #[cfg(unix)]
817        {
818            use std::os::unix::fs::PermissionsExt;
819            let mut perms = fs::metadata(&outside).unwrap().permissions();
820            perms.set_mode(0o755);
821            fs::set_permissions(&outside, perms).unwrap();
822        }
823        let manifest = MINIMAL_MANIFEST.replace("\"widget.py\"", "\"../outside.sh\"");
824        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
825        let (plugins, warnings) = discover_plugins(tmp.path());
826        assert!(plugins.is_empty(), "`..` escape must not be discovered");
827        assert_eq!(warnings.len(), 1);
828        assert!(warnings[0].reason.contains("escapes the plugin directory"));
829    }
830
831    #[test]
832    fn missing_entry_file_is_skipped_with_a_warning() {
833        let tmp = TempDir::new().unwrap();
834        write_plugin(tmp.path(), "com.example.test", MINIMAL_MANIFEST, None);
835        let (plugins, warnings) = discover_plugins(tmp.path());
836        assert!(plugins.is_empty());
837        assert_eq!(warnings.len(), 1);
838        assert!(warnings[0].reason.contains("not found"));
839    }
840
841    #[cfg(unix)]
842    #[test]
843    fn non_executable_entry_is_skipped_with_a_warning() {
844        use std::os::unix::fs::PermissionsExt;
845        let tmp = TempDir::new().unwrap();
846        // Non-`.py` entries exec directly, so they must carry the bit;
847        // `.py` entries are interpreter-routed (see
848        // `py_entry_without_exec_bit_is_discovered`).
849        let manifest = MINIMAL_MANIFEST.replace("widget.py", "widget.sh");
850        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.sh"));
851        let entry = tmp.path().join("com.example.test/widget.sh");
852        let mut perms = fs::metadata(&entry).unwrap().permissions();
853        perms.set_mode(0o644);
854        fs::set_permissions(&entry, perms).unwrap();
855        let (plugins, warnings) = discover_plugins(tmp.path());
856        assert!(plugins.is_empty());
857        assert_eq!(warnings.len(), 1);
858        assert!(warnings[0].reason.contains("not executable"));
859    }
860
861    #[test]
862    fn one_broken_plugin_never_blocks_its_neighbours() {
863        let tmp = TempDir::new().unwrap();
864        write_plugin(
865            tmp.path(),
866            "com.example.a",
867            MINIMAL_MANIFEST,
868            Some("widget.py"),
869        );
870        let dir = tmp.path().join("com.example.broken");
871        fs::create_dir_all(&dir).unwrap();
872        fs::write(dir.join("manifest.json"), "{ not json").unwrap();
873        write_plugin(
874            tmp.path(),
875            "com.example.c",
876            MINIMAL_MANIFEST,
877            Some("widget.py"),
878        );
879        let (plugins, warnings) = discover_plugins(tmp.path());
880        assert_eq!(plugins.len(), 2, "both valid neighbours must be discovered");
881        assert_eq!(warnings.len(), 1);
882        assert_eq!(warnings[0].dir, "com.example.broken");
883        assert!(warnings[0].reason.contains("invalid manifest JSON"));
884    }
885
886    #[test]
887    fn widget_kind_without_status_bar_widget_block_is_skipped() {
888        let tmp = TempDir::new().unwrap();
889        // Rename only the widget BLOCK key (the entryPoints key of the same
890        // name sits inline on a different line); the block's contents become
891        // an unknown field, so the manifest parses without a widget block.
892        let manifest = MINIMAL_MANIFEST.replace(
893            "\"statusBarWidget\": {\n            \"displayName\"",
894            "\"notAWidgetBlock\": {\n            \"displayName\"",
895        );
896        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
897        let (plugins, warnings) = discover_plugins(tmp.path());
898        assert!(plugins.is_empty());
899        assert!(
900            warnings[0]
901                .reason
902                .contains("requires a statusBarWidget block")
903        );
904    }
905
906    #[test]
907    fn widget_kind_without_entry_point_is_skipped() {
908        let tmp = TempDir::new().unwrap();
909        // Remove the whole entryPoints line; the widget block survives, so
910        // the failure is specifically the missing entry point.
911        let manifest = MINIMAL_MANIFEST.replace(
912            "\"entryPoints\": { \"statusBarWidget\": { \"command\": \"widget.py\", \"args\": [] } },\n",
913            "",
914        );
915        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
916        let (plugins, warnings) = discover_plugins(tmp.path());
917        assert!(plugins.is_empty());
918        assert!(
919            warnings[0]
920                .reason
921                .contains("requires entryPoints.statusBarWidget")
922        );
923    }
924
925    #[test]
926    fn action_kind_manifest_discovers_with_action_entry_path() {
927        let tmp = TempDir::new().unwrap();
928        write_plugin(
929            tmp.path(),
930            "com.example.actions",
931            MINIMAL_ACTION_MANIFEST,
932            Some("actions.py"),
933        );
934        let (plugins, warnings) = discover_plugins(tmp.path());
935        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
936        assert_eq!(plugins.len(), 1);
937        let p = &plugins[0];
938        let action_entry = p.action_entry_path.as_ref().expect("action entry set");
939        assert!(
940            action_entry.starts_with(&p.dir),
941            "must stay inside the plugin dir"
942        );
943        assert!(action_entry.ends_with("actions.py"));
944        assert_eq!(p.manifest.actions.len(), 1);
945        assert_eq!(p.manifest.actions[0].id, "say-hello");
946        assert_eq!(p.manifest.actions[0].label, "Say hello");
947        assert_eq!(p.manifest.actions[0].description, None);
948        assert!(p.panel_entry_path.is_none());
949    }
950
951    #[test]
952    fn panel_kind_manifest_discovers_with_panel_entry_path() {
953        let tmp = TempDir::new().unwrap();
954        write_plugin(
955            tmp.path(),
956            "com.example.panel",
957            MINIMAL_PANEL_MANIFEST,
958            Some("panel.py"),
959        );
960        let (plugins, warnings) = discover_plugins(tmp.path());
961        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
962        assert_eq!(plugins.len(), 1);
963        let p = &plugins[0];
964        let panel_entry = p.panel_entry_path.as_ref().expect("panel entry set");
965        assert!(
966            panel_entry.starts_with(&p.dir),
967            "must stay inside the plugin dir"
968        );
969        assert!(panel_entry.ends_with("panel.py"));
970        // A panel-only manifest has no widget block and no actions, and the
971        // panel entry is the plugin's primary entry.
972        assert!(p.manifest.status_bar_widget.is_none());
973        assert!(p.manifest.actions.is_empty());
974        assert!(p.entry_path.ends_with("panel.py"));
975        assert!(p.action_entry_path.is_none());
976    }
977
978    #[test]
979    fn panel_kind_without_entry_point_is_skipped() {
980        let tmp = TempDir::new().unwrap();
981        let manifest = MINIMAL_PANEL_MANIFEST.replace(
982            "\"entryPoints\": { \"panel\": { \"command\": \"panel.py\", \"args\": [] } }",
983            "\"entryPoints\": {}",
984        );
985        write_plugin(tmp.path(), "com.example.panel", &manifest, Some("panel.py"));
986        let (plugins, warnings) = discover_plugins(tmp.path());
987        assert!(plugins.is_empty());
988        assert!(
989            warnings[0].reason.contains("requires entryPoints.panel"),
990            "got: {}",
991            warnings[0].reason
992        );
993    }
994
995    #[test]
996    fn both_kinds_manifest_discovers_with_both_entry_paths() {
997        let tmp = TempDir::new().unwrap();
998        write_plugin(
999            tmp.path(),
1000            "com.example.both",
1001            BOTH_KINDS_MANIFEST,
1002            Some("widget.py"),
1003        );
1004        write_exec_file(&tmp.path().join("com.example.both/actions.py"));
1005        let (plugins, warnings) = discover_plugins(tmp.path());
1006        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1007        assert_eq!(plugins.len(), 1);
1008        let p = &plugins[0];
1009        assert!(
1010            p.entry_path.ends_with("widget.py"),
1011            "widget entry stays the primary entry"
1012        );
1013        let action_entry = p.action_entry_path.as_ref().expect("action entry set");
1014        assert!(
1015            action_entry.starts_with(&p.dir),
1016            "must stay inside the plugin dir"
1017        );
1018        assert!(action_entry.ends_with("actions.py"));
1019        assert_eq!(
1020            p.manifest.kinds,
1021            vec![
1022                KIND_STATUS_BAR_WIDGET.to_string(),
1023                KIND_ACTION_CONTRIBUTOR.to_string()
1024            ]
1025        );
1026    }
1027
1028    #[test]
1029    fn action_kind_with_empty_actions_is_skipped() {
1030        let tmp = TempDir::new().unwrap();
1031        let manifest = MINIMAL_ACTION_MANIFEST
1032            .replace(r#"[ { "id": "say-hello", "label": "Say hello" } ]"#, "[]");
1033        write_plugin(
1034            tmp.path(),
1035            "com.example.actions",
1036            &manifest,
1037            Some("actions.py"),
1038        );
1039        let (plugins, warnings) = discover_plugins(tmp.path());
1040        assert!(plugins.is_empty());
1041        assert!(
1042            warnings[0]
1043                .reason
1044                .contains("action-contributor kind requires")
1045        );
1046    }
1047
1048    #[test]
1049    fn action_id_with_a_colon_is_skipped() {
1050        let tmp = TempDir::new().unwrap();
1051        // A colon in an action id would collide with the wire format's id
1052        // separator (design D1), so the manifest is skipped outright.
1053        let manifest = MINIMAL_ACTION_MANIFEST.replace("\"say-hello\"", "\"say:hello\"");
1054        write_plugin(
1055            tmp.path(),
1056            "com.example.actions",
1057            &manifest,
1058            Some("actions.py"),
1059        );
1060        let (plugins, warnings) = discover_plugins(tmp.path());
1061        assert!(plugins.is_empty());
1062        assert!(warnings[0].reason.contains("action id `say:hello`"));
1063    }
1064
1065    #[test]
1066    fn duplicate_action_ids_are_skipped() {
1067        let tmp = TempDir::new().unwrap();
1068        let manifest = MINIMAL_ACTION_MANIFEST.replace(
1069            r#"{ "id": "say-hello", "label": "Say hello" }"#,
1070            r#"{ "id": "say-hello", "label": "First" }, { "id": "say-hello", "label": "Second" }"#,
1071        );
1072        write_plugin(
1073            tmp.path(),
1074            "com.example.actions",
1075            &manifest,
1076            Some("actions.py"),
1077        );
1078        let (plugins, warnings) = discover_plugins(tmp.path());
1079        assert!(plugins.is_empty());
1080        assert!(
1081            warnings[0]
1082                .reason
1083                .contains("duplicate action id `say-hello`")
1084        );
1085    }
1086
1087    #[test]
1088    fn action_with_empty_label_is_skipped() {
1089        let tmp = TempDir::new().unwrap();
1090        let manifest =
1091            MINIMAL_ACTION_MANIFEST.replace("\"label\": \"Say hello\"", "\"label\": \"\"");
1092        write_plugin(
1093            tmp.path(),
1094            "com.example.actions",
1095            &manifest,
1096            Some("actions.py"),
1097        );
1098        let (plugins, warnings) = discover_plugins(tmp.path());
1099        assert!(plugins.is_empty());
1100        assert!(warnings[0].reason.contains("non-empty label"));
1101    }
1102
1103    #[test]
1104    fn actions_without_the_action_kind_are_valid_and_ignored() {
1105        let tmp = TempDir::new().unwrap();
1106        // Forward compat: a newer host's action plugin must still discover
1107        // here as a plain widget plugin; the inert `actions` block is not a
1108        // reason to skip.
1109        let manifest = MINIMAL_MANIFEST.replacen(
1110            "\"kinds\": [\"status-bar-widget\"],",
1111            concat!(
1112                "\"kinds\": [\"status-bar-widget\"], ",
1113                "\"actions\": [ { \"id\": \"orphan\", \"label\": \"Orphan\" } ],"
1114            ),
1115            1,
1116        );
1117        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1118        let (plugins, warnings) = discover_plugins(tmp.path());
1119        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1120        assert_eq!(plugins.len(), 1);
1121        assert!(
1122            plugins[0].action_entry_path.is_none(),
1123            "no action kind declared, so no action entry is required or resolved"
1124        );
1125    }
1126
1127    #[test]
1128    fn subscriptions_parse_and_survive_discovery() {
1129        let tmp = TempDir::new().unwrap();
1130        let manifest = MINIMAL_MANIFEST.replacen(
1131            "\"kinds\": [\"status-bar-widget\"],",
1132            concat!(
1133                "\"kinds\": [\"status-bar-widget\"], ",
1134                "\"subscriptions\": [\"bell_rang\", \"command_complete\"],"
1135            ),
1136            1,
1137        );
1138        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1139        let (plugins, warnings) = discover_plugins(tmp.path());
1140        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1141        assert_eq!(plugins.len(), 1);
1142        assert_eq!(
1143            plugins[0].manifest.subscriptions,
1144            vec!["bell_rang".to_string(), "command_complete".to_string()]
1145        );
1146    }
1147
1148    #[test]
1149    fn manifest_without_subscriptions_defaults_to_empty() {
1150        // The self-scheduled contract: an absent subscriptions block must
1151        // parse to an empty list, never a delivery of every event.
1152        let tmp = TempDir::new().unwrap();
1153        write_plugin(
1154            tmp.path(),
1155            "com.example.test",
1156            MINIMAL_MANIFEST,
1157            Some("widget.py"),
1158        );
1159        let (plugins, warnings) = discover_plugins(tmp.path());
1160        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1161        assert_eq!(plugins.len(), 1);
1162        assert!(plugins[0].manifest.subscriptions.is_empty());
1163    }
1164
1165    #[test]
1166    fn unknown_subscription_kind_skips_the_plugin() {
1167        let tmp = TempDir::new().unwrap();
1168        let manifest = MINIMAL_MANIFEST.replacen(
1169            "\"kinds\": [\"status-bar-widget\"],",
1170            concat!(
1171                "\"kinds\": [\"status-bar-widget\"], ",
1172                "\"subscriptions\": [\"bell_rang\", \"not_an_event\"],"
1173            ),
1174            1,
1175        );
1176        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1177        let (plugins, warnings) = discover_plugins(tmp.path());
1178        assert!(plugins.is_empty());
1179        assert_eq!(warnings.len(), 1);
1180        assert!(
1181            warnings[0]
1182                .reason
1183                .contains("unknown subscription kind `not_an_event`"),
1184            "got: {}",
1185            warnings[0].reason
1186        );
1187    }
1188
1189    #[test]
1190    fn duplicate_subscription_kind_is_skipped() {
1191        let tmp = TempDir::new().unwrap();
1192        let manifest = MINIMAL_MANIFEST.replacen(
1193            "\"kinds\": [\"status-bar-widget\"],",
1194            concat!(
1195                "\"kinds\": [\"status-bar-widget\"], ",
1196                "\"subscriptions\": [\"bell_rang\", \"bell_rang\"],"
1197            ),
1198            1,
1199        );
1200        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1201        let (plugins, warnings) = discover_plugins(tmp.path());
1202        assert!(plugins.is_empty());
1203        assert!(
1204            warnings[0]
1205                .reason
1206                .contains("duplicate subscription kind `bell_rang`"),
1207            "got: {}",
1208            warnings[0].reason
1209        );
1210    }
1211
1212    #[test]
1213    fn manifest_without_restart_defaults_to_on_failure() {
1214        // Today's behaviour is the default: a manifest without the field
1215        // must keep restarting on failure (the config enum's own default,
1216        // Never, must NOT leak into plugins).
1217        let tmp = TempDir::new().unwrap();
1218        write_plugin(
1219            tmp.path(),
1220            "com.example.test",
1221            MINIMAL_MANIFEST,
1222            Some("widget.py"),
1223        );
1224        let (plugins, warnings) = discover_plugins(tmp.path());
1225        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1226        assert_eq!(
1227            plugins[0].manifest.restart,
1228            par_term_config::RestartPolicy::OnFailure
1229        );
1230    }
1231
1232    #[test]
1233    fn overlay_capability_block_parses_and_defaults_to_display_only() {
1234        let tmp = TempDir::new().unwrap();
1235        let display_only = MINIMAL_PANEL_MANIFEST.replace(
1236            "\"kinds\": [\"panel\"],\n        \"entryPoints\": { \"panel\":",
1237            "\"kinds\": [\"overlay\"],\n        \"entryPoints\": { \"overlay\":",
1238        );
1239        write_plugin(
1240            tmp.path(),
1241            "com.example.test",
1242            &display_only,
1243            Some("panel.py"),
1244        );
1245        let (plugins, warnings) = discover_plugins(tmp.path());
1246        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1247        // No overlay block at all: display-only.
1248        assert_eq!(plugins[0].manifest.overlay, None);
1249
1250        let interactive = display_only.replace(
1251            "\"kinds\": [\"overlay\"],",
1252            "\"kinds\": [\"overlay\"], \"overlay\": { \"interactive\": true },",
1253        );
1254        let tmp2 = TempDir::new().unwrap();
1255        write_plugin(
1256            tmp2.path(),
1257            "com.example.test",
1258            &interactive,
1259            Some("panel.py"),
1260        );
1261        let (plugins, _warnings) = discover_plugins(tmp2.path());
1262        assert_eq!(
1263            plugins[0].manifest.overlay,
1264            Some(OverlayCapability { interactive: true })
1265        );
1266
1267        // The block without the flag defaults the flag off — claiming the
1268        // block alone grants nothing.
1269        let block_only = display_only.replace(
1270            "\"kinds\": [\"overlay\"],",
1271            "\"kinds\": [\"overlay\"], \"overlay\": {},",
1272        );
1273        let tmp3 = TempDir::new().unwrap();
1274        write_plugin(
1275            tmp3.path(),
1276            "com.example.test",
1277            &block_only,
1278            Some("panel.py"),
1279        );
1280        let (plugins, _warnings) = discover_plugins(tmp3.path());
1281        assert_eq!(
1282            plugins[0].manifest.overlay,
1283            Some(OverlayCapability { interactive: false })
1284        );
1285    }
1286
1287    #[test]
1288    fn explicit_restart_never_parses() {
1289        let tmp = TempDir::new().unwrap();
1290        let manifest = MINIMAL_MANIFEST.replace(
1291            "\"kinds\": [\"status-bar-widget\"],",
1292            "\"kinds\": [\"status-bar-widget\"], \"restart\": \"never\",",
1293        );
1294        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1295        let (plugins, warnings) = discover_plugins(tmp.path());
1296        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1297        assert_eq!(plugins.len(), 1);
1298        assert_eq!(
1299            plugins[0].manifest.restart,
1300            par_term_config::RestartPolicy::Never
1301        );
1302    }
1303
1304    #[test]
1305    fn unknown_restart_mode_is_skipped() {
1306        // The enum parse rejects unknown strings at manifest parse time;
1307        // the skip reason must name the restart value, not just "invalid".
1308        let tmp = TempDir::new().unwrap();
1309        let manifest = MINIMAL_MANIFEST.replace(
1310            "\"kinds\": [\"status-bar-widget\"],",
1311            "\"kinds\": [\"status-bar-widget\"], \"restart\": \"sometimes\",",
1312        );
1313        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1314        let (plugins, warnings) = discover_plugins(tmp.path());
1315        assert!(plugins.is_empty());
1316        assert!(
1317            warnings[0].reason.contains("sometimes"),
1318            "got: {}",
1319            warnings[0].reason
1320        );
1321    }
1322
1323    #[test]
1324    fn absent_root_is_empty_with_no_warnings() {
1325        let (plugins, warnings) = discover_plugins(Path::new("/nonexistent/plugins-root-xyz"));
1326        assert!(plugins.is_empty());
1327        assert!(warnings.is_empty());
1328    }
1329
1330    #[test]
1331    fn empty_kinds_is_skipped() {
1332        let tmp = TempDir::new().unwrap();
1333        let manifest =
1334            MINIMAL_MANIFEST.replace(r#""kinds": ["status-bar-widget"]"#, r#""kinds": []"#);
1335        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1336        let (plugins, warnings) = discover_plugins(tmp.path());
1337        assert!(plugins.is_empty());
1338        assert!(warnings[0].reason.contains("no kinds declared"));
1339    }
1340
1341    #[test]
1342    fn unsupported_schema_version_is_skipped() {
1343        let tmp = TempDir::new().unwrap();
1344        let manifest = MINIMAL_MANIFEST.replace("\"schemaVersion\": 1", "\"schemaVersion\": 2");
1345        write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1346        let (plugins, warnings) = discover_plugins(tmp.path());
1347        assert!(plugins.is_empty());
1348        assert!(warnings[0].reason.contains("unsupported schemaVersion 2"));
1349    }
1350
1351    /// Schema used by the validate_settings tests: one entry per type.
1352    fn test_schema() -> Vec<SettingSchemaEntry> {
1353        serde_json::from_str(
1354            r#"[
1355                { "key": "format24h", "type": "boolean", "label": "24h", "defaultValue": true },
1356                { "key": "interval", "type": "integer", "label": "Interval",
1357                  "min": 30, "max": 3600, "defaultValue": 900 },
1358                { "key": "label", "type": "string", "label": "Label", "defaultValue": "hi" }
1359            ]"#,
1360        )
1361        .unwrap()
1362    }
1363
1364    fn settings(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1365        pairs
1366            .iter()
1367            .map(|(k, v)| (k.to_string(), v.clone()))
1368            .collect()
1369    }
1370
1371    #[test]
1372    fn validate_settings_fills_defaults_for_absent_keys() {
1373        let schema = test_schema();
1374        let values = settings(&[("interval", serde_json::json!(120))]);
1375        let out = validate_settings(&schema, &values).unwrap();
1376        assert_eq!(out.get("format24h"), Some(&serde_json::json!(true)));
1377        assert_eq!(out.get("interval"), Some(&serde_json::json!(120)));
1378        assert_eq!(out.get("label"), Some(&serde_json::json!("hi")));
1379    }
1380
1381    #[test]
1382    fn validate_settings_drops_keys_the_schema_no_longer_declares() {
1383        let schema = test_schema();
1384        let values = settings(&[("goneKey", serde_json::json!(1))]);
1385        let out = validate_settings(&schema, &values).unwrap();
1386        assert!(!out.contains_key("goneKey"));
1387    }
1388
1389    #[test]
1390    fn validate_settings_rejects_wrong_type() {
1391        let schema = test_schema();
1392        let values = settings(&[("interval", serde_json::json!("fast"))]);
1393        let err = validate_settings(&schema, &values).unwrap_err();
1394        assert!(err.contains("`interval` expects integer"), "got: {err}");
1395    }
1396
1397    #[test]
1398    fn validate_settings_rejects_fractional_integers() {
1399        let schema = test_schema();
1400        let values = settings(&[("interval", serde_json::json!(30.5))]);
1401        assert!(validate_settings(&schema, &values).is_err());
1402    }
1403
1404    #[test]
1405    fn validate_settings_enforces_min_and_max() {
1406        let schema = test_schema();
1407        let too_low = settings(&[("interval", serde_json::json!(10))]);
1408        assert!(
1409            validate_settings(&schema, &too_low)
1410                .unwrap_err()
1411                .contains("below the minimum 30")
1412        );
1413        let too_high = settings(&[("interval", serde_json::json!(9999))]);
1414        assert!(
1415            validate_settings(&schema, &too_high)
1416                .unwrap_err()
1417                .contains("above the maximum 3600")
1418        );
1419    }
1420}