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!(
369 m.compatibility.as_ref().unwrap().synaps.as_deref(),
370 Some(">=0.1.0")
371 );
372 assert_eq!(
373 m.compatibility
374 .as_ref()
375 .unwrap()
376 .extension_protocol
377 .as_deref(),
378 Some("1")
379 );
380 }
381
382 #[test]
383 fn plugin_manifest_parses_help_entries_with_usage_examples() {
384 let json = r#"{
385 "name": "web-tools",
386 "help_entries": [
387 {
388 "id": "web-search-help",
389 "command": "/web:search",
390 "title": "Web Search",
391 "summary": "Search the web from a plugin.",
392 "category": "Plugin",
393 "topic": "Command",
394 "protected": false,
395 "common": false,
396 "keywords": ["web", "search"],
397 "usage": "/web:search <query>",
398 "examples": [
399 {
400 "command": "/web:search rust serde",
401 "description": "Search for Rust serde resources."
402 }
403 ]
404 }
405 ]
406 }"#;
407 let m: PluginManifest = serde_json::from_str(json).unwrap();
408 assert_eq!(m.help_entries.len(), 1);
409 assert_eq!(m.help_entries[0].command, "/web:search");
410 assert_eq!(
411 m.help_entries[0].usage.as_deref(),
412 Some("/web:search <query>")
413 );
414 assert_eq!(
415 m.help_entries[0].examples[0].command,
416 "/web:search rust serde"
417 );
418 }
419
420 #[test]
421 fn plugin_manifest_accepts_help_alias_for_help_entries() {
422 let json = r#"{
423 "name": "web-tools",
424 "help": [
425 {
426 "id": "web-help",
427 "command": "/help web",
428 "title": "Web Tools",
429 "summary": "Use web tools from the plugin.",
430 "category": "Plugin",
431 "topic": "Branch",
432 "protected": false,
433 "common": false
434 }
435 ]
436 }"#;
437 let m: PluginManifest = serde_json::from_str(json).unwrap();
438 assert_eq!(m.help_entries.len(), 1);
439 assert_eq!(m.help_entries[0].command, "/help web");
440 assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Branch);
441 }
442
443 #[test]
444 fn plugin_manifest_can_add_command_and_matching_help_entries_together() {
445 let json = r#"{
446 "name": "dev-tools",
447 "commands": [
448 {
449 "name": "lint",
450 "description": "Run lint",
451 "command": "bash",
452 "args": ["scripts/lint.sh"]
453 }
454 ],
455 "help_entries": [
456 {
457 "id": "dev-lint-help",
458 "command": "/dev-tools:lint",
459 "title": "Lint",
460 "summary": "Run plugin lint checks.",
461 "category": "Plugin",
462 "topic": "Command",
463 "protected": false,
464 "common": false,
465 "usage": "/dev-tools:lint"
466 }
467 ]
468 }"#;
469 let m: PluginManifest = serde_json::from_str(json).unwrap();
470 assert_eq!(m.commands.len(), 1);
471 assert_eq!(m.help_entries.len(), 1);
472 assert_eq!(m.help_entries[0].command, "/dev-tools:lint");
473 }
474
475 #[test]
476 fn plugin_manifest_help_entries_default_boilerplate_fields() {
477 let json = r#"{
478 "name": "dev-tools",
479 "help": [
480 {
481 "id": "dev-lint-help",
482 "command": "/dev-tools:lint",
483 "title": "Lint",
484 "summary": "Run plugin lint checks."
485 }
486 ]
487 }"#;
488 let m: PluginManifest = serde_json::from_str(json).unwrap();
489 assert_eq!(m.help_entries.len(), 1);
490 assert_eq!(m.help_entries[0].category, "Plugin");
491 assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Command);
492 assert!(!m.help_entries[0].protected);
493 assert!(!m.help_entries[0].common);
494 }
495
496 #[test]
497 fn plugin_manifest_rejects_legacy_legacy_sidecar_field() {
498 let json = r#"{
499 "name": "legacy",
500 "provides": {
501 "legacy_sidecar": {
502 "command": "bin/old",
503 "protocol_version": 1
504 }
505 }
506 }"#;
507 let m: PluginManifest = serde_json::from_str(json).unwrap();
508 let provides = m.provides.expect("provides block should deserialize");
509 assert!(
510 provides.sidecar.is_none(),
511 "legacy provides.legacy_sidecar must not populate provides.sidecar"
512 );
513 }
514
515 #[test]
516 fn plugin_manifest_parses_provides_sidecar_canonical() {
517 let json = r#"{
518 "name": "local-ocr",
519 "provides": {
520 "sidecar": {
521 "command": "bin/ocr-sidecar",
522 "protocol_version": 1
523 }
524 }
525 }"#;
526 let m: PluginManifest = serde_json::from_str(json).unwrap();
527 let provides = m.provides.expect("provides should deserialize");
528 let sidecar = provides
529 .sidecar
530 .expect("canonical `sidecar` field should deserialize");
531 assert_eq!(sidecar.command, "bin/ocr-sidecar");
532 assert_eq!(sidecar.protocol_version, 1);
533 }
534
535 #[test]
538 fn sidecar_lifecycle_parses_full_block() {
539 let json = r#"{
540 "name": "p",
541 "provides": {
542 "sidecar": {
543 "command": "bin/sidecar",
544 "protocol_version": 1,
545 "lifecycle": {
546 "command": "capture",
547 "settings_category": "capture",
548 "display_name": "Sample",
549 "importance": 50
550 }
551 }
552 }
553 }"#;
554 let m: PluginManifest = serde_json::from_str(json).unwrap();
555 let lc = m
556 .provides
557 .unwrap()
558 .sidecar
559 .unwrap()
560 .lifecycle
561 .expect("lifecycle should deserialize");
562 assert_eq!(lc.command, "capture");
563 assert_eq!(lc.settings_category.as_deref(), Some("capture"));
564 assert_eq!(lc.display_name.as_deref(), Some("Sample"));
565 assert_eq!(lc.importance, 50);
566 assert_eq!(lc.effective_display_name(), "Sample");
567 }
568
569 #[test]
570 fn sidecar_lifecycle_is_optional() {
571 let json = r#"{
572 "name": "p",
573 "provides": {
574 "sidecar": { "command": "bin/sidecar", "protocol_version": 1 }
575 }
576 }"#;
577 let m: PluginManifest = serde_json::from_str(json).unwrap();
578 assert!(m.provides.unwrap().sidecar.unwrap().lifecycle.is_none());
579 }
580
581 #[test]
582 fn sidecar_lifecycle_minimal_only_command_required() {
583 let json = r#"{
584 "name": "p",
585 "provides": {
586 "sidecar": {
587 "command": "bin/sidecar",
588 "protocol_version": 1,
589 "lifecycle": { "command": "capture" }
590 }
591 }
592 }"#;
593 let m: PluginManifest = serde_json::from_str(json).unwrap();
594 let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
595 assert_eq!(lc.command, "capture");
596 assert!(lc.settings_category.is_none());
597 assert!(lc.display_name.is_none());
598 assert_eq!(lc.importance, 0);
599 assert_eq!(lc.effective_display_name(), "capture");
601 }
602
603 #[test]
604 fn sidecar_lifecycle_clamps_importance_above_100() {
605 let json = r#"{
606 "name": "p",
607 "provides": {
608 "sidecar": {
609 "command": "bin/sidecar",
610 "protocol_version": 1,
611 "lifecycle": { "command": "v", "importance": 9999 }
612 }
613 }
614 }"#;
615 let m: PluginManifest = serde_json::from_str(json).unwrap();
616 let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
617 assert_eq!(lc.importance, 100);
618 }
619
620 #[test]
621 fn sidecar_lifecycle_clamps_importance_below_negative_100() {
622 let json = r#"{
623 "name": "p",
624 "provides": {
625 "sidecar": {
626 "command": "bin/sidecar",
627 "protocol_version": 1,
628 "lifecycle": { "command": "v", "importance": -9999 }
629 }
630 }
631 }"#;
632 let m: PluginManifest = serde_json::from_str(json).unwrap();
633 let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
634 assert_eq!(lc.importance, -100);
635 }
636
637 #[test]
638 fn sidecar_lifecycle_missing_command_fails() {
639 let json = r#"{
640 "name": "p",
641 "provides": {
642 "sidecar": {
643 "command": "bin/sidecar",
644 "protocol_version": 1,
645 "lifecycle": { "display_name": "no command" }
646 }
647 }
648 }"#;
649 let err = serde_json::from_str::<PluginManifest>(json).unwrap_err();
650 assert!(
651 err.to_string().contains("missing field `command`"),
652 "expected missing `command` error, got: {err}"
653 );
654 }
655
656 #[test]
657 fn plugin_manifest_without_provides_is_ok() {
658 let json = r#"{"name":"plain"}"#;
659 let m: PluginManifest = serde_json::from_str(json).unwrap();
660 assert!(m.provides.is_none());
661 }
662
663 #[test]
664 fn plugin_manifest_parses_interactive_command() {
665 let json = r#"{
666 "name": "demo-plugin",
667 "commands": [
668 {
669 "name": "demo",
670 "description": "Run interactive demo",
671 "interactive": true,
672 "subcommands": ["models", "download"]
673 }
674 ]
675 }"#;
676 let m: PluginManifest = serde_json::from_str(json).unwrap();
677 match &m.commands[0] {
678 ManifestCommand::Interactive(cmd) => {
679 assert_eq!(cmd.name, "demo");
680 assert_eq!(cmd.description.as_deref(), Some("Run interactive demo"));
681 assert_eq!(cmd.subcommands, vec!["models", "download"]);
682 }
683 other => panic!("expected interactive command, got {other:?}"),
684 }
685 }
686
687 #[test]
688 fn plugin_manifest_parses_commands() {
689 let json = r#"{
690 "name": "dev-tools",
691 "commands": [
692 {
693 "name": "lint",
694 "description": "Run lint",
695 "command": "bash",
696 "args": ["scripts/lint.sh"]
697 }
698 ]
699 }"#;
700 let m: PluginManifest = serde_json::from_str(json).unwrap();
701 assert_eq!(m.commands.len(), 1);
702 match &m.commands[0] {
703 ManifestCommand::Shell(cmd) => {
704 assert_eq!(cmd.name, "lint");
705 assert_eq!(cmd.description.as_deref(), Some("Run lint"));
706 assert_eq!(cmd.command, "bash");
707 assert_eq!(cmd.args, vec!["scripts/lint.sh"]);
708 }
709 other => panic!("expected shell command, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn plugin_manifest_parses_extension_tool_command() {
715 let json = r#"{
716 "name": "dev-tools",
717 "commands": [
718 {
719 "name": "echo",
720 "description": "Echo via extension tool",
721 "tool": "echo",
722 "input": {"text": "hello"}
723 }
724 ]
725 }"#;
726 let m: PluginManifest = serde_json::from_str(json).unwrap();
727 match &m.commands[0] {
728 ManifestCommand::ExtensionTool(cmd) => {
729 assert_eq!(cmd.name, "echo");
730 assert_eq!(cmd.tool, "echo");
731 assert_eq!(cmd.input["text"], "hello");
732 }
733 other => panic!("expected extension tool command, got {other:?}"),
734 }
735 }
736
737 #[test]
738 fn plugin_manifest_parses_skill_prompt_command() {
739 let json = r#"{
740 "name": "dev-tools",
741 "commands": [
742 {
743 "name": "review",
744 "description": "Run review skill",
745 "skill": "reviewer",
746 "prompt": "Review this diff"
747 }
748 ]
749 }"#;
750 let m: PluginManifest = serde_json::from_str(json).unwrap();
751 match &m.commands[0] {
752 ManifestCommand::SkillPrompt(cmd) => {
753 assert_eq!(cmd.name, "review");
754 assert_eq!(cmd.skill, "reviewer");
755 assert_eq!(cmd.prompt, "Review this diff");
756 }
757 other => panic!("expected skill prompt command, got {other:?}"),
758 }
759 }
760
761 #[test]
762 fn plugin_manifest_command_missing_command_fails() {
763 let json = r#"{"name":"p","commands":[{"name":"x"}]}"#;
764 let result: Result<PluginManifest, _> = serde_json::from_str(json);
765 assert!(result.is_err());
766 }
767
768 #[test]
769 fn plugin_manifest_missing_name_fails() {
770 let json = r#"{"version":"1.0.0"}"#;
771 let result: Result<PluginManifest, _> = serde_json::from_str(json);
772 assert!(result.is_err());
773 }
774
775 #[test]
776 fn marketplace_manifest_basic() {
777 let json = r#"{
778 "name": "pi-skills",
779 "version": "1.0.0",
780 "description": "Plugin index",
781 "categories": ["productivity"],
782 "keywords": ["local-first"],
783 "trust": {"publisher":"Maha Media","homepage":"https://example.com"},
784 "plugins": [
785 {"name": "web-tools", "source": "./web-tools-plugin", "category":"research", "keywords":["web"]},
786 {"name": "dev-tools", "source": "./dev-tools", "version": "2.0.0", "license":"MIT"}
787 ]
788 }"#;
789 let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
790 assert_eq!(m.name, "pi-skills");
791 assert_eq!(m.categories, vec!["productivity"]);
792 assert_eq!(m.keywords, vec!["local-first"]);
793 assert_eq!(
794 m.trust.as_ref().unwrap().publisher.as_deref(),
795 Some("Maha Media")
796 );
797 assert_eq!(m.plugins.len(), 2);
798 assert_eq!(m.plugins[0].name, "web-tools");
799 assert_eq!(m.plugins[0].source.as_deref(), Some("./web-tools-plugin"));
800 assert_eq!(m.plugins[0].category.as_deref(), Some("research"));
801 assert_eq!(m.plugins[0].keywords, vec!["web"]);
802 }
803
804 #[test]
805 fn marketplace_manifest_missing_plugins_fails() {
806 let json = r#"{"name":"empty"}"#;
807 let result: Result<MarketplaceManifest, _> = serde_json::from_str(json);
808 assert!(result.is_err());
809 }
810
811 #[test]
812 fn plugin_manifest_parses_settings_categories_with_declarative_fields() {
813 let json = r#"{
814 "name": "demo",
815 "settings": {
816 "category": [
817 {
818 "id": "demo",
819 "label": "Demo",
820 "fields": [
821 {
822 "key": "backend",
823 "label": "Backend",
824 "editor": "cycler",
825 "options": ["auto", "cpu", "cuda"]
826 },
827 {
828 "key": "endpoint",
829 "label": "API endpoint",
830 "editor": "text",
831 "help": "Base URL"
832 },
833 {
834 "key": "max_tokens",
835 "label": "Max tokens",
836 "editor": "text",
837 "numeric": true,
838 "default": 2048
839 },
840 {
841 "key": "model_path",
842 "label": "Model",
843 "editor": "custom"
844 },
845 {
846 "key": "preset",
847 "label": "Preset",
848 "editor": "picker"
849 }
850 ]
851 }
852 ]
853 }
854 }"#;
855 let m: PluginManifest = serde_json::from_str(json).unwrap();
856 let s = m.settings.expect("settings should deserialize");
857 assert_eq!(s.categories.len(), 1);
858 let cat = &s.categories[0];
859 assert_eq!(cat.id, "demo");
860 assert_eq!(cat.label, "Demo");
861 assert_eq!(cat.fields.len(), 5);
862
863 assert_eq!(cat.fields[0].key, "backend");
864 assert_eq!(cat.fields[0].editor, ManifestEditorKind::Cycler);
865 assert_eq!(cat.fields[0].options, vec!["auto", "cpu", "cuda"]);
866
867 assert_eq!(cat.fields[1].editor, ManifestEditorKind::Text);
868 assert!(!cat.fields[1].numeric);
869 assert_eq!(cat.fields[1].help.as_deref(), Some("Base URL"));
870
871 assert_eq!(cat.fields[2].editor, ManifestEditorKind::Text);
872 assert!(cat.fields[2].numeric);
873 assert_eq!(cat.fields[2].default, Some(serde_json::json!(2048)));
874
875 assert_eq!(cat.fields[3].editor, ManifestEditorKind::Custom);
876 assert_eq!(cat.fields[4].editor, ManifestEditorKind::Picker);
877 }
878
879 #[test]
880 fn plugin_manifest_settings_default_to_none() {
881 let json = r#"{"name":"plain"}"#;
882 let m: PluginManifest = serde_json::from_str(json).unwrap();
883 assert!(m.settings.is_none());
884 }
885
886 #[test]
887 fn plugin_manifest_settings_unknown_editor_kind_fails() {
888 let json = r#"{
889 "name": "demo",
890 "settings": {
891 "category": [
892 { "id": "x", "label": "X", "fields": [
893 { "key": "k", "label": "L", "editor": "bogus" }
894 ] }
895 ]
896 }
897 }"#;
898 let result: Result<PluginManifest, _> = serde_json::from_str(json);
899 assert!(result.is_err());
900 }
901
902 #[test]
903 fn plugin_manifest_settings_additive_with_help_entries_field() {
904 let json = r#"{
907 "name": "merge-friendly",
908 "settings": {
909 "category": [
910 { "id": "x", "label": "X", "fields": [] }
911 ]
912 },
913 "help_entries": [
914 {
915 "id": "x-do",
916 "command": "/x:do",
917 "title": "Do",
918 "summary": "do a thing"
919 }
920 ]
921 }"#;
922 let m: PluginManifest = serde_json::from_str(json).unwrap();
923 assert!(m.settings.is_some());
924 assert_eq!(m.settings.unwrap().categories[0].id, "x");
925 assert_eq!(m.help_entries.len(), 1);
926 assert_eq!(m.help_entries[0].command, "/x:do");
927 }
928
929 #[test]
930 fn marketplace_entry_missing_source_is_allowed_for_index_backed_entries() {
931 let json = r#"{"name":"p","plugins":[{"name":"x"}]}"#;
932 let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
933 assert!(m.plugins[0].source.is_none());
934 }
935}