Skip to main content

mesh_llm_config/
plugin_validation.rs

1pub mod control_behavior;
2
3use self::control_behavior::PluginControlBehavior;
4
5use crate::PluginConfigEntry;
6use crate::diagnostic::DiagnosticResult;
7use crate::model::ConfigPath;
8use crate::validate::{
9    ConfigDiagnostic, ConfigDiagnosticCode, ConfigDiagnosticSchemaSource, ConfigDiagnosticSeverity,
10    ConfigDiagnosticSource,
11};
12use crate::validation_support::validation_diagnostic;
13use std::collections::{BTreeMap, BTreeSet};
14use toml::Value;
15
16pub const SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION: u32 = 1;
17
18#[derive(Clone, Debug, PartialEq)]
19pub enum PluginSchemaAvailability {
20    Available(PluginConfigSchema),
21    NotInstalled,
22    MissingSchema,
23    UnsupportedVersion { version: u32 },
24}
25
26#[derive(Clone, Debug, PartialEq)]
27pub struct PluginConfigSchema {
28    pub plugin_name: String,
29    pub schema_version: u32,
30    pub allow_unvalidated_config: bool,
31    pub settings: Vec<PluginSettingSchema>,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct PluginSettingSchema {
36    pub key: String,
37    pub value_schema: PluginValueSchema,
38    pub required: bool,
39    pub default_json: Option<String>,
40    pub constraints: Vec<PluginSettingConstraint>,
41    pub description: Option<String>,
42    pub control_behavior: Option<PluginControlBehavior>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct PluginValueSchema {
47    pub kind: PluginValueKind,
48    pub enum_values: Vec<String>,
49    pub items: Option<Box<PluginValueSchema>>,
50    pub object_properties: Vec<PluginObjectPropertySchema>,
51    pub allow_additional_properties: bool,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct PluginObjectPropertySchema {
56    pub key: String,
57    pub value_schema: PluginValueSchema,
58    pub required: bool,
59    pub description: Option<String>,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PluginValueKind {
64    Boolean,
65    Integer,
66    Float,
67    String,
68    Path,
69    Url,
70    Enum,
71    Array,
72    Object,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PluginSettingConstraint {
77    NonEmpty,
78    Positive,
79    Range {
80        min: Option<String>,
81        max: Option<String>,
82    },
83    AllowedValues {
84        values: Vec<String>,
85    },
86    Requires {
87        key: String,
88    },
89}
90
91pub(crate) fn validate_plugin_entries(entries: &[PluginConfigEntry]) -> DiagnosticResult {
92    for (index, entry) in entries.iter().enumerate() {
93        validate_plugin_startup(entry, index)?;
94    }
95    Ok(())
96}
97
98pub(crate) fn validate_plugin_entries_strict<F>(
99    entries: &[PluginConfigEntry],
100    raw_toml: Option<&str>,
101    mut schema_for_plugin: F,
102) -> Vec<ConfigDiagnostic>
103where
104    F: FnMut(&str) -> PluginSchemaAvailability,
105{
106    let mut diagnostics = plugin_misplaced_key_diagnostics(raw_toml);
107
108    for entry in entries {
109        let has_custom_settings = !entry.settings.is_empty();
110        let settings_path = plugin_settings_path(&entry.name);
111        match schema_for_plugin(&entry.name) {
112            PluginSchemaAvailability::Available(schema) => {
113                if schema.schema_version != SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION {
114                    diagnostics.push(plugin_diagnostic(
115                        ConfigDiagnosticCode::UnsupportedSchemaVersion,
116                        ConfigDiagnosticSeverity::Error,
117                        settings_path,
118                        format!(
119                            "plugin '{}' declares unsupported config schema_version {}; expected {}",
120                            entry.name, schema.schema_version, SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION
121                        ),
122                    ));
123                    continue;
124                }
125                if schema.allow_unvalidated_config {
126                    if has_custom_settings {
127                        diagnostics.push(plugin_diagnostic(
128                            ConfigDiagnosticCode::LegacyUnvalidatedConfig,
129                            ConfigDiagnosticSeverity::Warning,
130                            settings_path,
131                            format!(
132                                "plugin '{}' allows legacy unvalidated config; unknown custom settings are accepted, but declared settings are still schema-validated",
133                                entry.name
134                            ),
135                        ));
136                    }
137                    diagnostics.extend(validate_plugin_settings_against_schema(
138                        entry, &schema, true,
139                    ));
140                    continue;
141                }
142                diagnostics.extend(validate_plugin_settings_against_schema(
143                    entry, &schema, false,
144                ));
145            }
146            PluginSchemaAvailability::NotInstalled => {
147                if has_custom_settings {
148                    diagnostics.push(plugin_diagnostic(
149                        ConfigDiagnosticCode::SchemaUnavailable,
150                        ConfigDiagnosticSeverity::Error,
151                        settings_path,
152                        format!(
153                            "plugin '{}' is not installed, so custom settings cannot be validated in strict mode",
154                            entry.name
155                        ),
156                    ));
157                }
158            }
159            PluginSchemaAvailability::MissingSchema => {
160                if has_custom_settings {
161                    diagnostics.push(plugin_diagnostic(
162                        ConfigDiagnosticCode::SchemaUnavailable,
163                        ConfigDiagnosticSeverity::Error,
164                        settings_path,
165                        format!(
166                            "plugin '{}' does not expose install-time config schema metadata, so custom settings cannot be validated in strict mode",
167                            entry.name
168                        ),
169                    ));
170                }
171            }
172            PluginSchemaAvailability::UnsupportedVersion { version } => {
173                diagnostics.push(plugin_diagnostic(
174                    ConfigDiagnosticCode::UnsupportedSchemaVersion,
175                    ConfigDiagnosticSeverity::Error,
176                    settings_path,
177                    format!(
178                        "plugin '{}' declares unsupported config schema_version {}; expected {}",
179                        entry.name, version, SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION
180                    ),
181                ));
182            }
183        }
184    }
185
186    diagnostics
187}
188
189fn validate_plugin_startup(entry: &PluginConfigEntry, index: usize) -> DiagnosticResult {
190    if matches!(entry.startup.connect_timeout_secs, Some(0)) {
191        return Err(validation_diagnostic(
192            &format!("plugin[{index}].startup.connect_timeout_secs"),
193            format!("plugin[{index}].startup.connect_timeout_secs must be at least 1 when set"),
194        ));
195    }
196    if matches!(entry.startup.init_timeout_secs, Some(0)) {
197        return Err(validation_diagnostic(
198            &format!("plugin[{index}].startup.init_timeout_secs"),
199            format!("plugin[{index}].startup.init_timeout_secs must be at least 1 when set"),
200        ));
201    }
202    Ok(())
203}
204
205fn validate_plugin_settings_against_schema(
206    entry: &PluginConfigEntry,
207    schema: &PluginConfigSchema,
208    allow_unknown_settings: bool,
209) -> Vec<ConfigDiagnostic> {
210    let mut diagnostics = Vec::new();
211    let schema_by_key = schema
212        .settings
213        .iter()
214        .map(|setting| (setting.key.as_str(), setting))
215        .collect::<BTreeMap<_, _>>();
216
217    for key in entry.settings.keys() {
218        if !allow_unknown_settings && !schema_by_key.contains_key(key.as_str()) {
219            diagnostics.push(plugin_diagnostic(
220                ConfigDiagnosticCode::UnknownField,
221                ConfigDiagnosticSeverity::Error,
222                plugin_setting_path(&entry.name, [key.as_str()]),
223                format!(
224                    "plugin '{}' does not declare custom setting '{}' in [[plugin]].settings",
225                    entry.name, key
226                ),
227            ));
228        }
229    }
230
231    for setting in &schema.settings {
232        let Some(value) = entry.settings.get(&setting.key) else {
233            if setting.required {
234                diagnostics.push(plugin_diagnostic(
235                    ConfigDiagnosticCode::MissingRequiredValue,
236                    ConfigDiagnosticSeverity::Error,
237                    plugin_setting_path(&entry.name, [setting.key.as_str()]),
238                    format!(
239                        "plugin '{}' requires [[plugin]].settings.{} to be set",
240                        entry.name, setting.key
241                    ),
242                ));
243            }
244            continue;
245        };
246
247        validate_plugin_value(
248            &entry.name,
249            &[setting.key.as_str()],
250            value,
251            &setting.value_schema,
252            &setting.constraints,
253            &entry.settings,
254            &mut diagnostics,
255        );
256    }
257
258    diagnostics
259}
260
261fn validate_plugin_value(
262    plugin_name: &str,
263    path_segments: &[&str],
264    value: &Value,
265    schema: &PluginValueSchema,
266    constraints: &[PluginSettingConstraint],
267    root_settings: &BTreeMap<String, Value>,
268    diagnostics: &mut Vec<ConfigDiagnostic>,
269) {
270    if let Err(message) = validate_plugin_value_kind(value, schema) {
271        diagnostics.push(plugin_diagnostic(
272            ConfigDiagnosticCode::InvalidValue,
273            ConfigDiagnosticSeverity::Error,
274            plugin_setting_path(plugin_name, path_segments.iter().copied()),
275            message,
276        ));
277        return;
278    }
279
280    for constraint in constraints {
281        if let Err(message) = validate_plugin_constraint(value, constraint, root_settings) {
282            diagnostics.push(plugin_diagnostic(
283                ConfigDiagnosticCode::InvalidValue,
284                ConfigDiagnosticSeverity::Error,
285                plugin_setting_path(plugin_name, path_segments.iter().copied()),
286                message,
287            ));
288        }
289    }
290
291    match (&schema.kind, value) {
292        (PluginValueKind::Array, Value::Array(items)) => {
293            if let Some(item_schema) = schema.items.as_deref() {
294                for (index, item) in items.iter().enumerate() {
295                    let index_segment = index.to_string();
296                    let mut nested = path_segments.to_vec();
297                    nested.push(index_segment.as_str());
298                    validate_plugin_value(
299                        plugin_name,
300                        &nested,
301                        item,
302                        item_schema,
303                        &[],
304                        root_settings,
305                        diagnostics,
306                    );
307                }
308            }
309        }
310        (PluginValueKind::Object, Value::Table(table)) => {
311            let object_schema = schema
312                .object_properties
313                .iter()
314                .map(|property| (property.key.as_str(), property))
315                .collect::<BTreeMap<_, _>>();
316
317            for key in table.keys() {
318                if !schema.allow_additional_properties && !object_schema.contains_key(key.as_str())
319                {
320                    diagnostics.push(plugin_diagnostic(
321                        ConfigDiagnosticCode::UnknownField,
322                        ConfigDiagnosticSeverity::Error,
323                        plugin_setting_path(
324                            plugin_name,
325                            path_segments.iter().copied().chain([key.as_str()]),
326                        ),
327                        format!(
328                            "plugin '{}' does not allow object property '{}' here",
329                            plugin_name, key
330                        ),
331                    ));
332                }
333            }
334
335            for property in &schema.object_properties {
336                let Some(property_value) = table.get(&property.key) else {
337                    if property.required {
338                        diagnostics.push(plugin_diagnostic(
339                            ConfigDiagnosticCode::MissingRequiredValue,
340                            ConfigDiagnosticSeverity::Error,
341                            plugin_setting_path(
342                                plugin_name,
343                                path_segments.iter().copied().chain([property.key.as_str()]),
344                            ),
345                            format!(
346                                "plugin '{}' requires object property '{}' here",
347                                plugin_name, property.key
348                            ),
349                        ));
350                    }
351                    continue;
352                };
353
354                let mut nested = path_segments.to_vec();
355                nested.push(property.key.as_str());
356                validate_plugin_value(
357                    plugin_name,
358                    &nested,
359                    property_value,
360                    &property.value_schema,
361                    &[],
362                    root_settings,
363                    diagnostics,
364                );
365            }
366        }
367        _ => {}
368    }
369}
370
371fn validate_plugin_value_kind(value: &Value, schema: &PluginValueSchema) -> Result<(), String> {
372    match schema.kind {
373        PluginValueKind::Boolean if value.is_bool() => Ok(()),
374        PluginValueKind::Integer if value.as_integer().is_some() => Ok(()),
375        PluginValueKind::Float if numeric_value(value).is_some() => Ok(()),
376        PluginValueKind::String | PluginValueKind::Path if value.as_str().is_some() => Ok(()),
377        PluginValueKind::Url => {
378            let Some(raw) = value.as_str() else {
379                return Err("expected URL string".into());
380            };
381            if raw.contains("://") {
382                Ok(())
383            } else {
384                Err(format!("expected valid URL, got {raw:?}"))
385            }
386        }
387        PluginValueKind::Enum => {
388            let Some(raw) = value.as_str() else {
389                return Err("expected enum string".into());
390            };
391            if schema.enum_values.iter().any(|candidate| candidate == raw) {
392                Ok(())
393            } else {
394                Err(format!(
395                    "expected one of: {}",
396                    schema.enum_values.join(", ")
397                ))
398            }
399        }
400        PluginValueKind::Array if value.as_array().is_some() => Ok(()),
401        PluginValueKind::Object if value.as_table().is_some() => Ok(()),
402        PluginValueKind::Boolean => Err("expected boolean".into()),
403        PluginValueKind::Integer => Err("expected integer".into()),
404        PluginValueKind::Float => Err("expected number".into()),
405        PluginValueKind::String => Err("expected string".into()),
406        PluginValueKind::Path => Err("expected path string".into()),
407        PluginValueKind::Array => Err("expected array".into()),
408        PluginValueKind::Object => Err("expected object/table".into()),
409    }
410}
411
412fn validate_plugin_constraint(
413    value: &Value,
414    constraint: &PluginSettingConstraint,
415    root_settings: &BTreeMap<String, Value>,
416) -> Result<(), String> {
417    match constraint {
418        PluginSettingConstraint::NonEmpty => {
419            let valid = match value {
420                Value::String(inner) => !inner.trim().is_empty(),
421                Value::Array(inner) => !inner.is_empty(),
422                Value::Table(inner) => !inner.is_empty(),
423                _ => true,
424            };
425            if valid {
426                Ok(())
427            } else {
428                Err("must not be empty".into())
429            }
430        }
431        PluginSettingConstraint::Positive => {
432            let Some(number) = numeric_value(value) else {
433                return Err("must be numeric to apply positive constraint".into());
434            };
435            if number > 0.0 {
436                Ok(())
437            } else {
438                Err("must be greater than 0".into())
439            }
440        }
441        PluginSettingConstraint::Range { min, max } => {
442            let Some(number) = numeric_value(value) else {
443                return Err("must be numeric to apply range constraint".into());
444            };
445            if let Some(min) = parse_optional_constraint_number("min", min.as_deref())?
446                && number < min
447            {
448                return Err(format!("must be at least {}", render_number(min)));
449            }
450            if let Some(max) = parse_optional_constraint_number("max", max.as_deref())?
451                && number > max
452            {
453                return Err(format!("must be at most {}", render_number(max)));
454            }
455            Ok(())
456        }
457        PluginSettingConstraint::AllowedValues { values } => {
458            let Some(raw) = value.as_str() else {
459                return Err("must be string-like to apply allowed-values constraint".into());
460            };
461            if values.iter().any(|candidate| candidate == raw) {
462                Ok(())
463            } else {
464                Err(format!("expected one of: {}", values.join(", ")))
465            }
466        }
467        PluginSettingConstraint::Requires { key } => {
468            if root_settings.contains_key(key) {
469                Ok(())
470            } else {
471                Err(format!("requires [[plugin]].settings.{key} to also be set"))
472            }
473        }
474    }
475}
476
477fn plugin_misplaced_key_diagnostics(raw_toml: Option<&str>) -> Vec<ConfigDiagnostic> {
478    let Some(raw_toml) = raw_toml else {
479        return Vec::new();
480    };
481    let Ok(parsed) = toml::from_str::<Value>(raw_toml) else {
482        return Vec::new();
483    };
484    let Some(plugin_entries) = parsed.get("plugin").and_then(Value::as_array) else {
485        return Vec::new();
486    };
487
488    let allowed_top_level = BTreeSet::from([
489        "name",
490        "enabled",
491        "web_ui_enabled",
492        "command",
493        "args",
494        "url",
495        "startup",
496        "settings",
497    ]);
498    let allowed_startup = BTreeSet::from([
499        "connect_timeout_secs",
500        "init_timeout_secs",
501        "optional",
502        "lazy_start",
503    ]);
504
505    let mut diagnostics = Vec::new();
506    for (index, item) in plugin_entries.iter().enumerate() {
507        let Some(table) = item.as_table() else {
508            continue;
509        };
510        let plugin_name = table
511            .get("name")
512            .and_then(Value::as_str)
513            .unwrap_or("<plugin>")
514            .to_string();
515
516        for key in table.keys() {
517            if allowed_top_level.contains(key.as_str()) {
518                continue;
519            }
520            diagnostics.push(
521                plugin_diagnostic(
522                    ConfigDiagnosticCode::MisplacedField,
523                    ConfigDiagnosticSeverity::Error,
524                    plugin_setting_path(&plugin_name, [key.as_str()]),
525                    format!(
526                        "plugin[{index}].{key} is a custom plugin setting in a host-owned location; move it under [[plugin]].settings.{key}"
527                    ),
528                )
529                .at_path(ConfigPath::parse_rendered(&format!("plugin[{index}].{key}")).unwrap_or_default()),
530            );
531        }
532
533        if let Some(startup) = table.get("startup").and_then(Value::as_table) {
534            for key in startup.keys() {
535                if allowed_startup.contains(key.as_str()) {
536                    continue;
537                }
538                diagnostics.push(
539                    plugin_diagnostic(
540                        ConfigDiagnosticCode::MisplacedField,
541                        ConfigDiagnosticSeverity::Error,
542                        plugin_setting_path(&plugin_name, [key.as_str()]),
543                        format!(
544                            "plugin[{index}].startup.{key} is not a host-owned startup key; plugin custom settings must live under [[plugin]].settings.{key}"
545                        ),
546                    )
547                    .at_path(
548                        ConfigPath::parse_rendered(&format!("plugin[{index}].startup.{key}"))
549                            .unwrap_or_default(),
550                    ),
551                );
552            }
553        }
554    }
555
556    diagnostics
557}
558
559fn plugin_diagnostic(
560    code: ConfigDiagnosticCode,
561    severity: ConfigDiagnosticSeverity,
562    path: ConfigPath,
563    message: impl Into<String>,
564) -> ConfigDiagnostic {
565    ConfigDiagnostic::new(code, severity, ConfigDiagnosticSource::Plugin, message)
566        .with_schema_source(ConfigDiagnosticSchemaSource::Plugin)
567        .at_path(path.clone())
568        .with_canonical_path(path)
569}
570
571fn plugin_settings_path(plugin_name: &str) -> ConfigPath {
572    ConfigPath::from_fields(["plugin", plugin_name, "settings"])
573}
574
575fn plugin_setting_path<'a>(
576    plugin_name: &str,
577    segments: impl IntoIterator<Item = &'a str>,
578) -> ConfigPath {
579    let mut path = plugin_settings_path(plugin_name);
580    for segment in segments {
581        path.push_field(segment);
582    }
583    path
584}
585
586fn numeric_value(value: &Value) -> Option<f64> {
587    value
588        .as_float()
589        .or_else(|| value.as_integer().map(|integer| integer as f64))
590}
591
592fn parse_optional_constraint_number(
593    bound_name: &str,
594    raw: Option<&str>,
595) -> Result<Option<f64>, String> {
596    let Some(raw) = raw else {
597        return Ok(None);
598    };
599    raw.parse::<f64>()
600        .map(Some)
601        .map_err(|_| format!("range constraint {bound_name} bound must be numeric, got {raw:?}"))
602}
603
604fn render_number(value: f64) -> String {
605    if value.fract() == 0.0 {
606        format!("{value:.0}")
607    } else {
608        value.to_string()
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::PluginWebUiPreference;
616
617    fn schema() -> PluginConfigSchema {
618        PluginConfigSchema {
619            plugin_name: "blackboard".into(),
620            schema_version: SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION,
621            allow_unvalidated_config: false,
622            settings: vec![
623                PluginSettingSchema {
624                    key: "retention_days".into(),
625                    value_schema: PluginValueSchema {
626                        kind: PluginValueKind::Integer,
627                        enum_values: Vec::new(),
628                        items: None,
629                        object_properties: Vec::new(),
630                        allow_additional_properties: false,
631                    },
632                    required: true,
633                    default_json: Some("14".into()),
634                    constraints: vec![PluginSettingConstraint::Range {
635                        min: Some("1".into()),
636                        max: Some("365".into()),
637                    }],
638                    description: None,
639                    control_behavior: None,
640                },
641                PluginSettingSchema {
642                    key: "mode".into(),
643                    value_schema: PluginValueSchema {
644                        kind: PluginValueKind::Enum,
645                        enum_values: vec!["strict".into(), "relaxed".into()],
646                        items: None,
647                        object_properties: Vec::new(),
648                        allow_additional_properties: false,
649                    },
650                    required: false,
651                    default_json: Some("\"strict\"".into()),
652                    constraints: Vec::new(),
653                    description: None,
654                    control_behavior: None,
655                },
656            ],
657        }
658    }
659
660    #[test]
661    fn strict_plugin_validation_reports_misplaced_and_unknown_keys() {
662        let config: crate::MeshConfig = toml::from_str(
663            r#"
664[[plugin]]
665name = "blackboard"
666retention_days = 14
667
668[plugin.settings]
669mode = "strict"
670unknown = true
671"#,
672        )
673        .unwrap();
674
675        let diagnostics = validate_plugin_entries_strict(
676            &config.plugins,
677            Some(
678                r#"
679[[plugin]]
680name = "blackboard"
681retention_days = 14
682
683[plugin.settings]
684mode = "strict"
685unknown = true
686"#,
687            ),
688            |_| PluginSchemaAvailability::Available(schema()),
689        );
690
691        assert!(
692            diagnostics
693                .iter()
694                .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::MisplacedField)
695        );
696        assert!(
697            diagnostics
698                .iter()
699                .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::UnknownField)
700        );
701    }
702
703    #[test]
704    fn plugin_web_ui_enabled_is_host_owned_and_not_a_custom_setting() {
705        let raw = r#"
706[[plugin]]
707name = "blackboard"
708web_ui_enabled = false
709
710[plugin.settings]
711retention_days = 14
712"#;
713        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
714
715        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
716            PluginSchemaAvailability::Available(schema())
717        });
718
719        assert!(
720            !diagnostics
721                .iter()
722                .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::MisplacedField)
723        );
724    }
725
726    #[test]
727    fn plugin_web_ui_preference_resolves_declared_absent_and_explicit_choices() {
728        let absent = crate::PluginConfigEntry {
729            name: "blackboard".to_string(),
730            enabled: Some(false),
731            web_ui_enabled: None,
732            command: None,
733            args: Vec::new(),
734            url: None,
735            settings: BTreeMap::new(),
736            startup: Default::default(),
737        };
738        let disabled = crate::PluginConfigEntry {
739            web_ui_enabled: Some(false),
740            ..absent.clone()
741        };
742        let enabled = crate::PluginConfigEntry {
743            web_ui_enabled: Some(true),
744            ..absent.clone()
745        };
746
747        assert_eq!(
748            absent.web_ui_preference(true),
749            PluginWebUiPreference::Enabled
750        );
751        assert_eq!(
752            disabled.web_ui_preference(true),
753            PluginWebUiPreference::Disabled
754        );
755        assert_eq!(
756            enabled.web_ui_preference(true),
757            PluginWebUiPreference::Enabled
758        );
759        assert_eq!(
760            disabled.web_ui_preference(false),
761            PluginWebUiPreference::None
762        );
763    }
764
765    #[test]
766    fn strict_plugin_validation_rejects_required_settings_when_settings_table_is_absent() {
767        let raw = r#"
768[[plugin]]
769name = "blackboard"
770"#;
771        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
772
773        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
774            PluginSchemaAvailability::Available(schema())
775        });
776
777        assert!(diagnostics.iter().any(|diagnostic| {
778            diagnostic.code == ConfigDiagnosticCode::MissingRequiredValue
779                && diagnostic
780                    .canonical_path
781                    .as_ref()
782                    .map(ConfigPath::render)
783                    .as_deref()
784                    == Some("plugin.blackboard.settings.retention_days")
785        }));
786    }
787
788    #[test]
789    fn strict_plugin_validation_rejects_malformed_range_bound() {
790        let raw = r#"
791[[plugin]]
792name = "blackboard"
793
794[plugin.settings]
795retention_days = 14
796"#;
797        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
798        let mut malformed_schema = schema();
799        malformed_schema.settings[0].constraints = vec![PluginSettingConstraint::Range {
800            min: Some("low".into()),
801            max: Some("365".into()),
802        }];
803
804        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
805            PluginSchemaAvailability::Available(malformed_schema.clone())
806        });
807
808        assert_eq!(diagnostics.len(), 1);
809        assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::InvalidValue);
810        assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Error);
811        assert_eq!(
812            diagnostics[0]
813                .canonical_path
814                .as_ref()
815                .map(ConfigPath::render),
816            Some("plugin.blackboard.settings.retention_days".to_string())
817        );
818        assert!(
819            diagnostics[0]
820                .message
821                .contains("range constraint min bound must be numeric")
822        );
823    }
824
825    #[test]
826    fn strict_plugin_validation_rejects_missing_install_time_schema_metadata() {
827        let raw = r#"
828[[plugin]]
829name = "blackboard"
830
831[plugin.settings]
832retention_days = 14
833"#;
834        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
835
836        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
837            PluginSchemaAvailability::MissingSchema
838        });
839
840        assert_eq!(diagnostics.len(), 1);
841        assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::SchemaUnavailable);
842        assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Error);
843        assert_eq!(
844            diagnostics[0]
845                .canonical_path
846                .as_ref()
847                .map(ConfigPath::render),
848            Some("plugin.blackboard.settings".to_string())
849        );
850    }
851
852    #[test]
853    fn strict_plugin_validation_rejects_uninstalled_plugins_with_custom_settings() {
854        let raw = r#"
855[[plugin]]
856name = "blackboard"
857
858[plugin.settings]
859retention_days = 14
860"#;
861        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
862
863        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
864            PluginSchemaAvailability::NotInstalled
865        });
866
867        assert_eq!(diagnostics.len(), 1);
868        assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::SchemaUnavailable);
869        assert!(
870            diagnostics[0]
871                .message
872                .contains("custom settings cannot be validated in strict mode")
873        );
874    }
875
876    #[test]
877    fn strict_plugin_validation_only_allows_unbounded_settings_via_legacy_escape_hatch() {
878        let raw = r#"
879[[plugin]]
880name = "blackboard"
881
882[plugin.settings]
883retention_days = 14
884unknown = true
885"#;
886        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
887        let mut legacy_schema = schema();
888        legacy_schema.allow_unvalidated_config = true;
889
890        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
891            PluginSchemaAvailability::Available(legacy_schema.clone())
892        });
893
894        assert_eq!(diagnostics.len(), 1);
895        assert_eq!(
896            diagnostics[0].code,
897            ConfigDiagnosticCode::LegacyUnvalidatedConfig
898        );
899        assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Warning);
900        assert!(
901            diagnostics[0]
902                .message
903                .contains("allows legacy unvalidated config")
904        );
905    }
906
907    #[test]
908    fn strict_plugin_validation_legacy_escape_hatch_still_validates_known_settings() {
909        let raw = r#"
910[[plugin]]
911name = "blackboard"
912
913[plugin.settings]
914retention_days = 0
915mode = "mystery"
916unknown = true
917"#;
918        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
919        let mut legacy_schema = schema();
920        legacy_schema.allow_unvalidated_config = true;
921
922        let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
923            PluginSchemaAvailability::Available(legacy_schema.clone())
924        });
925
926        assert!(diagnostics.iter().any(|diagnostic| {
927            diagnostic.code == ConfigDiagnosticCode::LegacyUnvalidatedConfig
928                && diagnostic.severity == ConfigDiagnosticSeverity::Warning
929        }));
930        assert!(diagnostics.iter().any(|diagnostic| {
931            diagnostic.code == ConfigDiagnosticCode::InvalidValue
932                && diagnostic
933                    .canonical_path
934                    .as_ref()
935                    .map(ConfigPath::render)
936                    .as_deref()
937                    == Some("plugin.blackboard.settings.retention_days")
938        }));
939        assert!(diagnostics.iter().any(|diagnostic| {
940            diagnostic.code == ConfigDiagnosticCode::InvalidValue
941                && diagnostic
942                    .canonical_path
943                    .as_ref()
944                    .map(ConfigPath::render)
945                    .as_deref()
946                    == Some("plugin.blackboard.settings.mode")
947        }));
948        assert!(!diagnostics.iter().any(|diagnostic| {
949            diagnostic.code == ConfigDiagnosticCode::UnknownField
950                && diagnostic
951                    .canonical_path
952                    .as_ref()
953                    .map(ConfigPath::render)
954                    .as_deref()
955                    == Some("plugin.blackboard.settings.unknown")
956        }));
957    }
958
959    #[test]
960    fn strict_plugin_validation_rejects_unsupported_schema_version_boundaries() {
961        let raw = r#"
962[[plugin]]
963name = "blackboard"
964
965[plugin.settings]
966retention_days = 14
967"#;
968        let config: crate::MeshConfig = toml::from_str(raw).unwrap();
969
970        let mut mismatched_schema = schema();
971        mismatched_schema.schema_version = SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 1;
972        let available_diagnostics =
973            validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
974                PluginSchemaAvailability::Available(mismatched_schema.clone())
975            });
976        let unavailable_diagnostics =
977            validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
978                PluginSchemaAvailability::UnsupportedVersion {
979                    version: SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 2,
980                }
981            });
982
983        assert_eq!(
984            available_diagnostics[0].code,
985            ConfigDiagnosticCode::UnsupportedSchemaVersion
986        );
987        assert!(
988            available_diagnostics[0]
989                .message
990                .contains("unsupported config schema_version")
991        );
992        assert_eq!(
993            unavailable_diagnostics[0].code,
994            ConfigDiagnosticCode::UnsupportedSchemaVersion
995        );
996        assert!(
997            unavailable_diagnostics[0]
998                .message
999                .contains(&format!("{}", SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 2))
1000        );
1001    }
1002}