Skip to main content

greentic_types/
validate.rs

1//! Pack validation types and helpers.
2
3use alloc::collections::BTreeSet;
4use alloc::collections::BTreeSet as HashSet;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8use semver::Version;
9use serde_json::Value;
10
11use crate::pack::extensions::component_sources::{ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1};
12use crate::pack_manifest::ExtensionInline;
13use crate::{PackId, PackManifest};
14
15#[cfg(feature = "schemars")]
16use schemars::JsonSchema;
17#[cfg(feature = "serde")]
18use serde::{Deserialize, Serialize};
19
20fn empty_data() -> Value {
21    Value::Null
22}
23
24fn data_is_empty(value: &Value) -> bool {
25    value.is_null()
26}
27
28/// Severity level for validation diagnostics.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
31#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
32#[cfg_attr(feature = "schemars", derive(JsonSchema))]
33pub enum Severity {
34    /// Informational validation message.
35    Info,
36    /// Warning-level validation message.
37    Warn,
38    /// Error-level validation message.
39    Error,
40}
41
42/// Diagnostic entry produced by pack validators.
43#[derive(Clone, Debug, PartialEq)]
44#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
45#[cfg_attr(feature = "schemars", derive(JsonSchema))]
46pub struct Diagnostic {
47    /// Severity of the diagnostic.
48    pub severity: Severity,
49    /// Stable machine-readable identifier (for example `PACK_MISSING_SCHEMA`).
50    pub code: String,
51    /// Human-readable description.
52    pub message: String,
53    /// Optional path inside the pack or manifest (for example `extensions.messaging.setup`).
54    #[cfg_attr(
55        feature = "serde",
56        serde(default, skip_serializing_if = "Option::is_none")
57    )]
58    pub path: Option<String>,
59    /// Optional actionable guidance.
60    #[cfg_attr(
61        feature = "serde",
62        serde(default, skip_serializing_if = "Option::is_none")
63    )]
64    pub hint: Option<String>,
65    /// Optional structured payload for tooling.
66    #[cfg_attr(
67        feature = "serde",
68        serde(default = "empty_data", skip_serializing_if = "data_is_empty")
69    )]
70    #[cfg_attr(feature = "schemars", schemars(default = "empty_data"))]
71    pub data: Value,
72}
73
74/// Aggregated validation report for a pack.
75#[derive(Clone, Debug, PartialEq, Default)]
76#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77#[cfg_attr(feature = "schemars", derive(JsonSchema))]
78pub struct ValidationReport {
79    /// Optional pack identifier this report refers to.
80    #[cfg_attr(
81        feature = "serde",
82        serde(default, skip_serializing_if = "Option::is_none")
83    )]
84    pub pack_id: Option<PackId>,
85    /// Optional pack semantic version.
86    #[cfg_attr(
87        feature = "serde",
88        serde(default, skip_serializing_if = "Option::is_none")
89    )]
90    #[cfg_attr(
91        feature = "schemars",
92        schemars(with = "String", description = "SemVer version")
93    )]
94    pub pack_version: Option<Version>,
95    /// Collected diagnostics.
96    #[cfg_attr(feature = "serde", serde(default))]
97    pub diagnostics: Vec<Diagnostic>,
98}
99
100impl ValidationReport {
101    /// Returns `true` when the report includes error diagnostics.
102    pub fn has_errors(&self) -> bool {
103        self.diagnostics
104            .iter()
105            .any(|diag| matches!(diag.severity, Severity::Error))
106    }
107
108    /// Appends a diagnostic to the report.
109    pub fn push(&mut self, diagnostic: Diagnostic) {
110        self.diagnostics.push(diagnostic);
111    }
112}
113
114/// Validator for pack manifests that emits diagnostics.
115pub trait PackValidator {
116    /// Returns the stable validator identifier.
117    fn id(&self) -> &'static str;
118    /// Returns `true` when the validator applies to the provided manifest.
119    fn applies(&self, manifest: &PackManifest) -> bool;
120    /// Validates the manifest and returns diagnostics.
121    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic>;
122}
123
124/// Performs domain-agnostic structural validation for a pack manifest.
125pub fn validate_pack_manifest_core(manifest: &PackManifest) -> Vec<Diagnostic> {
126    let mut diagnostics = Vec::new();
127
128    if manifest.schema_version.trim().is_empty() {
129        diagnostics.push(core_diagnostic(
130            Severity::Error,
131            "PACK_SCHEMA_VERSION_MISSING",
132            "Pack manifest schema_version is required.",
133            Some("schema_version".to_owned()),
134            Some("Set schema_version to a supported pack manifest version.".to_owned()),
135        ));
136    }
137
138    if manifest.pack_id.as_str().trim().is_empty() {
139        diagnostics.push(core_diagnostic(
140            Severity::Error,
141            "PACK_ID_MISSING",
142            "Pack manifest pack_id is required.",
143            Some("pack_id".to_owned()),
144            Some("Provide a non-empty pack identifier.".to_owned()),
145        ));
146    }
147
148    let mut component_ids = BTreeSet::new();
149    for component in &manifest.components {
150        if !component_ids.insert(component.id.clone()) {
151            diagnostics.push(core_diagnostic(
152                Severity::Error,
153                "PACK_COMPONENT_ID_DUPLICATE",
154                "Duplicate component identifiers are not allowed.",
155                Some(format!("components.{}", component.id.as_str())),
156                Some("Ensure each component id is unique within the pack.".to_owned()),
157            ));
158        }
159    }
160
161    let declared_components = declared_component_keys(manifest);
162    let explicit_components: HashSet<String> = manifest
163        .components
164        .iter()
165        .map(|component| component.id.as_str().to_owned())
166        .collect();
167    let mut non_explicit_components: HashSet<String> = HashSet::new();
168
169    let mut dependency_aliases = BTreeSet::new();
170    for dependency in &manifest.dependencies {
171        if dependency.alias.trim().is_empty() {
172            diagnostics.push(core_diagnostic(
173                Severity::Error,
174                "PACK_DEPENDENCY_ALIAS_MISSING",
175                "Pack dependency alias is required.",
176                Some("dependencies".to_owned()),
177                Some("Provide a non-empty alias for each dependency.".to_owned()),
178            ));
179        }
180        if !dependency_aliases.insert(dependency.alias.clone()) {
181            diagnostics.push(core_diagnostic(
182                Severity::Error,
183                "PACK_DEPENDENCY_ALIAS_DUPLICATE",
184                "Duplicate dependency aliases are not allowed.",
185                Some(format!("dependencies.{}", dependency.alias)),
186                Some("Ensure each dependency alias is unique within the pack.".to_owned()),
187            ));
188        }
189    }
190
191    let mut flow_ids = BTreeSet::new();
192    for entry in &manifest.flows {
193        if !flow_ids.insert(entry.id.clone()) {
194            diagnostics.push(core_diagnostic(
195                Severity::Error,
196                "PACK_FLOW_ID_DUPLICATE",
197                "Duplicate flow identifiers are not allowed.",
198                Some(format!("flows.{}", entry.id.as_str())),
199                Some("Ensure each flow id is unique within the pack.".to_owned()),
200            ));
201        }
202
203        if entry.id != entry.flow.id {
204            diagnostics.push(core_diagnostic(
205                Severity::Error,
206                "PACK_FLOW_ID_MISMATCH",
207                "Pack flow entry id must match the embedded flow id.",
208                Some(format!("flows.{}.id", entry.id.as_str())),
209                Some("Align the entry id with the flow.id field.".to_owned()),
210            ));
211        }
212
213        if entry.kind != entry.flow.kind {
214            diagnostics.push(core_diagnostic(
215                Severity::Error,
216                "PACK_FLOW_KIND_MISMATCH",
217                "Pack flow entry kind must match the embedded flow kind.",
218                Some(format!("flows.{}.kind", entry.id.as_str())),
219                Some("Align the entry kind with the flow.kind field.".to_owned()),
220            ));
221        }
222
223        if entry.flow.schema_version.trim().is_empty() {
224            diagnostics.push(core_diagnostic(
225                Severity::Error,
226                "PACK_FLOW_SCHEMA_VERSION_MISSING",
227                "Embedded flow schema_version is required.",
228                Some(format!("flows.{}.flow.schema_version", entry.id.as_str())),
229                Some("Set schema_version to a supported flow version.".to_owned()),
230            ));
231        }
232    }
233
234    for component in &manifest.components {
235        if let Some(configurators) = &component.configurators {
236            if let Some(flow_id) = &configurators.basic {
237                if !flow_ids.contains(flow_id) {
238                    diagnostics.push(core_diagnostic(
239                        Severity::Error,
240                        "PACK_COMPONENT_CONFIG_FLOW_MISSING",
241                        "Component configurator flow is not present in the pack manifest.",
242                        Some(format!(
243                            "components.{}.configurators.basic",
244                            component.id.as_str()
245                        )),
246                        Some("Add the referenced flow to the pack manifest flows.".to_owned()),
247                    ));
248                }
249            }
250            if let Some(flow_id) = &configurators.full {
251                if !flow_ids.contains(flow_id) {
252                    diagnostics.push(core_diagnostic(
253                        Severity::Error,
254                        "PACK_COMPONENT_CONFIG_FLOW_MISSING",
255                        "Component configurator flow is not present in the pack manifest.",
256                        Some(format!(
257                            "components.{}.configurators.full",
258                            component.id.as_str()
259                        )),
260                        Some("Add the referenced flow to the pack manifest flows.".to_owned()),
261                    ));
262                }
263            }
264        }
265    }
266
267    for entry in &manifest.flows {
268        for (node_id, node) in entry.flow.nodes.iter() {
269            match &node.component.pack_alias {
270                Some(alias) => {
271                    if !dependency_aliases.contains(alias) {
272                        diagnostics.push(core_diagnostic(
273                            Severity::Error,
274                            "PACK_FLOW_DEPENDENCY_ALIAS_MISSING",
275                            "Flow node references an unknown dependency alias.",
276                            Some(format!(
277                                "flows.{}.nodes.{}.component.pack_alias",
278                                entry.id.as_str(),
279                                node_id.as_str()
280                            )),
281                            Some("Add the dependency alias to the pack manifest.".to_owned()),
282                        ));
283                    }
284                }
285                None => {
286                    let component_key = node.component.id.as_str();
287                    if !declared_components.contains(component_key) {
288                        diagnostics.push(core_diagnostic(
289                            Severity::Error,
290                            "PACK_FLOW_COMPONENT_MISSING",
291                            "Flow node references a component not resolvable by the pack.",
292                            Some(format!(
293                                "flows.{}.nodes.{}.component.id",
294                                entry.id.as_str(),
295                                node_id.as_str()
296                            )),
297                            Some(
298                                "Declare or source the component in the pack manifest.".to_owned(),
299                            ),
300                        ));
301                    } else if !explicit_components.contains(component_key)
302                        && non_explicit_components.insert(component_key.to_owned())
303                    {
304                        diagnostics.push(core_diagnostic(
305                            Severity::Warn,
306                            "PACK_COMPONENT_NOT_EXPLICIT",
307                            "Component is resolved via component sources or lock but is not declared in manifest.components.",
308                            Some(format!(
309                                "flows.{}.nodes.{}.component.id",
310                                entry.id.as_str(),
311                                node_id.as_str()
312                            )),
313                            Some("Consider declaring the component explicitly in manifest.components.".to_owned()),
314                        ));
315                    }
316                }
317            }
318        }
319    }
320
321    diagnostics
322}
323
324fn declared_component_keys(manifest: &PackManifest) -> HashSet<String> {
325    let mut declared = HashSet::new();
326    for component in &manifest.components {
327        declared.insert(component.id.as_str().to_owned());
328    }
329
330    #[cfg(feature = "serde")]
331    {
332        if let Some(extensions) = manifest.extensions.as_ref() {
333            if let Some(extension) = extensions.get(EXT_COMPONENT_SOURCES_V1) {
334                if let Some(ExtensionInline::Other(value)) = extension.inline.as_ref() {
335                    if let Ok(payload) = ComponentSourcesV1::from_extension_value(value) {
336                        for entry in payload.components {
337                            declared.insert(entry.name);
338                            if let Some(component_id) = entry.component_id {
339                                declared.insert(component_id.as_str().to_owned());
340                            }
341                        }
342                    }
343                }
344            }
345        }
346    }
347
348    declared
349}
350
351fn core_diagnostic(
352    severity: Severity,
353    code: &str,
354    message: &str,
355    path: Option<String>,
356    hint: Option<String>,
357) -> Diagnostic {
358    Diagnostic {
359        severity,
360        code: code.to_owned(),
361        message: message.to_owned(),
362        path,
363        hint,
364        data: empty_data(),
365    }
366}