1use crate::{
20 external_models::{
21 date_time::DateTime,
22 normalized_string::{validate_normalized_string, NormalizedString},
23 uri::{validate_uri, Uri},
24 validate_date_time,
25 },
26 validation::{Validate, ValidationContext, ValidationError, ValidationResult},
27};
28
29use super::{attached_text::AttachedText, bom::SpecVersion};
30
31#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct Commit {
33 pub uid: Option<NormalizedString>,
34 pub url: Option<Uri>,
35 pub author: Option<IdentifiableAction>,
36 pub committer: Option<IdentifiableAction>,
37 pub message: Option<NormalizedString>,
38}
39
40impl Validate for Commit {
41 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
42 ValidationContext::new()
43 .add_field_option("uid", self.uid.as_ref(), validate_normalized_string)
44 .add_field_option("url", self.url.as_ref(), validate_uri)
45 .add_struct_option("author", self.author.as_ref(), version)
46 .add_struct_option("committer", self.committer.as_ref(), version)
47 .add_field_option("message", self.message.as_ref(), validate_normalized_string)
48 .into()
49 }
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Hash)]
53pub struct Commits(pub Vec<Commit>);
54
55impl Validate for Commits {
56 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
57 ValidationContext::new()
58 .add_list("inner", &self.0, |commit| commit.validate_version(version))
59 .into()
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub struct Diff {
65 pub text: Option<AttachedText>,
66 pub url: Option<Uri>,
67}
68
69impl Validate for Diff {
70 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
71 ValidationContext::new()
72 .add_struct_option("text", self.text.as_ref(), version)
73 .add_field_option("url", self.url.as_ref(), validate_uri)
74 .into()
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79pub struct IdentifiableAction {
80 pub timestamp: Option<DateTime>,
81 pub name: Option<NormalizedString>,
82 pub email: Option<NormalizedString>,
83}
84
85impl Validate for IdentifiableAction {
86 fn validate_version(&self, _version: SpecVersion) -> ValidationResult {
87 ValidationContext::new()
88 .add_field_option("timestamp", self.timestamp.as_ref(), validate_date_time)
89 .add_field_option("name", self.name.as_ref(), validate_normalized_string)
90 .add_field_option("email", self.email.as_ref(), validate_normalized_string)
91 .into()
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Hash)]
96pub struct Issue {
97 pub issue_type: IssueClassification,
98 pub id: Option<NormalizedString>,
99 pub name: Option<NormalizedString>,
100 pub description: Option<NormalizedString>,
101 pub source: Option<Source>,
102 pub references: Option<Vec<Uri>>,
103}
104
105impl Validate for Issue {
106 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
107 ValidationContext::new()
108 .add_field(
109 "issue_type",
110 &self.issue_type,
111 validate_issue_classification,
112 )
113 .add_field_option("id", self.id.as_ref(), validate_normalized_string)
114 .add_field_option("name", self.name.as_ref(), validate_normalized_string)
115 .add_field_option(
116 "description",
117 self.description.as_ref(),
118 validate_normalized_string,
119 )
120 .add_struct_option("source", self.source.as_ref(), version)
121 .add_list_option("references", self.references.as_ref(), validate_uri)
122 .into()
123 }
124}
125
126pub fn validate_issue_classification(
127 classification: &IssueClassification,
128) -> Result<(), ValidationError> {
129 if matches!(
130 classification,
131 IssueClassification::UnknownIssueClassification(_)
132 ) {
133 return Err(ValidationError::new("Unknown issue classification"));
134 }
135 Ok(())
136}
137
138#[derive(Clone, Debug, PartialEq, Eq, strum::Display, Hash)]
139#[strum(serialize_all = "snake_case")]
140pub enum IssueClassification {
141 Defect,
142 Enhancement,
143 Security,
144 #[doc(hidden)]
145 #[strum(default)]
146 UnknownIssueClassification(String),
147}
148
149impl IssueClassification {
150 pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
151 match value.as_ref() {
152 "defect" => Self::Defect,
153 "enhancement" => Self::Enhancement,
154 "security" => Self::Security,
155 unknown => Self::UnknownIssueClassification(unknown.to_string()),
156 }
157 }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Hash)]
161pub struct Patch {
162 pub patch_type: PatchClassification,
163 pub diff: Option<Diff>,
164 pub resolves: Option<Vec<Issue>>,
165}
166
167impl Validate for Patch {
168 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
169 ValidationContext::new()
170 .add_enum(
171 "patch_type",
172 &self.patch_type,
173 validate_patch_classification,
174 )
175 .add_struct_option("diff", self.diff.as_ref(), version)
176 .add_list_option("resolves", self.resolves.as_ref(), |issue| {
177 issue.validate_version(version)
178 })
179 .into()
180 }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Hash)]
184pub struct Patches(pub Vec<Patch>);
185
186impl Validate for Patches {
187 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
188 ValidationContext::new()
189 .add_list("inner", &self.0, |patch| patch.validate_version(version))
190 .into()
191 }
192}
193
194pub fn validate_patch_classification(
195 classification: &PatchClassification,
196) -> Result<(), ValidationError> {
197 if matches!(
198 classification,
199 PatchClassification::UnknownPatchClassification(_)
200 ) {
201 return Err("Unknown patch classification".into());
202 }
203 Ok(())
204}
205
206#[derive(Clone, Debug, PartialEq, Eq, strum::Display, Hash)]
207#[strum(serialize_all = "kebab-case")]
208pub enum PatchClassification {
209 Unofficial,
210 Monkey,
211 Backport,
212 CherryPick,
213 #[doc(hidden)]
214 #[strum(default)]
215 UnknownPatchClassification(String),
216}
217
218impl PatchClassification {
219 pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
220 match value.as_ref() {
221 "unofficial" => Self::Unofficial,
222 "monkey" => Self::Monkey,
223 "backport" => Self::Backport,
224 "cherry-pick" => Self::CherryPick,
225 unknown => Self::UnknownPatchClassification(unknown.to_string()),
226 }
227 }
228}
229
230#[derive(Clone, Debug, PartialEq, Eq, Hash)]
231pub struct Source {
232 pub name: Option<NormalizedString>,
233 pub url: Option<Uri>,
234}
235
236impl Validate for Source {
237 fn validate_version(&self, _version: SpecVersion) -> ValidationResult {
238 ValidationContext::new()
239 .add_field_option("name", self.name.as_ref(), validate_normalized_string)
240 .add_field_option("url", self.url.as_ref(), validate_uri)
241 .into()
242 }
243}
244
245#[cfg(test)]
246mod test {
247 use crate::validation;
248
249 use super::*;
250 use pretty_assertions::assert_eq;
251
252 #[test]
253 fn valid_commits_should_pass_validation() {
254 let validation_result = Commits(vec![Commit {
255 uid: Some(NormalizedString("no_whitespace".to_string())),
256 url: Some(Uri("https://www.example.com".to_string())),
257 author: Some(IdentifiableAction {
258 timestamp: Some(DateTime("1969-06-28T01:20:00.00-04:00".to_string())),
259 name: Some(NormalizedString("Name".to_string())),
260 email: Some(NormalizedString("email@example.com".to_string())),
261 }),
262 committer: Some(IdentifiableAction {
263 timestamp: Some(DateTime("1969-06-28T01:20:00.00-04:00".to_string())),
264 name: Some(NormalizedString("Name".to_string())),
265 email: Some(NormalizedString("email@example.com".to_string())),
266 }),
267 message: Some(NormalizedString("no_whitespace".to_string())),
268 }])
269 .validate();
270
271 assert!(validation_result.passed());
272 }
273
274 #[test]
275 fn invalid_commits_should_fail_validation() {
276 let validation_result = Commits(vec![Commit {
277 uid: Some(NormalizedString("spaces and\ttabs".to_string())),
278 url: Some(Uri("invalid uri".to_string())),
279 author: Some(IdentifiableAction {
280 timestamp: Some(DateTime("Thursday".to_string())),
281 name: Some(NormalizedString("spaces and\ttabs".to_string())),
282 email: Some(NormalizedString("spaces and\ttabs".to_string())),
283 }),
284 committer: Some(IdentifiableAction {
285 timestamp: Some(DateTime("1970-01-01".to_string())),
286 name: Some(NormalizedString("spaces and\ttabs".to_string())),
287 email: Some(NormalizedString("spaces and\ttabs".to_string())),
288 }),
289 message: Some(NormalizedString("spaces and\ttabs".to_string())),
290 }])
291 .validate();
292
293 assert_eq!(
294 validation_result,
295 validation::list(
296 "inner",
297 [(
298 0,
299 vec![
300 validation::field(
301 "uid",
302 "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"
303 ),
304 validation::field("url", "Uri does not conform to RFC 3986"),
305 validation::r#struct(
306 "author",
307 vec![
308 validation::field("timestamp", "DateTime does not conform to ISO 8601"),
309 validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
310 validation::field("email", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n")
311 ]
312 ),
313 validation::r#struct(
314 "committer",
315 vec![
316 validation::field("timestamp", "DateTime does not conform to ISO 8601"),
317 validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
318 validation::field("email", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
319 ]
320 ),
321 validation::field("message", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n")
322 ]
323 )]
324 )
325 );
326 }
327
328 #[test]
329 fn valid_patches_should_pass_validation() {
330 let validation_result = Patches(vec![Patch {
331 patch_type: PatchClassification::Backport,
332 diff: Some(Diff {
333 text: Some(AttachedText {
334 content_type: None,
335 encoding: None,
336 content: "content".to_string(),
337 }),
338 url: Some(Uri("https://www.example.com".to_string())),
339 }),
340 resolves: Some(vec![Issue {
341 issue_type: IssueClassification::Defect,
342 id: Some(NormalizedString("issue_id".to_string())),
343 name: Some(NormalizedString("issue_name".to_string())),
344 description: Some(NormalizedString("issue_description".to_string())),
345 source: Some(Source {
346 name: Some(NormalizedString("source_name".to_string())),
347 url: Some(Uri("https://example.com".to_string())),
348 }),
349 references: Some(vec![Uri("https://example.com".to_string())]),
350 }]),
351 }])
352 .validate();
353
354 assert!(validation_result.passed());
355 }
356
357 #[test]
358 fn invalid_patches_should_fail_validation() {
359 let validation_result = Patches(vec![Patch {
360 patch_type: PatchClassification::UnknownPatchClassification("unknown".to_string()),
361 diff: Some(Diff {
362 text: Some(AttachedText {
363 content_type: Some(NormalizedString("spaces and \ttabs".to_string())),
364 encoding: None,
365 content: "content".to_string(),
366 }),
367 url: Some(Uri("invalid uri".to_string())),
368 }),
369 resolves: Some(vec![Issue {
370 issue_type: IssueClassification::UnknownIssueClassification("unknown".to_string()),
371 id: Some(NormalizedString("spaces and \ttabs".to_string())),
372 name: Some(NormalizedString("spaces and \ttabs".to_string())),
373 description: Some(NormalizedString("spaces and \ttabs".to_string())),
374 source: Some(Source {
375 name: Some(NormalizedString("spaces and \ttabs".to_string())),
376 url: Some(Uri("invalid uri".to_string())),
377 }),
378 references: Some(vec![Uri("invalid uri".to_string())]),
379 }]),
380 }])
381 .validate();
382
383 assert_eq!(
384 validation_result,
385 validation::list(
386 "inner",
387 [(
388 0,
389 vec![
390 validation::r#enum("patch_type", "Unknown patch classification"),
391 validation::r#struct(
392 "diff",
393 vec![
394 validation::r#struct(
395 "text",
396 vec![
397 validation::field(
398 "content_type",
399 "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"
400 )
401 ]
402 ),
403 validation::field("url", "Uri does not conform to RFC 3986")
404 ]
405 ),
406 validation::list(
407 "resolves",
408 [(
409 0,
410 vec![
411 validation::field("issue_type", "Unknown issue classification"),
412 validation::field("id", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
413 validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
414 validation::field("description", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
415 validation::r#struct(
416 "source",
417 vec![
418 validation::field(
419 "name",
420 "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n",
421 ),
422 validation::field(
423 "url",
424 "Uri does not conform to RFC 3986",
425 )
426 ]
427 ),
428 validation::list("references", [(0, validation::custom("", ["Uri does not conform to RFC 3986"]))])
429 ]
430 )]
431 )
432 ]
433 )]
434 )
435 );
436 }
437}