Skip to main content

alpm_types/relation/
base.rs

1//! Basic relation types used in metadata files.
2
3use std::{
4    fmt::{Display, Formatter},
5    str::FromStr,
6};
7
8use alpm_parsers::traits::{AlpmParser, ParserUntil, ParserUntilInclusive};
9use serde::{Deserialize, Serialize};
10use winnow::{
11    ModalResult,
12    Parser,
13    ascii::space1,
14    combinator::{opt, peek, seq, terminated},
15    error::{StrContext, StrContextValue},
16    token::{none_of, take_till},
17};
18
19use crate::{
20    Epoch,
21    Error,
22    Name,
23    PackageRelease,
24    PackageVersion,
25    Version,
26    VersionComparison,
27    VersionRequirement,
28};
29
30/// A package relation
31///
32/// Describes a relation to a component.
33/// Package relations may either consist of only a [`Name`] *or* of a [`Name`] and a
34/// [`VersionRequirement`].
35///
36/// ## Note
37///
38/// A [`PackageRelation`] covers all [alpm-package-relations] *except* optional
39/// dependencies, as those behave differently.
40///
41/// [alpm-package-relations]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43pub struct PackageRelation {
44    /// The name of the package
45    pub name: Name,
46    /// The version requirement for the package
47    pub version_requirement: Option<VersionRequirement>,
48}
49
50impl PackageRelation {
51    /// Creates a new [`PackageRelation`]
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use alpm_types::{PackageRelation, VersionComparison, VersionRequirement};
57    ///
58    /// # fn main() -> Result<(), alpm_types::Error> {
59    /// PackageRelation::new(
60    ///     "example".parse()?,
61    ///     Some(VersionRequirement {
62    ///         comparison: VersionComparison::Less,
63    ///         version: "1.0.0".parse()?,
64    ///     }),
65    /// );
66    ///
67    /// PackageRelation::new("example".parse()?, None);
68    /// # Ok(())
69    /// # }
70    /// ```
71    pub fn new(name: Name, version_requirement: Option<VersionRequirement>) -> Self {
72        Self {
73            name,
74            version_requirement,
75        }
76    }
77}
78
79impl AlpmParser for PackageRelation {
80    /// Recognizes a [`PackageRelation`] in a string slice.
81    ///
82    /// # Examples
83    ///
84    /// See [`Self::from_str`] for code examples.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if `input` does not begin with a valid [alpm-package-relation].
89    ///
90    /// [alpm-package-relation]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html
91    fn parser(input: &mut &str) -> ModalResult<Self> {
92        seq!(Self {
93            name: Name::parser.context(StrContext::Label("package name")),
94            version_requirement: opt(VersionRequirement::parser),
95        })
96        .parse_next(input)
97    }
98
99    fn delimiter_error_context<'a, O, P>(
100        parser: P,
101    ) -> impl Parser<&'a str, O, winnow::error::ErrMode<winnow::error::ContextError>>
102    where
103        P: Parser<&'a str, O, winnow::error::ErrMode<winnow::error::ContextError>>,
104    {
105        parser
106            .context(StrContext::Label("alpm-package-relation"))
107            .context(StrContext::Expected(StrContextValue::Description(
108                "end of input after version requirement",
109            )))
110    }
111}
112
113impl Display for PackageRelation {
114    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
115        if let Some(version_requirement) = self.version_requirement.as_ref() {
116            write!(f, "{}{}", self.name, version_requirement)
117        } else {
118            write!(f, "{}", self.name)
119        }
120    }
121}
122
123impl FromStr for PackageRelation {
124    type Err = Error;
125    /// Parses a [`PackageRelation`] from a string slice.
126    ///
127    /// Delegates to [`PackageRelation::parser`].
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if [`PackageRelation::parser`] fails.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use std::str::FromStr;
137    ///
138    /// use alpm_types::{PackageRelation, VersionComparison, VersionRequirement};
139    ///
140    /// # fn main() -> Result<(), alpm_types::Error> {
141    /// assert_eq!(
142    ///     PackageRelation::from_str("example<1.0.0")?,
143    ///     PackageRelation::new(
144    ///         "example".parse()?,
145    ///         Some(VersionRequirement {
146    ///             comparison: VersionComparison::Less,
147    ///             version: "1.0.0".parse()?
148    ///         })
149    ///     ),
150    /// );
151    ///
152    /// assert_eq!(
153    ///     PackageRelation::from_str("example<=1.0.0")?,
154    ///     PackageRelation::new(
155    ///         "example".parse()?,
156    ///         Some(VersionRequirement {
157    ///             comparison: VersionComparison::LessOrEqual,
158    ///             version: "1.0.0".parse()?
159    ///         })
160    ///     ),
161    /// );
162    ///
163    /// assert_eq!(
164    ///     PackageRelation::from_str("example=1.0.0")?,
165    ///     PackageRelation::new(
166    ///         "example".parse()?,
167    ///         Some(VersionRequirement {
168    ///             comparison: VersionComparison::Equal,
169    ///             version: "1.0.0".parse()?
170    ///         })
171    ///     ),
172    /// );
173    ///
174    /// assert_eq!(
175    ///     PackageRelation::from_str("example>1.0.0")?,
176    ///     PackageRelation::new(
177    ///         "example".parse()?,
178    ///         Some(VersionRequirement {
179    ///             comparison: VersionComparison::Greater,
180    ///             version: "1.0.0".parse()?
181    ///         })
182    ///     ),
183    /// );
184    ///
185    /// assert_eq!(
186    ///     PackageRelation::from_str("example>=1.0.0")?,
187    ///     PackageRelation::new(
188    ///         "example".parse()?,
189    ///         Some(VersionRequirement {
190    ///             comparison: VersionComparison::GreaterOrEqual,
191    ///             version: "1.0.0".parse()?
192    ///         })
193    ///     ),
194    /// );
195    ///
196    /// assert_eq!(
197    ///     PackageRelation::from_str("example")?,
198    ///     PackageRelation::new("example".parse()?, None),
199    /// );
200    ///
201    /// assert!(PackageRelation::from_str("example<").is_err());
202    /// # Ok(())
203    /// # }
204    /// ```
205    fn from_str(s: &str) -> Result<Self, Self::Err> {
206        Ok(Self::parser.parse(s)?)
207    }
208}
209
210/// An optional dependency for a package.
211///
212/// This type is used for representing dependencies that are not essential for base functionality
213/// of a package, but may be necessary to make use of certain features of a package.
214///
215/// An [`OptionalDependency`] consists of a package relation and an optional description separated
216/// by a colon (`:`).
217///
218/// - The package relation component must be a valid [`PackageRelation`].
219/// - If a description is provided it must be at least one character long.
220///
221/// Refer to [alpm-package-relation] of type [optional dependency] for details on the format.
222/// ## Examples
223///
224/// ```
225/// use std::str::FromStr;
226///
227/// use alpm_types::{Name, OptionalDependency};
228///
229/// # fn main() -> Result<(), alpm_types::Error> {
230/// // Create OptionalDependency from &str
231/// let opt_depend = OptionalDependency::from_str("example: this is an example dependency")?;
232///
233/// // Get the name
234/// assert_eq!("example", opt_depend.name().as_ref());
235///
236/// // Get the description
237/// assert_eq!(
238///     Some("this is an example dependency"),
239///     opt_depend.description().as_deref()
240/// );
241///
242/// // Format as String
243/// assert_eq!(
244///     "example: this is an example dependency",
245///     format!("{opt_depend}")
246/// );
247/// # Ok(())
248/// # }
249/// ```
250///
251/// [alpm-package-relation]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html
252/// [optional dependency]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html#optional-dependency
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254pub struct OptionalDependency {
255    package_relation: PackageRelation,
256    description: Option<String>,
257}
258
259impl OptionalDependency {
260    /// Create a new OptionalDependency in a Result
261    pub fn new(
262        package_relation: PackageRelation,
263        description: Option<String>,
264    ) -> OptionalDependency {
265        OptionalDependency {
266            package_relation,
267            description,
268        }
269    }
270
271    /// Return the name of the optional dependency
272    pub fn name(&self) -> &Name {
273        &self.package_relation.name
274    }
275
276    /// Return the version requirement of the optional dependency
277    pub fn version_requirement(&self) -> &Option<VersionRequirement> {
278        &self.package_relation.version_requirement
279    }
280
281    /// Return the description for the optional dependency, if it exists
282    pub fn description(&self) -> &Option<String> {
283        &self.description
284    }
285
286    /// Returns a reference to the tracked [`PackageRelation`].
287    pub fn package_relation(&self) -> &PackageRelation {
288        &self.package_relation
289    }
290}
291
292impl AlpmParser for OptionalDependency {
293    /// Recognizes an [`OptionalDependency`] in a string slice.
294    ///
295    /// This format is inherently flawed, as the `:` delimiter may exist in two different, optional
296    /// places.
297    /// 1. **After** the optional epoch
298    /// 2. **Before** the optional description
299    ///
300    /// The `:` delimiter may also appear **inside** the description, although that isn't an issue
301    /// during parsing.
302    ///
303    /// ```text
304    /// why>=1:17.0.1-5: my dependency
305    /// is>=1:17.0.1-5
306    /// it>=17.0.1-5: my other dependency :::::
307    /// this: 1:17.0.1-5 my other dependency
308    /// way>1: 17.0.1-5 ambiguous.
309    /// ```
310    ///
311    /// Due to this, the parser disambiguates the two cases as follows:
312    ///
313    /// - A `:` directly followed by a non-whitespace character is considered an epoch delimiter.
314    /// - A `:` followed by whitespace starts a description.
315    ///
316    /// As such, ambiguous input like `example>=1:foo bar` is treated as containing an epoch and
317    /// rejected, as `foo bar` is not a valid version.
318    /// Input like `example>=1: 3.2.1-5 foo bar` is successfully parsed with the version being `1`
319    /// and the description being `3.2.1-5 foo bar`.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if `input` is not a valid _alpm-package-relation_ of type _optional
324    /// dependency_.
325    fn parser(input: &mut &str) -> ModalResult<Self> {
326        // Due to the ambiguous nature of this format, we must implement our own PackageRelation and
327        // VersionRequirement parser handling.
328
329        // Handle the dependency name:
330        // `example>=1.0.0: my-description` -> `>=1.0.0: my-description`
331        let name = Name::parser
332            .context(StrContext::Label("package name"))
333            .parse_next(input)?;
334
335        // Handle the optional Comparison operator:
336        // `example>=1.0.0: my-description` -> `1.0.0: my-description`
337        let comparison = opt(VersionComparison::parser).parse_next(input)?;
338
339        // Branch into the path where a comparison exists.
340        let version_requirement = if let Some(comparison) = comparison {
341            // Parse an optional epoch, e.g.:
342            // "1:17.0.1-5: my-description" -> "17.0.1-5: my-description"
343            //
344            // An epoch delimiter ':' must always be directly followed by a non-whitespace
345            // character, while a description ':' delimiter is always followed by whitespace.
346            // The lookahead on the character after the ':' disambiguates the two.
347            let epoch = opt(terminated(
348                Epoch::parser_until_inclusive(":"),
349                peek(none_of(|c: char| c.is_whitespace())),
350            ))
351            .parse_next(input)?;
352
353            // Advance the parser until the next '-', e.g.:
354            // "17.0.1-5: my-description" -> "-5: my-description"
355            let pkgver = PackageVersion::parser.parse_next(input)?;
356
357            // Parse an optional PackageRelease, e.g.:
358            // "-5: my-description" -> ": my-description"
359            //
360            // If an `-` is found, the PackageRelease is expected and must exist
361            let delimiter = opt('-').parse_next(input)?;
362            let pkgrel = if delimiter.is_some() {
363                Some(PackageRelease::parser.parse_next(input)?)
364            } else {
365                None
366            };
367
368            Some(VersionRequirement {
369                comparison,
370                version: Version::new(pkgver, epoch, pkgrel),
371            })
372        } else {
373            None
374        };
375
376        let package_relation = PackageRelation::new(name, version_requirement);
377
378        // Check if there's a `:`, which indicates the existence of an description.
379        let delimiter = opt(":").parse_next(input)?;
380        if delimiter.is_some() {
381            space1
382                .context(StrContext::Label(
383                    "dependency delimiter in optional dependency",
384                ))
385                .context(StrContext::Expected(StrContextValue::Description(
386                    "A colon followed by a whitespace ': '",
387                )))
388                .parse_next(input)?;
389        }
390
391        let description = if delimiter.is_some() {
392            // Descriptions are at the end of a `OptionalDependency` and may contain any character,
393            // except '\n' or '\r'. So this parser consumes everything till newline or `eof`.
394            let description = take_till(0.., ('\n', '\r'))
395                .context(StrContext::Label("optional dependency description"))
396                .parse_next(input)?
397                .trim_ascii();
398
399            if description.is_empty() {
400                None
401            } else {
402                Some(description.to_string())
403            }
404        } else {
405            None
406        };
407
408        Ok(Self {
409            package_relation,
410            description,
411        })
412    }
413
414    fn delimiter_error_context<'a, O, P>(
415        parser: P,
416    ) -> impl Parser<&'a str, O, winnow::error::ErrMode<winnow::error::ContextError>>
417    where
418        P: Parser<&'a str, O, winnow::error::ErrMode<winnow::error::ContextError>>,
419    {
420        parser
421            .context(StrContext::Label("character in optional dependency"))
422            .context(StrContext::Expected(StrContextValue::Description(
423                "end of input.",
424            )))
425    }
426}
427
428impl FromStr for OptionalDependency {
429    type Err = Error;
430
431    /// Creates a new [`OptionalDependency`] from a string slice.
432    ///
433    /// Delegates to [`OptionalDependency::parser`].
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if [`OptionalDependency::parser`] fails.
438    fn from_str(s: &str) -> Result<Self, Self::Err> {
439        Ok(Self::parser_until_eof.parse(s)?)
440    }
441}
442
443impl Display for OptionalDependency {
444    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
445        match self.description {
446            Some(ref description) => write!(fmt, "{}: {}", self.package_relation, description),
447            None => write!(fmt, "{}", self.package_relation),
448        }
449    }
450}
451
452/// Group of a package
453///
454/// Represents an arbitrary collection of packages that share a common
455/// characteristic or functionality.
456///
457/// While group names can be any valid UTF-8 string, it is recommended to follow
458/// the format of [`Name`] (`[a-z\d\-._@+]` but must not start with `[-.]`)
459/// to ensure consistency and ease of use.
460///
461/// This is a type alias for [`String`].
462///
463/// ## Examples
464/// ```
465/// use alpm_types::Group;
466///
467/// // Create a Group
468/// let group: Group = "package-group".to_string();
469/// ```
470pub type Group = String;
471
472#[cfg(test)]
473mod tests {
474    use insta::assert_snapshot;
475    use proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
476    use rstest::rstest;
477
478    use super::*;
479    use crate::{VersionComparison, configure_insta};
480
481    const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
482    /// NOTE: [`Epoch`][alpm_types::Epoch] is implicitly constrained by [`std::usize::MAX`].
483    /// However, it's unrealistic to ever reach that many forced downgrades for a package, hence
484    /// we don't test that fully
485    const EPOCH_REGEX: &str = r"(0|[1-9][0-9]{0,10})";
486    const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
487    const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
488    const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
489    const DESCRIPTION_REGEX: &str = "[^\n\r]*";
490
491    proptest! {
492        #![proptest_config(ProptestConfig::with_cases(1000))]
493
494
495        #[test]
496        fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
497            println!("s: {s}");
498            let name = PackageRelation::from_str(&s).unwrap();
499            prop_assert_eq!(s, format!("{}", name));
500        }
501    }
502
503    proptest! {
504        #[test]
505        fn opt_depend_from_str(
506            name in NAME_REGEX,
507            desc in DESCRIPTION_REGEX,
508            use_desc in proptest::bool::ANY
509        ) {
510            let desc_trimmed = desc.trim_ascii();
511            let desc_is_blank = desc_trimmed.is_empty();
512
513            let (raw_in, formatted_expected) = if use_desc {
514                // Raw input and expected formatted output.
515                // These are different because `desc` will be trimmed by the parser;
516                // if it is *only* ascii whitespace then it will be skipped altogether.
517                (
518                    format!("{name}: {desc}"),
519                    if !desc_is_blank {
520                        format!("{name}: {desc_trimmed}")
521                    } else {
522                        name.clone()
523                    }
524                )
525            } else {
526                (name.clone(), name.clone())
527            };
528
529            println!("input string: {raw_in}");
530            let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
531            let formatted_actual = format!("{opt_depend}");
532            prop_assert_eq!(
533                formatted_expected,
534                formatted_actual,
535                "Formatted output doesn't match input"
536            );
537        }
538    }
539
540    #[rstest]
541    #[case(
542        "python>=3",
543        Ok(PackageRelation {
544            name: Name::new("python").unwrap(),
545            version_requirement: Some(VersionRequirement {
546                comparison: VersionComparison::GreaterOrEqual,
547                version: "3".parse().unwrap(),
548            }),
549        }),
550    )]
551    #[case(
552        "java-environment>=17",
553        Ok(PackageRelation {
554            name: Name::new("java-environment").unwrap(),
555            version_requirement: Some(VersionRequirement {
556                comparison: VersionComparison::GreaterOrEqual,
557                version: "17".parse().unwrap(),
558            }),
559        }),
560    )]
561    fn valid_package_relation(
562        #[case] input: &str,
563        #[case] expected: Result<PackageRelation, Error>,
564    ) {
565        assert_eq!(PackageRelation::from_str(input), expected);
566    }
567
568    #[rstest]
569    #[case(
570        "example: this is an example dependency",
571        OptionalDependency {
572            package_relation: PackageRelation {
573                name: Name::new("example").unwrap(),
574                version_requirement: None,
575            },
576            description: Some("this is an example dependency".to_string()),
577        },
578    )]
579    #[case(
580        "example-two:     a description with lots of whitespace padding     ",
581        OptionalDependency {
582            package_relation: PackageRelation {
583                name: Name::new("example-two").unwrap(),
584                version_requirement: None,
585            },
586            description: Some("a description with lots of whitespace padding".to_string())
587        },
588    )]
589    #[case(
590        "dep_name",
591        OptionalDependency {
592            package_relation: PackageRelation {
593                name: Name::new("dep_name").unwrap(),
594                version_requirement: None,
595            },
596            description: None,
597        },
598    )]
599    #[case(
600        "dep_name: ",
601        OptionalDependency {
602            package_relation: PackageRelation {
603                name: Name::new("dep_name").unwrap(),
604                version_requirement: None,
605            },
606            description: None,
607        },
608    )]
609    #[case(
610        "dep_name_with_special_chars-123: description with !@#$%^&*",
611        OptionalDependency {
612            package_relation: PackageRelation {
613                name: Name::new("dep_name_with_special_chars-123").unwrap(),
614                version_requirement: None,
615            },
616            description: Some("description with !@#$%^&*".to_string()),
617        },
618    )]
619    // versioned optional dependencies
620    #[case(
621        "elfutils=0.192: for translations",
622        OptionalDependency {
623            package_relation: PackageRelation {
624                name: Name::new("elfutils").unwrap(),
625                version_requirement: Some(VersionRequirement {
626                    comparison: VersionComparison::Equal,
627                    version: "0.192".parse().unwrap(),
628                }),
629            },
630            description: Some("for translations".to_string()),
631        },
632    )]
633    #[case(
634        "python>=3: For Python bindings",
635        OptionalDependency {
636            package_relation: PackageRelation {
637                name: Name::new("python").unwrap(),
638                version_requirement: Some(VersionRequirement {
639                    comparison: VersionComparison::GreaterOrEqual,
640                    version: "3".parse().unwrap(),
641                }),
642            },
643            description: Some("For Python bindings".to_string()),
644        },
645    )]
646    #[case(
647        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
648        OptionalDependency {
649            package_relation: PackageRelation {
650                name: Name::new("java-environment").unwrap(),
651                version_requirement: Some(VersionRequirement {
652                    comparison: VersionComparison::GreaterOrEqual,
653                    version: "17".parse().unwrap(),
654                }),
655            },
656            description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
657        },
658    )]
659    // A ':' directly followed by a non-whitespace character acts as an epoch delimiter.
660    #[case(
661        "example>=1:17.0.1-5: my dependency",
662        OptionalDependency {
663            package_relation: PackageRelation {
664                name: Name::new("example").unwrap(),
665                version_requirement: Some(VersionRequirement {
666                    comparison: VersionComparison::GreaterOrEqual,
667                    version: "1:17.0.1-5".parse().unwrap(),
668                }),
669            },
670            description: Some("my dependency".to_string()),
671        },
672    )]
673    // A ':' followed by whitespace acts as a description delimiter.
674    #[case(
675        "example>1: 17.0.1-5 ambiguous.",
676        OptionalDependency {
677            package_relation: PackageRelation {
678                name: Name::new("example").unwrap(),
679                version_requirement: Some(VersionRequirement {
680                    comparison: VersionComparison::Greater,
681                    version: "1".parse().unwrap(),
682                }),
683            },
684            description: Some("17.0.1-5 ambiguous.".to_string()),
685        },
686    )]
687    fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
688        let opt_depend_result = OptionalDependency::from_str(input);
689        let optional_dependency = match opt_depend_result {
690            Ok(dep) => dep,
691            Err(err) => {
692                panic!("Encountered unexpected error when parsing optional dependency:\n {err}")
693            }
694        };
695
696        assert_eq!(
697            expected, optional_dependency,
698            "Optional dependency has not been correctly parsed."
699        );
700    }
701
702    #[rstest]
703    #[case(
704        "example: this is an example dependency",
705        "example: this is an example dependency"
706    )]
707    #[case(
708        "example-two:     a description with lots of whitespace padding     ",
709        "example-two: a description with lots of whitespace padding"
710    )]
711    #[case(
712        "tabs:    a description with a tab directly after the colon",
713        "tabs: a description with a tab directly after the colon"
714    )]
715    #[case("dep_name", "dep_name")]
716    #[case("dep_name: ", "dep_name")]
717    #[case(
718        "dep_name_with_special_chars-123: description with !@#$%^&*",
719        "dep_name_with_special_chars-123: description with !@#$%^&*"
720    )]
721    // versioned optional dependencies
722    #[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
723    #[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
724    #[case(
725        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
726        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
727    )]
728    fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
729        let opt_depend_result = OptionalDependency::from_str(input);
730        let Ok(optional_dependency) = opt_depend_result else {
731            panic!(
732                "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
733            )
734        };
735        assert_eq!(
736            expected,
737            optional_dependency.to_string(),
738            "OptionalDependency to_string is erroneous."
739        );
740    }
741
742    #[rstest]
743    #[case("#invalid-name: this is an example dependency")]
744    #[case(": no_name_colon")]
745    #[case("name:description with no leading whitespace")]
746    #[case("dep-name>=10: \n\ndescription with\rnewlines")]
747    fn opt_depend_invalid_string_parse_error(#[case] input: &str) {
748        let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
749            panic!("'{input}' erroneously parsed as a OptionalDependency")
750        };
751
752        let (test_name, _guard) = configure_insta();
753        assert_snapshot!(test_name, err_msg.to_string());
754    }
755}