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 proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
363 use rstest::rstest;
364
365 use super::*;
366 use crate::VersionComparison;
367
368 const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
369 const EPOCH_REGEX: &str = r"[1-9]{1}[0-9]{0,10}";
373 const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
374 const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
375 const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
376 const DESCRIPTION_REGEX: &str = "[^\n\r]*";
377
378 proptest! {
379 #![proptest_config(ProptestConfig::with_cases(1000))]
380
381
382 #[test]
383 fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
384 println!("s: {s}");
385 let name = PackageRelation::from_str(&s).unwrap();
386 prop_assert_eq!(s, format!("{}", name));
387 }
388 }
389
390 proptest! {
391 #[test]
392 fn opt_depend_from_str(
393 name in NAME_REGEX,
394 desc in DESCRIPTION_REGEX,
395 use_desc in proptest::bool::ANY
396 ) {
397 let desc_trimmed = desc.trim_ascii();
398 let desc_is_blank = desc_trimmed.is_empty();
399
400 let (raw_in, formatted_expected) = if use_desc {
401 (
405 format!("{name}: {desc}"),
406 if !desc_is_blank {
407 format!("{name}: {desc_trimmed}")
408 } else {
409 name.clone()
410 }
411 )
412 } else {
413 (name.clone(), name.clone())
414 };
415
416 println!("input string: {raw_in}");
417 let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
418 let formatted_actual = format!("{opt_depend}");
419 prop_assert_eq!(
420 formatted_expected,
421 formatted_actual,
422 "Formatted output doesn't match input"
423 );
424 }
425 }
426
427 #[rstest]
428 #[case(
429 "python>=3",
430 Ok(PackageRelation {
431 name: Name::new("python").unwrap(),
432 version_requirement: Some(VersionRequirement {
433 comparison: VersionComparison::GreaterOrEqual,
434 version: "3".parse().unwrap(),
435 }),
436 }),
437 )]
438 #[case(
439 "java-environment>=17",
440 Ok(PackageRelation {
441 name: Name::new("java-environment").unwrap(),
442 version_requirement: Some(VersionRequirement {
443 comparison: VersionComparison::GreaterOrEqual,
444 version: "17".parse().unwrap(),
445 }),
446 }),
447 )]
448 fn valid_package_relation(
449 #[case] input: &str,
450 #[case] expected: Result<PackageRelation, Error>,
451 ) {
452 assert_eq!(PackageRelation::from_str(input), expected);
453 }
454
455 #[rstest]
456 #[case(
457 "example: this is an example dependency",
458 OptionalDependency {
459 package_relation: PackageRelation {
460 name: Name::new("example").unwrap(),
461 version_requirement: None,
462 },
463 description: Some("this is an example dependency".to_string()),
464 },
465 )]
466 #[case(
467 "example-two: a description with lots of whitespace padding ",
468 OptionalDependency {
469 package_relation: PackageRelation {
470 name: Name::new("example-two").unwrap(),
471 version_requirement: None,
472 },
473 description: Some("a description with lots of whitespace padding".to_string())
474 },
475 )]
476 #[case(
477 "dep_name",
478 OptionalDependency {
479 package_relation: PackageRelation {
480 name: Name::new("dep_name").unwrap(),
481 version_requirement: None,
482 },
483 description: None,
484 },
485 )]
486 #[case(
487 "dep_name: ",
488 OptionalDependency {
489 package_relation: PackageRelation {
490 name: Name::new("dep_name").unwrap(),
491 version_requirement: None,
492 },
493 description: None,
494 },
495 )]
496 #[case(
497 "dep_name_with_special_chars-123: description with !@#$%^&*",
498 OptionalDependency {
499 package_relation: PackageRelation {
500 name: Name::new("dep_name_with_special_chars-123").unwrap(),
501 version_requirement: None,
502 },
503 description: Some("description with !@#$%^&*".to_string()),
504 },
505 )]
506 #[case(
508 "elfutils=0.192: for translations",
509 OptionalDependency {
510 package_relation: PackageRelation {
511 name: Name::new("elfutils").unwrap(),
512 version_requirement: Some(VersionRequirement {
513 comparison: VersionComparison::Equal,
514 version: "0.192".parse().unwrap(),
515 }),
516 },
517 description: Some("for translations".to_string()),
518 },
519 )]
520 #[case(
521 "python>=3: For Python bindings",
522 OptionalDependency {
523 package_relation: PackageRelation {
524 name: Name::new("python").unwrap(),
525 version_requirement: Some(VersionRequirement {
526 comparison: VersionComparison::GreaterOrEqual,
527 version: "3".parse().unwrap(),
528 }),
529 },
530 description: Some("For Python bindings".to_string()),
531 },
532 )]
533 #[case(
534 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
535 OptionalDependency {
536 package_relation: PackageRelation {
537 name: Name::new("java-environment").unwrap(),
538 version_requirement: Some(VersionRequirement {
539 comparison: VersionComparison::GreaterOrEqual,
540 version: "17".parse().unwrap(),
541 }),
542 },
543 description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
544 },
545 )]
546 fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
547 let opt_depend_result = OptionalDependency::from_str(input);
548 let Ok(optional_dependency) = opt_depend_result else {
549 panic!(
550 "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
551 )
552 };
553
554 assert_eq!(
555 expected, optional_dependency,
556 "Optional dependency has not been correctly parsed."
557 );
558 }
559
560 #[rstest]
561 #[case(
562 "example: this is an example dependency",
563 "example: this is an example dependency"
564 )]
565 #[case(
566 "example-two: a description with lots of whitespace padding ",
567 "example-two: a description with lots of whitespace padding"
568 )]
569 #[case(
570 "tabs: a description with a tab directly after the colon",
571 "tabs: a description with a tab directly after the colon"
572 )]
573 #[case("dep_name", "dep_name")]
574 #[case("dep_name: ", "dep_name")]
575 #[case(
576 "dep_name_with_special_chars-123: description with !@#$%^&*",
577 "dep_name_with_special_chars-123: description with !@#$%^&*"
578 )]
579 #[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
581 #[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
582 #[case(
583 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
584 "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
585 )]
586 fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
587 let opt_depend_result = OptionalDependency::from_str(input);
588 let Ok(optional_dependency) = opt_depend_result else {
589 panic!(
590 "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
591 )
592 };
593 assert_eq!(
594 expected,
595 optional_dependency.to_string(),
596 "OptionalDependency to_string is erroneous."
597 );
598 }
599
600 #[rstest]
601 #[case(
602 "#invalid-name: this is an example dependency",
603 "invalid first character of package name"
604 )]
605 #[case(": no_name_colon", "invalid first character of package name")]
606 #[case(
607 "name:description with no leading whitespace",
608 "invalid character in package name"
609 )]
610 #[case(
611 "dep-name>=10: \n\ndescription with\rnewlines",
612 "expected no carriage returns or newlines"
613 )]
614 fn opt_depend_invalid_string_parse_error(#[case] input: &str, #[case] err_snippet: &str) {
615 let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
616 panic!("'{input}' did not fail to parse as expected")
617 };
618 assert!(
619 err_msg.contains(err_snippet),
620 "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
621 );
622 }
623}