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