1use std::collections::{BTreeMap, BTreeSet};
2
3use semifold_core::{EcosystemId, PackageId};
4use semver::Version;
5use serde::{Deserialize, Serialize};
6
7pub const PLUGIN_PROTOCOL_SCHEMA_VERSION: u32 = 1;
8
9#[cfg(feature = "ts-rs")]
10struct PluginSchemaVersion<const VERSION: u32>;
11
12#[cfg(feature = "ts-rs")]
13impl<const VERSION: u32> ts_rs::TS for PluginSchemaVersion<VERSION> {
14 type WithoutGenerics = Self;
15 type OptionInnerType = Self;
16
17 fn name(_config: &ts_rs::Config) -> String {
18 VERSION.to_string()
19 }
20
21 fn inline(config: &ts_rs::Config) -> String {
22 Self::name(config)
23 }
24}
25
26pub const PLUGIN_OPERATIONS: [PluginOperation; 3] = [
27 PluginOperation::Discover,
28 PluginOperation::Inspect,
29 PluginOperation::PlanEdits,
30];
31
32#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
33#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
34#[serde(rename_all = "kebab-case")]
35#[cfg_attr(feature = "ts-rs", ts(rename = "PluginOperationV1"))]
36pub enum PluginOperation {
37 Discover,
38 Inspect,
39 PlanEdits,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
44#[serde(rename_all = "kebab-case")]
45pub struct PluginMetadataV1 {
46 #[cfg_attr(
47 feature = "ts-rs",
48 ts(as = "PluginSchemaVersion<PLUGIN_PROTOCOL_SCHEMA_VERSION>")
49 )]
50 pub schema_version: u32,
51 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
52 pub ecosystem: EcosystemId,
53 pub plugin_version: Version,
54 pub operations: BTreeSet<PluginOperation>,
55 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
56 pub read_patterns: BTreeSet<String>,
57}
58
59impl PluginMetadataV1 {
60 #[must_use]
61 pub fn new(
62 ecosystem: EcosystemId,
63 plugin_version: Version,
64 read_patterns: BTreeSet<String>,
65 ) -> Self {
66 Self {
67 schema_version: PLUGIN_PROTOCOL_SCHEMA_VERSION,
68 ecosystem,
69 plugin_version,
70 operations: PLUGIN_OPERATIONS.into_iter().collect(),
71 read_patterns,
72 }
73 }
74
75 pub fn validate(&self) -> Result<(), PluginProtocolError> {
76 validate_schema_version(self.schema_version)?;
77 if self.ecosystem.is_builtin() {
78 return Err(PluginProtocolError::BuiltInEcosystemReserved {
79 ecosystem: self.ecosystem.clone(),
80 });
81 }
82 for operation in PLUGIN_OPERATIONS {
83 if !self.operations.contains(&operation) {
84 return Err(PluginProtocolError::MissingOperation { operation });
85 }
86 }
87 Ok(())
88 }
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
93#[serde(rename_all = "kebab-case")]
94pub struct PluginRequestV1 {
95 #[cfg_attr(
96 feature = "ts-rs",
97 ts(as = "PluginSchemaVersion<PLUGIN_PROTOCOL_SCHEMA_VERSION>")
98 )]
99 pub schema_version: u32,
100 #[serde(flatten)]
101 pub call: PluginCallV1,
102}
103
104impl PluginRequestV1 {
105 #[must_use]
106 pub const fn new(call: PluginCallV1) -> Self {
107 Self {
108 schema_version: PLUGIN_PROTOCOL_SCHEMA_VERSION,
109 call,
110 }
111 }
112
113 #[must_use]
114 pub const fn operation(&self) -> PluginOperation {
115 self.call.operation()
116 }
117
118 pub fn validate(&self) -> Result<(), PluginProtocolError> {
119 validate_schema_version(self.schema_version)
120 }
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
125#[serde(tag = "operation", content = "input", rename_all = "kebab-case")]
126pub enum PluginCallV1 {
127 Discover(PluginDiscoverInputV1),
128 Inspect(PluginInspectInputV1),
129 PlanEdits(PluginPlanEditsInputV1),
130}
131
132impl PluginCallV1 {
133 #[must_use]
134 pub const fn operation(&self) -> PluginOperation {
135 match self {
136 Self::Discover(_) => PluginOperation::Discover,
137 Self::Inspect(_) => PluginOperation::Inspect,
138 Self::PlanEdits(_) => PluginOperation::PlanEdits,
139 }
140 }
141}
142
143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
145#[serde(rename_all = "kebab-case")]
146pub struct PluginDiscoverInputV1 {
147 pub project_root: String,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
152#[serde(rename_all = "kebab-case")]
153pub struct PluginInspectInputV1 {
154 pub project_root: String,
155 pub package: PluginPackageLocationV1,
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
160#[serde(rename_all = "kebab-case")]
161pub struct PluginPlanEditsInputV1 {
162 pub project_root: String,
163 pub workspace_packages: Vec<PluginPackageSnapshotV1>,
164 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
165 pub released_packages: Vec<PackageId>,
166 #[cfg_attr(feature = "ts-rs", ts(as = "BTreeMap<String, String>"))]
167 pub versions: BTreeMap<PackageId, Version>,
168}
169
170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
171#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
172#[serde(rename_all = "kebab-case")]
173pub struct PluginPackageLocationV1 {
174 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
175 pub id: PackageId,
176 pub path: String,
177}
178
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
181#[serde(rename_all = "kebab-case")]
182pub struct PluginPackageInspectionV1 {
183 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
184 pub id: PackageId,
185 pub manifest_name: String,
186 pub version: Version,
187 pub version_source: PluginVersionSourceV1,
188 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
189 pub ecosystem: EcosystemId,
190 pub path: String,
191 pub publishable: bool,
192 pub dependencies: Vec<PluginManifestDependencyV1>,
193}
194
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
196#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
197#[serde(rename_all = "kebab-case")]
198pub struct PluginPackageSnapshotV1 {
199 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
200 pub id: PackageId,
201 pub manifest_name: String,
202 pub version: Version,
203 pub version_source: PluginVersionSourceV1,
204 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
205 pub ecosystem: EcosystemId,
206 pub path: String,
207 pub publishable: bool,
208 pub dependencies: Vec<PluginDependencyV1>,
209}
210
211#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
212#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
213#[serde(
214 tag = "kind",
215 rename_all = "kebab-case",
216 rename_all_fields = "kebab-case"
217)]
218pub enum PluginVersionSourceV1 {
219 PackageManifest,
220 Shared { manifest: String, field: String },
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
225#[serde(rename_all = "kebab-case")]
226pub struct PluginManifestDependencyV1 {
227 pub manifest_name: String,
228 pub kind: PluginDependencyKindV1,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub requirement: Option<String>,
231}
232
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
235#[serde(rename_all = "kebab-case")]
236pub struct PluginDependencyV1 {
237 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
238 pub package: PackageId,
239 pub kind: PluginDependencyKindV1,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub requirement: Option<String>,
242 pub source: PluginDependencySourceV1,
243}
244
245#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
246#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
247#[serde(rename_all = "kebab-case")]
248pub enum PluginDependencyKindV1 {
249 Unspecified,
250 Runtime,
251 Development,
252 Build,
253 Optional,
254 Peer,
255}
256
257#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
258#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
259#[serde(rename_all = "kebab-case")]
260pub enum PluginDependencySourceV1 {
261 Manifest,
262 Config,
263}
264
265#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
266#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
267#[serde(rename_all = "kebab-case")]
268pub struct PluginResponseV1 {
269 #[cfg_attr(
270 feature = "ts-rs",
271 ts(as = "PluginSchemaVersion<PLUGIN_PROTOCOL_SCHEMA_VERSION>")
272 )]
273 pub schema_version: u32,
274 pub diagnostics: Vec<PluginDiagnosticV1>,
275 #[serde(flatten)]
276 pub outcome: PluginOutcomeV1,
277}
278
279impl PluginResponseV1 {
280 pub fn validate_for(
281 &self,
282 request: &PluginRequestV1,
283 plugin: &EcosystemId,
284 ) -> Result<(), PluginProtocolError> {
285 validate_schema_version(self.schema_version)?;
286 request.validate()?;
287 for diagnostic in &self.diagnostics {
288 if diagnostic.plugin != *plugin {
289 return Err(PluginProtocolError::DiagnosticPluginMismatch {
290 expected: plugin.clone(),
291 actual: diagnostic.plugin.clone(),
292 });
293 }
294 if diagnostic.operation != request.operation() {
295 return Err(PluginProtocolError::DiagnosticOperationMismatch {
296 expected: request.operation(),
297 actual: diagnostic.operation,
298 });
299 }
300 }
301
302 match &self.outcome {
303 PluginOutcomeV1::Success { output } => {
304 let actual = output.operation();
305 let expected = request.operation();
306 if actual != expected {
307 return Err(PluginProtocolError::ResponseOperationMismatch {
308 expected,
309 actual,
310 });
311 }
312 }
313 PluginOutcomeV1::Failure => {
314 if !self
315 .diagnostics
316 .iter()
317 .any(|diagnostic| diagnostic.severity == PluginDiagnosticSeverityV1::Error)
318 {
319 return Err(PluginProtocolError::FailureWithoutErrorDiagnostic);
320 }
321 }
322 }
323 Ok(())
324 }
325}
326
327#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
328#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
329#[serde(
330 tag = "status",
331 rename_all = "kebab-case",
332 rename_all_fields = "kebab-case"
333)]
334pub enum PluginOutcomeV1 {
335 Success { output: Box<PluginOutputV1> },
336 Failure,
337}
338
339#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
340#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
341#[serde(
342 tag = "operation",
343 content = "output",
344 rename_all = "kebab-case",
345 rename_all_fields = "kebab-case"
346)]
347pub enum PluginOutputV1 {
348 Discover {
349 packages: Vec<PluginPackageInspectionV1>,
350 },
351 Inspect {
352 package: PluginPackageInspectionV1,
353 },
354 PlanEdits {
355 edits: Vec<PluginFileEditV1>,
356 },
357}
358
359impl PluginOutputV1 {
360 #[must_use]
361 pub const fn operation(&self) -> PluginOperation {
362 match self {
363 Self::Discover { .. } => PluginOperation::Discover,
364 Self::Inspect { .. } => PluginOperation::Inspect,
365 Self::PlanEdits { .. } => PluginOperation::PlanEdits,
366 }
367 }
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
371#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
372#[serde(rename_all = "kebab-case")]
373pub struct PluginFileEditV1 {
374 pub path: String,
375 pub expected: PluginFileEditExpectationV1,
376 pub new_content: String,
377 pub source: PluginEditSourceV1,
378}
379
380#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
381#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
382#[serde(
383 tag = "kind",
384 rename_all = "kebab-case",
385 rename_all_fields = "kebab-case"
386)]
387pub enum PluginFileEditExpectationV1 {
388 Existing { sha256: String },
389 Missing,
390}
391
392#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
393#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
394#[serde(
395 tag = "kind",
396 rename_all = "kebab-case",
397 rename_all_fields = "kebab-case"
398)]
399pub enum PluginEditSourceV1 {
400 PackageVersion {
401 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
402 package: PackageId,
403 },
404 DependencyVersion {
405 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
406 package: PackageId,
407 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
408 dependency: PackageId,
409 },
410 WorkspaceDependencies {
411 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
412 dependencies: Vec<PackageId>,
413 },
414 WorkspaceManifest {
415 shared_versions: Vec<PluginSharedVersionEditV1>,
416 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
417 dependencies: Vec<PackageId>,
418 },
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
422#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
423#[serde(rename_all = "kebab-case")]
424pub struct PluginSharedVersionEditV1 {
425 pub manifest: String,
426 pub field: String,
427 #[cfg_attr(feature = "ts-rs", ts(as = "Vec<String>"))]
428 pub packages: Vec<PackageId>,
429}
430
431#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
432#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
433#[serde(rename_all = "kebab-case")]
434pub struct PluginDiagnosticV1 {
435 #[cfg_attr(feature = "ts-rs", ts(as = "String"))]
436 pub plugin: EcosystemId,
437 pub operation: PluginOperation,
438 pub severity: PluginDiagnosticSeverityV1,
439 pub code: String,
440 pub message: String,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 #[cfg_attr(feature = "ts-rs", ts(as = "Option<String>"))]
443 pub package: Option<PackageId>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub path: Option<String>,
446}
447
448#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
449#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
450#[serde(rename_all = "kebab-case")]
451pub enum PluginDiagnosticSeverityV1 {
452 Info,
453 Warning,
454 Error,
455}
456
457fn validate_schema_version(actual: u32) -> Result<(), PluginProtocolError> {
458 if actual == PLUGIN_PROTOCOL_SCHEMA_VERSION {
459 Ok(())
460 } else {
461 Err(PluginProtocolError::UnsupportedSchemaVersion {
462 expected: PLUGIN_PROTOCOL_SCHEMA_VERSION,
463 actual,
464 })
465 }
466}
467
468#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
469pub enum PluginProtocolError {
470 #[error("unsupported plugin protocol schema version {actual}; expected {expected}")]
471 UnsupportedSchemaVersion { expected: u32, actual: u32 },
472 #[error("plugin ecosystem id {ecosystem} is reserved for a built-in ecosystem")]
473 BuiltInEcosystemReserved { ecosystem: EcosystemId },
474 #[error("plugin metadata does not declare required operation {operation:?}")]
475 MissingOperation { operation: PluginOperation },
476 #[error("plugin response operation {actual:?} does not match request {expected:?}")]
477 ResponseOperationMismatch {
478 expected: PluginOperation,
479 actual: PluginOperation,
480 },
481 #[error("plugin diagnostic identifies {actual}, but the registered plugin is {expected}")]
482 DiagnosticPluginMismatch {
483 expected: EcosystemId,
484 actual: EcosystemId,
485 },
486 #[error("plugin diagnostic operation {actual:?} does not match request {expected:?}")]
487 DiagnosticOperationMismatch {
488 expected: PluginOperation,
489 actual: PluginOperation,
490 },
491 #[error("a failed plugin response must include at least one error diagnostic")]
492 FailureWithoutErrorDiagnostic,
493}
494
495#[cfg(test)]
496mod tests {
497 use std::collections::{BTreeMap, BTreeSet};
498
499 use semifold_core::{EcosystemId, PackageId};
500 use semver::Version;
501 use serde_json::json;
502
503 use super::*;
504
505 fn plugin_id() -> EcosystemId {
506 EcosystemId::new("com.example.engine").unwrap()
507 }
508
509 #[test]
510 fn metadata_requires_current_schema_custom_identity_and_complete_operations() {
511 let metadata = PluginMetadataV1::new(
512 plugin_id(),
513 Version::new(1, 2, 3),
514 BTreeSet::from(["manifests/**/*.json".to_string()]),
515 );
516 assert_eq!(metadata.validate(), Ok(()));
517 assert_eq!(
518 serde_json::to_value(&metadata).unwrap(),
519 json!({
520 "schema-version": 1,
521 "ecosystem": "com.example.engine",
522 "plugin-version": "1.2.3",
523 "operations": ["discover", "inspect", "plan-edits"],
524 "read-patterns": ["manifests/**/*.json"]
525 })
526 );
527
528 let mut missing = metadata.clone();
529 missing.operations.remove(&PluginOperation::Inspect);
530 assert_eq!(
531 missing.validate(),
532 Err(PluginProtocolError::MissingOperation {
533 operation: PluginOperation::Inspect
534 })
535 );
536
537 let built_in = PluginMetadataV1::new(
538 EcosystemId::new("rust").unwrap(),
539 Version::new(1, 0, 0),
540 BTreeSet::new(),
541 );
542 assert!(matches!(
543 built_in.validate(),
544 Err(PluginProtocolError::BuiltInEcosystemReserved { .. })
545 ));
546 }
547
548 #[test]
549 fn discover_request_has_a_stable_versioned_json_shape() {
550 let request = PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
551 project_root: ".".to_string(),
552 }));
553 let value = serde_json::to_value(&request).unwrap();
554
555 assert_eq!(
556 value,
557 json!({
558 "schema-version": 1,
559 "operation": "discover",
560 "input": { "project-root": "." }
561 })
562 );
563 assert_eq!(
564 serde_json::from_value::<PluginRequestV1>(value).unwrap(),
565 request
566 );
567 }
568
569 #[test]
570 fn plan_edits_request_preserves_sorted_version_facts() {
571 let request = PluginRequestV1::new(PluginCallV1::PlanEdits(PluginPlanEditsInputV1 {
572 project_root: ".".to_string(),
573 workspace_packages: Vec::new(),
574 released_packages: vec![PackageId::new("app")],
575 versions: BTreeMap::from([
576 (PackageId::new("zeta"), Version::new(2, 0, 0)),
577 (PackageId::new("app"), Version::new(1, 1, 0)),
578 ]),
579 }));
580 let serialized = serde_json::to_string(&request).unwrap();
581
582 assert!(serialized.contains(r#""versions":{"app":"1.1.0","zeta":"2.0.0"}"#));
583 assert_eq!(
584 serde_json::from_str::<PluginRequestV1>(&serialized).unwrap(),
585 request
586 );
587 }
588
589 #[test]
590 fn workspace_manifest_edit_source_uses_kebab_case_fields() {
591 let source = PluginEditSourceV1::WorkspaceManifest {
592 shared_versions: vec![PluginSharedVersionEditV1 {
593 manifest: "workspace.toml".to_string(),
594 field: "workspace.version".to_string(),
595 packages: vec![PackageId::new("app")],
596 }],
597 dependencies: vec![PackageId::new("shared")],
598 };
599
600 assert_eq!(
601 serde_json::to_value(source).unwrap(),
602 json!({
603 "kind": "workspace-manifest",
604 "shared-versions": [{
605 "manifest": "workspace.toml",
606 "field": "workspace.version",
607 "packages": ["app"]
608 }],
609 "dependencies": ["shared"]
610 })
611 );
612 }
613
614 #[test]
615 fn response_validation_binds_diagnostics_and_output_to_the_request() {
616 let plugin = plugin_id();
617 let request = PluginRequestV1::new(PluginCallV1::Inspect(PluginInspectInputV1 {
618 project_root: ".".to_string(),
619 package: PluginPackageLocationV1 {
620 id: PackageId::new("game"),
621 path: "game".to_string(),
622 },
623 }));
624 let package = PluginPackageInspectionV1 {
625 id: PackageId::new("game"),
626 manifest_name: "game".to_string(),
627 version: Version::new(1, 0, 0),
628 version_source: PluginVersionSourceV1::PackageManifest,
629 ecosystem: plugin.clone(),
630 path: "game".to_string(),
631 publishable: true,
632 dependencies: Vec::new(),
633 };
634 let response = PluginResponseV1 {
635 schema_version: PLUGIN_PROTOCOL_SCHEMA_VERSION,
636 diagnostics: vec![PluginDiagnosticV1 {
637 plugin: plugin.clone(),
638 operation: PluginOperation::Inspect,
639 severity: PluginDiagnosticSeverityV1::Warning,
640 code: "manifest-field-deprecated".to_string(),
641 message: "The legacy field remains readable.".to_string(),
642 package: Some(PackageId::new("game")),
643 path: Some("game/manifest.json".to_string()),
644 }],
645 outcome: PluginOutcomeV1::Success {
646 output: Box::new(PluginOutputV1::Inspect { package }),
647 },
648 };
649
650 assert_eq!(response.validate_for(&request, &plugin), Ok(()));
651
652 let wrong_request = PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
653 project_root: ".".to_string(),
654 }));
655 assert!(matches!(
656 response.validate_for(&wrong_request, &plugin),
657 Err(PluginProtocolError::DiagnosticOperationMismatch { .. })
658 ));
659 }
660
661 #[test]
662 fn failed_response_requires_an_error_diagnostic() {
663 let plugin = plugin_id();
664 let request = PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
665 project_root: ".".to_string(),
666 }));
667 let response = PluginResponseV1 {
668 schema_version: PLUGIN_PROTOCOL_SCHEMA_VERSION,
669 diagnostics: Vec::new(),
670 outcome: PluginOutcomeV1::Failure,
671 };
672
673 assert_eq!(
674 response.validate_for(&request, &plugin),
675 Err(PluginProtocolError::FailureWithoutErrorDiagnostic)
676 );
677 }
678
679 #[test]
680 fn typescript_sdk_fixtures_match_the_rust_schema_v1_contract() {
681 let metadata: PluginMetadataV1 = serde_json::from_str(include_str!(
682 "../../../../packages/plugin-sdk/test/fixtures/plugin-metadata-v1.json"
683 ))
684 .unwrap();
685 assert_eq!(metadata.validate(), Ok(()));
686
687 let plugin = plugin_id();
688 assert_eq!(metadata.ecosystem, plugin);
689 let request = PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
690 project_root: ".".to_string(),
691 }));
692
693 for fixture in [
694 include_str!(
695 "../../../../packages/plugin-sdk/test/fixtures/plugin-discover-success-v1.json"
696 ),
697 include_str!(
698 "../../../../packages/plugin-sdk/test/fixtures/plugin-discover-failure-v1.json"
699 ),
700 ] {
701 let response: PluginResponseV1 = serde_json::from_str(fixture).unwrap();
702 assert_eq!(response.validate_for(&request, &plugin), Ok(()));
703 }
704 }
705}