1use 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#[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 Info,
36 Warn,
38 Error,
40}
41
42#[derive(Clone, Debug, PartialEq)]
44#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
45#[cfg_attr(feature = "schemars", derive(JsonSchema))]
46pub struct Diagnostic {
47 pub severity: Severity,
49 pub code: String,
51 pub message: String,
53 #[cfg_attr(
55 feature = "serde",
56 serde(default, skip_serializing_if = "Option::is_none")
57 )]
58 pub path: Option<String>,
59 #[cfg_attr(
61 feature = "serde",
62 serde(default, skip_serializing_if = "Option::is_none")
63 )]
64 pub hint: Option<String>,
65 #[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#[derive(Clone, Debug, PartialEq, Default)]
76#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77#[cfg_attr(feature = "schemars", derive(JsonSchema))]
78pub struct ValidationReport {
79 #[cfg_attr(
81 feature = "serde",
82 serde(default, skip_serializing_if = "Option::is_none")
83 )]
84 pub pack_id: Option<PackId>,
85 #[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 #[cfg_attr(feature = "serde", serde(default))]
97 pub diagnostics: Vec<Diagnostic>,
98}
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub struct ValidationCounts {
103 pub info: usize,
105 pub warn: usize,
107 pub error: usize,
109}
110
111impl ValidationReport {
112 pub fn has_errors(&self) -> bool {
114 self.diagnostics
115 .iter()
116 .any(|diag| matches!(diag.severity, Severity::Error))
117 }
118
119 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 pub fn push(&mut self, diagnostic: Diagnostic) {
134 self.diagnostics.push(diagnostic);
135 }
136}
137
138pub trait PackValidator {
140 fn id(&self) -> &'static str;
142 fn applies(&self, manifest: &PackManifest) -> bool;
144 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic>;
146}
147
148pub 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}