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