1use std::{
4 fmt::{Display, Formatter},
5 str::FromStr,
6};
7
8use serde::{Deserialize, Serialize};
9use winnow::{
10 ModalResult,
11 Parser,
12 ascii::space1,
13 combinator::{alt, cut_err, eof, opt, separated_pair, seq, terminated},
14 error::{StrContext, StrContextValue},
15 token::{rest, take_till, take_until},
16};
17
18use crate::{Error, Name, VersionRequirement};
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33pub struct PackageRelation {
34 pub name: Name,
36 pub version_requirement: Option<VersionRequirement>,
38}
39
40impl PackageRelation {
41 pub fn new(name: Name, version_requirement: Option<VersionRequirement>) -> Self {
62 Self {
63 name,
64 version_requirement,
65 }
66 }
67
68 pub fn parser(input: &mut &str) -> ModalResult<Self> {
80 seq!(Self {
81 name: take_till(1.., ('<', '>', '=')).and_then(Name::parser).context(StrContext::Label("package name")),
82 version_requirement: opt(VersionRequirement::parser),
83 _: eof.context(StrContext::Expected(StrContextValue::Description("end of relation version requirement"))),
84 })
85 .parse_next(input)
86 }
87}
88
89impl Display for PackageRelation {
90 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91 if let Some(version_requirement) = self.version_requirement.as_ref() {
92 write!(f, "{}{}", self.name, version_requirement)
93 } else {
94 write!(f, "{}", self.name)
95 }
96 }
97}
98
99impl FromStr for PackageRelation {
100 type Err = Error;
101 fn from_str(s: &str) -> Result<Self, Self::Err> {
182 Ok(Self::parser.parse(s)?)
183 }
184}
185
186#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
230pub struct OptionalDependency {
231 package_relation: PackageRelation,
232 description: Option<String>,
233}
234
235impl OptionalDependency {
236 pub fn new(
238 package_relation: PackageRelation,
239 description: Option<String>,
240 ) -> OptionalDependency {
241 OptionalDependency {
242 package_relation,
243 description,
244 }
245 }
246
247 pub fn name(&self) -> &Name {
249 &self.package_relation.name
250 }
251
252 pub fn version_requirement(&self) -> &Option<VersionRequirement> {
254 &self.package_relation.version_requirement
255 }
256
257 pub fn description(&self) -> &Option<String> {
259 &self.description
260 }
261
262 pub fn package_relation(&self) -> &PackageRelation {
264 &self.package_relation
265 }
266
267 pub fn parser(input: &mut &str) -> ModalResult<Self> {
276 let description_parser = terminated(
277 take_till(0.., ('\n', '\r')),
284 eof,
285 )
286 .context(StrContext::Label("optional dependency description"))
287 .context(StrContext::Expected(StrContextValue::Description(
288 r"no carriage returns or newlines",
289 )))
290 .map(|d: &str| match d.trim_ascii() {
291 "" => None,
292 t => Some(t.to_string()),
293 });
294
295 let (package_relation, description) = alt((
296 separated_pair(
299 take_until(1.., ":").and_then(cut_err(PackageRelation::parser)),
300 (":", space1),
301 rest.and_then(cut_err(description_parser)),
302 ),
303 (rest.and_then(PackageRelation::parser), eof.value(None)),
306 ))
307 .parse_next(input)?;
308
309 Ok(Self {
310 package_relation,
311 description,
312 })
313 }
314}
315
316impl FromStr for OptionalDependency {
317 type Err = Error;
318
319 fn from_str(s: &str) -> Result<Self, Self::Err> {
327 Ok(Self::parser.parse(s)?)
328 }
329}
330
331impl Display for OptionalDependency {
332 fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
333 match self.description {
334 Some(ref description) => write!(fmt, "{}: {}", self.package_relation, description),
335 None => write!(fmt, "{}", self.package_relation),
336 }
337 }
338}
339
340pub type Group = String;
359
360#[cfg(test)]
361mod tests {
362 use insta::assert_snapshot;
363 use proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
364 use rstest::rstest;
365
366 use super::*;
367 use crate::{VersionComparison, configure_insta};
368
369 const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
370 const EPOCH_REGEX: &str = r"[1-9]{1}[0-9]{0,10}";
374 const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
375 const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
376 const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
377 const DESCRIPTION_REGEX: &str = "[^\n\r]*";
378
379 proptest! {
380 #![proptest_config(ProptestConfig::with_cases(1000))]
381
382
383 #[test]
384 fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
385 println!("s: {s}");
386 let name = PackageRelation::from_str(&s).unwrap();
387 prop_assert_eq!(s, format!("{}", name));
388 }
389 }
390
391 proptest! {
392 #[test]
393 fn opt_depend_from_str(
394 name in NAME_REGEX,
395 desc in DESCRIPTION_REGEX,
396 use_desc in proptest::bool::ANY
397 ) {
398 let desc_trimmed = desc.trim_ascii();
399 let desc_is_blank = desc_trimmed.is_empty();
400
401 let (raw_in, formatted_expected) = if use_desc {
402 (
406 format!("{name}: {desc}"),
407 if !desc_is_blank {
408 format!("{name}: {desc_trimmed}")
409 } else {
410 name.clone()
411 }
412 )
413 } else {
414 (name.clone(), name.clone())
415 };
416
417 println!("input string: {raw_in}");
418 let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
419 let formatted_actual = format!("{opt_depend}");
420 prop_assert_eq!(
421 formatted_expected,
422 formatted_actual,
423 "Formatted output doesn't match input"
424 );
425 }
426 }
427
428 #[rstest]
429 #[case(
430 "python>=3",
431 Ok(PackageRelation {
432 name: Name::new("python").unwrap(),
433 version_requirement: Some(VersionRequirement {
434 comparison: VersionComparison::GreaterOrEqual,
435 version: "3".parse().unwrap(),
436 }),
437 }),
438 )]
439 #[case(
440 "java-environment>=17",
441 Ok(PackageRelation {
442 name: Name::new("java-environment").unwrap(),
443 version_requirement: Some(VersionRequirement {
444 comparison: VersionComparison::GreaterOrEqual,
445 version: "17".parse().unwrap(),
446 }),
447 }),
448 )]
449 fn valid_package_relation(
450 #[case] input: &str,
451 #[case] expected: Result<PackageRelation, Error>,
452 ) {
453 assert_eq!(PackageRelation::from_str(input), expected);
454 }
455
456 #[rstest]
457 #[case(
458 "example: this is an example dependency",
459 OptionalDependency {
460 package_relation: PackageRelation {
461 name: Name::new("example").unwrap(),
462 version_requirement: None,
463 },
464 description: Some("this is an example dependency".to_string()),
465 },
466 )]
467 #[case(
468 "example-two: a description with lots of whitespace padding ",
469 OptionalDependency {
470 package_relation: PackageRelation {
471 name: Name::new("example-two").unwrap(),
472 version_requirement: None,
473 },
474 description: Some("a description with lots of whitespace padding".to_string())
475 },
476 )]
477 #[case(
478 "dep_name",
479 OptionalDependency {
480 package_relation: PackageRelation {
481 name: Name::new("dep_name").unwrap(),
482 version_requirement: None,
483 },
484 description: None,
485 },
486 )]
487 #[case(
488 "dep_name: ",
489 OptionalDependency {
490 package_relation: PackageRelation {
491 name: Name::new("dep_name").unwrap(),
492 version_requirement: None,
493 },
494 description: None,
495 },
496 )]
497 #[case(
498 "dep_name_with_special_chars-123: description with !@#$%^&*",
499 OptionalDependency {
500 package_relation: PackageRelation {
501 name: Name::new("dep_name_with_special_chars-123").unwrap(),
502 version_requirement: None,
503 },
504 description: Some("description with !@#$%^&*".to_string()),
505 },
506 )]
507 #[case(
509 "elfutils=0.192: for translations",
510 OptionalDependency {
511 package_relation: PackageRelation {
512 name: Name::new("elfutils").unwrap(),
513 version_requirement: Some(VersionRequirement {
514 comparison: VersionComparison::Equal,
515 version: "0.192".parse().unwrap(),
516 }),
517 },
518 description: Some("for translations".to_string()),
519 },
520 )]
521 #[case(
522 "python>=3: For Python bindings",
523 OptionalDependency {
524 package_relation: PackageRelation {
525 name: Name::new("python").unwrap(),
526 version_requirement: Some(VersionRequirement {
527 comparison: VersionComparison::GreaterOrEqual,
528 version: "3".parse().unwrap(),
529 }),
530 },
531 description: Some("For Python bindings".to_string()),
532 },
533 )]
534 #[case(
535 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
536 OptionalDependency {
537 package_relation: PackageRelation {
538 name: Name::new("java-environment").unwrap(),
539 version_requirement: Some(VersionRequirement {
540 comparison: VersionComparison::GreaterOrEqual,
541 version: "17".parse().unwrap(),
542 }),
543 },
544 description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
545 },
546 )]
547 fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
548 let opt_depend_result = OptionalDependency::from_str(input);
549 let Ok(optional_dependency) = opt_depend_result else {
550 panic!(
551 "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
552 )
553 };
554
555 assert_eq!(
556 expected, optional_dependency,
557 "Optional dependency has not been correctly parsed."
558 );
559 }
560
561 #[rstest]
562 #[case(
563 "example: this is an example dependency",
564 "example: this is an example dependency"
565 )]
566 #[case(
567 "example-two: a description with lots of whitespace padding ",
568 "example-two: a description with lots of whitespace padding"
569 )]
570 #[case(
571 "tabs: a description with a tab directly after the colon",
572 "tabs: a description with a tab directly after the colon"
573 )]
574 #[case("dep_name", "dep_name")]
575 #[case("dep_name: ", "dep_name")]
576 #[case(
577 "dep_name_with_special_chars-123: description with !@#$%^&*",
578 "dep_name_with_special_chars-123: description with !@#$%^&*"
579 )]
580 #[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
582 #[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
583 #[case(
584 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
585 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
586 )]
587 fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
588 let opt_depend_result = OptionalDependency::from_str(input);
589 let Ok(optional_dependency) = opt_depend_result else {
590 panic!(
591 "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
592 )
593 };
594 assert_eq!(
595 expected,
596 optional_dependency.to_string(),
597 "OptionalDependency to_string is erroneous."
598 );
599 }
600
601 #[rstest]
602 #[case("#invalid-name: this is an example dependency")]
603 #[case(": no_name_colon")]
604 #[case("name:description with no leading whitespace")]
605 #[case("dep-name>=10: \n\ndescription with\rnewlines")]
606 fn opt_depend_invalid_string_parse_error(#[case] input: &str) {
607 let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
608 panic!("'{input}' erroneously parsed as a OptionalDependency")
609 };
610
611 let (test_name, _guard) = configure_insta();
612 assert_snapshot!(test_name, err_msg.to_string());
613 }
614}