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
100impl ValidationReport {
101 pub fn has_errors(&self) -> bool {
103 self.diagnostics
104 .iter()
105 .any(|diag| matches!(diag.severity, Severity::Error))
106 }
107
108 pub fn push(&mut self, diagnostic: Diagnostic) {
110 self.diagnostics.push(diagnostic);
111 }
112}
113
114pub trait PackValidator {
116 fn id(&self) -> &'static str;
118 fn applies(&self, manifest: &PackManifest) -> bool;
120 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic>;
122}
123
124pub 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}