1use 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#[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 Info,
33 Warn,
35 Error,
37}
38
39#[derive(Clone, Debug, PartialEq)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(feature = "schemars", derive(JsonSchema))]
43pub struct Diagnostic {
44 pub severity: Severity,
46 pub code: String,
48 pub message: String,
50 #[cfg_attr(
52 feature = "serde",
53 serde(default, skip_serializing_if = "Option::is_none")
54 )]
55 pub path: Option<String>,
56 #[cfg_attr(
58 feature = "serde",
59 serde(default, skip_serializing_if = "Option::is_none")
60 )]
61 pub hint: Option<String>,
62 #[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#[derive(Clone, Debug, PartialEq, Default)]
73#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
74#[cfg_attr(feature = "schemars", derive(JsonSchema))]
75pub struct ValidationReport {
76 #[cfg_attr(
78 feature = "serde",
79 serde(default, skip_serializing_if = "Option::is_none")
80 )]
81 pub pack_id: Option<PackId>,
82 #[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 #[cfg_attr(feature = "serde", serde(default))]
94 pub diagnostics: Vec<Diagnostic>,
95}
96
97impl ValidationReport {
98 pub fn has_errors(&self) -> bool {
100 self.diagnostics
101 .iter()
102 .any(|diag| matches!(diag.severity, Severity::Error))
103 }
104
105 pub fn push(&mut self, diagnostic: Diagnostic) {
107 self.diagnostics.push(diagnostic);
108 }
109}
110
111pub trait PackValidator {
113 fn id(&self) -> &'static str;
115 fn applies(&self, manifest: &PackManifest) -> bool;
117 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic>;
119}
120
121pub 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}