1use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13use par_term_config::{RestartPolicy, StatusBarSection};
14use serde::{Deserialize, Serialize};
15
16pub const KIND_STATUS_BAR_WIDGET: &str = "status-bar-widget";
19
20pub const KIND_ACTION_CONTRIBUTOR: &str = "action-contributor";
23
24pub const KIND_PANEL: &str = "panel";
28
29pub const KIND_OVERLAY: &str = "overlay";
35
36pub const ENTRY_POINT_STATUS_BAR_WIDGET: &str = "statusBarWidget";
38
39pub const ENTRY_POINT_ACTION_CONTRIBUTOR: &str = "actionContributor";
41
42pub const ENTRY_POINT_PANEL: &str = "panel";
44
45pub const ENTRY_POINT_OVERLAY: &str = "overlay";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PluginActivation {
52 #[default]
54 Manual,
55 OnStartup,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SettingType {
63 String,
65 Integer,
67 Number,
69 Boolean,
71}
72
73impl SettingType {
74 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct SettingSchemaEntry {
89 pub key: String,
91 #[serde(rename = "type")]
93 pub setting_type: SettingType,
94 pub label: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub min: Option<f64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub max: Option<f64>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub step: Option<f64>,
105 pub default_value: serde_json::Value,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct StatusBarWidgetKind {
114 pub display_name: String,
116 #[serde(default = "default_widget_section")]
119 pub section: StatusBarSection,
120 #[serde(default)]
122 pub defaults: HashMap<String, serde_json::Value>,
123 pub schema: Vec<SettingSchemaEntry>,
125}
126
127fn default_widget_section() -> StatusBarSection {
128 StatusBarSection::Right
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ActionContribution {
136 pub id: String,
139 pub label: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub description: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct PluginEntryPoint {
152 pub command: String,
154 #[serde(default)]
156 pub args: Vec<String>,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct PluginManifest {
163 pub schema_version: u32,
165 pub id: String,
167 pub name: String,
169 pub version: String,
171 #[serde(default)]
173 pub author: Option<String>,
174 #[serde(default)]
176 pub license: Option<String>,
177 #[serde(default)]
179 pub description: Option<String>,
180 #[serde(default)]
182 pub kinds: Vec<String>,
183 #[serde(default)]
185 pub activation: PluginActivation,
186 #[serde(default)]
188 pub entry_points: HashMap<String, PluginEntryPoint>,
189 #[serde(default)]
191 pub status_bar_widget: Option<StatusBarWidgetKind>,
192 #[serde(default)]
195 pub actions: Vec<ActionContribution>,
196 #[serde(default)]
204 pub subscriptions: Vec<String>,
205 #[serde(default = "default_plugin_restart")]
212 pub restart: RestartPolicy,
213 #[serde(default)]
218 pub overlay: Option<OverlayCapability>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct OverlayCapability {
225 #[serde(default)]
229 pub interactive: bool,
230}
231
232fn default_plugin_restart() -> RestartPolicy {
237 RestartPolicy::OnFailure
238}
239
240#[derive(Debug, Clone, PartialEq)]
242pub struct DiscoveredPlugin {
243 pub manifest: PluginManifest,
245 pub dir: PathBuf,
247 pub entry_path: PathBuf,
251 pub action_entry_path: Option<PathBuf>,
255 pub panel_entry_path: Option<PathBuf>,
258 pub overlay_entry_path: Option<PathBuf>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct DiscoveryWarning {
266 pub dir: String,
268 pub reason: String,
270}
271
272pub 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
306pub(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 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 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 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 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 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 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 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
495fn 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
529pub 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
581fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 theme_changed_is_a_valid_subscription_kind() {
1150 let tmp = TempDir::new().unwrap();
1155 let manifest = MINIMAL_MANIFEST.replacen(
1156 "\"kinds\": [\"status-bar-widget\"],",
1157 concat!(
1158 "\"kinds\": [\"status-bar-widget\"], ",
1159 "\"subscriptions\": [\"theme_changed\"],"
1160 ),
1161 1,
1162 );
1163 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1164 let (plugins, warnings) = discover_plugins(tmp.path());
1165 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1166 assert_eq!(plugins.len(), 1);
1167 assert_eq!(
1168 plugins[0].manifest.subscriptions,
1169 vec!["theme_changed".to_string()]
1170 );
1171 }
1172
1173 #[test]
1174 fn manifest_without_subscriptions_defaults_to_empty() {
1175 let tmp = TempDir::new().unwrap();
1178 write_plugin(
1179 tmp.path(),
1180 "com.example.test",
1181 MINIMAL_MANIFEST,
1182 Some("widget.py"),
1183 );
1184 let (plugins, warnings) = discover_plugins(tmp.path());
1185 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1186 assert_eq!(plugins.len(), 1);
1187 assert!(plugins[0].manifest.subscriptions.is_empty());
1188 }
1189
1190 #[test]
1191 fn unknown_subscription_kind_skips_the_plugin() {
1192 let tmp = TempDir::new().unwrap();
1193 let manifest = MINIMAL_MANIFEST.replacen(
1194 "\"kinds\": [\"status-bar-widget\"],",
1195 concat!(
1196 "\"kinds\": [\"status-bar-widget\"], ",
1197 "\"subscriptions\": [\"bell_rang\", \"not_an_event\"],"
1198 ),
1199 1,
1200 );
1201 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1202 let (plugins, warnings) = discover_plugins(tmp.path());
1203 assert!(plugins.is_empty());
1204 assert_eq!(warnings.len(), 1);
1205 assert!(
1206 warnings[0]
1207 .reason
1208 .contains("unknown subscription kind `not_an_event`"),
1209 "got: {}",
1210 warnings[0].reason
1211 );
1212 }
1213
1214 #[test]
1215 fn duplicate_subscription_kind_is_skipped() {
1216 let tmp = TempDir::new().unwrap();
1217 let manifest = MINIMAL_MANIFEST.replacen(
1218 "\"kinds\": [\"status-bar-widget\"],",
1219 concat!(
1220 "\"kinds\": [\"status-bar-widget\"], ",
1221 "\"subscriptions\": [\"bell_rang\", \"bell_rang\"],"
1222 ),
1223 1,
1224 );
1225 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1226 let (plugins, warnings) = discover_plugins(tmp.path());
1227 assert!(plugins.is_empty());
1228 assert!(
1229 warnings[0]
1230 .reason
1231 .contains("duplicate subscription kind `bell_rang`"),
1232 "got: {}",
1233 warnings[0].reason
1234 );
1235 }
1236
1237 #[test]
1238 fn manifest_without_restart_defaults_to_on_failure() {
1239 let tmp = TempDir::new().unwrap();
1243 write_plugin(
1244 tmp.path(),
1245 "com.example.test",
1246 MINIMAL_MANIFEST,
1247 Some("widget.py"),
1248 );
1249 let (plugins, warnings) = discover_plugins(tmp.path());
1250 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1251 assert_eq!(
1252 plugins[0].manifest.restart,
1253 par_term_config::RestartPolicy::OnFailure
1254 );
1255 }
1256
1257 #[test]
1258 fn overlay_capability_block_parses_and_defaults_to_display_only() {
1259 let tmp = TempDir::new().unwrap();
1260 let display_only = MINIMAL_PANEL_MANIFEST.replace(
1261 "\"kinds\": [\"panel\"],\n \"entryPoints\": { \"panel\":",
1262 "\"kinds\": [\"overlay\"],\n \"entryPoints\": { \"overlay\":",
1263 );
1264 write_plugin(
1265 tmp.path(),
1266 "com.example.test",
1267 &display_only,
1268 Some("panel.py"),
1269 );
1270 let (plugins, warnings) = discover_plugins(tmp.path());
1271 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1272 assert_eq!(plugins[0].manifest.overlay, None);
1274
1275 let interactive = display_only.replace(
1276 "\"kinds\": [\"overlay\"],",
1277 "\"kinds\": [\"overlay\"], \"overlay\": { \"interactive\": true },",
1278 );
1279 let tmp2 = TempDir::new().unwrap();
1280 write_plugin(
1281 tmp2.path(),
1282 "com.example.test",
1283 &interactive,
1284 Some("panel.py"),
1285 );
1286 let (plugins, _warnings) = discover_plugins(tmp2.path());
1287 assert_eq!(
1288 plugins[0].manifest.overlay,
1289 Some(OverlayCapability { interactive: true })
1290 );
1291
1292 let block_only = display_only.replace(
1295 "\"kinds\": [\"overlay\"],",
1296 "\"kinds\": [\"overlay\"], \"overlay\": {},",
1297 );
1298 let tmp3 = TempDir::new().unwrap();
1299 write_plugin(
1300 tmp3.path(),
1301 "com.example.test",
1302 &block_only,
1303 Some("panel.py"),
1304 );
1305 let (plugins, _warnings) = discover_plugins(tmp3.path());
1306 assert_eq!(
1307 plugins[0].manifest.overlay,
1308 Some(OverlayCapability { interactive: false })
1309 );
1310 }
1311
1312 #[test]
1313 fn explicit_restart_never_parses() {
1314 let tmp = TempDir::new().unwrap();
1315 let manifest = MINIMAL_MANIFEST.replace(
1316 "\"kinds\": [\"status-bar-widget\"],",
1317 "\"kinds\": [\"status-bar-widget\"], \"restart\": \"never\",",
1318 );
1319 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1320 let (plugins, warnings) = discover_plugins(tmp.path());
1321 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1322 assert_eq!(plugins.len(), 1);
1323 assert_eq!(
1324 plugins[0].manifest.restart,
1325 par_term_config::RestartPolicy::Never
1326 );
1327 }
1328
1329 #[test]
1330 fn unknown_restart_mode_is_skipped() {
1331 let tmp = TempDir::new().unwrap();
1334 let manifest = MINIMAL_MANIFEST.replace(
1335 "\"kinds\": [\"status-bar-widget\"],",
1336 "\"kinds\": [\"status-bar-widget\"], \"restart\": \"sometimes\",",
1337 );
1338 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1339 let (plugins, warnings) = discover_plugins(tmp.path());
1340 assert!(plugins.is_empty());
1341 assert!(
1342 warnings[0].reason.contains("sometimes"),
1343 "got: {}",
1344 warnings[0].reason
1345 );
1346 }
1347
1348 #[test]
1349 fn absent_root_is_empty_with_no_warnings() {
1350 let (plugins, warnings) = discover_plugins(Path::new("/nonexistent/plugins-root-xyz"));
1351 assert!(plugins.is_empty());
1352 assert!(warnings.is_empty());
1353 }
1354
1355 #[test]
1356 fn empty_kinds_is_skipped() {
1357 let tmp = TempDir::new().unwrap();
1358 let manifest =
1359 MINIMAL_MANIFEST.replace(r#""kinds": ["status-bar-widget"]"#, r#""kinds": []"#);
1360 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1361 let (plugins, warnings) = discover_plugins(tmp.path());
1362 assert!(plugins.is_empty());
1363 assert!(warnings[0].reason.contains("no kinds declared"));
1364 }
1365
1366 #[test]
1367 fn unsupported_schema_version_is_skipped() {
1368 let tmp = TempDir::new().unwrap();
1369 let manifest = MINIMAL_MANIFEST.replace("\"schemaVersion\": 1", "\"schemaVersion\": 2");
1370 write_plugin(tmp.path(), "com.example.test", &manifest, Some("widget.py"));
1371 let (plugins, warnings) = discover_plugins(tmp.path());
1372 assert!(plugins.is_empty());
1373 assert!(warnings[0].reason.contains("unsupported schemaVersion 2"));
1374 }
1375
1376 fn test_schema() -> Vec<SettingSchemaEntry> {
1378 serde_json::from_str(
1379 r#"[
1380 { "key": "format24h", "type": "boolean", "label": "24h", "defaultValue": true },
1381 { "key": "interval", "type": "integer", "label": "Interval",
1382 "min": 30, "max": 3600, "defaultValue": 900 },
1383 { "key": "label", "type": "string", "label": "Label", "defaultValue": "hi" }
1384 ]"#,
1385 )
1386 .unwrap()
1387 }
1388
1389 fn settings(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1390 pairs
1391 .iter()
1392 .map(|(k, v)| (k.to_string(), v.clone()))
1393 .collect()
1394 }
1395
1396 #[test]
1397 fn validate_settings_fills_defaults_for_absent_keys() {
1398 let schema = test_schema();
1399 let values = settings(&[("interval", serde_json::json!(120))]);
1400 let out = validate_settings(&schema, &values).unwrap();
1401 assert_eq!(out.get("format24h"), Some(&serde_json::json!(true)));
1402 assert_eq!(out.get("interval"), Some(&serde_json::json!(120)));
1403 assert_eq!(out.get("label"), Some(&serde_json::json!("hi")));
1404 }
1405
1406 #[test]
1407 fn validate_settings_drops_keys_the_schema_no_longer_declares() {
1408 let schema = test_schema();
1409 let values = settings(&[("goneKey", serde_json::json!(1))]);
1410 let out = validate_settings(&schema, &values).unwrap();
1411 assert!(!out.contains_key("goneKey"));
1412 }
1413
1414 #[test]
1415 fn validate_settings_rejects_wrong_type() {
1416 let schema = test_schema();
1417 let values = settings(&[("interval", serde_json::json!("fast"))]);
1418 let err = validate_settings(&schema, &values).unwrap_err();
1419 assert!(err.contains("`interval` expects integer"), "got: {err}");
1420 }
1421
1422 #[test]
1423 fn validate_settings_rejects_fractional_integers() {
1424 let schema = test_schema();
1425 let values = settings(&[("interval", serde_json::json!(30.5))]);
1426 assert!(validate_settings(&schema, &values).is_err());
1427 }
1428
1429 #[test]
1430 fn validate_settings_enforces_min_and_max() {
1431 let schema = test_schema();
1432 let too_low = settings(&[("interval", serde_json::json!(10))]);
1433 assert!(
1434 validate_settings(&schema, &too_low)
1435 .unwrap_err()
1436 .contains("below the minimum 30")
1437 );
1438 let too_high = settings(&[("interval", serde_json::json!(9999))]);
1439 assert!(
1440 validate_settings(&schema, &too_high)
1441 .unwrap_err()
1442 .contains("above the maximum 3600")
1443 );
1444 }
1445}