Skip to main content

loong_kernel/
plugin_ir.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::Path,
4};
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::{
9    contracts::Capability,
10    integration::IntegrationCatalog,
11    plugin::{
12        PLUGIN_SLOT_CLAIMS_METADATA_KEY, PluginCompatibility, PluginCompatibilityMode,
13        PluginCompatibilityShim, PluginContractDialect, PluginDescriptor, PluginDiagnosticCode,
14        PluginDiagnosticFinding, PluginDiagnosticPhase, PluginDiagnosticSeverity, PluginManifest,
15        PluginScanReport, PluginSetup, PluginSlotClaim, PluginSlotMode, PluginSourceKind,
16        PluginTrustTier, plugin_host_compatibility_issue, slot_modes_conflict,
17    },
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum PluginBridgeKind {
23    HttpJson,
24    ProcessStdio,
25    NativeFfi,
26    WasmComponent,
27    McpServer,
28    AcpBridge,
29    AcpRuntime,
30    #[default]
31    Unknown,
32}
33
34impl PluginBridgeKind {
35    #[must_use]
36    pub fn parse_label(raw: &str) -> Option<Self> {
37        parse_bridge_kind(raw)
38    }
39
40    #[must_use]
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::HttpJson => "http_json",
44            Self::ProcessStdio => "process_stdio",
45            Self::NativeFfi => "native_ffi",
46            Self::WasmComponent => "wasm_component",
47            Self::McpServer => "mcp_server",
48            Self::AcpBridge => "acp_bridge",
49            Self::AcpRuntime => "acp_runtime",
50            Self::Unknown => "unknown",
51        }
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(default)]
57pub struct PluginRuntimeProfile {
58    pub source_language: String,
59    pub bridge_kind: PluginBridgeKind,
60    pub adapter_family: String,
61    pub entrypoint_hint: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct PluginRuntimeScaffoldDefaults {
66    pub source_language: Option<String>,
67    pub bridge_kind: PluginBridgeKind,
68    pub adapter_family: String,
69    pub entrypoint_hint: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct PluginChannelBridgeReadiness {
74    pub ready: bool,
75    #[serde(default)]
76    pub missing_fields: Vec<String>,
77}
78
79impl Default for PluginChannelBridgeReadiness {
80    fn default() -> Self {
81        Self {
82            ready: true,
83            missing_fields: Vec::new(),
84        }
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct PluginChannelBridgeContract {
90    #[serde(default)]
91    pub channel_id: Option<String>,
92    #[serde(default)]
93    pub setup_surface: Option<String>,
94    #[serde(default)]
95    pub transport_family: Option<String>,
96    #[serde(default)]
97    pub target_contract: Option<String>,
98    #[serde(default)]
99    pub account_scope: Option<String>,
100    #[serde(default)]
101    pub runtime_contract: Option<String>,
102    #[serde(default)]
103    pub runtime_operations: Vec<String>,
104    #[serde(default)]
105    pub runtime_metadata_issues: Vec<String>,
106    #[serde(default)]
107    pub readiness: PluginChannelBridgeReadiness,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct PluginIR {
112    pub manifest_api_version: Option<String>,
113    pub plugin_version: Option<String>,
114    #[serde(default)]
115    pub dialect: PluginContractDialect,
116    pub dialect_version: Option<String>,
117    #[serde(default)]
118    pub compatibility_mode: PluginCompatibilityMode,
119    pub plugin_id: String,
120    pub provider_id: String,
121    pub connector_name: String,
122    pub channel_id: Option<String>,
123    pub endpoint: Option<String>,
124    pub capabilities: BTreeSet<Capability>,
125    #[serde(default)]
126    pub trust_tier: PluginTrustTier,
127    pub metadata: BTreeMap<String, String>,
128    pub source_path: String,
129    pub source_kind: PluginSourceKind,
130    pub package_root: String,
131    pub package_manifest_path: Option<String>,
132    #[serde(default)]
133    pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
134    pub setup: Option<PluginSetup>,
135    #[serde(default)]
136    pub channel_bridge: Option<PluginChannelBridgeContract>,
137    #[serde(default)]
138    pub slot_claims: Vec<PluginSlotClaim>,
139    pub compatibility: Option<PluginCompatibility>,
140    #[serde(default)]
141    pub runtime: PluginRuntimeProfile,
142}
143
144#[derive(Debug, Deserialize)]
145struct PluginIRSerde {
146    manifest_api_version: Option<String>,
147    plugin_version: Option<String>,
148    dialect: Option<PluginContractDialect>,
149    dialect_version: Option<String>,
150    compatibility_mode: Option<PluginCompatibilityMode>,
151    plugin_id: String,
152    provider_id: String,
153    connector_name: String,
154    channel_id: Option<String>,
155    endpoint: Option<String>,
156    capabilities: BTreeSet<Capability>,
157    #[serde(default)]
158    trust_tier: PluginTrustTier,
159    metadata: BTreeMap<String, String>,
160    source_path: String,
161    source_kind: PluginSourceKind,
162    package_root: String,
163    package_manifest_path: Option<String>,
164    #[serde(default)]
165    diagnostic_findings: Vec<PluginDiagnosticFinding>,
166    setup: Option<PluginSetup>,
167    #[serde(default)]
168    channel_bridge: Option<PluginChannelBridgeContract>,
169    #[serde(default)]
170    slot_claims: Vec<PluginSlotClaim>,
171    compatibility: Option<PluginCompatibility>,
172    runtime: Option<PluginRuntimeProfile>,
173}
174
175impl<'de> Deserialize<'de> for PluginIR {
176    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177    where
178        D: Deserializer<'de>,
179    {
180        let raw = PluginIRSerde::deserialize(deserializer)?;
181        let dialect = raw
182            .dialect
183            .unwrap_or_else(|| legacy_plugin_ir_dialect(raw.source_kind));
184        let compatibility_mode = raw.compatibility_mode.unwrap_or_default();
185        let runtime = raw.runtime.unwrap_or_else(|| {
186            legacy_plugin_ir_runtime_profile(
187                &raw.source_path,
188                raw.source_kind,
189                &raw.metadata,
190                raw.endpoint.as_deref(),
191            )
192        });
193
194        Ok(Self {
195            manifest_api_version: raw.manifest_api_version,
196            plugin_version: raw.plugin_version,
197            dialect,
198            dialect_version: raw.dialect_version,
199            compatibility_mode,
200            plugin_id: raw.plugin_id,
201            provider_id: raw.provider_id,
202            connector_name: raw.connector_name,
203            channel_id: raw.channel_id,
204            endpoint: raw.endpoint,
205            capabilities: raw.capabilities,
206            trust_tier: raw.trust_tier,
207            metadata: raw.metadata,
208            source_path: raw.source_path,
209            source_kind: raw.source_kind,
210            package_root: raw.package_root,
211            package_manifest_path: raw.package_manifest_path,
212            diagnostic_findings: raw.diagnostic_findings,
213            setup: raw.setup,
214            channel_bridge: raw.channel_bridge,
215            slot_claims: raw.slot_claims,
216            compatibility: raw.compatibility,
217            runtime,
218        })
219    }
220}
221
222/// Declares which setup requirements are already verified for a plugin.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
224pub struct PluginSetupReadinessContext {
225    pub verified_env_vars: BTreeSet<String>,
226    pub verified_config_keys: BTreeSet<String>,
227}
228
229/// Summarizes whether manifest-declared setup requirements are satisfied.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct PluginSetupReadiness {
232    pub ready: bool,
233    pub missing_required_env_vars: Vec<String>,
234    pub missing_required_config_keys: Vec<String>,
235}
236
237impl Default for PluginSetupReadiness {
238    fn default() -> Self {
239        Self {
240            ready: true,
241            missing_required_env_vars: Vec::new(),
242            missing_required_config_keys: Vec::new(),
243        }
244    }
245}
246
247/// Evaluates manifest-declared setup requirements against verified runtime context.
248pub fn evaluate_plugin_setup_requirements(
249    required_env_vars: &[String],
250    required_config_keys: &[String],
251    context: &PluginSetupReadinessContext,
252) -> PluginSetupReadiness {
253    let mut missing_required_env_vars = Vec::new();
254    for required_env_var in required_env_vars {
255        let env_var_is_verified =
256            verified_env_var_names_contain(&context.verified_env_vars, required_env_var);
257        if !env_var_is_verified {
258            missing_required_env_vars.push(required_env_var.clone());
259        }
260    }
261
262    let mut missing_required_config_keys = Vec::new();
263    for required_config_key in required_config_keys {
264        let config_key_is_verified = context.verified_config_keys.contains(required_config_key);
265        if !config_key_is_verified {
266            missing_required_config_keys.push(required_config_key.clone());
267        }
268    }
269
270    let env_ready = missing_required_env_vars.is_empty();
271    let config_ready = missing_required_config_keys.is_empty();
272    let ready = env_ready && config_ready;
273
274    PluginSetupReadiness {
275        ready,
276        missing_required_env_vars,
277        missing_required_config_keys,
278    }
279}
280
281fn verified_env_var_names_contain(
282    verified_env_vars: &BTreeSet<String>,
283    required_env_var: &str,
284) -> bool {
285    #[cfg(windows)]
286    {
287        verified_env_vars
288            .iter()
289            .any(|verified_env_var| verified_env_var.eq_ignore_ascii_case(required_env_var))
290    }
291
292    #[cfg(not(windows))]
293    {
294        verified_env_vars.contains(required_env_var)
295    }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
299pub struct PluginTranslationReport {
300    pub translated_plugins: usize,
301    pub bridge_distribution: BTreeMap<String, usize>,
302    pub entries: Vec<PluginIR>,
303}
304
305/// Serialized activation outcomes are additive.
306///
307/// Consumers that deserialize persisted or remote payloads should tolerate newer
308/// snake_case variants and treat unknown values as forward-compatible contract
309/// growth rather than a malformed payload.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum PluginActivationStatus {
313    Ready,
314    /// The runtime surface is supported, but declared setup requirements are not.
315    SetupIncomplete,
316    BlockedInvalidManifestContract,
317    BlockedCompatibilityMode,
318    BlockedIncompatibleHost,
319    BlockedUnsupportedBridge,
320    BlockedUnsupportedAdapterFamily,
321    BlockedSlotClaimConflict,
322    #[serde(other)]
323    Unknown,
324}
325
326impl PluginActivationStatus {
327    #[must_use]
328    pub fn as_str(self) -> &'static str {
329        match self {
330            Self::Ready => "ready",
331            Self::SetupIncomplete => "setup_incomplete",
332            Self::BlockedInvalidManifestContract => "blocked_invalid_manifest_contract",
333            Self::BlockedCompatibilityMode => "blocked_compatibility_mode",
334            Self::BlockedIncompatibleHost => "blocked_incompatible_host",
335            Self::BlockedUnsupportedBridge => "blocked_unsupported_bridge",
336            Self::BlockedUnsupportedAdapterFamily => "blocked_unsupported_adapter_family",
337            Self::BlockedSlotClaimConflict => "blocked_slot_claim_conflict",
338            Self::Unknown => "unknown",
339        }
340    }
341}
342
343/// Captures activation planning details for a single plugin candidate.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct PluginActivationCandidate {
346    pub plugin_id: String,
347    pub source_path: String,
348    pub source_kind: PluginSourceKind,
349    pub package_root: String,
350    pub package_manifest_path: Option<String>,
351    #[serde(default)]
352    pub trust_tier: PluginTrustTier,
353    #[serde(default)]
354    pub compatibility_mode: PluginCompatibilityMode,
355    #[serde(default)]
356    pub compatibility_shim: Option<PluginCompatibilityShim>,
357    #[serde(default)]
358    pub compatibility_shim_support: Option<PluginCompatibilityShimSupport>,
359    #[serde(default)]
360    pub compatibility_shim_support_mismatch_reasons: Vec<String>,
361    pub bridge_kind: PluginBridgeKind,
362    pub adapter_family: String,
363    #[serde(default)]
364    pub slot_claims: Vec<PluginSlotClaim>,
365    #[serde(default)]
366    pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
367    pub status: PluginActivationStatus,
368    pub reason: String,
369    #[serde(default)]
370    pub missing_required_env_vars: Vec<String>,
371    #[serde(default)]
372    pub missing_required_config_keys: Vec<String>,
373    pub bootstrap_hint: String,
374}
375
376/// Summarizes activation readiness across all translated plugin candidates.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
378pub struct PluginActivationPlan {
379    pub total_plugins: usize,
380    pub ready_plugins: usize,
381    #[serde(default)]
382    pub setup_incomplete_plugins: usize,
383    pub blocked_plugins: usize,
384    pub candidates: Vec<PluginActivationCandidate>,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct PluginActivationInventoryEntry {
389    pub manifest_api_version: Option<String>,
390    pub plugin_version: Option<String>,
391    pub dialect: PluginContractDialect,
392    pub dialect_version: Option<String>,
393    pub compatibility_mode: PluginCompatibilityMode,
394    pub compatibility_shim: Option<PluginCompatibilityShim>,
395    pub compatibility_shim_support: Option<PluginCompatibilityShimSupport>,
396    pub compatibility_shim_support_mismatch_reasons: Vec<String>,
397    pub plugin_id: String,
398    pub provider_id: String,
399    pub connector_name: String,
400    pub source_path: String,
401    pub source_kind: PluginSourceKind,
402    pub package_root: String,
403    pub package_manifest_path: Option<String>,
404    pub bridge_kind: PluginBridgeKind,
405    pub adapter_family: String,
406    pub entrypoint_hint: String,
407    pub source_language: String,
408    pub slot_claims: Vec<PluginSlotClaim>,
409    pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
410    pub compatibility: Option<PluginCompatibility>,
411    pub activation_status: Option<PluginActivationStatus>,
412    pub activation_reason: Option<String>,
413    pub bootstrap_hint: Option<String>,
414}
415
416impl PluginActivationPlan {
417    #[must_use]
418    pub fn has_blockers(&self) -> bool {
419        self.blocked_plugins > 0
420    }
421
422    #[must_use]
423    pub fn candidate_for(
424        &self,
425        source_path: &str,
426        plugin_id: &str,
427    ) -> Option<&PluginActivationCandidate> {
428        self.candidates.iter().find(|candidate| {
429            candidate.source_path == source_path && candidate.plugin_id == plugin_id
430        })
431    }
432
433    #[must_use]
434    pub fn inventory_entries(
435        &self,
436        translation: &PluginTranslationReport,
437    ) -> Vec<PluginActivationInventoryEntry> {
438        translation
439            .entries
440            .iter()
441            .map(|entry| {
442                let candidate = self.candidate_for(&entry.source_path, &entry.plugin_id);
443                PluginActivationInventoryEntry {
444                    manifest_api_version: entry.manifest_api_version.clone(),
445                    plugin_version: entry.plugin_version.clone(),
446                    dialect: entry.dialect,
447                    dialect_version: entry.dialect_version.clone(),
448                    compatibility_mode: entry.compatibility_mode,
449                    compatibility_shim: candidate
450                        .and_then(|candidate| candidate.compatibility_shim.clone())
451                        .or_else(|| PluginCompatibilityShim::for_mode(entry.compatibility_mode)),
452                    compatibility_shim_support: candidate
453                        .and_then(|candidate| candidate.compatibility_shim_support.clone()),
454                    compatibility_shim_support_mismatch_reasons: candidate
455                        .map(|candidate| {
456                            candidate
457                                .compatibility_shim_support_mismatch_reasons
458                                .clone()
459                        })
460                        .unwrap_or_default(),
461                    plugin_id: entry.plugin_id.clone(),
462                    provider_id: entry.provider_id.clone(),
463                    connector_name: entry.connector_name.clone(),
464                    source_path: entry.source_path.clone(),
465                    source_kind: entry.source_kind,
466                    package_root: entry.package_root.clone(),
467                    package_manifest_path: entry.package_manifest_path.clone(),
468                    bridge_kind: entry.runtime.bridge_kind,
469                    adapter_family: entry.runtime.adapter_family.clone(),
470                    entrypoint_hint: entry.runtime.entrypoint_hint.clone(),
471                    source_language: entry.runtime.source_language.clone(),
472                    slot_claims: entry.slot_claims.clone(),
473                    diagnostic_findings: candidate
474                        .map(|candidate| candidate.diagnostic_findings.clone())
475                        .unwrap_or_else(|| entry.diagnostic_findings.clone()),
476                    compatibility: entry.compatibility.clone(),
477                    activation_status: candidate.map(|candidate| candidate.status),
478                    activation_reason: candidate.map(|candidate| candidate.reason.clone()),
479                    bootstrap_hint: candidate.map(|candidate| candidate.bootstrap_hint.clone()),
480                }
481            })
482            .collect()
483    }
484
485    #[must_use]
486    pub fn blocker_summary(&self, limit: usize) -> String {
487        if self.blocked_plugins == 0 {
488            return "no blocked plugins".to_owned();
489        }
490
491        let capped_limit = limit.clamp(1, 16);
492        let mut details = self
493            .candidates
494            .iter()
495            .filter(|candidate| {
496                matches!(
497                    candidate.status,
498                    PluginActivationStatus::BlockedInvalidManifestContract
499                        | PluginActivationStatus::BlockedCompatibilityMode
500                        | PluginActivationStatus::BlockedUnsupportedBridge
501                        | PluginActivationStatus::BlockedIncompatibleHost
502                        | PluginActivationStatus::BlockedUnsupportedAdapterFamily
503                        | PluginActivationStatus::BlockedSlotClaimConflict
504                        | PluginActivationStatus::Unknown
505                )
506            })
507            .take(capped_limit)
508            .map(|candidate| {
509                format!(
510                    "{} [{}]: {}",
511                    candidate.plugin_id,
512                    candidate.status.as_str(),
513                    candidate.reason
514                )
515            })
516            .collect::<Vec<_>>();
517
518        if self.blocked_plugins > capped_limit {
519            details.push(format!(
520                "+{} more blocked plugin(s)",
521                self.blocked_plugins - capped_limit
522            ));
523        }
524
525        details.join("; ")
526    }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct BridgeSupportMatrix {
531    pub supported_bridges: BTreeSet<PluginBridgeKind>,
532    pub supported_adapter_families: BTreeSet<String>,
533    pub supported_compatibility_modes: BTreeSet<PluginCompatibilityMode>,
534    pub supported_compatibility_shims: BTreeSet<PluginCompatibilityShim>,
535    pub supported_compatibility_shim_profiles:
536        BTreeMap<PluginCompatibilityShim, PluginCompatibilityShimSupport>,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct PluginCompatibilityShimSupport {
541    pub shim: PluginCompatibilityShim,
542    #[serde(default)]
543    pub version: Option<String>,
544    #[serde(default)]
545    pub supported_dialects: BTreeSet<PluginContractDialect>,
546    #[serde(default)]
547    pub supported_bridges: BTreeSet<PluginBridgeKind>,
548    #[serde(default)]
549    pub supported_adapter_families: BTreeSet<String>,
550    #[serde(default)]
551    pub supported_source_languages: BTreeSet<String>,
552}
553
554impl PluginCompatibilityShimSupport {
555    #[must_use]
556    pub fn normalized(self) -> Self {
557        Self {
558            shim: PluginCompatibilityShim {
559                shim_id: self.shim.shim_id.trim().to_owned(),
560                family: self.shim.family.trim().to_owned(),
561            },
562            version: self
563                .version
564                .map(|value| value.trim().to_owned())
565                .filter(|value| !value.is_empty()),
566            supported_dialects: self.supported_dialects,
567            supported_bridges: self.supported_bridges,
568            supported_adapter_families: self
569                .supported_adapter_families
570                .into_iter()
571                .map(|value| value.trim().to_ascii_lowercase())
572                .filter(|value| !value.is_empty())
573                .collect(),
574            supported_source_languages: self
575                .supported_source_languages
576                .into_iter()
577                .map(|value| normalize_language(&value))
578                .filter(|value| value != "unknown")
579                .collect(),
580        }
581    }
582
583    fn mismatch_reasons(&self, ir: &PluginIR) -> Vec<String> {
584        let mut reasons = Vec::new();
585
586        if !self.supported_dialects.is_empty() && !self.supported_dialects.contains(&ir.dialect) {
587            reasons.push(format!("dialect `{}`", ir.dialect.as_str()));
588        }
589
590        if !self.supported_bridges.is_empty()
591            && !self.supported_bridges.contains(&ir.runtime.bridge_kind)
592        {
593            reasons.push(format!("bridge kind `{}`", ir.runtime.bridge_kind.as_str()));
594        }
595
596        if !self.supported_adapter_families.is_empty()
597            && !self
598                .supported_adapter_families
599                .contains(&ir.runtime.adapter_family.trim().to_ascii_lowercase())
600        {
601            reasons.push(format!("adapter family `{}`", ir.runtime.adapter_family));
602        }
603
604        let normalized_source_language = normalize_language(&ir.runtime.source_language);
605        if !self.supported_source_languages.is_empty()
606            && !self
607                .supported_source_languages
608                .contains(&normalized_source_language)
609        {
610            reasons.push(format!("source language `{}`", ir.runtime.source_language));
611        }
612
613        reasons
614    }
615}
616
617impl Default for BridgeSupportMatrix {
618    fn default() -> Self {
619        Self {
620            supported_bridges: BTreeSet::from([
621                PluginBridgeKind::HttpJson,
622                PluginBridgeKind::ProcessStdio,
623                PluginBridgeKind::NativeFfi,
624                PluginBridgeKind::WasmComponent,
625                PluginBridgeKind::McpServer,
626                PluginBridgeKind::AcpBridge,
627                PluginBridgeKind::AcpRuntime,
628            ]),
629            supported_adapter_families: BTreeSet::new(),
630            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
631            supported_compatibility_shims: BTreeSet::new(),
632            supported_compatibility_shim_profiles: BTreeMap::new(),
633        }
634    }
635}
636
637impl BridgeSupportMatrix {
638    #[must_use]
639    pub fn is_bridge_supported(&self, bridge_kind: PluginBridgeKind) -> bool {
640        self.supported_bridges.contains(&bridge_kind)
641    }
642
643    #[must_use]
644    pub fn is_adapter_family_supported(&self, adapter_family: &str) -> bool {
645        self.supported_adapter_families.is_empty()
646            || self.supported_adapter_families.contains(adapter_family)
647    }
648
649    #[must_use]
650    pub fn is_compatibility_mode_supported(
651        &self,
652        compatibility_mode: PluginCompatibilityMode,
653    ) -> bool {
654        self.supported_compatibility_modes
655            .contains(&compatibility_mode)
656    }
657
658    #[must_use]
659    pub fn is_compatibility_shim_supported(
660        &self,
661        compatibility_shim: Option<&PluginCompatibilityShim>,
662    ) -> bool {
663        compatibility_shim.is_none_or(|shim| {
664            self.supported_compatibility_shims.contains(shim)
665                || self
666                    .supported_compatibility_shim_profiles
667                    .contains_key(shim)
668        })
669    }
670
671    #[must_use]
672    pub fn compatibility_shim_support_issue(
673        &self,
674        ir: &PluginIR,
675        compatibility_shim: Option<&PluginCompatibilityShim>,
676    ) -> Option<String> {
677        let shim = compatibility_shim?;
678        let profile = self.supported_compatibility_shim_profiles.get(shim)?;
679        let mismatches = profile.mismatch_reasons(ir);
680        compatibility_shim_support_issue(shim, profile, &mismatches)
681    }
682
683    #[must_use]
684    pub fn compatibility_shim_support_profile(
685        &self,
686        compatibility_shim: Option<&PluginCompatibilityShim>,
687    ) -> Option<&PluginCompatibilityShimSupport> {
688        compatibility_shim.and_then(|shim| self.supported_compatibility_shim_profiles.get(shim))
689    }
690}
691
692fn compatibility_shim_support_issue(
693    shim: &PluginCompatibilityShim,
694    profile: &PluginCompatibilityShimSupport,
695    mismatches: &[String],
696) -> Option<String> {
697    if mismatches.is_empty() {
698        return None;
699    }
700
701    let version_clause = profile
702        .version
703        .as_deref()
704        .map(|version| format!(" version `{version}`"))
705        .unwrap_or_default();
706
707    Some(format!(
708        "compatibility shim `{}` ({}) is enabled but its support profile{} does not support {}",
709        shim.shim_id,
710        shim.family,
711        version_clause,
712        mismatches.join(", ")
713    ))
714}
715
716#[derive(Debug, Default)]
717pub struct PluginTranslator;
718
719impl PluginTranslator {
720    #[must_use]
721    pub fn new() -> Self {
722        Self
723    }
724
725    #[must_use]
726    pub fn translate_scan_report(&self, report: &PluginScanReport) -> PluginTranslationReport {
727        let mut translated = PluginTranslationReport::default();
728        let mut diagnostics_by_key: BTreeMap<(String, String), Vec<PluginDiagnosticFinding>> =
729            BTreeMap::new();
730
731        for finding in &report.diagnostic_findings {
732            let (Some(source_path), Some(plugin_id)) =
733                (finding.source_path.clone(), finding.plugin_id.clone())
734            else {
735                continue;
736            };
737
738            diagnostics_by_key
739                .entry((source_path, plugin_id))
740                .or_default()
741                .push(finding.clone());
742        }
743
744        for descriptor in &report.descriptors {
745            let mut ir = self.translate_descriptor(descriptor);
746            ir.diagnostic_findings = diagnostics_by_key
747                .remove(&(
748                    descriptor.path.clone(),
749                    descriptor.manifest.plugin_id.clone(),
750                ))
751                .unwrap_or_default();
752            let bridge = ir.runtime.bridge_kind.as_str().to_owned();
753            *translated.bridge_distribution.entry(bridge).or_insert(0) += 1;
754            translated.translated_plugins = translated.translated_plugins.saturating_add(1);
755            translated.entries.push(ir);
756        }
757
758        translated
759    }
760
761    #[must_use]
762    pub fn translate_descriptor(&self, descriptor: &PluginDescriptor) -> PluginIR {
763        let runtime = infer_runtime_profile(&descriptor.language, &descriptor.manifest);
764        let channel_bridge = derive_channel_bridge_contract(&descriptor.manifest);
765
766        PluginIR {
767            manifest_api_version: descriptor.manifest.api_version.clone(),
768            plugin_version: descriptor.manifest.version.clone(),
769            dialect: descriptor.dialect,
770            dialect_version: descriptor.dialect_version.clone(),
771            compatibility_mode: descriptor.compatibility_mode,
772            plugin_id: descriptor.manifest.plugin_id.clone(),
773            provider_id: descriptor.manifest.provider_id.clone(),
774            connector_name: descriptor.manifest.connector_name.clone(),
775            channel_id: descriptor.manifest.channel_id.clone(),
776            endpoint: descriptor.manifest.endpoint.clone(),
777            capabilities: descriptor.manifest.capabilities.clone(),
778            trust_tier: descriptor.manifest.trust_tier,
779            metadata: descriptor.manifest.metadata.clone(),
780            source_path: descriptor.path.clone(),
781            source_kind: descriptor.source_kind,
782            package_root: descriptor.package_root.clone(),
783            package_manifest_path: descriptor.package_manifest_path.clone(),
784            diagnostic_findings: Vec::new(),
785            setup: descriptor.manifest.setup.clone(),
786            channel_bridge,
787            slot_claims: descriptor.manifest.slot_claims.clone(),
788            compatibility: descriptor.manifest.compatibility.clone(),
789            runtime,
790        }
791    }
792
793    #[must_use]
794    pub fn plan_activation(
795        &self,
796        translation: &PluginTranslationReport,
797        matrix: &BridgeSupportMatrix,
798        setup_readiness_context: &PluginSetupReadinessContext,
799    ) -> PluginActivationPlan {
800        self.plan_activation_with_catalog(translation, matrix, setup_readiness_context, None)
801    }
802
803    #[must_use]
804    pub fn plan_activation_with_catalog(
805        &self,
806        translation: &PluginTranslationReport,
807        matrix: &BridgeSupportMatrix,
808        setup_readiness_context: &PluginSetupReadinessContext,
809        catalog: Option<&IntegrationCatalog>,
810    ) -> PluginActivationPlan {
811        let mut plan = PluginActivationPlan::default();
812        let slot_conflicts = collect_slot_claim_conflicts(&translation.entries, catalog);
813
814        for ir in &translation.entries {
815            plan.total_plugins = plan.total_plugins.saturating_add(1);
816            let compatibility_shim = PluginCompatibilityShim::for_mode(ir.compatibility_mode);
817            let compatibility_shim_support = matrix
818                .compatibility_shim_support_profile(compatibility_shim.as_ref())
819                .cloned();
820            let compatibility_shim_support_mismatch_reasons = compatibility_shim_support
821                .as_ref()
822                .map(|profile| profile.mismatch_reasons(ir))
823                .unwrap_or_default();
824
825            let setup_readiness =
826                evaluate_plugin_setup_readiness(ir.setup.as_ref(), setup_readiness_context);
827            let setup_is_incomplete = !setup_readiness.ready;
828            let slot_conflict_key = (ir.source_path.clone(), ir.plugin_id.clone());
829            let invalid_manifest_contract = plugin_manifest_contract_is_invalid(ir);
830            let (status, reason) = if !matrix.is_compatibility_mode_supported(ir.compatibility_mode)
831            {
832                let shim_clause = compatibility_shim
833                    .as_ref()
834                    .map(|shim| format!(" via shim `{}` ({})", shim.shim_id, shim.family))
835                    .unwrap_or_default();
836                (
837                    PluginActivationStatus::BlockedCompatibilityMode,
838                    format!(
839                        "compatibility mode {} requires a host shim that is not enabled in the current runtime matrix{}",
840                        ir.compatibility_mode.as_str(),
841                        shim_clause
842                    ),
843                )
844            } else if !matrix.is_compatibility_shim_supported(compatibility_shim.as_ref()) {
845                let maybe_shim = compatibility_shim.as_ref();
846                let missing_shim_reason = format!(
847                    "compatibility mode {} did not resolve a canonical shim before runtime-matrix evaluation",
848                    ir.compatibility_mode.as_str()
849                );
850                let reason = match maybe_shim {
851                    Some(shim) => {
852                        let shim_id = shim.shim_id.as_str();
853                        let shim_family = shim.family.as_str();
854
855                        format!(
856                            "compatibility mode {} requires compatibility shim `{}` ({}) that is not enabled in the current runtime matrix",
857                            ir.compatibility_mode.as_str(),
858                            shim_id,
859                            shim_family
860                        )
861                    }
862                    None => missing_shim_reason,
863                };
864
865                (PluginActivationStatus::BlockedCompatibilityMode, reason)
866            } else if let Some(reason) = plugin_host_compatibility_issue(ir.compatibility.as_ref())
867            {
868                (PluginActivationStatus::BlockedIncompatibleHost, reason)
869            } else if let Some(reason) = compatibility_shim
870                .as_ref()
871                .zip(compatibility_shim_support.as_ref())
872                .and_then(|(shim, profile)| {
873                    compatibility_shim_support_issue(
874                        shim,
875                        profile,
876                        &compatibility_shim_support_mismatch_reasons,
877                    )
878                })
879            {
880                (PluginActivationStatus::BlockedCompatibilityMode, reason)
881            } else if let Some(reason) = slot_conflicts.get(&slot_conflict_key) {
882                (
883                    PluginActivationStatus::BlockedSlotClaimConflict,
884                    reason.clone(),
885                )
886            } else if invalid_manifest_contract {
887                (
888                    PluginActivationStatus::BlockedInvalidManifestContract,
889                    format_invalid_manifest_contract_reason(ir),
890                )
891            } else if !matrix.is_bridge_supported(ir.runtime.bridge_kind) {
892                (
893                    PluginActivationStatus::BlockedUnsupportedBridge,
894                    format!(
895                        "bridge kind {} is not supported by current runtime matrix",
896                        ir.runtime.bridge_kind.as_str()
897                    ),
898                )
899            } else if !matrix.is_adapter_family_supported(&ir.runtime.adapter_family) {
900                (
901                    PluginActivationStatus::BlockedUnsupportedAdapterFamily,
902                    format!(
903                        "adapter family {} is not supported by current runtime matrix",
904                        ir.runtime.adapter_family
905                    ),
906                )
907            } else if setup_is_incomplete {
908                (
909                    PluginActivationStatus::SetupIncomplete,
910                    format_plugin_setup_incomplete_reason(&setup_readiness),
911                )
912            } else {
913                (
914                    PluginActivationStatus::Ready,
915                    "plugin runtime profile is supported by current runtime matrix".to_owned(),
916                )
917            };
918
919            let mut diagnostic_findings = ir.diagnostic_findings.clone();
920            if let Some(finding) = activation_diagnostic_finding(ir, status, &reason) {
921                diagnostic_findings.push(finding);
922            }
923
924            match status {
925                PluginActivationStatus::Ready => {
926                    plan.ready_plugins = plan.ready_plugins.saturating_add(1)
927                }
928                PluginActivationStatus::SetupIncomplete => {
929                    plan.setup_incomplete_plugins = plan.setup_incomplete_plugins.saturating_add(1)
930                }
931                PluginActivationStatus::BlockedInvalidManifestContract
932                | PluginActivationStatus::BlockedCompatibilityMode
933                | PluginActivationStatus::BlockedUnsupportedBridge
934                | PluginActivationStatus::BlockedIncompatibleHost
935                | PluginActivationStatus::BlockedUnsupportedAdapterFamily
936                | PluginActivationStatus::BlockedSlotClaimConflict
937                | PluginActivationStatus::Unknown => {
938                    plan.blocked_plugins = plan.blocked_plugins.saturating_add(1)
939                }
940            }
941
942            plan.candidates.push(PluginActivationCandidate {
943                plugin_id: ir.plugin_id.clone(),
944                source_path: ir.source_path.clone(),
945                source_kind: ir.source_kind,
946                package_root: ir.package_root.clone(),
947                package_manifest_path: ir.package_manifest_path.clone(),
948                trust_tier: ir.trust_tier,
949                compatibility_mode: ir.compatibility_mode,
950                compatibility_shim,
951                compatibility_shim_support,
952                compatibility_shim_support_mismatch_reasons,
953                bridge_kind: ir.runtime.bridge_kind,
954                adapter_family: ir.runtime.adapter_family.clone(),
955                slot_claims: ir.slot_claims.clone(),
956                diagnostic_findings,
957                status,
958                reason,
959                missing_required_env_vars: setup_readiness.missing_required_env_vars,
960                missing_required_config_keys: setup_readiness.missing_required_config_keys,
961                bootstrap_hint: bootstrap_hint(ir),
962            });
963        }
964
965        plan
966    }
967}
968
969fn evaluate_plugin_setup_readiness(
970    setup: Option<&PluginSetup>,
971    context: &PluginSetupReadinessContext,
972) -> PluginSetupReadiness {
973    let Some(setup) = setup else {
974        return PluginSetupReadiness::default();
975    };
976
977    evaluate_plugin_setup_requirements(
978        &setup.required_env_vars,
979        &setup.required_config_keys,
980        context,
981    )
982}
983
984fn format_plugin_setup_incomplete_reason(readiness: &PluginSetupReadiness) -> String {
985    let mut reasons = Vec::new();
986
987    if !readiness.missing_required_env_vars.is_empty() {
988        let missing_env_vars = readiness.missing_required_env_vars.join(", ");
989        let env_reason = format!("missing required env vars: {missing_env_vars}");
990        reasons.push(env_reason);
991    }
992
993    if !readiness.missing_required_config_keys.is_empty() {
994        let missing_config_keys = readiness.missing_required_config_keys.join(", ");
995        let config_reason = format!("missing required config keys: {missing_config_keys}");
996        reasons.push(config_reason);
997    }
998
999    let combined_reasons = reasons.join("; ");
1000    format!("plugin setup is incomplete: {combined_reasons}")
1001}
1002
1003fn plugin_manifest_contract_is_invalid(ir: &PluginIR) -> bool {
1004    let Some(channel_bridge) = ir.channel_bridge.as_ref() else {
1005        return false;
1006    };
1007
1008    !channel_bridge.readiness.ready
1009}
1010
1011fn format_invalid_manifest_contract_reason(ir: &PluginIR) -> String {
1012    let Some(channel_bridge) = ir.channel_bridge.as_ref() else {
1013        return "plugin manifest contract is invalid".to_owned();
1014    };
1015
1016    let missing_fields = channel_bridge.readiness.missing_fields.join(", ");
1017    format!("plugin channel bridge contract is incomplete: {missing_fields}")
1018}
1019
1020fn activation_diagnostic_finding(
1021    ir: &PluginIR,
1022    status: PluginActivationStatus,
1023    reason: &str,
1024) -> Option<PluginDiagnosticFinding> {
1025    let (code, field_path, remediation) = match status {
1026        PluginActivationStatus::Ready => return None,
1027        PluginActivationStatus::SetupIncomplete => return None,
1028        PluginActivationStatus::BlockedInvalidManifestContract => (
1029            PluginDiagnosticCode::InvalidManifestContract,
1030            Some("channel_bridge".to_owned()),
1031            Some(
1032                "declare the required channel bridge contract fields in the manifest before activation"
1033                    .to_owned(),
1034            ),
1035        ),
1036        PluginActivationStatus::BlockedCompatibilityMode => (
1037            PluginDiagnosticCode::CompatibilityShimRequired,
1038            Some("compatibility_mode".to_owned()),
1039            Some(
1040                "enable or widen the required compatibility shim support policy in the runtime bridge matrix, or migrate the plugin to the native Loong contract before activation"
1041                    .to_owned(),
1042            ),
1043        ),
1044        PluginActivationStatus::BlockedIncompatibleHost => (
1045            PluginDiagnosticCode::IncompatibleHost,
1046            Some("compatibility".to_owned()),
1047            Some(
1048                "align `compatibility.host_api` / `compatibility.host_version_req` with the current host, or upgrade Loong before activation"
1049                    .to_owned(),
1050            ),
1051        ),
1052        PluginActivationStatus::BlockedUnsupportedBridge => (
1053            PluginDiagnosticCode::UnsupportedBridge,
1054            Some("metadata.bridge_kind".to_owned()),
1055            Some(
1056                "switch the plugin to a supported bridge kind or widen the runtime bridge support policy before activation"
1057                    .to_owned(),
1058            ),
1059        ),
1060        PluginActivationStatus::BlockedUnsupportedAdapterFamily => (
1061            PluginDiagnosticCode::UnsupportedAdapterFamily,
1062            Some("metadata.adapter_family".to_owned()),
1063            Some(
1064                "switch the plugin adapter family to one supported by the current runtime matrix"
1065                    .to_owned(),
1066            ),
1067        ),
1068        PluginActivationStatus::BlockedSlotClaimConflict => (
1069            PluginDiagnosticCode::SlotClaimConflict,
1070            Some("slot_claims".to_owned()),
1071            Some(
1072                "choose a different slot/key pair or relax ownership to shared/advisory only when the surface is intentionally multi-owner"
1073                    .to_owned(),
1074            ),
1075        ),
1076        PluginActivationStatus::Unknown => return None,
1077    };
1078
1079    Some(PluginDiagnosticFinding {
1080        code,
1081        severity: PluginDiagnosticSeverity::Error,
1082        phase: PluginDiagnosticPhase::Activation,
1083        blocking: true,
1084        plugin_id: Some(ir.plugin_id.clone()),
1085        source_path: Some(ir.source_path.clone()),
1086        source_kind: Some(ir.source_kind),
1087        field_path,
1088        message: reason.to_owned(),
1089        remediation,
1090    })
1091}
1092
1093#[derive(Debug, Clone)]
1094struct SlotClaimOwner {
1095    plugin_id: String,
1096    provider_id: String,
1097    mode: PluginSlotMode,
1098    source_path: Option<String>,
1099}
1100
1101fn collect_slot_claim_conflicts(
1102    entries: &[PluginIR],
1103    catalog: Option<&IntegrationCatalog>,
1104) -> BTreeMap<(String, String), String> {
1105    let mut conflicts: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1106
1107    if let Some(catalog) = catalog {
1108        let existing_claims = existing_slot_claims_from_catalog(catalog);
1109
1110        for entry in entries {
1111            for claim in &entry.slot_claims {
1112                let key = (claim.slot.clone(), claim.key.clone());
1113                let Some(existing_owners) = existing_claims.get(&key) else {
1114                    continue;
1115                };
1116
1117                for owner in existing_owners {
1118                    if owner.plugin_id == entry.plugin_id
1119                        || !slot_modes_conflict(owner.mode, claim.mode)
1120                    {
1121                        continue;
1122                    }
1123
1124                    conflicts
1125                        .entry((entry.source_path.clone(), entry.plugin_id.clone()))
1126                        .or_default()
1127                        .insert(format!(
1128                            "slot claim `{}`:`{}` ({}) conflicts with existing plugin `{}` (provider `{}`{})",
1129                            claim.slot,
1130                            claim.key,
1131                            claim.mode.as_str(),
1132                            owner.plugin_id,
1133                            owner.provider_id,
1134                            owner
1135                                .source_path
1136                                .as_deref()
1137                                .map(|path| format!(", source `{path}`"))
1138                                .unwrap_or_default()
1139                        ));
1140                }
1141            }
1142        }
1143    }
1144
1145    for (index, entry) in entries.iter().enumerate() {
1146        for other in entries.iter().skip(index + 1) {
1147            for claim in &entry.slot_claims {
1148                let Some(other_claim) = other
1149                    .slot_claims
1150                    .iter()
1151                    .find(|candidate| candidate.slot == claim.slot && candidate.key == claim.key)
1152                else {
1153                    continue;
1154                };
1155
1156                if !slot_modes_conflict(claim.mode, other_claim.mode) {
1157                    continue;
1158                }
1159
1160                conflicts
1161                    .entry((entry.source_path.clone(), entry.plugin_id.clone()))
1162                    .or_default()
1163                    .insert(format!(
1164                        "slot claim `{}`:`{}` ({}) conflicts with plugin `{}` (provider `{}`, source `{}`) as `{}`",
1165                        claim.slot,
1166                        claim.key,
1167                        claim.mode.as_str(),
1168                        other.plugin_id,
1169                        other.provider_id,
1170                        other.source_path,
1171                        other_claim.mode.as_str()
1172                    ));
1173                conflicts
1174                    .entry((other.source_path.clone(), other.plugin_id.clone()))
1175                    .or_default()
1176                    .insert(format!(
1177                        "slot claim `{}`:`{}` ({}) conflicts with plugin `{}` (provider `{}`, source `{}`) as `{}`",
1178                        other_claim.slot,
1179                        other_claim.key,
1180                        other_claim.mode.as_str(),
1181                        entry.plugin_id,
1182                        entry.provider_id,
1183                        entry.source_path,
1184                        claim.mode.as_str()
1185                    ));
1186            }
1187        }
1188    }
1189
1190    conflicts
1191        .into_iter()
1192        .map(|(key, reasons)| (key, reasons.into_iter().collect::<Vec<_>>().join("; ")))
1193        .collect()
1194}
1195
1196fn existing_slot_claims_from_catalog(
1197    catalog: &IntegrationCatalog,
1198) -> BTreeMap<(String, String), Vec<SlotClaimOwner>> {
1199    let mut registry: BTreeMap<(String, String), Vec<SlotClaimOwner>> = BTreeMap::new();
1200
1201    for provider in catalog.providers() {
1202        let Some(raw_json) = provider.metadata.get(PLUGIN_SLOT_CLAIMS_METADATA_KEY) else {
1203            continue;
1204        };
1205        let Ok(claims) = serde_json::from_str::<Vec<PluginSlotClaim>>(raw_json) else {
1206            continue;
1207        };
1208
1209        let plugin_id = provider
1210            .metadata
1211            .get("plugin_id")
1212            .cloned()
1213            .unwrap_or_else(|| format!("provider:{}", provider.provider_id));
1214        let source_path = provider.metadata.get("plugin_source_path").cloned();
1215
1216        for claim in claims {
1217            registry
1218                .entry((claim.slot, claim.key))
1219                .or_default()
1220                .push(SlotClaimOwner {
1221                    plugin_id: plugin_id.clone(),
1222                    provider_id: provider.provider_id.clone(),
1223                    mode: claim.mode,
1224                    source_path: source_path.clone(),
1225                });
1226        }
1227    }
1228
1229    registry
1230}
1231
1232fn infer_runtime_profile(language: &str, manifest: &PluginManifest) -> PluginRuntimeProfile {
1233    let endpoint = manifest.endpoint.as_deref();
1234    infer_runtime_profile_from_parts(language, &manifest.metadata, endpoint)
1235}
1236
1237fn infer_runtime_profile_from_parts(
1238    language: &str,
1239    metadata: &BTreeMap<String, String>,
1240    endpoint: Option<&str>,
1241) -> PluginRuntimeProfile {
1242    let source_language = metadata
1243        .get("source_language")
1244        .map(|value| normalize_language(value))
1245        .filter(|value| value != "unknown")
1246        .unwrap_or_else(|| normalize_language(language));
1247
1248    let bridge_kind = metadata
1249        .get("bridge_kind")
1250        .and_then(|value| parse_bridge_kind(value))
1251        .or_else(|| {
1252            metadata
1253                .get("protocol")
1254                .filter(|value| value.eq_ignore_ascii_case("mcp"))
1255                .map(|_| PluginBridgeKind::McpServer)
1256        })
1257        .unwrap_or_else(|| default_bridge_kind(&source_language, endpoint));
1258
1259    let adapter_family = metadata
1260        .get("adapter_family")
1261        .cloned()
1262        .unwrap_or_else(|| default_adapter_family(&source_language, bridge_kind));
1263
1264    let entrypoint_hint = metadata
1265        .get("entrypoint")
1266        .cloned()
1267        .or_else(|| default_entrypoint_hint(bridge_kind, endpoint))
1268        .unwrap_or_else(|| "invoke".to_owned());
1269
1270    PluginRuntimeProfile {
1271        source_language,
1272        bridge_kind,
1273        adapter_family,
1274        entrypoint_hint,
1275    }
1276}
1277
1278fn derive_channel_bridge_contract(
1279    manifest: &PluginManifest,
1280) -> Option<PluginChannelBridgeContract> {
1281    let channel_id = normalized_optional_value(manifest.channel_id.as_deref());
1282    let setup_surface = normalized_optional_value(
1283        manifest
1284            .setup
1285            .as_ref()
1286            .and_then(|setup| setup.surface.as_deref()),
1287    );
1288    let transport_family = normalized_manifest_metadata_value(manifest, "transport_family");
1289    let target_contract = normalized_manifest_metadata_value(manifest, "target_contract");
1290    let account_scope = normalized_manifest_metadata_value(manifest, "account_scope");
1291    let runtime_contract = normalized_manifest_metadata_value(manifest, "channel_runtime_contract");
1292    let runtime_operations =
1293        normalized_manifest_metadata_string_list(manifest, "channel_runtime_operations_json");
1294    let mut runtime_metadata_issues = Vec::new();
1295    let runtime_operations = match runtime_operations {
1296        Ok(runtime_operations) => runtime_operations,
1297        Err(issue) => {
1298            runtime_metadata_issues.push(issue);
1299            Vec::new()
1300        }
1301    };
1302    let adapter_family = normalized_manifest_metadata_value(manifest, "adapter_family");
1303
1304    let has_channel_bridge_metadata =
1305        transport_family.is_some() || target_contract.is_some() || account_scope.is_some();
1306    let adapter_declares_channel_bridge = adapter_family.as_deref() == Some("channel-bridge");
1307    // OpenClaw packages can legitimately use setup.surface=channel without being
1308    // managed channel-bridge plugins. Treat explicit bridge metadata or the
1309    // sanctioned adapter family as the bridge declaration boundary instead.
1310    let declares_channel_bridge = has_channel_bridge_metadata || adapter_declares_channel_bridge;
1311
1312    if !declares_channel_bridge {
1313        return None;
1314    }
1315
1316    let readiness = evaluate_channel_bridge_readiness(
1317        channel_id.as_deref(),
1318        setup_surface.as_deref(),
1319        transport_family.as_deref(),
1320        target_contract.as_deref(),
1321    );
1322
1323    Some(PluginChannelBridgeContract {
1324        channel_id,
1325        setup_surface,
1326        transport_family,
1327        target_contract,
1328        account_scope,
1329        runtime_contract,
1330        runtime_operations,
1331        runtime_metadata_issues,
1332        readiness,
1333    })
1334}
1335
1336fn evaluate_channel_bridge_readiness(
1337    channel_id: Option<&str>,
1338    setup_surface: Option<&str>,
1339    transport_family: Option<&str>,
1340    target_contract: Option<&str>,
1341) -> PluginChannelBridgeReadiness {
1342    let mut missing_fields = Vec::new();
1343
1344    if channel_id.is_none() {
1345        missing_fields.push("channel_id".to_owned());
1346    }
1347
1348    if setup_surface != Some("channel") {
1349        missing_fields.push("setup.surface".to_owned());
1350    }
1351
1352    if transport_family.is_none() {
1353        missing_fields.push("metadata.transport_family".to_owned());
1354    }
1355
1356    if target_contract.is_none() {
1357        missing_fields.push("metadata.target_contract".to_owned());
1358    }
1359
1360    let ready = missing_fields.is_empty();
1361
1362    PluginChannelBridgeReadiness {
1363        ready,
1364        missing_fields,
1365    }
1366}
1367
1368fn normalized_manifest_metadata_value(manifest: &PluginManifest, key: &str) -> Option<String> {
1369    let value = manifest.metadata.get(key);
1370    let value = value.map(String::as_str);
1371    normalized_optional_value(value)
1372}
1373
1374fn normalized_manifest_metadata_string_list(
1375    manifest: &PluginManifest,
1376    key: &str,
1377) -> Result<Vec<String>, String> {
1378    let Some(raw_value) = manifest.metadata.get(key) else {
1379        return Ok(Vec::new());
1380    };
1381
1382    let parsed_values = serde_json::from_str::<Vec<String>>(raw_value)
1383        .map_err(|error| format!("metadata.{key} must be valid json string array: {error}"))?;
1384
1385    let mut normalized_values = Vec::new();
1386    for parsed_value in parsed_values {
1387        let trimmed_value = parsed_value.trim();
1388        if trimmed_value.is_empty() {
1389            continue;
1390        }
1391
1392        let normalized_value = trimmed_value.to_owned();
1393        normalized_values.push(normalized_value);
1394    }
1395
1396    Ok(normalized_values)
1397}
1398
1399fn normalized_optional_value(raw: Option<&str>) -> Option<String> {
1400    let value = raw?;
1401    let trimmed = value.trim();
1402
1403    if trimmed.is_empty() {
1404        return None;
1405    }
1406
1407    Some(trimmed.to_owned())
1408}
1409
1410pub fn plugin_runtime_scaffold_defaults(
1411    bridge_kind: PluginBridgeKind,
1412    source_language: Option<&str>,
1413) -> Result<PluginRuntimeScaffoldDefaults, String> {
1414    if matches!(bridge_kind, PluginBridgeKind::Unknown) {
1415        return Err("plugin scaffold does not support bridge_kind `unknown`".to_owned());
1416    }
1417
1418    let normalized_source_language = source_language
1419        .map(normalize_language)
1420        .filter(|value| value != "unknown" && value != "manifest");
1421
1422    let source_language_is_required = matches!(
1423        bridge_kind,
1424        PluginBridgeKind::ProcessStdio | PluginBridgeKind::NativeFfi
1425    );
1426
1427    if source_language_is_required && normalized_source_language.is_none() {
1428        return Err(format!(
1429            "plugin scaffold requires an explicit source language for bridge_kind `{}`",
1430            bridge_kind.as_str()
1431        ));
1432    }
1433
1434    let adapter_language = normalized_source_language.as_deref().unwrap_or("unknown");
1435    let adapter_family = default_adapter_family(adapter_language, bridge_kind);
1436    let entrypoint_hint =
1437        default_entrypoint_hint(bridge_kind, None).unwrap_or_else(|| "invoke".to_owned());
1438
1439    Ok(PluginRuntimeScaffoldDefaults {
1440        source_language: normalized_source_language,
1441        bridge_kind,
1442        adapter_family,
1443        entrypoint_hint,
1444    })
1445}
1446
1447fn legacy_plugin_ir_dialect(source_kind: PluginSourceKind) -> PluginContractDialect {
1448    match source_kind {
1449        PluginSourceKind::PackageManifest => PluginContractDialect::LoongPackageManifest,
1450        PluginSourceKind::EmbeddedSource => PluginContractDialect::LoongEmbeddedSource,
1451    }
1452}
1453
1454fn legacy_plugin_ir_runtime_profile(
1455    source_path: &str,
1456    source_kind: PluginSourceKind,
1457    metadata: &BTreeMap<String, String>,
1458    endpoint: Option<&str>,
1459) -> PluginRuntimeProfile {
1460    let source_language = legacy_plugin_ir_source_language(source_path, source_kind);
1461    infer_runtime_profile_from_parts(&source_language, metadata, endpoint)
1462}
1463
1464fn legacy_plugin_ir_source_language(source_path: &str, source_kind: PluginSourceKind) -> String {
1465    if source_kind == PluginSourceKind::PackageManifest {
1466        return "unknown".to_owned();
1467    }
1468
1469    let extension = Path::new(source_path)
1470        .extension()
1471        .and_then(|value| value.to_str())
1472        .unwrap_or_default();
1473    normalize_language(extension)
1474}
1475
1476fn normalize_language(language: &str) -> String {
1477    match language.trim().to_ascii_lowercase().as_str() {
1478        "rs" => "rust".to_owned(),
1479        "py" => "python".to_owned(),
1480        "js" => "javascript".to_owned(),
1481        "ts" => "typescript".to_owned(),
1482        "go" => "go".to_owned(),
1483        "wasm" => "wasm".to_owned(),
1484        "" => "unknown".to_owned(),
1485        other => other.to_owned(),
1486    }
1487}
1488
1489fn parse_bridge_kind(raw: &str) -> Option<PluginBridgeKind> {
1490    match raw.trim().to_ascii_lowercase().as_str() {
1491        "http_json" | "http" => Some(PluginBridgeKind::HttpJson),
1492        "process_stdio" | "stdio" => Some(PluginBridgeKind::ProcessStdio),
1493        "native_ffi" | "ffi" => Some(PluginBridgeKind::NativeFfi),
1494        "wasm_component" | "wasm" => Some(PluginBridgeKind::WasmComponent),
1495        "mcp_server" | "mcp" => Some(PluginBridgeKind::McpServer),
1496        "acp_bridge" | "acp" => Some(PluginBridgeKind::AcpBridge),
1497        "acp_runtime" | "acpx" => Some(PluginBridgeKind::AcpRuntime),
1498        "unknown" => Some(PluginBridgeKind::Unknown),
1499        _ => None,
1500    }
1501}
1502
1503fn default_bridge_kind(language: &str, endpoint: Option<&str>) -> PluginBridgeKind {
1504    match language {
1505        "rust" | "go" | "c" | "cpp" | "cxx" => PluginBridgeKind::NativeFfi,
1506        "python" | "javascript" | "typescript" | "java" => PluginBridgeKind::ProcessStdio,
1507        "wasm" | "wat" => PluginBridgeKind::WasmComponent,
1508        _ => {
1509            if let Some(endpoint) = endpoint
1510                && (endpoint.starts_with("http://") || endpoint.starts_with("https://"))
1511            {
1512                return PluginBridgeKind::HttpJson;
1513            }
1514            PluginBridgeKind::Unknown
1515        }
1516    }
1517}
1518
1519fn default_adapter_family(language: &str, bridge_kind: PluginBridgeKind) -> String {
1520    match bridge_kind {
1521        PluginBridgeKind::HttpJson => "http-adapter".to_owned(),
1522        PluginBridgeKind::ProcessStdio => format!("{language}-stdio-adapter"),
1523        PluginBridgeKind::NativeFfi => format!("{language}-ffi-adapter"),
1524        PluginBridgeKind::WasmComponent => "wasm-component-adapter".to_owned(),
1525        PluginBridgeKind::McpServer => "mcp-adapter".to_owned(),
1526        PluginBridgeKind::AcpBridge => "acp-bridge-adapter".to_owned(),
1527        PluginBridgeKind::AcpRuntime => "acp-runtime-adapter".to_owned(),
1528        PluginBridgeKind::Unknown => format!("{language}-unknown-adapter"),
1529    }
1530}
1531
1532fn default_entrypoint_hint(
1533    bridge_kind: PluginBridgeKind,
1534    endpoint: Option<&str>,
1535) -> Option<String> {
1536    match bridge_kind {
1537        PluginBridgeKind::HttpJson => {
1538            Some(endpoint.unwrap_or("https://localhost/invoke").to_owned())
1539        }
1540        PluginBridgeKind::ProcessStdio => Some("stdin/stdout::invoke".to_owned()),
1541        PluginBridgeKind::NativeFfi => Some("lib::invoke".to_owned()),
1542        PluginBridgeKind::WasmComponent => Some("component::run".to_owned()),
1543        PluginBridgeKind::McpServer => Some("mcp::stdio".to_owned()),
1544        PluginBridgeKind::AcpBridge => Some("acp::bridge".to_owned()),
1545        PluginBridgeKind::AcpRuntime => Some("acp::turn".to_owned()),
1546        PluginBridgeKind::Unknown => None,
1547    }
1548}
1549
1550fn bootstrap_hint(ir: &PluginIR) -> String {
1551    let compatibility_prefix = PluginCompatibilityShim::for_mode(ir.compatibility_mode)
1552        .map(|shim| {
1553            format!(
1554                "enable compatibility shim `{}` ({}) and then ",
1555                shim.shim_id, shim.family
1556            )
1557        })
1558        .unwrap_or_default();
1559
1560    match ir.runtime.bridge_kind {
1561        PluginBridgeKind::HttpJson => format!(
1562            "{}register http connector adapter for {} at {}",
1563            compatibility_prefix,
1564            ir.connector_name,
1565            ir.endpoint.as_deref().unwrap_or("https://localhost/invoke")
1566        ),
1567        PluginBridgeKind::ProcessStdio => format!(
1568            "{}spawn {} worker and bind stdio bridge {}",
1569            compatibility_prefix, ir.runtime.source_language, ir.runtime.entrypoint_hint
1570        ),
1571        PluginBridgeKind::NativeFfi => format!(
1572            "{}load native library adapter {} with symbol {}",
1573            compatibility_prefix, ir.runtime.adapter_family, ir.runtime.entrypoint_hint
1574        ),
1575        PluginBridgeKind::WasmComponent => {
1576            format!(
1577                "{}load wasm component and invoke {}",
1578                compatibility_prefix, ir.runtime.entrypoint_hint
1579            )
1580        }
1581        PluginBridgeKind::McpServer => format!(
1582            "{}register MCP server bridge and handshake capability schema",
1583            compatibility_prefix
1584        ),
1585        PluginBridgeKind::AcpBridge => format!(
1586            "{}register ACP bridge surface and bind the external gateway/runtime contract",
1587            compatibility_prefix
1588        ),
1589        PluginBridgeKind::AcpRuntime => {
1590            format!(
1591                "{}register ACP runtime backend and bind a session-aware control plane",
1592                compatibility_prefix
1593            )
1594        }
1595        PluginBridgeKind::Unknown => format!(
1596            "{}inspect plugin metadata and define explicit bridge_kind override",
1597            compatibility_prefix
1598        ),
1599    }
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604    use super::*;
1605    use crate::{
1606        integration::ProviderConfig,
1607        plugin::{
1608            CURRENT_PLUGIN_HOST_API, CURRENT_PLUGIN_MANIFEST_API_VERSION, PluginCompatibility,
1609            PluginCompatibilityMode, PluginContractDialect, PluginDiagnosticCode,
1610            PluginDiagnosticFinding, PluginDiagnosticPhase, PluginDiagnosticSeverity,
1611            PluginManifest, PluginSetup, PluginSetupMode, PluginSlotClaim, PluginSlotMode,
1612            PluginSourceKind, PluginTrustTier,
1613        },
1614    };
1615
1616    fn descriptor(language: &str, metadata: BTreeMap<String, String>) -> PluginDescriptor {
1617        let source_kind = if language == "manifest" {
1618            PluginSourceKind::PackageManifest
1619        } else {
1620            PluginSourceKind::EmbeddedSource
1621        };
1622        let path = if language == "manifest" {
1623            "/tmp/loong.plugin.json".to_owned()
1624        } else {
1625            format!("/tmp/plugin.{language}")
1626        };
1627        let package_manifest_path = if matches!(source_kind, PluginSourceKind::PackageManifest) {
1628            Some(path.clone())
1629        } else {
1630            None
1631        };
1632
1633        PluginDescriptor {
1634            path,
1635            source_kind,
1636            dialect: match source_kind {
1637                PluginSourceKind::PackageManifest => PluginContractDialect::LoongPackageManifest,
1638                PluginSourceKind::EmbeddedSource => PluginContractDialect::LoongEmbeddedSource,
1639            },
1640            dialect_version: matches!(source_kind, PluginSourceKind::PackageManifest)
1641                .then(|| CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1642            compatibility_mode: PluginCompatibilityMode::Native,
1643            package_root: "/tmp".to_owned(),
1644            package_manifest_path,
1645            language: language.to_owned(),
1646            manifest: PluginManifest {
1647                api_version: matches!(source_kind, PluginSourceKind::PackageManifest)
1648                    .then(|| CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1649                version: Some("1.0.0".to_owned()),
1650                plugin_id: format!("sample-{language}"),
1651                provider_id: "sample-provider".to_owned(),
1652                connector_name: "sample-connector".to_owned(),
1653                channel_id: Some("primary".to_owned()),
1654                endpoint: Some("https://example.com/invoke".to_owned()),
1655                capabilities: BTreeSet::from([Capability::InvokeConnector]),
1656                trust_tier: PluginTrustTier::VerifiedCommunity,
1657                metadata,
1658                summary: None,
1659                tags: Vec::new(),
1660                input_examples: Vec::new(),
1661                output_examples: Vec::new(),
1662                defer_loading: false,
1663                setup: Some(PluginSetup {
1664                    mode: PluginSetupMode::MetadataOnly,
1665                    surface: Some("web_search".to_owned()),
1666                    required_env_vars: vec!["TAVILY_API_KEY".to_owned()],
1667                    recommended_env_vars: vec!["TEAM_TAVILY_KEY".to_owned()],
1668                    required_config_keys: vec!["tools.web_search.default_provider".to_owned()],
1669                    default_env_var: Some("TAVILY_API_KEY".to_owned()),
1670                    docs_urls: vec!["https://docs.example.com/tavily".to_owned()],
1671                    remediation: Some("set a Tavily credential before enabling search".to_owned()),
1672                }),
1673                slot_claims: Vec::new(),
1674                compatibility: None,
1675            },
1676        }
1677    }
1678
1679    fn verified_setup_readiness_context() -> PluginSetupReadinessContext {
1680        PluginSetupReadinessContext {
1681            verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
1682            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
1683        }
1684    }
1685
1686    fn channel_bridge_descriptor(metadata: BTreeMap<String, String>) -> PluginDescriptor {
1687        let mut descriptor = descriptor("manifest", metadata);
1688
1689        descriptor.manifest.plugin_id = "weixin-clawbot-bridge".to_owned();
1690        descriptor.manifest.provider_id = "weixin-bridge".to_owned();
1691        descriptor.manifest.connector_name = "weixin-clawbot-http".to_owned();
1692        descriptor.manifest.channel_id = Some("weixin".to_owned());
1693        descriptor.manifest.endpoint = Some("http://127.0.0.1:8091/bridge".to_owned());
1694        descriptor
1695            .manifest
1696            .metadata
1697            .entry("adapter_family".to_owned())
1698            .or_insert_with(|| "channel-bridge".to_owned());
1699        descriptor.manifest.setup = Some(PluginSetup {
1700            mode: PluginSetupMode::MetadataOnly,
1701            surface: Some("channel".to_owned()),
1702            required_env_vars: vec!["WEIXIN_BRIDGE_URL".to_owned()],
1703            recommended_env_vars: vec!["WEIXIN_BRIDGE_ACCESS_TOKEN".to_owned()],
1704            required_config_keys: vec![
1705                "weixin.enabled".to_owned(),
1706                "weixin.bridge_url".to_owned(),
1707                "weixin.bridge_access_token".to_owned(),
1708            ],
1709            default_env_var: Some("WEIXIN_BRIDGE_URL".to_owned()),
1710            docs_urls: vec!["https://docs.example.com/weixin-bridge".to_owned()],
1711            remediation: Some("configure the sanctioned weixin bridge contract".to_owned()),
1712        });
1713
1714        descriptor
1715    }
1716
1717    #[test]
1718    fn translator_infers_bridge_from_source_language() {
1719        let scanner_report = PluginScanReport {
1720            scanned_files: 2,
1721            matched_plugins: 2,
1722            diagnostic_findings: Vec::new(),
1723            descriptors: vec![
1724                descriptor("rs", BTreeMap::new()),
1725                descriptor("py", BTreeMap::new()),
1726            ],
1727        };
1728
1729        let translator = PluginTranslator::new();
1730        let report = translator.translate_scan_report(&scanner_report);
1731
1732        assert_eq!(report.translated_plugins, 2);
1733        assert_eq!(
1734            report.bridge_distribution.get("native_ffi").copied(),
1735            Some(1)
1736        );
1737        assert_eq!(
1738            report.bridge_distribution.get("process_stdio").copied(),
1739            Some(1)
1740        );
1741        assert!(
1742            report
1743                .entries
1744                .iter()
1745                .all(|entry| entry.trust_tier == PluginTrustTier::VerifiedCommunity)
1746        );
1747    }
1748
1749    #[test]
1750    fn translator_honors_metadata_bridge_override() {
1751        let descriptor = descriptor(
1752            "js",
1753            BTreeMap::from([
1754                ("bridge_kind".to_owned(), "mcp_server".to_owned()),
1755                ("entrypoint".to_owned(), "custom::run".to_owned()),
1756            ]),
1757        );
1758
1759        let translator = PluginTranslator::new();
1760        let ir = translator.translate_descriptor(&descriptor);
1761
1762        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::McpServer);
1763        assert_eq!(ir.runtime.entrypoint_hint, "custom::run");
1764        assert_eq!(ir.runtime.adapter_family, "mcp-adapter");
1765    }
1766
1767    #[test]
1768    fn translator_honors_metadata_source_language_for_package_manifests() {
1769        let descriptor = descriptor(
1770            "manifest",
1771            BTreeMap::from([
1772                ("bridge_kind".to_owned(), "process_stdio".to_owned()),
1773                ("source_language".to_owned(), "py".to_owned()),
1774            ]),
1775        );
1776
1777        let translator = PluginTranslator::new();
1778        let ir = translator.translate_descriptor(&descriptor);
1779
1780        assert_eq!(ir.runtime.source_language, "python");
1781        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::ProcessStdio);
1782        assert_eq!(ir.runtime.adapter_family, "python-stdio-adapter");
1783        assert_eq!(ir.runtime.entrypoint_hint, "stdin/stdout::invoke");
1784    }
1785
1786    #[test]
1787    fn translator_defaults_manifest_descriptor_with_endpoint_to_http_json() {
1788        let descriptor = descriptor("manifest", BTreeMap::new());
1789
1790        let translator = PluginTranslator::new();
1791        let ir = translator.translate_descriptor(&descriptor);
1792
1793        assert_eq!(ir.runtime.source_language, "manifest");
1794        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::HttpJson);
1795        assert_eq!(ir.runtime.adapter_family, "http-adapter");
1796        assert_eq!(ir.trust_tier, PluginTrustTier::VerifiedCommunity);
1797        assert_eq!(ir.source_kind, PluginSourceKind::PackageManifest);
1798        assert_eq!(ir.package_root, "/tmp");
1799        assert_eq!(
1800            ir.setup.as_ref().and_then(|setup| setup.surface.as_deref()),
1801            Some("web_search")
1802        );
1803        assert_eq!(
1804            ir.package_manifest_path,
1805            Some("/tmp/loong.plugin.json".to_owned())
1806        );
1807    }
1808
1809    #[test]
1810    fn translator_derives_channel_bridge_contract_from_manifest_conventions() {
1811        let descriptor = channel_bridge_descriptor(BTreeMap::from([
1812            (
1813                "transport_family".to_owned(),
1814                "wechat_clawbot_ilink_bridge".to_owned(),
1815            ),
1816            (
1817                "target_contract".to_owned(),
1818                "weixin:<account>:contact:<id> | weixin:<account>:room:<id>".to_owned(),
1819            ),
1820            ("account_scope".to_owned(), "multi_account".to_owned()),
1821            (
1822                "channel_runtime_contract".to_owned(),
1823                "loong_channel_bridge_v1".to_owned(),
1824            ),
1825            (
1826                "channel_runtime_operations_json".to_owned(),
1827                "[\"send\",\"receive_batch\",\"ack_inbound\",\"complete_batch\"]".to_owned(),
1828            ),
1829        ]));
1830
1831        let translator = PluginTranslator::new();
1832        let ir = translator.translate_descriptor(&descriptor);
1833
1834        let channel_bridge = ir
1835            .channel_bridge
1836            .as_ref()
1837            .expect("channel bridge contract should exist");
1838
1839        assert_eq!(channel_bridge.channel_id.as_deref(), Some("weixin"));
1840        assert_eq!(channel_bridge.setup_surface.as_deref(), Some("channel"));
1841        assert_eq!(
1842            channel_bridge.transport_family.as_deref(),
1843            Some("wechat_clawbot_ilink_bridge")
1844        );
1845        assert_eq!(
1846            channel_bridge.target_contract.as_deref(),
1847            Some("weixin:<account>:contact:<id> | weixin:<account>:room:<id>")
1848        );
1849        assert_eq!(
1850            channel_bridge.account_scope.as_deref(),
1851            Some("multi_account")
1852        );
1853        assert_eq!(
1854            channel_bridge.runtime_contract.as_deref(),
1855            Some("loong_channel_bridge_v1")
1856        );
1857        assert_eq!(
1858            channel_bridge.runtime_operations,
1859            vec![
1860                "send".to_owned(),
1861                "receive_batch".to_owned(),
1862                "ack_inbound".to_owned(),
1863                "complete_batch".to_owned(),
1864            ]
1865        );
1866        assert!(channel_bridge.readiness.ready);
1867        assert!(channel_bridge.readiness.missing_fields.is_empty());
1868    }
1869
1870    #[test]
1871    fn translator_marks_declared_channel_bridge_contract_incomplete_when_required_fields_are_missing()
1872     {
1873        let descriptor = channel_bridge_descriptor(BTreeMap::new());
1874
1875        let translator = PluginTranslator::new();
1876        let ir = translator.translate_descriptor(&descriptor);
1877
1878        let channel_bridge = ir
1879            .channel_bridge
1880            .as_ref()
1881            .expect("channel bridge contract should exist");
1882
1883        assert!(!channel_bridge.readiness.ready);
1884        assert_eq!(
1885            channel_bridge.readiness.missing_fields,
1886            vec![
1887                "metadata.transport_family".to_owned(),
1888                "metadata.target_contract".to_owned(),
1889            ]
1890        );
1891    }
1892
1893    #[test]
1894    fn translator_preserves_invalid_runtime_operations_metadata_json_as_issue() {
1895        let descriptor = channel_bridge_descriptor(BTreeMap::from([
1896            (
1897                "transport_family".to_owned(),
1898                "wechat_clawbot_ilink_bridge".to_owned(),
1899            ),
1900            (
1901                "target_contract".to_owned(),
1902                "weixin:<account>:contact:<id> | weixin:<account>:room:<id>".to_owned(),
1903            ),
1904            (
1905                "channel_runtime_operations_json".to_owned(),
1906                "{not-json}".to_owned(),
1907            ),
1908        ]));
1909
1910        let translator = PluginTranslator::new();
1911        let ir = translator.translate_descriptor(&descriptor);
1912        let channel_bridge = ir
1913            .channel_bridge
1914            .as_ref()
1915            .expect("channel bridge contract should exist");
1916
1917        assert!(channel_bridge.runtime_operations.is_empty());
1918        assert_eq!(channel_bridge.runtime_metadata_issues.len(), 1);
1919        let issue = channel_bridge
1920            .runtime_metadata_issues
1921            .first()
1922            .expect("runtime metadata issue should exist");
1923        assert!(issue.starts_with(
1924            "metadata.channel_runtime_operations_json must be valid json string array:"
1925        ));
1926    }
1927
1928    #[test]
1929    fn translator_does_not_treat_channel_surface_only_setup_as_channel_bridge_contract() {
1930        let mut descriptor = descriptor("manifest", BTreeMap::new());
1931
1932        descriptor.manifest.channel_id = Some("weather".to_owned());
1933        descriptor.manifest.setup = Some(PluginSetup {
1934            mode: PluginSetupMode::GovernedEntry,
1935            surface: Some("channel".to_owned()),
1936            required_env_vars: vec!["WEATHER_API_KEY".to_owned()],
1937            recommended_env_vars: Vec::new(),
1938            required_config_keys: vec!["plugins.entries.weather-sdk".to_owned()],
1939            default_env_var: Some("WEATHER_API_KEY".to_owned()),
1940            docs_urls: vec!["https://example.com/weather-sdk".to_owned()],
1941            remediation: Some("configure the weather sdk before activation".to_owned()),
1942        });
1943
1944        let translator = PluginTranslator::new();
1945        let ir = translator.translate_descriptor(&descriptor);
1946
1947        assert!(
1948            ir.channel_bridge.is_none(),
1949            "plain channel-surface setup should not be treated as a managed channel bridge"
1950        );
1951    }
1952
1953    #[test]
1954    fn plugin_runtime_scaffold_defaults_require_source_language_for_process_stdio() {
1955        let error = plugin_runtime_scaffold_defaults(PluginBridgeKind::ProcessStdio, None)
1956            .expect_err("process bridge scaffold should require source language");
1957
1958        assert!(error.contains("source language"));
1959        assert!(error.contains("process_stdio"));
1960    }
1961
1962    #[test]
1963    fn plugin_runtime_scaffold_defaults_require_source_language_for_native_ffi() {
1964        let error = plugin_runtime_scaffold_defaults(PluginBridgeKind::NativeFfi, None)
1965            .expect_err("native ffi scaffold should require source language");
1966
1967        assert!(error.contains("source language"));
1968        assert!(error.contains("native_ffi"));
1969    }
1970
1971    #[test]
1972    fn plugin_runtime_scaffold_defaults_normalize_python_process_bridge() {
1973        let defaults = plugin_runtime_scaffold_defaults(PluginBridgeKind::ProcessStdio, Some("py"))
1974            .expect("python process bridge scaffold defaults should resolve");
1975
1976        assert_eq!(defaults.source_language.as_deref(), Some("python"));
1977        assert_eq!(defaults.bridge_kind, PluginBridgeKind::ProcessStdio);
1978        assert_eq!(defaults.adapter_family, "python-stdio-adapter");
1979        assert_eq!(defaults.entrypoint_hint, "stdin/stdout::invoke");
1980    }
1981
1982    #[test]
1983    fn translator_accepts_acpx_runtime_alias() {
1984        let descriptor = descriptor(
1985            "js",
1986            BTreeMap::from([("bridge_kind".to_owned(), "acpx".to_owned())]),
1987        );
1988
1989        let translator = PluginTranslator::new();
1990        let ir = translator.translate_descriptor(&descriptor);
1991
1992        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::AcpRuntime);
1993        assert_eq!(ir.runtime.adapter_family, "acp-runtime-adapter");
1994        assert_eq!(ir.runtime.entrypoint_hint, "acp::turn");
1995    }
1996
1997    #[test]
1998    fn translator_maps_acp_alias_to_bridge_surface() {
1999        let descriptor = descriptor(
2000            "js",
2001            BTreeMap::from([("bridge_kind".to_owned(), "acp".to_owned())]),
2002        );
2003
2004        let translator = PluginTranslator::new();
2005        let ir = translator.translate_descriptor(&descriptor);
2006
2007        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::AcpBridge);
2008        assert_eq!(ir.runtime.adapter_family, "acp-bridge-adapter");
2009        assert_eq!(ir.runtime.entrypoint_hint, "acp::bridge");
2010    }
2011
2012    #[test]
2013    fn translator_projects_scan_diagnostics_into_ir() {
2014        let descriptor = descriptor("js", BTreeMap::new());
2015        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2016            scanned_files: 1,
2017            matched_plugins: 1,
2018            diagnostic_findings: vec![PluginDiagnosticFinding {
2019                code: PluginDiagnosticCode::EmbeddedSourceLegacyContract,
2020                severity: PluginDiagnosticSeverity::Warning,
2021                phase: PluginDiagnosticPhase::Scan,
2022                blocking: false,
2023                plugin_id: Some("sample-js".to_owned()),
2024                source_path: Some("/tmp/plugin.js".to_owned()),
2025                source_kind: Some(PluginSourceKind::EmbeddedSource),
2026                field_path: None,
2027                message: "legacy source marker".to_owned(),
2028                remediation: Some("add loong.plugin.json".to_owned()),
2029            }],
2030            descriptors: vec![descriptor],
2031        });
2032
2033        assert_eq!(translation.entries.len(), 1);
2034        assert_eq!(translation.entries[0].diagnostic_findings.len(), 1);
2035        assert_eq!(
2036            translation.entries[0].diagnostic_findings[0].code,
2037            PluginDiagnosticCode::EmbeddedSourceLegacyContract
2038        );
2039        assert_eq!(
2040            translation.entries[0].diagnostic_findings[0].phase,
2041            PluginDiagnosticPhase::Scan
2042        );
2043        assert!(!translation.entries[0].diagnostic_findings[0].blocking);
2044    }
2045
2046    #[test]
2047    fn activation_plan_marks_setup_incomplete_when_required_setup_is_missing() {
2048        let descriptor = descriptor(
2049            "js",
2050            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2051        );
2052        let translator = PluginTranslator::new();
2053        let translation = translator.translate_scan_report(&PluginScanReport {
2054            scanned_files: 1,
2055            matched_plugins: 1,
2056            diagnostic_findings: Vec::new(),
2057            descriptors: vec![descriptor],
2058        });
2059
2060        let matrix = BridgeSupportMatrix {
2061            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2062            supported_adapter_families: BTreeSet::new(),
2063            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2064            supported_compatibility_shims: BTreeSet::new(),
2065            supported_compatibility_shim_profiles: BTreeMap::new(),
2066        };
2067        let setup_readiness_context = PluginSetupReadinessContext::default();
2068        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2069
2070        assert_eq!(plan.total_plugins, 1);
2071        assert_eq!(plan.ready_plugins, 0);
2072        assert_eq!(plan.setup_incomplete_plugins, 1);
2073        assert_eq!(plan.blocked_plugins, 0);
2074        assert_eq!(
2075            plan.candidates[0].source_kind,
2076            PluginSourceKind::EmbeddedSource
2077        );
2078        assert_eq!(plan.candidates[0].package_root, "/tmp");
2079        assert_eq!(plan.candidates[0].package_manifest_path, None);
2080        assert_eq!(
2081            plan.candidates[0].trust_tier,
2082            PluginTrustTier::VerifiedCommunity
2083        );
2084        assert!(matches!(
2085            plan.candidates[0].status,
2086            PluginActivationStatus::SetupIncomplete
2087        ));
2088        assert_eq!(
2089            plan.candidates[0].missing_required_env_vars,
2090            vec!["TAVILY_API_KEY".to_owned()]
2091        );
2092        assert_eq!(
2093            plan.candidates[0].missing_required_config_keys,
2094            vec!["tools.web_search.default_provider".to_owned()]
2095        );
2096    }
2097
2098    #[test]
2099    fn activation_plan_blocks_declared_channel_bridge_when_manifest_contract_fields_are_missing() {
2100        let descriptor = channel_bridge_descriptor(BTreeMap::new());
2101        let translator = PluginTranslator::new();
2102        let translation = translator.translate_scan_report(&PluginScanReport {
2103            scanned_files: 1,
2104            matched_plugins: 1,
2105            diagnostic_findings: Vec::new(),
2106            descriptors: vec![descriptor],
2107        });
2108
2109        let matrix = BridgeSupportMatrix {
2110            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2111            supported_adapter_families: BTreeSet::new(),
2112            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2113            supported_compatibility_shims: BTreeSet::new(),
2114            supported_compatibility_shim_profiles: BTreeMap::new(),
2115        };
2116        let setup_readiness_context = PluginSetupReadinessContext {
2117            verified_env_vars: BTreeSet::from(["WEIXIN_BRIDGE_URL".to_owned()]),
2118            verified_config_keys: BTreeSet::from([
2119                "weixin.enabled".to_owned(),
2120                "weixin.bridge_url".to_owned(),
2121                "weixin.bridge_access_token".to_owned(),
2122            ]),
2123        };
2124        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2125
2126        assert_eq!(plan.ready_plugins, 0);
2127        assert_eq!(plan.setup_incomplete_plugins, 0);
2128        assert_eq!(plan.blocked_plugins, 1);
2129        assert!(matches!(
2130            plan.candidates[0].status,
2131            PluginActivationStatus::BlockedInvalidManifestContract
2132        ));
2133        assert!(
2134            plan.candidates[0]
2135                .reason
2136                .contains("metadata.transport_family"),
2137            "reason should surface missing transport family"
2138        );
2139        assert!(
2140            plan.candidates[0]
2141                .reason
2142                .contains("metadata.target_contract"),
2143            "reason should surface missing target contract"
2144        );
2145        assert!(
2146            plan.candidates[0]
2147                .diagnostic_findings
2148                .iter()
2149                .any(|finding| finding.code == PluginDiagnosticCode::InvalidManifestContract)
2150        );
2151    }
2152
2153    #[test]
2154    fn evaluate_plugin_setup_requirements_uses_platform_env_name_rules() {
2155        let required_env_vars = vec!["PATH".to_owned()];
2156        let required_config_keys = Vec::new();
2157        let context = PluginSetupReadinessContext {
2158            verified_env_vars: BTreeSet::from(["Path".to_owned()]),
2159            verified_config_keys: BTreeSet::new(),
2160        };
2161
2162        let readiness =
2163            evaluate_plugin_setup_requirements(&required_env_vars, &required_config_keys, &context);
2164
2165        #[cfg(windows)]
2166        {
2167            assert!(readiness.ready);
2168            assert!(readiness.missing_required_env_vars.is_empty());
2169        }
2170
2171        #[cfg(not(windows))]
2172        {
2173            assert!(!readiness.ready);
2174            assert_eq!(readiness.missing_required_env_vars, required_env_vars);
2175        }
2176    }
2177
2178    #[test]
2179    fn activation_plan_deserializes_legacy_payloads_without_setup_fields() {
2180        let legacy_payload = r#"{
2181            "total_plugins": 1,
2182            "ready_plugins": 1,
2183            "blocked_plugins": 0,
2184            "candidates": [
2185                {
2186                    "plugin_id": "sample-plugin",
2187                    "source_path": "/tmp/plugin.py",
2188                    "source_kind": "embedded_source",
2189                    "package_root": "/tmp",
2190                    "package_manifest_path": null,
2191                    "bridge_kind": "http_json",
2192                    "adapter_family": "http-adapter",
2193                    "status": "ready",
2194                    "reason": "plugin runtime profile is supported by current runtime matrix",
2195                    "bootstrap_hint": "register http connector adapter"
2196                }
2197            ]
2198        }"#;
2199
2200        let plan: PluginActivationPlan =
2201            serde_json::from_str(legacy_payload).expect("legacy payload should deserialize");
2202
2203        assert_eq!(plan.setup_incomplete_plugins, 0);
2204        assert_eq!(plan.candidates.len(), 1);
2205        assert!(plan.candidates[0].missing_required_env_vars.is_empty());
2206        assert!(plan.candidates[0].missing_required_config_keys.is_empty());
2207    }
2208
2209    #[test]
2210    fn plugin_ir_deserializes_legacy_embedded_source_payload_with_inferred_defaults() {
2211        let raw = r#"{
2212            "plugin_id": "legacy-plugin",
2213            "provider_id": "legacy-provider",
2214            "connector_name": "legacy-connector",
2215            "capabilities": [],
2216            "metadata": {},
2217            "source_path": "/tmp/legacy-plugin.py",
2218            "source_kind": "embedded_source",
2219            "package_root": "/tmp"
2220        }"#;
2221
2222        let ir: PluginIR =
2223            serde_json::from_str(raw).expect("legacy embedded-source payload should deserialize");
2224
2225        assert_eq!(ir.dialect, PluginContractDialect::LoongEmbeddedSource);
2226        assert_eq!(ir.compatibility_mode, PluginCompatibilityMode::Native);
2227        assert!(ir.diagnostic_findings.is_empty());
2228        assert!(ir.slot_claims.is_empty());
2229        assert_eq!(ir.runtime.source_language, "python");
2230        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::ProcessStdio);
2231        assert_eq!(ir.runtime.adapter_family, "python-stdio-adapter");
2232    }
2233
2234    #[test]
2235    fn plugin_ir_deserializes_legacy_package_manifest_payload_with_inferred_defaults() {
2236        let raw = r#"{
2237            "plugin_id": "legacy-package",
2238            "provider_id": "legacy-provider",
2239            "connector_name": "legacy-connector",
2240            "endpoint": "https://plugins.example.test/invoke",
2241            "capabilities": [],
2242            "metadata": {},
2243            "source_path": "/tmp/loong.plugin.json",
2244            "source_kind": "package_manifest",
2245            "package_root": "/tmp"
2246        }"#;
2247
2248        let ir: PluginIR =
2249            serde_json::from_str(raw).expect("legacy package payload should deserialize");
2250
2251        assert_eq!(ir.dialect, PluginContractDialect::LoongPackageManifest);
2252        assert_eq!(ir.compatibility_mode, PluginCompatibilityMode::Native);
2253        assert_eq!(ir.runtime.source_language, "unknown");
2254        assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::HttpJson);
2255        assert_eq!(
2256            ir.runtime.entrypoint_hint,
2257            "https://plugins.example.test/invoke"
2258        );
2259    }
2260
2261    #[test]
2262    fn plugin_activation_status_deserializes_unknown_variants_as_unknown() {
2263        let raw = "\"blocked_future_contract_surface\"";
2264        let status: PluginActivationStatus =
2265            serde_json::from_str(raw).expect("unknown activation status should deserialize");
2266
2267        assert_eq!(status, PluginActivationStatus::Unknown);
2268        assert_eq!(status.as_str(), "unknown");
2269    }
2270
2271    #[test]
2272    fn activation_plan_blocks_unsupported_bridge() {
2273        let descriptor = descriptor(
2274            "js",
2275            BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
2276        );
2277        let translator = PluginTranslator::new();
2278        let translation = translator.translate_scan_report(&PluginScanReport {
2279            scanned_files: 1,
2280            matched_plugins: 1,
2281            diagnostic_findings: Vec::new(),
2282            descriptors: vec![descriptor],
2283        });
2284
2285        let matrix = BridgeSupportMatrix {
2286            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2287            supported_adapter_families: BTreeSet::new(),
2288            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2289            supported_compatibility_shims: BTreeSet::new(),
2290            supported_compatibility_shim_profiles: BTreeMap::new(),
2291        };
2292        let setup_readiness_context = PluginSetupReadinessContext::default();
2293        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2294
2295        assert_eq!(plan.total_plugins, 1);
2296        assert_eq!(plan.ready_plugins, 0);
2297        assert_eq!(plan.blocked_plugins, 1);
2298        assert_eq!(
2299            plan.candidates[0].source_kind,
2300            PluginSourceKind::EmbeddedSource
2301        );
2302        assert_eq!(plan.candidates[0].package_root, "/tmp");
2303        assert_eq!(plan.candidates[0].package_manifest_path, None);
2304        assert!(matches!(
2305            plan.candidates[0].status,
2306            PluginActivationStatus::BlockedUnsupportedBridge
2307        ));
2308        assert_eq!(
2309            plan.candidates[0].diagnostic_findings[0].code,
2310            PluginDiagnosticCode::UnsupportedBridge
2311        );
2312        assert_eq!(
2313            plan.candidates[0].diagnostic_findings[0].phase,
2314            PluginDiagnosticPhase::Activation
2315        );
2316        assert!(plan.candidates[0].diagnostic_findings[0].blocking);
2317    }
2318
2319    #[test]
2320    fn activation_plan_blocks_unsupported_adapter_family() {
2321        let descriptor = descriptor(
2322            "py",
2323            BTreeMap::from([(
2324                "adapter_family".to_owned(),
2325                "python-stdio-adapter".to_owned(),
2326            )]),
2327        );
2328        let translator = PluginTranslator::new();
2329        let translation = translator.translate_scan_report(&PluginScanReport {
2330            scanned_files: 1,
2331            matched_plugins: 1,
2332            diagnostic_findings: Vec::new(),
2333            descriptors: vec![descriptor],
2334        });
2335
2336        let matrix = BridgeSupportMatrix {
2337            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2338            supported_adapter_families: BTreeSet::from(["rust-stdio-adapter".to_owned()]),
2339            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2340            supported_compatibility_shims: BTreeSet::new(),
2341            supported_compatibility_shim_profiles: BTreeMap::new(),
2342        };
2343        let setup_readiness_context = PluginSetupReadinessContext {
2344            verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
2345            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
2346        };
2347        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2348
2349        assert_eq!(plan.total_plugins, 1);
2350        assert_eq!(plan.ready_plugins, 0);
2351        assert_eq!(plan.blocked_plugins, 1);
2352        assert!(matches!(
2353            plan.candidates[0].status,
2354            PluginActivationStatus::BlockedUnsupportedAdapterFamily
2355        ));
2356        assert_eq!(
2357            plan.candidates[0].diagnostic_findings[0].code,
2358            PluginDiagnosticCode::UnsupportedAdapterFamily
2359        );
2360    }
2361
2362    #[test]
2363    fn activation_plan_blocks_conflicting_slot_claims_within_translation() {
2364        let mut first = descriptor(
2365            "js",
2366            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2367        );
2368        first.manifest.plugin_id = "search-a".to_owned();
2369        first.manifest.provider_id = "search-a".to_owned();
2370        first.manifest.connector_name = "search-a".to_owned();
2371        first.path = "/tmp/search-a.js".to_owned();
2372        first.manifest.slot_claims = vec![PluginSlotClaim {
2373            slot: "provider:web_search".to_owned(),
2374            key: "tavily".to_owned(),
2375            mode: PluginSlotMode::Exclusive,
2376        }];
2377
2378        let mut second = descriptor(
2379            "ts",
2380            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2381        );
2382        second.manifest.plugin_id = "search-b".to_owned();
2383        second.manifest.provider_id = "search-b".to_owned();
2384        second.manifest.connector_name = "search-b".to_owned();
2385        second.path = "/tmp/search-b.ts".to_owned();
2386        second.manifest.slot_claims = vec![PluginSlotClaim {
2387            slot: "provider:web_search".to_owned(),
2388            key: "tavily".to_owned(),
2389            mode: PluginSlotMode::Exclusive,
2390        }];
2391
2392        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2393            scanned_files: 2,
2394            matched_plugins: 2,
2395            diagnostic_findings: Vec::new(),
2396            descriptors: vec![first, second],
2397        });
2398        let matrix = BridgeSupportMatrix {
2399            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2400            supported_adapter_families: BTreeSet::new(),
2401            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2402            supported_compatibility_shims: BTreeSet::new(),
2403            supported_compatibility_shim_profiles: BTreeMap::new(),
2404        };
2405
2406        let setup_readiness_context = verified_setup_readiness_context();
2407        let plan = PluginTranslator::new().plan_activation(
2408            &translation,
2409            &matrix,
2410            &setup_readiness_context,
2411        );
2412
2413        assert_eq!(plan.total_plugins, 2);
2414        assert_eq!(plan.ready_plugins, 0);
2415        assert_eq!(plan.blocked_plugins, 2);
2416        assert!(plan.candidates.iter().all(|candidate| matches!(
2417            candidate.status,
2418            PluginActivationStatus::BlockedSlotClaimConflict
2419        )));
2420        assert!(
2421            plan.candidates[0].reason.contains("provider:web_search"),
2422            "slot conflict reason should mention the claimed surface"
2423        );
2424        assert!(plan.candidates.iter().all(|candidate| {
2425            candidate
2426                .diagnostic_findings
2427                .iter()
2428                .any(|finding| finding.code == PluginDiagnosticCode::SlotClaimConflict)
2429        }));
2430    }
2431
2432    #[test]
2433    fn activation_plan_blocks_slot_claim_conflicts_against_existing_catalog() {
2434        let mut descriptor = descriptor(
2435            "js",
2436            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2437        );
2438        descriptor.manifest.plugin_id = "incoming-search".to_owned();
2439        descriptor.manifest.provider_id = "incoming-search".to_owned();
2440        descriptor.manifest.connector_name = "incoming-search".to_owned();
2441        descriptor.path = "/tmp/incoming-search.js".to_owned();
2442        descriptor.manifest.slot_claims = vec![PluginSlotClaim {
2443            slot: "provider:web_search".to_owned(),
2444            key: "tavily".to_owned(),
2445            mode: PluginSlotMode::Exclusive,
2446        }];
2447
2448        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2449            scanned_files: 1,
2450            matched_plugins: 1,
2451            diagnostic_findings: Vec::new(),
2452            descriptors: vec![descriptor],
2453        });
2454        let matrix = BridgeSupportMatrix {
2455            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2456            supported_adapter_families: BTreeSet::new(),
2457            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2458            supported_compatibility_shims: BTreeSet::new(),
2459            supported_compatibility_shim_profiles: BTreeMap::new(),
2460        };
2461        let mut catalog = IntegrationCatalog::new();
2462        catalog.upsert_provider(ProviderConfig {
2463            provider_id: "existing-search".to_owned(),
2464            connector_name: "existing-search".to_owned(),
2465            version: "1.0.0".to_owned(),
2466            metadata: BTreeMap::from([
2467                ("plugin_id".to_owned(), "existing-search".to_owned()),
2468                (
2469                    PLUGIN_SLOT_CLAIMS_METADATA_KEY.to_owned(),
2470                    "[{\"slot\":\"provider:web_search\",\"key\":\"tavily\",\"mode\":\"exclusive\"}]"
2471                        .to_owned(),
2472                ),
2473                (
2474                    "plugin_source_path".to_owned(),
2475                    "/tmp/existing-search.plugin.json".to_owned(),
2476                ),
2477            ]),
2478        });
2479
2480        let setup_readiness_context = PluginSetupReadinessContext::default();
2481        let plan = PluginTranslator::new().plan_activation_with_catalog(
2482            &translation,
2483            &matrix,
2484            &setup_readiness_context,
2485            Some(&catalog),
2486        );
2487
2488        assert_eq!(plan.total_plugins, 1);
2489        assert_eq!(plan.ready_plugins, 0);
2490        assert_eq!(plan.blocked_plugins, 1);
2491        assert!(matches!(
2492            plan.candidates[0].status,
2493            PluginActivationStatus::BlockedSlotClaimConflict
2494        ));
2495        assert!(
2496            plan.candidates[0]
2497                .reason
2498                .contains("existing plugin `existing-search`")
2499        );
2500        assert!(
2501            plan.candidates[0]
2502                .diagnostic_findings
2503                .iter()
2504                .any(|finding| { finding.code == PluginDiagnosticCode::SlotClaimConflict })
2505        );
2506    }
2507
2508    #[test]
2509    fn activation_plan_blocks_incompatible_host_before_bridge_checks() {
2510        let mut descriptor = descriptor(
2511            "js",
2512            BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
2513        );
2514        descriptor.manifest.compatibility = Some(PluginCompatibility {
2515            host_api: Some("loong-plugin/v999".to_owned()),
2516            host_version_req: None,
2517        });
2518
2519        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2520            scanned_files: 1,
2521            matched_plugins: 1,
2522            diagnostic_findings: Vec::new(),
2523            descriptors: vec![descriptor],
2524        });
2525        let matrix = BridgeSupportMatrix {
2526            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2527            supported_adapter_families: BTreeSet::new(),
2528            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2529            supported_compatibility_shims: BTreeSet::new(),
2530            supported_compatibility_shim_profiles: BTreeMap::new(),
2531        };
2532
2533        let setup_readiness_context = verified_setup_readiness_context();
2534        let plan = PluginTranslator::new().plan_activation(
2535            &translation,
2536            &matrix,
2537            &setup_readiness_context,
2538        );
2539
2540        assert_eq!(plan.total_plugins, 1);
2541        assert_eq!(plan.ready_plugins, 0);
2542        assert_eq!(plan.blocked_plugins, 1);
2543        assert!(matches!(
2544            plan.candidates[0].status,
2545            PluginActivationStatus::BlockedIncompatibleHost
2546        ));
2547        assert!(
2548            plan.candidates[0].reason.contains(CURRENT_PLUGIN_HOST_API),
2549            "compatibility reason should mention the supported host api"
2550        );
2551        assert!(
2552            plan.candidates[0]
2553                .diagnostic_findings
2554                .iter()
2555                .any(|finding| { finding.code == PluginDiagnosticCode::IncompatibleHost })
2556        );
2557    }
2558
2559    #[test]
2560    fn activation_plan_projects_inventory_entries_with_activation_truth() {
2561        let descriptor = descriptor(
2562            "manifest",
2563            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2564        );
2565        let translator = PluginTranslator::new();
2566        let translation = translator.translate_scan_report(&PluginScanReport {
2567            scanned_files: 1,
2568            matched_plugins: 1,
2569            diagnostic_findings: Vec::new(),
2570            descriptors: vec![descriptor],
2571        });
2572        let setup_readiness_context = verified_setup_readiness_context();
2573        let plan = translator.plan_activation(
2574            &translation,
2575            &BridgeSupportMatrix::default(),
2576            &setup_readiness_context,
2577        );
2578
2579        let inventory = plan.inventory_entries(&translation);
2580
2581        assert_eq!(inventory.len(), 1);
2582        assert_eq!(inventory[0].plugin_id, "sample-manifest");
2583        assert_eq!(
2584            inventory[0].manifest_api_version.as_deref(),
2585            Some("v1alpha1")
2586        );
2587        assert_eq!(inventory[0].plugin_version.as_deref(), Some("1.0.0"));
2588        assert_eq!(
2589            inventory[0].dialect,
2590            PluginContractDialect::LoongPackageManifest
2591        );
2592        assert_eq!(
2593            inventory[0].compatibility_mode,
2594            PluginCompatibilityMode::Native
2595        );
2596        assert_eq!(inventory[0].provider_id, "sample-provider");
2597        assert_eq!(inventory[0].connector_name, "sample-connector");
2598        assert_eq!(inventory[0].bridge_kind, PluginBridgeKind::HttpJson);
2599        assert_eq!(inventory[0].source_language, "manifest");
2600        assert_eq!(
2601            inventory[0]
2602                .activation_status
2603                .map(|status| status.as_str().to_owned()),
2604            Some("ready".to_owned())
2605        );
2606        assert!(
2607            inventory[0]
2608                .activation_reason
2609                .as_deref()
2610                .is_some_and(|reason| reason.contains("runtime profile"))
2611        );
2612        assert!(inventory[0].bootstrap_hint.is_some());
2613        assert!(inventory[0].diagnostic_findings.is_empty());
2614    }
2615
2616    #[test]
2617    fn activation_plan_blocker_summary_includes_specific_plugin_reasons() {
2618        let mut first = descriptor(
2619            "js",
2620            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2621        );
2622        first.manifest.plugin_id = "search-a".to_owned();
2623        first.manifest.provider_id = "search-a".to_owned();
2624        first.manifest.connector_name = "search-a".to_owned();
2625        first.path = "/tmp/search-a.js".to_owned();
2626        first.manifest.slot_claims = vec![PluginSlotClaim {
2627            slot: "provider:web_search".to_owned(),
2628            key: "default".to_owned(),
2629            mode: PluginSlotMode::Exclusive,
2630        }];
2631
2632        let mut second = descriptor(
2633            "ts",
2634            BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2635        );
2636        second.manifest.plugin_id = "search-b".to_owned();
2637        second.manifest.provider_id = "search-b".to_owned();
2638        second.manifest.connector_name = "search-b".to_owned();
2639        second.path = "/tmp/search-b.ts".to_owned();
2640        second.manifest.slot_claims = vec![PluginSlotClaim {
2641            slot: "provider:web_search".to_owned(),
2642            key: "default".to_owned(),
2643            mode: PluginSlotMode::Exclusive,
2644        }];
2645
2646        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2647            scanned_files: 2,
2648            matched_plugins: 2,
2649            diagnostic_findings: Vec::new(),
2650            descriptors: vec![first, second],
2651        });
2652        let setup_readiness_context = PluginSetupReadinessContext::default();
2653        let plan = PluginTranslator::new().plan_activation(
2654            &translation,
2655            &BridgeSupportMatrix::default(),
2656            &setup_readiness_context,
2657        );
2658
2659        let summary = plan.blocker_summary(1);
2660
2661        assert!(summary.contains("search-a") || summary.contains("search-b"));
2662        assert!(summary.contains("blocked_slot_claim_conflict"));
2663        assert!(summary.contains("provider:web_search"));
2664        assert!(summary.contains("+1 more blocked plugin(s)"));
2665    }
2666
2667    #[test]
2668    fn activation_plan_blocks_unsupported_compatibility_mode() {
2669        let mut descriptor = descriptor(
2670            "js",
2671            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2672        );
2673        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2674        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2675        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2676
2677        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2678            scanned_files: 1,
2679            matched_plugins: 1,
2680            diagnostic_findings: Vec::new(),
2681            descriptors: vec![descriptor],
2682        });
2683        let matrix = BridgeSupportMatrix {
2684            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2685            supported_adapter_families: BTreeSet::new(),
2686            supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2687            supported_compatibility_shims: BTreeSet::new(),
2688            supported_compatibility_shim_profiles: BTreeMap::new(),
2689        };
2690
2691        let setup_readiness_context = verified_setup_readiness_context();
2692        let plan = PluginTranslator::new().plan_activation(
2693            &translation,
2694            &matrix,
2695            &setup_readiness_context,
2696        );
2697
2698        assert_eq!(plan.blocked_plugins, 1);
2699        assert!(matches!(
2700            plan.candidates[0].status,
2701            PluginActivationStatus::BlockedCompatibilityMode
2702        ));
2703        assert_eq!(
2704            plan.candidates[0].compatibility_mode,
2705            PluginCompatibilityMode::OpenClawModern
2706        );
2707        assert_eq!(
2708            plan.candidates[0]
2709                .compatibility_shim
2710                .as_ref()
2711                .map(|shim| shim.shim_id.as_str()),
2712            Some("openclaw-modern-compat")
2713        );
2714        assert!(plan.candidates[0].reason.contains("openclaw-modern-compat"));
2715        assert!(
2716            plan.candidates[0]
2717                .bootstrap_hint
2718                .contains("enable compatibility shim `openclaw-modern-compat`")
2719        );
2720        assert!(
2721            plan.candidates[0]
2722                .diagnostic_findings
2723                .iter()
2724                .any(|finding| {
2725                    finding.code == PluginDiagnosticCode::CompatibilityShimRequired
2726                        && finding.blocking
2727                })
2728        );
2729    }
2730
2731    #[test]
2732    fn activation_plan_allows_supported_compatibility_mode() {
2733        let mut descriptor = descriptor(
2734            "js",
2735            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2736        );
2737        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2738        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2739        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2740
2741        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2742            scanned_files: 1,
2743            matched_plugins: 1,
2744            diagnostic_findings: Vec::new(),
2745            descriptors: vec![descriptor],
2746        });
2747        let matrix = BridgeSupportMatrix {
2748            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2749            supported_adapter_families: BTreeSet::new(),
2750            supported_compatibility_modes: BTreeSet::from([
2751                PluginCompatibilityMode::Native,
2752                PluginCompatibilityMode::OpenClawModern,
2753            ]),
2754            supported_compatibility_shims: BTreeSet::new(),
2755            supported_compatibility_shim_profiles: BTreeMap::new(),
2756        };
2757
2758        let setup_readiness_context = verified_setup_readiness_context();
2759        let plan = PluginTranslator::new().plan_activation(
2760            &translation,
2761            &matrix,
2762            &setup_readiness_context,
2763        );
2764
2765        assert_eq!(plan.ready_plugins, 0);
2766        assert_eq!(plan.blocked_plugins, 1);
2767        assert!(matches!(
2768            plan.candidates[0].status,
2769            PluginActivationStatus::BlockedCompatibilityMode
2770        ));
2771        assert_eq!(
2772            plan.candidates[0]
2773                .compatibility_shim
2774                .as_ref()
2775                .map(|shim| shim.shim_id.as_str()),
2776            Some("openclaw-modern-compat")
2777        );
2778        assert!(
2779            plan.candidates[0]
2780                .reason
2781                .contains("requires compatibility shim `openclaw-modern-compat`")
2782        );
2783        assert!(
2784            plan.candidates[0]
2785                .bootstrap_hint
2786                .contains("enable compatibility shim `openclaw-modern-compat`")
2787        );
2788    }
2789
2790    #[test]
2791    fn activation_plan_allows_supported_compatibility_mode_when_shim_is_enabled() {
2792        let mut descriptor = descriptor(
2793            "js",
2794            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2795        );
2796        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2797        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2798        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2799
2800        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2801            scanned_files: 1,
2802            matched_plugins: 1,
2803            diagnostic_findings: Vec::new(),
2804            descriptors: vec![descriptor],
2805        });
2806        let matrix = BridgeSupportMatrix {
2807            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2808            supported_adapter_families: BTreeSet::new(),
2809            supported_compatibility_modes: BTreeSet::from([
2810                PluginCompatibilityMode::Native,
2811                PluginCompatibilityMode::OpenClawModern,
2812            ]),
2813            supported_compatibility_shims: BTreeSet::from([PluginCompatibilityShim {
2814                shim_id: "openclaw-modern-compat".to_owned(),
2815                family: "openclaw-modern-compat".to_owned(),
2816            }]),
2817            supported_compatibility_shim_profiles: BTreeMap::new(),
2818        };
2819
2820        let setup_readiness_context = verified_setup_readiness_context();
2821        let plan = PluginTranslator::new().plan_activation(
2822            &translation,
2823            &matrix,
2824            &setup_readiness_context,
2825        );
2826
2827        assert_eq!(plan.ready_plugins, 1);
2828        assert_eq!(plan.blocked_plugins, 0);
2829        assert!(matches!(
2830            plan.candidates[0].status,
2831            PluginActivationStatus::Ready
2832        ));
2833    }
2834
2835    #[test]
2836    fn activation_plan_blocks_enabled_shim_profile_when_runtime_projection_mismatches() {
2837        let mut descriptor = descriptor(
2838            "js",
2839            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2840        );
2841        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2842        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2843        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2844
2845        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2846            scanned_files: 1,
2847            matched_plugins: 1,
2848            diagnostic_findings: Vec::new(),
2849            descriptors: vec![descriptor],
2850        });
2851        let shim = PluginCompatibilityShim {
2852            shim_id: "openclaw-modern-compat".to_owned(),
2853            family: "openclaw-modern-compat".to_owned(),
2854        };
2855        let matrix = BridgeSupportMatrix {
2856            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2857            supported_adapter_families: BTreeSet::new(),
2858            supported_compatibility_modes: BTreeSet::from([
2859                PluginCompatibilityMode::Native,
2860                PluginCompatibilityMode::OpenClawModern,
2861            ]),
2862            supported_compatibility_shims: BTreeSet::new(),
2863            supported_compatibility_shim_profiles: BTreeMap::from([(
2864                shim.clone(),
2865                PluginCompatibilityShimSupport {
2866                    shim,
2867                    version: Some("openclaw-modern@1".to_owned()),
2868                    supported_dialects: BTreeSet::from([
2869                        PluginContractDialect::OpenClawModernManifest,
2870                    ]),
2871                    supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2872                    supported_adapter_families: BTreeSet::new(),
2873                    supported_source_languages: BTreeSet::from(["python".to_owned()]),
2874                },
2875            )]),
2876        };
2877
2878        let setup_readiness_context = verified_setup_readiness_context();
2879        let plan = PluginTranslator::new().plan_activation(
2880            &translation,
2881            &matrix,
2882            &setup_readiness_context,
2883        );
2884
2885        assert_eq!(plan.ready_plugins, 0);
2886        assert_eq!(plan.blocked_plugins, 1);
2887        assert!(matches!(
2888            plan.candidates[0].status,
2889            PluginActivationStatus::BlockedCompatibilityMode
2890        ));
2891        assert_eq!(
2892            plan.candidates[0]
2893                .compatibility_shim_support
2894                .as_ref()
2895                .and_then(|support| support.version.as_deref()),
2896            Some("openclaw-modern@1")
2897        );
2898        assert_eq!(
2899            plan.candidates[0].compatibility_shim_support_mismatch_reasons,
2900            vec!["source language `javascript`".to_owned()]
2901        );
2902        assert!(
2903            plan.candidates[0]
2904                .reason
2905                .contains("source language `javascript`")
2906        );
2907        assert!(plan.candidates[0].reason.contains("openclaw-modern@1"));
2908    }
2909
2910    #[test]
2911    fn activation_plan_allows_enabled_shim_profile_when_runtime_projection_matches() {
2912        let mut descriptor = descriptor(
2913            "js",
2914            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2915        );
2916        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2917        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2918        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2919
2920        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2921            scanned_files: 1,
2922            matched_plugins: 1,
2923            diagnostic_findings: Vec::new(),
2924            descriptors: vec![descriptor],
2925        });
2926        let shim = PluginCompatibilityShim {
2927            shim_id: "openclaw-modern-compat".to_owned(),
2928            family: "openclaw-modern-compat".to_owned(),
2929        };
2930        let matrix = BridgeSupportMatrix {
2931            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2932            supported_adapter_families: BTreeSet::new(),
2933            supported_compatibility_modes: BTreeSet::from([
2934                PluginCompatibilityMode::Native,
2935                PluginCompatibilityMode::OpenClawModern,
2936            ]),
2937            supported_compatibility_shims: BTreeSet::new(),
2938            supported_compatibility_shim_profiles: BTreeMap::from([(
2939                shim.clone(),
2940                PluginCompatibilityShimSupport {
2941                    shim,
2942                    version: Some("openclaw-modern@1".to_owned()),
2943                    supported_dialects: BTreeSet::from([
2944                        PluginContractDialect::OpenClawModernManifest,
2945                    ]),
2946                    supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2947                    supported_adapter_families: BTreeSet::new(),
2948                    supported_source_languages: BTreeSet::from(["javascript".to_owned()]),
2949                },
2950            )]),
2951        };
2952
2953        let setup_readiness_context = verified_setup_readiness_context();
2954        let plan = PluginTranslator::new().plan_activation(
2955            &translation,
2956            &matrix,
2957            &setup_readiness_context,
2958        );
2959
2960        assert_eq!(plan.ready_plugins, 1);
2961        assert_eq!(plan.blocked_plugins, 0);
2962        assert!(matches!(
2963            plan.candidates[0].status,
2964            PluginActivationStatus::Ready
2965        ));
2966        assert_eq!(
2967            plan.candidates[0]
2968                .compatibility_shim_support
2969                .as_ref()
2970                .and_then(|support| support.version.as_deref()),
2971            Some("openclaw-modern@1")
2972        );
2973        assert!(
2974            plan.candidates[0]
2975                .compatibility_shim_support_mismatch_reasons
2976                .is_empty()
2977        );
2978    }
2979
2980    #[test]
2981    fn activation_plan_normalizes_source_language_before_shim_profile_match() {
2982        let mut descriptor = descriptor(
2983            "JavaScript",
2984            BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2985        );
2986        descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2987        descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2988        descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2989
2990        let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2991            scanned_files: 1,
2992            matched_plugins: 1,
2993            diagnostic_findings: Vec::new(),
2994            descriptors: vec![descriptor],
2995        });
2996        let shim = PluginCompatibilityShim {
2997            shim_id: "openclaw-modern-compat".to_owned(),
2998            family: "openclaw-modern-compat".to_owned(),
2999        };
3000        let matrix = BridgeSupportMatrix {
3001            supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
3002            supported_adapter_families: BTreeSet::new(),
3003            supported_compatibility_modes: BTreeSet::from([
3004                PluginCompatibilityMode::Native,
3005                PluginCompatibilityMode::OpenClawModern,
3006            ]),
3007            supported_compatibility_shims: BTreeSet::new(),
3008            supported_compatibility_shim_profiles: BTreeMap::from([(
3009                shim.clone(),
3010                PluginCompatibilityShimSupport {
3011                    shim,
3012                    version: Some("openclaw-modern@1".to_owned()),
3013                    supported_dialects: BTreeSet::from([
3014                        PluginContractDialect::OpenClawModernManifest,
3015                    ]),
3016                    supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
3017                    supported_adapter_families: BTreeSet::new(),
3018                    supported_source_languages: BTreeSet::from(["javascript".to_owned()]),
3019                },
3020            )]),
3021        };
3022
3023        let setup_readiness_context = verified_setup_readiness_context();
3024        let plan = PluginTranslator::new().plan_activation(
3025            &translation,
3026            &matrix,
3027            &setup_readiness_context,
3028        );
3029
3030        assert_eq!(plan.ready_plugins, 1);
3031        assert_eq!(plan.blocked_plugins, 0);
3032        assert!(matches!(
3033            plan.candidates[0].status,
3034            PluginActivationStatus::Ready
3035        ));
3036        assert!(
3037            plan.candidates[0]
3038                .compatibility_shim_support_mismatch_reasons
3039                .is_empty()
3040        );
3041    }
3042
3043    #[test]
3044    fn activation_plan_marks_plugin_ready_when_setup_requirements_are_verified() {
3045        let descriptor = descriptor("manifest", BTreeMap::new());
3046        let translator = PluginTranslator::new();
3047        let translation = translator.translate_scan_report(&PluginScanReport {
3048            scanned_files: 1,
3049            matched_plugins: 1,
3050            diagnostic_findings: Vec::new(),
3051            descriptors: vec![descriptor],
3052        });
3053
3054        let matrix = BridgeSupportMatrix {
3055            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3056            supported_adapter_families: BTreeSet::new(),
3057            ..BridgeSupportMatrix::default()
3058        };
3059        let setup_readiness_context = PluginSetupReadinessContext {
3060            verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
3061            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3062        };
3063        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3064
3065        assert_eq!(plan.total_plugins, 1);
3066        assert_eq!(plan.ready_plugins, 1);
3067        assert_eq!(plan.setup_incomplete_plugins, 0);
3068        assert_eq!(plan.blocked_plugins, 0);
3069        assert!(matches!(
3070            plan.candidates[0].status,
3071            PluginActivationStatus::Ready
3072        ));
3073        assert!(plan.candidates[0].missing_required_env_vars.is_empty());
3074        assert!(plan.candidates[0].missing_required_config_keys.is_empty());
3075    }
3076
3077    #[cfg(windows)]
3078    #[test]
3079    fn activation_plan_matches_verified_env_vars_case_insensitively_on_windows() {
3080        let descriptor = descriptor("manifest", BTreeMap::new());
3081        let translator = PluginTranslator::new();
3082        let translation = translator.translate_scan_report(&PluginScanReport {
3083            scanned_files: 1,
3084            matched_plugins: 1,
3085            diagnostic_findings: Vec::new(),
3086            descriptors: vec![descriptor],
3087        });
3088
3089        let matrix = BridgeSupportMatrix {
3090            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3091            supported_adapter_families: BTreeSet::new(),
3092            ..BridgeSupportMatrix::default()
3093        };
3094        let setup_readiness_context = PluginSetupReadinessContext {
3095            verified_env_vars: BTreeSet::from(["tavily_api_key".to_owned()]),
3096            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3097        };
3098        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3099
3100        assert!(matches!(
3101            plan.candidates[0].status,
3102            PluginActivationStatus::Ready
3103        ));
3104    }
3105
3106    #[cfg(not(windows))]
3107    #[test]
3108    fn activation_plan_keeps_verified_env_vars_case_sensitive_off_windows() {
3109        let descriptor = descriptor("manifest", BTreeMap::new());
3110        let translator = PluginTranslator::new();
3111        let translation = translator.translate_scan_report(&PluginScanReport {
3112            scanned_files: 1,
3113            matched_plugins: 1,
3114            diagnostic_findings: Vec::new(),
3115            descriptors: vec![descriptor],
3116        });
3117
3118        let matrix = BridgeSupportMatrix {
3119            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3120            supported_adapter_families: BTreeSet::new(),
3121            ..BridgeSupportMatrix::default()
3122        };
3123        let setup_readiness_context = PluginSetupReadinessContext {
3124            verified_env_vars: BTreeSet::from(["tavily_api_key".to_owned()]),
3125            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3126        };
3127        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3128
3129        assert!(matches!(
3130            plan.candidates[0].status,
3131            PluginActivationStatus::SetupIncomplete
3132        ));
3133    }
3134
3135    #[test]
3136    fn activation_plan_deserializes_old_payload_without_new_readiness_fields() {
3137        let raw = r#"
3138{
3139  "total_plugins": 1,
3140  "ready_plugins": 0,
3141  "blocked_plugins": 1,
3142  "candidates": [
3143    {
3144      "plugin_id": "legacy-plugin",
3145      "source_path": "/tmp/legacy-plugin.py",
3146      "source_kind": "embedded_source",
3147      "package_root": "/tmp",
3148      "package_manifest_path": null,
3149      "bridge_kind": "http_json",
3150      "adapter_family": "web-search",
3151      "status": "blocked_unsupported_bridge",
3152      "reason": "legacy payload",
3153      "bootstrap_hint": "skip"
3154    }
3155  ]
3156}
3157"#;
3158
3159        let plan: PluginActivationPlan =
3160            serde_json::from_str(raw).expect("legacy activation payload should deserialize");
3161
3162        assert_eq!(plan.setup_incomplete_plugins, 0);
3163        assert!(plan.candidates[0].missing_required_env_vars.is_empty());
3164        assert!(plan.candidates[0].missing_required_config_keys.is_empty());
3165    }
3166
3167    #[test]
3168    fn activation_plan_still_blocks_unsupported_bridge_before_setup_readiness() {
3169        let descriptor = descriptor(
3170            "js",
3171            BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
3172        );
3173        let translator = PluginTranslator::new();
3174        let translation = translator.translate_scan_report(&PluginScanReport {
3175            scanned_files: 1,
3176            matched_plugins: 1,
3177            diagnostic_findings: Vec::new(),
3178            descriptors: vec![descriptor],
3179        });
3180
3181        let matrix = BridgeSupportMatrix {
3182            supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3183            supported_adapter_families: BTreeSet::new(),
3184            ..BridgeSupportMatrix::default()
3185        };
3186        let setup_readiness_context = PluginSetupReadinessContext {
3187            verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
3188            verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3189        };
3190        let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3191
3192        assert_eq!(plan.ready_plugins, 0);
3193        assert_eq!(plan.setup_incomplete_plugins, 0);
3194        assert_eq!(plan.blocked_plugins, 1);
3195        assert!(matches!(
3196            plan.candidates[0].status,
3197            PluginActivationStatus::BlockedUnsupportedBridge
3198        ));
3199    }
3200
3201    #[test]
3202    fn blocker_summary_excludes_setup_incomplete_candidates() {
3203        let plan = PluginActivationPlan {
3204            total_plugins: 2,
3205            ready_plugins: 0,
3206            setup_incomplete_plugins: 1,
3207            blocked_plugins: 1,
3208            candidates: vec![
3209                PluginActivationCandidate {
3210                    plugin_id: "setup-plugin".to_owned(),
3211                    source_path: "/tmp/setup-plugin.py".to_owned(),
3212                    source_kind: PluginSourceKind::EmbeddedSource,
3213                    package_root: "/tmp".to_owned(),
3214                    package_manifest_path: None,
3215                    trust_tier: PluginTrustTier::Unverified,
3216                    compatibility_mode: PluginCompatibilityMode::Native,
3217                    compatibility_shim: None,
3218                    compatibility_shim_support: None,
3219                    compatibility_shim_support_mismatch_reasons: Vec::new(),
3220                    bridge_kind: PluginBridgeKind::HttpJson,
3221                    adapter_family: "http-adapter".to_owned(),
3222                    slot_claims: Vec::new(),
3223                    diagnostic_findings: Vec::new(),
3224                    status: PluginActivationStatus::SetupIncomplete,
3225                    reason: "missing TAVILY_API_KEY".to_owned(),
3226                    missing_required_env_vars: vec!["TAVILY_API_KEY".to_owned()],
3227                    missing_required_config_keys: Vec::new(),
3228                    bootstrap_hint: "export TAVILY_API_KEY".to_owned(),
3229                },
3230                PluginActivationCandidate {
3231                    plugin_id: "blocked-plugin".to_owned(),
3232                    source_path: "/tmp/blocked-plugin.py".to_owned(),
3233                    source_kind: PluginSourceKind::EmbeddedSource,
3234                    package_root: "/tmp".to_owned(),
3235                    package_manifest_path: None,
3236                    trust_tier: PluginTrustTier::Unverified,
3237                    compatibility_mode: PluginCompatibilityMode::Native,
3238                    compatibility_shim: None,
3239                    compatibility_shim_support: None,
3240                    compatibility_shim_support_mismatch_reasons: Vec::new(),
3241                    bridge_kind: PluginBridgeKind::HttpJson,
3242                    adapter_family: "http-adapter".to_owned(),
3243                    slot_claims: Vec::new(),
3244                    diagnostic_findings: Vec::new(),
3245                    status: PluginActivationStatus::BlockedUnsupportedBridge,
3246                    reason: "http_json bridge is disabled".to_owned(),
3247                    missing_required_env_vars: Vec::new(),
3248                    missing_required_config_keys: Vec::new(),
3249                    bootstrap_hint: "enable http bridge".to_owned(),
3250                },
3251            ],
3252        };
3253
3254        let summary = plan.blocker_summary(4);
3255
3256        assert!(summary.contains("blocked-plugin"));
3257        assert!(!summary.contains("setup-plugin"));
3258    }
3259}