1use serde::Deserialize;
4
5use super::keybinds::ManifestKeybind;
6use super::plugin_index::PluginIndexEntry;
7
8#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9pub struct PluginCompatibility {
10 #[serde(default)]
11 pub synaps: Option<String>,
12 #[serde(default)]
13 pub extension_protocol: Option<String>,
14}
15
16#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
17#[serde(untagged)]
18pub enum ManifestCommand {
19 Shell(ManifestShellCommand),
20 ExtensionTool(ManifestExtensionToolCommand),
21 SkillPrompt(ManifestSkillPromptCommand),
22 Interactive(ManifestInteractiveCommand),
23}
24
25#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
26pub struct ManifestShellCommand {
27 pub name: String,
28 #[serde(default)]
29 pub description: Option<String>,
30 pub command: String,
31 #[serde(default)]
32 pub args: Vec<String>,
33}
34
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36pub struct ManifestExtensionToolCommand {
37 pub name: String,
38 #[serde(default)]
39 pub description: Option<String>,
40 pub tool: String,
41 #[serde(default)]
42 pub input: serde_json::Value,
43}
44
45#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
46pub struct ManifestSkillPromptCommand {
47 pub name: String,
48 #[serde(default)]
49 pub description: Option<String>,
50 pub skill: String,
51 pub prompt: String,
52}
53
54#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
55pub struct ManifestInteractiveCommand {
56 pub name: String,
57 #[serde(default)]
58 pub description: Option<String>,
59 pub interactive: bool,
61 #[serde(default)]
62 pub subcommands: Vec<String>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct PluginManifest {
67 pub name: String,
68 #[serde(default)]
69 pub version: Option<String>,
70 #[serde(default)]
71 pub description: Option<String>,
72 #[serde(default)]
73 pub keybinds: Vec<ManifestKeybind>,
74 #[serde(default)]
75 pub compatibility: Option<PluginCompatibility>,
76 #[serde(default)]
77 pub commands: Vec<ManifestCommand>,
78 #[serde(default)]
79 pub extension: Option<crate::extensions::manifest::ExtensionManifest>,
80 #[serde(default, alias = "help")]
81 pub help_entries: Vec<crate::help::HelpEntry>,
82 #[serde(default)]
83 pub provides: Option<PluginProvides>,
84 #[serde(default)]
89 pub settings: Option<ManifestSettings>,
90}
91
92#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
105pub struct ManifestSettings {
106 #[serde(default, alias = "category")]
107 pub categories: Vec<ManifestSettingsCategory>,
108}
109
110#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
111pub struct ManifestSettingsCategory {
112 pub id: String,
113 pub label: String,
114 #[serde(default)]
115 pub fields: Vec<ManifestSettingsField>,
116}
117
118#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
119pub struct ManifestSettingsField {
120 pub key: String,
121 pub label: String,
122 pub editor: ManifestEditorKind,
123 #[serde(default)]
125 pub options: Vec<String>,
126 #[serde(default)]
127 pub help: Option<String>,
128 #[serde(default)]
132 pub default: Option<serde_json::Value>,
133 #[serde(default)]
136 pub numeric: bool,
137}
138
139#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "lowercase")]
141pub enum ManifestEditorKind {
142 Text,
144 Cycler,
146 Picker,
149 Custom,
154}
155
156#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
164pub struct PluginProvides {
165 #[serde(default)]
166 pub sidecar: Option<SidecarManifest>,
167}
168
169#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
177pub struct SidecarManifest {
178 pub command: String,
179 #[serde(default)]
180 pub setup: Option<String>,
181 #[serde(default = "default_sidecar_protocol_version")]
182 pub protocol_version: u16,
183 #[serde(default)]
184 pub model: Option<SidecarModel>,
185 #[serde(default)]
192 pub lifecycle: Option<SidecarLifecycle>,
193}
194
195#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
207pub struct SidecarLifecycle {
208 pub command: String,
212 #[serde(default)]
216 pub settings_category: Option<String>,
217 #[serde(default)]
220 pub display_name: Option<String>,
221 #[serde(default, deserialize_with = "deserialize_clamped_importance")]
223 pub importance: i32,
224}
225
226impl SidecarLifecycle {
227 pub fn effective_display_name(&self) -> &str {
229 self.display_name.as_deref().unwrap_or(&self.command)
230 }
231}
232
233fn deserialize_clamped_importance<'de, D>(d: D) -> Result<i32, D::Error>
235where
236 D: serde::Deserializer<'de>,
237{
238 let raw = i32::deserialize(d)?;
239 Ok(raw.clamp(-100, 100))
240}
241
242fn default_sidecar_protocol_version() -> u16 {
243 1
244}
245
246#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
247pub struct SidecarModel {
248 #[serde(default)]
249 pub default_path: Option<String>,
250 #[serde(default)]
251 pub required: bool,
252}
253
254#[derive(Debug, Clone, Deserialize)]
255pub struct MarketplaceManifest {
256 pub name: String,
257 #[serde(default)]
258 pub version: Option<String>,
259 #[serde(default)]
260 pub description: Option<String>,
261 #[serde(default)]
262 pub categories: Vec<String>,
263 #[serde(default)]
264 pub keywords: Vec<String>,
265 #[serde(default)]
266 pub trust: Option<MarketplaceTrust>,
267 pub plugins: Vec<MarketplacePluginEntry>,
268}
269
270#[derive(Debug, Clone, Deserialize)]
271pub struct MarketplaceTrust {
272 #[serde(default)]
273 pub publisher: Option<String>,
274 #[serde(default)]
275 pub homepage: Option<String>,
276}
277
278#[derive(Debug, Clone, Deserialize)]
279pub struct MarketplacePluginEntry {
280 pub name: String,
281 #[serde(default)]
282 pub source: Option<String>,
283 #[serde(default)]
284 pub version: Option<String>,
285 #[serde(default)]
286 pub description: Option<String>,
287 #[serde(default)]
288 pub category: Option<String>,
289 #[serde(default)]
290 pub keywords: Vec<String>,
291 #[serde(default)]
292 pub license: Option<String>,
293 #[serde(default)]
294 pub index: Option<PluginIndexEntry>,
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
307 fn local_capture_plugin_json_parses_with_phase8_lifecycle_and_keybinds() {
308 let path = "/home/jr/Projects/Maha-Media/.worktrees/\
309 synaps-skills-local-sidecar-plugin-commands-tasks/local-sidecar-plugin/\
310 .synaps-plugin/plugin.json";
311 let Ok(json) = std::fs::read_to_string(path) else {
312 eprintln!("skip: {path} not found");
315 return;
316 };
317 let m: PluginManifest =
318 serde_json::from_str(&json).expect("sample-sidecar manifest must deserialize");
319 assert_eq!(m.name, "sample-sidecar");
320
321 let provides = m.provides.expect("provides present");
322 let sidecar = provides.sidecar.expect("sidecar present");
323 assert_eq!(sidecar.command, "bin/synaps-sidecar-plugin");
324 let lc = sidecar.lifecycle.expect("lifecycle present");
325 assert_eq!(lc.command, "capture");
326 assert_eq!(lc.settings_category.as_deref(), Some("capture"));
327 assert_eq!(lc.effective_display_name(), "Sample");
328 assert_eq!(lc.importance, 50);
329
330 assert_eq!(m.keybinds.len(), 1);
331 let kb = &m.keybinds[0];
332 assert_eq!(kb.key, "C-Space");
333 assert_eq!(kb.action, "slash_command");
334 assert_eq!(kb.command.as_deref(), Some("capture toggle"));
335 }
336
337 #[test]
338 fn plugin_manifest_minimal() {
339 let json = r#"{"name":"web-tools"}"#;
340 let m: PluginManifest = serde_json::from_str(json).unwrap();
341 assert_eq!(m.name, "web-tools");
342 assert_eq!(m.version, None);
343 assert_eq!(m.description, None);
344 assert!(m.commands.is_empty());
345 assert!(m.help_entries.is_empty());
346 assert!(m.compatibility.is_none());
347 }
348
349 #[test]
350 fn plugin_manifest_full_with_extras() {
351 let json = r#"{
352 "name": "web-tools",
353 "version": "1.0.0",
354 "description": "Web tools",
355 "author": {"name": "x"},
356 "repository": "https://...",
357 "license": "MIT",
358 "compatibility": {
359 "synaps": ">=0.1.0",
360 "extension_protocol": "1"
361 },
362 "unknown_field": 42
363 }"#;
364 let m: PluginManifest = serde_json::from_str(json).unwrap();
365 assert_eq!(m.name, "web-tools");
366 assert_eq!(m.version.as_deref(), Some("1.0.0"));
367 assert_eq!(m.description.as_deref(), Some("Web tools"));
368 assert_eq!(m.compatibility.as_ref().unwrap().synaps.as_deref(), Some(">=0.1.0"));
369 assert_eq!(m.compatibility.as_ref().unwrap().extension_protocol.as_deref(), Some("1"));
370 }
371
372 #[test]
373 fn plugin_manifest_parses_help_entries_with_usage_examples() {
374 let json = r#"{
375 "name": "web-tools",
376 "help_entries": [
377 {
378 "id": "web-search-help",
379 "command": "/web:search",
380 "title": "Web Search",
381 "summary": "Search the web from a plugin.",
382 "category": "Plugin",
383 "topic": "Command",
384 "protected": false,
385 "common": false,
386 "keywords": ["web", "search"],
387 "usage": "/web:search <query>",
388 "examples": [
389 {
390 "command": "/web:search rust serde",
391 "description": "Search for Rust serde resources."
392 }
393 ]
394 }
395 ]
396 }"#;
397 let m: PluginManifest = serde_json::from_str(json).unwrap();
398 assert_eq!(m.help_entries.len(), 1);
399 assert_eq!(m.help_entries[0].command, "/web:search");
400 assert_eq!(m.help_entries[0].usage.as_deref(), Some("/web:search <query>"));
401 assert_eq!(m.help_entries[0].examples[0].command, "/web:search rust serde");
402 }
403
404 #[test]
405 fn plugin_manifest_accepts_help_alias_for_help_entries() {
406 let json = r#"{
407 "name": "web-tools",
408 "help": [
409 {
410 "id": "web-help",
411 "command": "/help web",
412 "title": "Web Tools",
413 "summary": "Use web tools from the plugin.",
414 "category": "Plugin",
415 "topic": "Branch",
416 "protected": false,
417 "common": false
418 }
419 ]
420 }"#;
421 let m: PluginManifest = serde_json::from_str(json).unwrap();
422 assert_eq!(m.help_entries.len(), 1);
423 assert_eq!(m.help_entries[0].command, "/help web");
424 assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Branch);
425 }
426
427 #[test]
428 fn plugin_manifest_can_add_command_and_matching_help_entries_together() {
429 let json = r#"{
430 "name": "dev-tools",
431 "commands": [
432 {
433 "name": "lint",
434 "description": "Run lint",
435 "command": "bash",
436 "args": ["scripts/lint.sh"]
437 }
438 ],
439 "help_entries": [
440 {
441 "id": "dev-lint-help",
442 "command": "/dev-tools:lint",
443 "title": "Lint",
444 "summary": "Run plugin lint checks.",
445 "category": "Plugin",
446 "topic": "Command",
447 "protected": false,
448 "common": false,
449 "usage": "/dev-tools:lint"
450 }
451 ]
452 }"#;
453 let m: PluginManifest = serde_json::from_str(json).unwrap();
454 assert_eq!(m.commands.len(), 1);
455 assert_eq!(m.help_entries.len(), 1);
456 assert_eq!(m.help_entries[0].command, "/dev-tools:lint");
457 }
458
459 #[test]
460 fn plugin_manifest_help_entries_default_boilerplate_fields() {
461 let json = r#"{
462 "name": "dev-tools",
463 "help": [
464 {
465 "id": "dev-lint-help",
466 "command": "/dev-tools:lint",
467 "title": "Lint",
468 "summary": "Run plugin lint checks."
469 }
470 ]
471 }"#;
472 let m: PluginManifest = serde_json::from_str(json).unwrap();
473 assert_eq!(m.help_entries.len(), 1);
474 assert_eq!(m.help_entries[0].category, "Plugin");
475 assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Command);
476 assert!(!m.help_entries[0].protected);
477 assert!(!m.help_entries[0].common);
478 }
479
480 #[test]
481 fn plugin_manifest_rejects_legacy_legacy_sidecar_field() {
482 let json = r#"{
483 "name": "legacy",
484 "provides": {
485 "legacy_sidecar": {
486 "command": "bin/old",
487 "protocol_version": 1
488 }
489 }
490 }"#;
491 let m: PluginManifest = serde_json::from_str(json).unwrap();
492 let provides = m.provides.expect("provides block should deserialize");
493 assert!(
494 provides.sidecar.is_none(),
495 "legacy provides.legacy_sidecar must not populate provides.sidecar"
496 );
497 }
498
499 #[test]
500 fn plugin_manifest_parses_provides_sidecar_canonical() {
501 let json = r#"{
502 "name": "local-ocr",
503 "provides": {
504 "sidecar": {
505 "command": "bin/ocr-sidecar",
506 "protocol_version": 1
507 }
508 }
509 }"#;
510 let m: PluginManifest = serde_json::from_str(json).unwrap();
511 let provides = m.provides.expect("provides should deserialize");
512 let sidecar = provides.sidecar.expect("canonical `sidecar` field should deserialize");
513 assert_eq!(sidecar.command, "bin/ocr-sidecar");
514 assert_eq!(sidecar.protocol_version, 1);
515 }
516
517 #[test]
520 fn sidecar_lifecycle_parses_full_block() {
521 let json = r#"{
522 "name": "p",
523 "provides": {
524 "sidecar": {
525 "command": "bin/sidecar",
526 "protocol_version": 1,
527 "lifecycle": {
528 "command": "capture",
529 "settings_category": "capture",
530 "display_name": "Sample",
531 "importance": 50
532 }
533 }
534 }
535 }"#;
536 let m: PluginManifest = serde_json::from_str(json).unwrap();
537 let lc = m
538 .provides
539 .unwrap()
540 .sidecar
541 .unwrap()
542 .lifecycle
543 .expect("lifecycle should deserialize");
544 assert_eq!(lc.command, "capture");
545 assert_eq!(lc.settings_category.as_deref(), Some("capture"));
546 assert_eq!(lc.display_name.as_deref(), Some("Sample"));
547 assert_eq!(lc.importance, 50);
548 assert_eq!(lc.effective_display_name(), "Sample");
549 }
550
551 #[test]
552 fn sidecar_lifecycle_is_optional() {
553 let json = r#"{
554 "name": "p",
555 "provides": {
556 "sidecar": { "command": "bin/sidecar", "protocol_version": 1 }
557 }
558 }"#;
559 let m: PluginManifest = serde_json::from_str(json).unwrap();
560 assert!(m.provides.unwrap().sidecar.unwrap().lifecycle.is_none());
561 }
562
563 #[test]
564 fn sidecar_lifecycle_minimal_only_command_required() {
565 let json = r#"{
566 "name": "p",
567 "provides": {
568 "sidecar": {
569 "command": "bin/sidecar",
570 "protocol_version": 1,
571 "lifecycle": { "command": "capture" }
572 }
573 }
574 }"#;
575 let m: PluginManifest = serde_json::from_str(json).unwrap();
576 let lc = m
577 .provides
578 .unwrap()
579 .sidecar
580 .unwrap()
581 .lifecycle
582 .unwrap();
583 assert_eq!(lc.command, "capture");
584 assert!(lc.settings_category.is_none());
585 assert!(lc.display_name.is_none());
586 assert_eq!(lc.importance, 0);
587 assert_eq!(lc.effective_display_name(), "capture");
589 }
590
591 #[test]
592 fn sidecar_lifecycle_clamps_importance_above_100() {
593 let json = r#"{
594 "name": "p",
595 "provides": {
596 "sidecar": {
597 "command": "bin/sidecar",
598 "protocol_version": 1,
599 "lifecycle": { "command": "v", "importance": 9999 }
600 }
601 }
602 }"#;
603 let m: PluginManifest = serde_json::from_str(json).unwrap();
604 let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
605 assert_eq!(lc.importance, 100);
606 }
607
608 #[test]
609 fn sidecar_lifecycle_clamps_importance_below_negative_100() {
610 let json = r#"{
611 "name": "p",
612 "provides": {
613 "sidecar": {
614 "command": "bin/sidecar",
615 "protocol_version": 1,
616 "lifecycle": { "command": "v", "importance": -9999 }
617 }
618 }
619 }"#;
620 let m: PluginManifest = serde_json::from_str(json).unwrap();
621 let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
622 assert_eq!(lc.importance, -100);
623 }
624
625 #[test]
626 fn sidecar_lifecycle_missing_command_fails() {
627 let json = r#"{
628 "name": "p",
629 "provides": {
630 "sidecar": {
631 "command": "bin/sidecar",
632 "protocol_version": 1,
633 "lifecycle": { "display_name": "no command" }
634 }
635 }
636 }"#;
637 let err = serde_json::from_str::<PluginManifest>(json).unwrap_err();
638 assert!(
639 err.to_string().contains("missing field `command`"),
640 "expected missing `command` error, got: {err}"
641 );
642 }
643
644 #[test]
645 fn plugin_manifest_without_provides_is_ok() {
646 let json = r#"{"name":"plain"}"#;
647 let m: PluginManifest = serde_json::from_str(json).unwrap();
648 assert!(m.provides.is_none());
649 }
650
651
652 #[test]
653 fn plugin_manifest_parses_interactive_command() {
654 let json = r#"{
655 "name": "demo-plugin",
656 "commands": [
657 {
658 "name": "demo",
659 "description": "Run interactive demo",
660 "interactive": true,
661 "subcommands": ["models", "download"]
662 }
663 ]
664 }"#;
665 let m: PluginManifest = serde_json::from_str(json).unwrap();
666 match &m.commands[0] {
667 ManifestCommand::Interactive(cmd) => {
668 assert_eq!(cmd.name, "demo");
669 assert_eq!(cmd.description.as_deref(), Some("Run interactive demo"));
670 assert_eq!(cmd.subcommands, vec!["models", "download"]);
671 }
672 other => panic!("expected interactive command, got {other:?}"),
673 }
674 }
675
676 #[test]
677 fn plugin_manifest_parses_commands() {
678 let json = r#"{
679 "name": "dev-tools",
680 "commands": [
681 {
682 "name": "lint",
683 "description": "Run lint",
684 "command": "bash",
685 "args": ["scripts/lint.sh"]
686 }
687 ]
688 }"#;
689 let m: PluginManifest = serde_json::from_str(json).unwrap();
690 assert_eq!(m.commands.len(), 1);
691 match &m.commands[0] {
692 ManifestCommand::Shell(cmd) => {
693 assert_eq!(cmd.name, "lint");
694 assert_eq!(cmd.description.as_deref(), Some("Run lint"));
695 assert_eq!(cmd.command, "bash");
696 assert_eq!(cmd.args, vec!["scripts/lint.sh"]);
697 }
698 other => panic!("expected shell command, got {other:?}"),
699 }
700 }
701
702 #[test]
703 fn plugin_manifest_parses_extension_tool_command() {
704 let json = r#"{
705 "name": "dev-tools",
706 "commands": [
707 {
708 "name": "echo",
709 "description": "Echo via extension tool",
710 "tool": "echo",
711 "input": {"text": "hello"}
712 }
713 ]
714 }"#;
715 let m: PluginManifest = serde_json::from_str(json).unwrap();
716 match &m.commands[0] {
717 ManifestCommand::ExtensionTool(cmd) => {
718 assert_eq!(cmd.name, "echo");
719 assert_eq!(cmd.tool, "echo");
720 assert_eq!(cmd.input["text"], "hello");
721 }
722 other => panic!("expected extension tool command, got {other:?}"),
723 }
724 }
725
726 #[test]
727 fn plugin_manifest_parses_skill_prompt_command() {
728 let json = r#"{
729 "name": "dev-tools",
730 "commands": [
731 {
732 "name": "review",
733 "description": "Run review skill",
734 "skill": "reviewer",
735 "prompt": "Review this diff"
736 }
737 ]
738 }"#;
739 let m: PluginManifest = serde_json::from_str(json).unwrap();
740 match &m.commands[0] {
741 ManifestCommand::SkillPrompt(cmd) => {
742 assert_eq!(cmd.name, "review");
743 assert_eq!(cmd.skill, "reviewer");
744 assert_eq!(cmd.prompt, "Review this diff");
745 }
746 other => panic!("expected skill prompt command, got {other:?}"),
747 }
748 }
749
750 #[test]
751 fn plugin_manifest_command_missing_command_fails() {
752 let json = r#"{"name":"p","commands":[{"name":"x"}]}"#;
753 let result: Result<PluginManifest, _> = serde_json::from_str(json);
754 assert!(result.is_err());
755 }
756
757 #[test]
758 fn plugin_manifest_missing_name_fails() {
759 let json = r#"{"version":"1.0.0"}"#;
760 let result: Result<PluginManifest, _> = serde_json::from_str(json);
761 assert!(result.is_err());
762 }
763
764 #[test]
765 fn marketplace_manifest_basic() {
766 let json = r#"{
767 "name": "pi-skills",
768 "version": "1.0.0",
769 "description": "Plugin index",
770 "categories": ["productivity"],
771 "keywords": ["local-first"],
772 "trust": {"publisher":"Maha Media","homepage":"https://example.com"},
773 "plugins": [
774 {"name": "web-tools", "source": "./web-tools-plugin", "category":"research", "keywords":["web"]},
775 {"name": "dev-tools", "source": "./dev-tools", "version": "2.0.0", "license":"MIT"}
776 ]
777 }"#;
778 let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
779 assert_eq!(m.name, "pi-skills");
780 assert_eq!(m.categories, vec!["productivity"]);
781 assert_eq!(m.keywords, vec!["local-first"]);
782 assert_eq!(m.trust.as_ref().unwrap().publisher.as_deref(), Some("Maha Media"));
783 assert_eq!(m.plugins.len(), 2);
784 assert_eq!(m.plugins[0].name, "web-tools");
785 assert_eq!(m.plugins[0].source.as_deref(), Some("./web-tools-plugin"));
786 assert_eq!(m.plugins[0].category.as_deref(), Some("research"));
787 assert_eq!(m.plugins[0].keywords, vec!["web"]);
788 }
789
790 #[test]
791 fn marketplace_manifest_missing_plugins_fails() {
792 let json = r#"{"name":"empty"}"#;
793 let result: Result<MarketplaceManifest, _> = serde_json::from_str(json);
794 assert!(result.is_err());
795 }
796
797 #[test]
798 fn plugin_manifest_parses_settings_categories_with_declarative_fields() {
799 let json = r#"{
800 "name": "demo",
801 "settings": {
802 "category": [
803 {
804 "id": "demo",
805 "label": "Demo",
806 "fields": [
807 {
808 "key": "backend",
809 "label": "Backend",
810 "editor": "cycler",
811 "options": ["auto", "cpu", "cuda"]
812 },
813 {
814 "key": "endpoint",
815 "label": "API endpoint",
816 "editor": "text",
817 "help": "Base URL"
818 },
819 {
820 "key": "max_tokens",
821 "label": "Max tokens",
822 "editor": "text",
823 "numeric": true,
824 "default": 2048
825 },
826 {
827 "key": "model_path",
828 "label": "Model",
829 "editor": "custom"
830 },
831 {
832 "key": "preset",
833 "label": "Preset",
834 "editor": "picker"
835 }
836 ]
837 }
838 ]
839 }
840 }"#;
841 let m: PluginManifest = serde_json::from_str(json).unwrap();
842 let s = m.settings.expect("settings should deserialize");
843 assert_eq!(s.categories.len(), 1);
844 let cat = &s.categories[0];
845 assert_eq!(cat.id, "demo");
846 assert_eq!(cat.label, "Demo");
847 assert_eq!(cat.fields.len(), 5);
848
849 assert_eq!(cat.fields[0].key, "backend");
850 assert_eq!(cat.fields[0].editor, ManifestEditorKind::Cycler);
851 assert_eq!(cat.fields[0].options, vec!["auto", "cpu", "cuda"]);
852
853 assert_eq!(cat.fields[1].editor, ManifestEditorKind::Text);
854 assert!(!cat.fields[1].numeric);
855 assert_eq!(cat.fields[1].help.as_deref(), Some("Base URL"));
856
857 assert_eq!(cat.fields[2].editor, ManifestEditorKind::Text);
858 assert!(cat.fields[2].numeric);
859 assert_eq!(cat.fields[2].default, Some(serde_json::json!(2048)));
860
861 assert_eq!(cat.fields[3].editor, ManifestEditorKind::Custom);
862 assert_eq!(cat.fields[4].editor, ManifestEditorKind::Picker);
863 }
864
865 #[test]
866 fn plugin_manifest_settings_default_to_none() {
867 let json = r#"{"name":"plain"}"#;
868 let m: PluginManifest = serde_json::from_str(json).unwrap();
869 assert!(m.settings.is_none());
870 }
871
872 #[test]
873 fn plugin_manifest_settings_unknown_editor_kind_fails() {
874 let json = r#"{
875 "name": "demo",
876 "settings": {
877 "category": [
878 { "id": "x", "label": "X", "fields": [
879 { "key": "k", "label": "L", "editor": "bogus" }
880 ] }
881 ]
882 }
883 }"#;
884 let result: Result<PluginManifest, _> = serde_json::from_str(json);
885 assert!(result.is_err());
886 }
887
888 #[test]
889 fn plugin_manifest_settings_additive_with_help_entries_field() {
890 let json = r#"{
893 "name": "merge-friendly",
894 "settings": {
895 "category": [
896 { "id": "x", "label": "X", "fields": [] }
897 ]
898 },
899 "help_entries": [
900 {
901 "id": "x-do",
902 "command": "/x:do",
903 "title": "Do",
904 "summary": "do a thing"
905 }
906 ]
907 }"#;
908 let m: PluginManifest = serde_json::from_str(json).unwrap();
909 assert!(m.settings.is_some());
910 assert_eq!(m.settings.unwrap().categories[0].id, "x");
911 assert_eq!(m.help_entries.len(), 1);
912 assert_eq!(m.help_entries[0].command, "/x:do");
913 }
914
915 #[test]
916 fn marketplace_entry_missing_source_is_allowed_for_index_backed_entries() {
917 let json = r#"{"name":"p","plugins":[{"name":"x"}]}"#;
918 let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
919 assert!(m.plugins[0].source.is_none());
920 }
921}