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
100/// Breakdown of diagnostics by severity.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub struct ValidationCounts {
103    /// Count of informational diagnostics.
104    pub info: usize,
105    /// Count of warning diagnostics.
106    pub warn: usize,
107    /// Count of error diagnostics.
108    pub error: usize,
109}
110
111impl ValidationReport {
112    /// Returns `true` when the report includes error diagnostics.
113    pub fn has_errors(&self) -> bool {
114        self.diagnostics
115            .iter()
116            .any(|diag| matches!(diag.severity, Severity::Error))
117    }
118
119    /// Returns the number of diagnostics per severity.
120    pub fn counts(&self) -> ValidationCounts {
121        let mut counts = ValidationCounts::default();
122        for diag in &self.diagnostics {
123            match diag.severity {
124                Severity::Info => counts.info += 1,
125                Severity::Warn => counts.warn += 1,
126                Severity::Error => counts.error += 1,
127            }
128        }
129        counts
130    }
131
132    /// Appends a diagnostic to the report.
133    pub fn push(&mut self, diagnostic: Diagnostic) {
134        self.diagnostics.push(diagnostic);
135    }
136}
137
138/// Validator for pack manifests that emits diagnostics.
139pub trait PackValidator {
140    /// Returns the stable validator identifier.
141    fn id(&self) -> &'static str;
142    /// Returns `true` when the validator applies to the provided manifest.
143    fn applies(&self, manifest: &PackManifest) -> bool;
144    /// Validates the manifest and returns diagnostics.
145    fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic>;
146}
147
148/// Performs domain-agnostic structural validation for a pack manifest.
149pub fn validate_pack_manifest_core(manifest: &PackManifest) -> Vec<Diagnostic> {
150    let mut diagnostics = Vec::new();
151
152    if manifest.schema_version.trim().is_empty() {
153        diagnostics.push(core_diagnostic(
154            Severity::Error,
155            "PACK_SCHEMA_VERSION_MISSING",
156            "Pack manifest schema_version is required.",
157            Some("schema_version".to_owned()),
158            Some("Set schema_version to a supported pack manifest version.".to_owned()),
159        ));
160    }
161
162    if manifest.pack_id.as_str().trim().is_empty() {
163        diagnostics.push(core_diagnostic(
164            Severity::Error,
165            "PACK_ID_MISSING",
166            "Pack manifest pack_id is required.",
167            Some("pack_id".to_owned()),
168            Some("Provide a non-empty pack identifier.".to_owned()),
169        ));
170    }
171
172    let mut component_ids = BTreeSet::new();
173    for component in &manifest.components {
174        if !component_ids.insert(component.id.clone()) {
175            diagnostics.push(core_diagnostic(
176                Severity::Error,
177                "PACK_COMPONENT_ID_DUPLICATE",
178                "Duplicate component identifiers are not allowed.",
179                Some(format!("components.{}", component.id.as_str())),
180                Some("Ensure each component id is unique within the pack.".to_owned()),
181            ));
182        }
183    }
184
185    let declared_components = declared_component_keys(manifest);
186    let explicit_components: HashSet<String> = manifest
187        .components
188        .iter()
189        .map(|component| component.id.as_str().to_owned())
190        .collect();
191    let mut non_explicit_components: HashSet<String> = HashSet::new();
192
193    let mut dependency_aliases = BTreeSet::new();
194    for dependency in &manifest.dependencies {
195        if dependency.alias.trim().is_empty() {
196            diagnostics.push(core_diagnostic(
197                Severity::Error,
198                "PACK_DEPENDENCY_ALIAS_MISSING",
199                "Pack dependency alias is required.",
200                Some("dependencies".to_owned()),
201                Some("Provide a non-empty alias for each dependency.".to_owned()),
202            ));
203        }
204        if !dependency_aliases.insert(dependency.alias.clone()) {
205            diagnostics.push(core_diagnostic(
206                Severity::Error,
207                "PACK_DEPENDENCY_ALIAS_DUPLICATE",
208                "Duplicate dependency aliases are not allowed.",
209                Some(format!("dependencies.{}", dependency.alias)),
210                Some("Ensure each dependency alias is unique within the pack.".to_owned()),
211            ));
212        }
213    }
214
215    let mut flow_ids = BTreeSet::new();
216    for entry in &manifest.flows {
217        if !flow_ids.insert(entry.id.clone()) {
218            diagnostics.push(core_diagnostic(
219                Severity::Error,
220                "PACK_FLOW_ID_DUPLICATE",
221                "Duplicate flow identifiers are not allowed.",
222                Some(format!("flows.{}", entry.id.as_str())),
223                Some("Ensure each flow id is unique within the pack.".to_owned()),
224            ));
225        }
226
227        if entry.id != entry.flow.id {
228            diagnostics.push(core_diagnostic(
229                Severity::Error,
230                "PACK_FLOW_ID_MISMATCH",
231                "Pack flow entry id must match the embedded flow id.",
232                Some(format!("flows.{}.id", entry.id.as_str())),
233                Some("Align the entry id with the flow.id field.".to_owned()),
234            ));
235        }
236
237        if entry.kind != entry.flow.kind {
238            diagnostics.push(core_diagnostic(
239                Severity::Error,
240                "PACK_FLOW_KIND_MISMATCH",
241                "Pack flow entry kind must match the embedded flow kind.",
242                Some(format!("flows.{}.kind", entry.id.as_str())),
243                Some("Align the entry kind with the flow.kind field.".to_owned()),
244            ));
245        }
246
247        if entry.flow.schema_version.trim().is_empty() {
248            diagnostics.push(core_diagnostic(
249                Severity::Error,
250                "PACK_FLOW_SCHEMA_VERSION_MISSING",
251                "Embedded flow schema_version is required.",
252                Some(format!("flows.{}.flow.schema_version", entry.id.as_str())),
253                Some("Set schema_version to a supported flow version.".to_owned()),
254            ));
255        }
256    }
257
258    for component in &manifest.components {
259        if let Some(configurators) = &component.configurators {
260            if let Some(flow_id) = &configurators.basic {
261                if !flow_ids.contains(flow_id) {
262                    diagnostics.push(core_diagnostic(
263                        Severity::Error,
264                        "PACK_COMPONENT_CONFIG_FLOW_MISSING",
265                        "Component configurator flow is not present in the pack manifest.",
266                        Some(format!(
267                            "components.{}.configurators.basic",
268                            component.id.as_str()
269                        )),
270                        Some("Add the referenced flow to the pack manifest flows.".to_owned()),
271                    ));
272                }
273            }
274            if let Some(flow_id) = &configurators.full {
275                if !flow_ids.contains(flow_id) {
276                    diagnostics.push(core_diagnostic(
277                        Severity::Error,
278                        "PACK_COMPONENT_CONFIG_FLOW_MISSING",
279                        "Component configurator flow is not present in the pack manifest.",
280                        Some(format!(
281                            "components.{}.configurators.full",
282                            component.id.as_str()
283                        )),
284                        Some("Add the referenced flow to the pack manifest flows.".to_owned()),
285                    ));
286                }
287            }
288        }
289    }
290
291    for entry in &manifest.flows {
292        for (node_id, node) in entry.flow.nodes.iter() {
293            match &node.component.pack_alias {
294                Some(alias) => {
295                    if !dependency_aliases.contains(alias) {
296                        diagnostics.push(core_diagnostic(
297                            Severity::Error,
298                            "PACK_FLOW_DEPENDENCY_ALIAS_MISSING",
299                            "Flow node references an unknown dependency alias.",
300                            Some(format!(
301                                "flows.{}.nodes.{}.component.pack_alias",
302                                entry.id.as_str(),
303                                node_id.as_str()
304                            )),
305                            Some("Add the dependency alias to the pack manifest.".to_owned()),
306                        ));
307                    }
308                }
309                None => {
310                    let component_key = node.component.id.as_str();
311                    if !declared_components.contains(component_key) {
312                        diagnostics.push(core_diagnostic(
313                            Severity::Error,
314                            "PACK_FLOW_COMPONENT_MISSING",
315                            "Flow node references a component not resolvable by the pack.",
316                            Some(format!(
317                                "flows.{}.nodes.{}.component.id",
318                                entry.id.as_str(),
319                                node_id.as_str()
320                            )),
321                            Some(
322                                "Declare or source the component in the pack manifest.".to_owned(),
323                            ),
324                        ));
325                    } else if !explicit_components.contains(component_key)
326                        && non_explicit_components.insert(component_key.to_owned())
327                    {
328                        diagnostics.push(core_diagnostic(
329                            Severity::Warn,
330                            "PACK_COMPONENT_NOT_EXPLICIT",
331                            "Component is resolved via component sources or lock but is not declared in manifest.components.",
332                            Some(format!(
333                                "flows.{}.nodes.{}.component.id",
334                                entry.id.as_str(),
335                                node_id.as_str()
336                            )),
337                            Some("Consider declaring the component explicitly in manifest.components.".to_owned()),
338                        ));
339                    }
340                }
341            }
342        }
343    }
344
345    diagnostics
346}
347
348fn declared_component_keys(manifest: &PackManifest) -> HashSet<String> {
349    let mut declared = HashSet::new();
350    for component in &manifest.components {
351        declared.insert(component.id.as_str().to_owned());
352    }
353
354    #[cfg(feature = "serde")]
355    {
356        if let Some(extensions) = manifest.extensions.as_ref() {
357            if let Some(extension) = extensions.get(EXT_COMPONENT_SOURCES_V1) {
358                if let Some(ExtensionInline::Other(value)) = extension.inline.as_ref() {
359                    if let Ok(payload) = ComponentSourcesV1::from_extension_value(value) {
360                        for entry in payload.components {
361                            declared.insert(entry.name);
362                            if let Some(component_id) = entry.component_id {
363                                declared.insert(component_id.as_str().to_owned());
364                            }
365                        }
366                    }
367                }
368            }
369        }
370    }
371
372    declared
373}
374
375fn core_diagnostic(
376    severity: Severity,
377    code: &str,
378    message: &str,
379    path: Option<String>,
380    hint: Option<String>,
381) -> Diagnostic {
382    Diagnostic {
383        severity,
384        code: code.to_owned(),
385        message: message.to_owned(),
386        path,
387        hint,
388        data: empty_data(),
389    }
390}