Skip to main content

greentic_types/
validate.rs

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