Skip to main content

semifold_resolver/plugin/
protocol.rs

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}