alpm_types/
env.rs

1use std::{
2    fmt::{Display, Formatter},
3    str::FromStr,
4    string::ToString,
5};
6
7use alpm_parsers::{iter_char_context, iter_str_context};
8use serde::{Deserialize, Serialize};
9use winnow::{
10    ModalResult,
11    Parser,
12    combinator::{alt, cut_err, eof, fail, opt, peek, repeat},
13    error::{StrContext, StrContextValue::*},
14    token::one_of,
15};
16
17use crate::{Architecture, Name, Version, error::Error};
18
19/// Recognizes the `!` boolean operator in option names.
20///
21/// This parser **does not** fully consume its input.
22/// It also expects the package name to be there, if the `!` does not exist.
23///
24/// # Format
25///
26/// The parser expects a `!` or either one of ASCII alphanumeric character, hyphen, dot, or
27/// underscore.
28///
29/// # Errors
30///
31/// If the input string does not match the expected format, an error will be returned.
32fn option_bool_parser(input: &mut &str) -> ModalResult<bool> {
33    let alphanum = |c: char| c.is_ascii_alphanumeric();
34    let special_first_chars = ['-', '.', '_', '!'];
35    let valid_chars = one_of((alphanum, special_first_chars));
36
37    // Make sure that we have either a `!` at the start or the first char of a name.
38    cut_err(peek(valid_chars))
39        .context(StrContext::Expected(CharLiteral('!')))
40        .context(StrContext::Expected(Description(
41            "ASCII alphanumeric character",
42        )))
43        .context_with(iter_char_context!(special_first_chars))
44        .parse_next(input)?;
45
46    Ok(opt('!').parse_next(input)?.is_none())
47}
48
49/// Recognizes option names.
50///
51/// This parser fully consumes its input.
52///
53/// # Format
54///
55/// The parser expects a sequence of ASCII alphanumeric characters, hyphens, dots, or underscores.
56///
57/// # Errors
58///
59/// If the input string does not match the expected format, an error will be returned.
60fn option_name_parser<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
61    let alphanum = |c: char| c.is_ascii_alphanumeric();
62
63    let special_chars = ['-', '.', '_'];
64    let valid_chars = one_of((alphanum, special_chars));
65    let name = repeat::<_, _, (), _, _>(0.., valid_chars)
66        .take()
67        .parse_next(input)?;
68
69    eof.context(StrContext::Label("character in makepkg option"))
70        .context(StrContext::Expected(Description(
71            "ASCII alphanumeric character",
72        )))
73        .context_with(iter_char_context!(special_chars))
74        .parse_next(input)?;
75
76    Ok(name)
77}
78
79/// Wraps the [`PackageOption`] and [`BuildEnvironmentOption`] enums.
80///
81/// This is necessary for metadata files such as [SRCINFO] or [PKGBUILD] package scripts that don't
82/// differentiate between the different types and scopes of options.
83///
84/// [SRCINFO]: https://alpm.archlinux.page/specifications/SRCINFO.5.html
85/// [PKGBUILD]: https://man.archlinux.org/man/PKGBUILD.5
86#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum MakepkgOption {
89    /// A [`BuildEnvironmentOption`]
90    BuildEnvironment(BuildEnvironmentOption),
91    /// A [`PackageOption`]
92    Package(PackageOption),
93}
94
95impl MakepkgOption {
96    /// Recognizes any [`PackageOption`] and [`BuildEnvironmentOption`] in a
97    /// string slice.
98    ///
99    /// Consumes all of its input.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if `input` is neither of the listed options.
104    pub fn parser(input: &mut &str) -> ModalResult<Self> {
105        alt((
106            BuildEnvironmentOption::parser.map(MakepkgOption::BuildEnvironment),
107            PackageOption::parser.map(MakepkgOption::Package),
108            fail.context(StrContext::Label("packaging or build environment option"))
109                .context_with(iter_str_context!([
110                    BuildEnvironmentOption::VARIANTS.to_vec(),
111                    PackageOption::VARIANTS.to_vec()
112                ])),
113        ))
114        .parse_next(input)
115    }
116}
117
118impl FromStr for MakepkgOption {
119    type Err = Error;
120    /// Creates a [`MakepkgOption`] from string slice.
121    fn from_str(s: &str) -> Result<Self, Self::Err> {
122        Ok(Self::parser.parse(s)?)
123    }
124}
125
126impl Display for MakepkgOption {
127    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
128        match self {
129            MakepkgOption::BuildEnvironment(option) => write!(fmt, "{option}"),
130            MakepkgOption::Package(option) => write!(fmt, "{option}"),
131        }
132    }
133}
134
135/// An option string used in a build environment
136///
137/// The option string is identified by its name and whether it is on (not prefixed with "!") or off
138/// (prefixed with "!").
139///
140/// See [the makepkg.conf manpage](https://man.archlinux.org/man/makepkg.conf.5.en) for more information.
141///
142/// ## Examples
143/// ```
144/// # fn main() -> Result<(), alpm_types::Error> {
145/// use alpm_types::BuildEnvironmentOption;
146///
147/// let option = BuildEnvironmentOption::new("distcc")?;
148/// assert_eq!(option.on(), true);
149/// assert_eq!(option.name(), "distcc");
150///
151/// let not_option = BuildEnvironmentOption::new("!ccache")?;
152/// assert_eq!(not_option.on(), false);
153/// assert_eq!(not_option.name(), "ccache");
154/// # Ok(())
155/// # }
156/// ```
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "lowercase")]
159pub enum BuildEnvironmentOption {
160    /// Use or unset the values of build flags (e.g. `CPPFLAGS`, `CFLAGS`, `CXXFLAGS`, `LDFLAGS`)
161    /// specified in user-specific configs (e.g. [makepkg.conf]).
162    ///
163    /// [makepkg.conf]: https://man.archlinux.org/man/makepkg.conf.5
164    BuildFlags(bool),
165    /// Use ccache to cache compilation
166    Ccache(bool),
167    /// Run the check() function if present in the PKGBUILD
168    Check(bool),
169    /// Colorize output messages
170    Color(bool),
171    /// Use the Distributed C/C++/ObjC compiler
172    Distcc(bool),
173    /// Generate PGP signature file
174    Sign(bool),
175    /// Use or unset the value of the `MAKEFLAGS` environment variable specified in
176    /// user-specific configs (e.g. [makepkg.conf]).
177    ///
178    /// [makepkg.conf]: https://man.archlinux.org/man/makepkg.conf.5
179    MakeFlags(bool),
180}
181
182impl BuildEnvironmentOption {
183    /// Create a new [`BuildEnvironmentOption`] in a Result
184    ///
185    /// # Errors
186    ///
187    /// An error is returned if the string slice does not match a valid build environment option.
188    pub fn new(option: &str) -> Result<Self, Error> {
189        Self::from_str(option)
190    }
191
192    /// Get the name of the BuildEnvironmentOption
193    pub fn name(&self) -> &str {
194        match self {
195            Self::BuildFlags(_) => "buildflags",
196            Self::Ccache(_) => "ccache",
197            Self::Check(_) => "check",
198            Self::Color(_) => "color",
199            Self::Distcc(_) => "distcc",
200            Self::MakeFlags(_) => "makeflags",
201            Self::Sign(_) => "sign",
202        }
203    }
204
205    /// Get whether the BuildEnvironmentOption is on
206    pub fn on(&self) -> bool {
207        match self {
208            Self::BuildFlags(on)
209            | Self::Ccache(on)
210            | Self::Check(on)
211            | Self::Color(on)
212            | Self::Distcc(on)
213            | Self::MakeFlags(on)
214            | Self::Sign(on) => *on,
215        }
216    }
217
218    const VARIANTS: [&str; 7] = [
219        "buildflags",
220        "ccache",
221        "check",
222        "color",
223        "distcc",
224        "makeflags",
225        "sign",
226    ];
227
228    /// Recognizes a [`BuildEnvironmentOption`] in a string slice.
229    ///
230    /// Consumes all of its input.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if `input` is not a valid build environment option.
235    pub fn parser(input: &mut &str) -> ModalResult<Self> {
236        let on = option_bool_parser.parse_next(input)?;
237        let mut name = option_name_parser.parse_next(input)?;
238
239        let name = alt(BuildEnvironmentOption::VARIANTS)
240            .context(StrContext::Label("makepkg build environment option"))
241            .context_with(iter_str_context!([BuildEnvironmentOption::VARIANTS]))
242            .parse_next(&mut name)?;
243
244        match name {
245            "buildflags" => Ok(Self::BuildFlags(on)),
246            "ccache" => Ok(Self::Ccache(on)),
247            "check" => Ok(Self::Check(on)),
248            "color" => Ok(Self::Color(on)),
249            "distcc" => Ok(Self::Distcc(on)),
250            "makeflags" => Ok(Self::MakeFlags(on)),
251            "sign" => Ok(Self::Sign(on)),
252            // Unreachable because the winnow parser returns an error above.
253            _ => unreachable!(),
254        }
255    }
256}
257
258impl FromStr for BuildEnvironmentOption {
259    type Err = Error;
260    /// Creates a [`BuildEnvironmentOption`] from a string slice.
261    ///
262    /// Delegates to [`BuildEnvironmentOption::parser`].
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if [`BuildEnvironmentOption::parser`] fails.
267    fn from_str(s: &str) -> Result<Self, Self::Err> {
268        Ok(Self::parser.parse(s)?)
269    }
270}
271
272impl Display for BuildEnvironmentOption {
273    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
274        write!(fmt, "{}{}", if self.on() { "" } else { "!" }, self.name())
275    }
276}
277
278/// An option string used in packaging
279///
280/// The option string is identified by its name and whether it is on (not prefixed with "!") or off
281/// (prefixed with "!").
282///
283/// See [the makepkg.conf manpage](https://man.archlinux.org/man/makepkg.conf.5.en) for more information.
284///
285/// ## Examples
286/// ```
287/// # fn main() -> Result<(), alpm_types::Error> {
288/// use alpm_types::PackageOption;
289///
290/// let option = PackageOption::new("debug")?;
291/// assert_eq!(option.on(), true);
292/// assert_eq!(option.name(), "debug");
293///
294/// let not_option = PackageOption::new("!lto")?;
295/// assert_eq!(not_option.on(), false);
296/// assert_eq!(not_option.name(), "lto");
297/// # Ok(())
298/// # }
299/// ```
300#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
301#[serde(rename_all = "lowercase")]
302pub enum PackageOption {
303    /// Automatically add dependencies and provisions (see [alpm-sonamev2]).
304    ///
305    /// [alpm-sonamev2]: https://alpm.archlinux.page/specifications/alpm-sonamev2.7.html
306    AutoDeps(bool),
307
308    /// Add debugging flags as specified in DEBUG_* variables
309    Debug(bool),
310
311    /// Save doc directories specified by DOC_DIRS
312    Docs(bool),
313
314    /// Leave empty directories in packages
315    EmptyDirs(bool),
316
317    /// Leave libtool (.la) files in packages
318    Libtool(bool),
319
320    /// Add compile flags for building with link time optimization
321    Lto(bool),
322
323    /// Remove files specified by PURGE_TARGETS
324    Purge(bool),
325
326    /// Leave static library (.a) files in packages
327    StaticLibs(bool),
328
329    /// Strip symbols from binaries/libraries
330    Strip(bool),
331
332    /// Compress manual (man and info) pages in MAN_DIRS with gzip
333    Zipman(bool),
334}
335
336impl PackageOption {
337    /// Creates a new [`PackageOption`] from a string slice.
338    ///
339    /// # Errors
340    ///
341    /// An error is returned if the string slice does not match a valid package option.
342    pub fn new(option: &str) -> Result<Self, Error> {
343        Self::from_str(option)
344    }
345
346    /// Returns the name of the [`PackageOption`] as string slice.
347    pub fn name(&self) -> &str {
348        match self {
349            Self::AutoDeps(_) => "autodeps",
350            Self::Debug(_) => "debug",
351            Self::Docs(_) => "docs",
352            Self::EmptyDirs(_) => "emptydirs",
353            Self::Libtool(_) => "libtool",
354            Self::Lto(_) => "lto",
355            Self::Purge(_) => "purge",
356            Self::StaticLibs(_) => "staticlibs",
357            Self::Strip(_) => "strip",
358            Self::Zipman(_) => "zipman",
359        }
360    }
361
362    /// Returns whether the [`PackageOption`] is on or off.
363    pub fn on(&self) -> bool {
364        match self {
365            Self::AutoDeps(on)
366            | Self::Debug(on)
367            | Self::Docs(on)
368            | Self::EmptyDirs(on)
369            | Self::Libtool(on)
370            | Self::Lto(on)
371            | Self::Purge(on)
372            | Self::StaticLibs(on)
373            | Self::Strip(on)
374            | Self::Zipman(on) => *on,
375        }
376    }
377
378    const VARIANTS: [&str; 11] = [
379        "autodeps",
380        "debug",
381        "docs",
382        "emptydirs",
383        "libtool",
384        "lto",
385        "debug",
386        "purge",
387        "staticlibs",
388        "strip",
389        "zipman",
390    ];
391
392    /// Recognizes a [`PackageOption`] in a string slice.
393    ///
394    /// Consumes all of its input.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error if `input` is not the valid string representation of a [`PackageOption`].
399    pub fn parser(input: &mut &str) -> ModalResult<Self> {
400        let on = option_bool_parser.parse_next(input)?;
401        let mut name = option_name_parser.parse_next(input)?;
402
403        let value = alt(PackageOption::VARIANTS)
404            .context(StrContext::Label("makepkg packaging option"))
405            .context_with(iter_str_context!([PackageOption::VARIANTS]))
406            .parse_next(&mut name)?;
407
408        match value {
409            "autodeps" => Ok(Self::AutoDeps(on)),
410            "debug" => Ok(Self::Debug(on)),
411            "docs" => Ok(Self::Docs(on)),
412            "emptydirs" => Ok(Self::EmptyDirs(on)),
413            "libtool" => Ok(Self::Libtool(on)),
414            "lto" => Ok(Self::Lto(on)),
415            "purge" => Ok(Self::Purge(on)),
416            "staticlibs" => Ok(Self::StaticLibs(on)),
417            "strip" => Ok(Self::Strip(on)),
418            "zipman" => Ok(Self::Zipman(on)),
419            // Unreachable because the winnow parser returns an error above.
420            _ => unreachable!(),
421        }
422    }
423}
424
425impl FromStr for PackageOption {
426    type Err = Error;
427    /// Creates a [`PackageOption`] from a string slice.
428    ///
429    /// Delegates to [`PackageOption::parser`].
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if [`PackageOption::parser`] fails.
434    fn from_str(s: &str) -> Result<Self, Self::Err> {
435        Ok(Self::parser.parse(s)?)
436    }
437}
438
439impl Display for PackageOption {
440    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
441        write!(fmt, "{}{}", if self.on() { "" } else { "!" }, self.name())
442    }
443}
444
445/// Information on an installed package in an environment
446///
447/// Tracks a `Name`, `Version` (which is guaranteed to have a `PackageRelease`) and `Architecture`
448/// of a package in an environment.
449///
450/// ## Examples
451/// ```
452/// use std::str::FromStr;
453///
454/// use alpm_types::InstalledPackage;
455///
456/// assert!(InstalledPackage::from_str("foo-bar-1:1.0.0-1-any").is_ok());
457/// assert!(InstalledPackage::from_str("foo-bar-1:1.0.0-1").is_err());
458/// assert!(InstalledPackage::from_str("foo-bar-1:1.0.0-any").is_err());
459/// assert!(InstalledPackage::from_str("1:1.0.0-1-any").is_err());
460/// ```
461#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
462pub struct InstalledPackage {
463    name: Name,
464    version: Version,
465    architecture: Architecture,
466}
467
468impl InstalledPackage {
469    /// Create a new InstalledPackage
470    pub fn new(name: Name, version: Version, architecture: Architecture) -> Result<Self, Error> {
471        Ok(InstalledPackage {
472            name,
473            version,
474            architecture,
475        })
476    }
477}
478
479impl FromStr for InstalledPackage {
480    type Err = Error;
481    /// Create an Installed from a string
482    fn from_str(s: &str) -> Result<InstalledPackage, Self::Err> {
483        const DELIMITER: char = '-';
484        let mut parts = s.rsplitn(4, DELIMITER);
485
486        let architecture = parts.next().ok_or(Error::MissingComponent {
487            component: "architecture",
488        })?;
489        let architecture = architecture.parse()?;
490        let version = {
491            let Some(pkgrel) = parts.next() else {
492                return Err(Error::MissingComponent {
493                    component: "pkgrel",
494                })?;
495            };
496            let Some(epoch_pkgver) = parts.next() else {
497                return Err(Error::MissingComponent {
498                    component: "epoch_pkgver",
499                })?;
500            };
501            epoch_pkgver.to_string() + "-" + pkgrel
502        };
503        let name = parts
504            .next()
505            .ok_or(Error::MissingComponent { component: "name" })?
506            .to_string();
507
508        Ok(InstalledPackage {
509            name: Name::new(&name)?,
510            version: Version::with_pkgrel(version.as_str())?,
511            architecture,
512        })
513    }
514}
515
516impl Display for InstalledPackage {
517    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
518        write!(fmt, "{}-{}-{}", self.name, self.version, self.architecture)
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use rstest::rstest;
525
526    use super::*;
527
528    #[rstest]
529    #[case(
530        "!makeflags",
531        MakepkgOption::BuildEnvironment(BuildEnvironmentOption::MakeFlags(false))
532    )]
533    #[case("autodeps", MakepkgOption::Package(PackageOption::AutoDeps(true)))]
534    #[case(
535        "ccache",
536        MakepkgOption::BuildEnvironment(BuildEnvironmentOption::Ccache(true))
537    )]
538    fn makepkg_option(#[case] input: &str, #[case] expected: MakepkgOption) {
539        let result = MakepkgOption::from_str(input).expect("Parser should be successful");
540        assert_eq!(result, expected);
541    }
542
543    #[rstest]
544    #[case(
545        "!somethingelse",
546        concat!(
547            "expected `buildflags`, `ccache`, `check`, `color`, `distcc`, `makeflags`, `sign`, ",
548            "`autodeps`, `debug`, `docs`, `emptydirs`, `libtool`, `lto`, `debug`, `purge`, ",
549            "`staticlibs`, `strip`, `zipman`",
550        )
551    )]
552    #[case(
553        "#somethingelse",
554        "expected `!`, ASCII alphanumeric character, `-`, `.`, `_`"
555    )]
556    fn invalid_makepkg_option(#[case] input: &str, #[case] err_snippet: &str) {
557        let Err(Error::ParseError(err_msg)) = MakepkgOption::from_str(input) else {
558            panic!("'{input}' erroneously parsed as VersionRequirement")
559        };
560        assert!(
561            err_msg.contains(err_snippet),
562            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
563        );
564    }
565
566    #[rstest]
567    #[case("autodeps", PackageOption::AutoDeps(true))]
568    #[case("debug", PackageOption::Debug(true))]
569    #[case("docs", PackageOption::Docs(true))]
570    #[case("emptydirs", PackageOption::EmptyDirs(true))]
571    #[case("!libtool", PackageOption::Libtool(false))]
572    #[case("lto", PackageOption::Lto(true))]
573    #[case("purge", PackageOption::Purge(true))]
574    #[case("staticlibs", PackageOption::StaticLibs(true))]
575    #[case("strip", PackageOption::Strip(true))]
576    #[case("zipman", PackageOption::Zipman(true))]
577    fn package_option(#[case] s: &str, #[case] expected: PackageOption) {
578        let result = PackageOption::from_str(s).expect("Parser should be successful");
579        assert_eq!(result, expected);
580    }
581
582    #[rstest]
583    #[case(
584        "!somethingelse",
585        "expected `autodeps`, `debug`, `docs`, `emptydirs`, `libtool`, `lto`, `debug`, `purge`, `staticlibs`, `strip`, `zipman`"
586    )]
587    #[case(
588        "#somethingelse",
589        "expected `!`, ASCII alphanumeric character, `-`, `.`, `_`"
590    )]
591    fn invalid_package_option(#[case] input: &str, #[case] err_snippet: &str) {
592        let Err(Error::ParseError(err_msg)) = PackageOption::from_str(input) else {
593            panic!("'{input}' erroneously parsed as VersionRequirement")
594        };
595        assert!(
596            err_msg.contains(err_snippet),
597            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
598        );
599    }
600
601    #[rstest]
602    #[case("buildflags", BuildEnvironmentOption::BuildFlags(true))]
603    #[case("ccache", BuildEnvironmentOption::Ccache(true))]
604    #[case("check", BuildEnvironmentOption::Check(true))]
605    #[case("color", BuildEnvironmentOption::Color(true))]
606    #[case("distcc", BuildEnvironmentOption::Distcc(true))]
607    #[case("!makeflags", BuildEnvironmentOption::MakeFlags(false))]
608    #[case("sign", BuildEnvironmentOption::Sign(true))]
609    #[case("!sign", BuildEnvironmentOption::Sign(false))]
610    fn build_environment_option(#[case] input: &str, #[case] expected: BuildEnvironmentOption) {
611        let result = BuildEnvironmentOption::from_str(input).expect("Parser should be successful");
612        assert_eq!(result, expected);
613    }
614
615    #[rstest]
616    #[case(
617        "!somethingelse",
618        "expected `buildflags`, `ccache`, `check`, `color`, `distcc`, `makeflags`, `sign`"
619    )]
620    #[case(
621        "#somethingelse",
622        "expected `!`, ASCII alphanumeric character, `-`, `.`, `_`"
623    )]
624    fn invalid_build_environment_option(#[case] input: &str, #[case] err_snippet: &str) {
625        let Err(Error::ParseError(err_msg)) = BuildEnvironmentOption::from_str(input) else {
626            panic!("'{input}' erroneously parsed as VersionRequirement")
627        };
628        assert!(
629            err_msg.contains(err_snippet),
630            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
631        );
632    }
633
634    #[rstest]
635    #[case("#test", "invalid character in makepkg option")]
636    #[case("test!", "invalid character in makepkg option")]
637    fn invalid_option(#[case] input: &str, #[case] error_snippet: &str) {
638        let result = option_name_parser.parse(input);
639        assert!(result.is_err(), "Expected makepkg option parsing to fail");
640        let err = result.unwrap_err();
641        let pretty_error = err.to_string();
642        assert!(
643            pretty_error.contains(error_snippet),
644            "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
645        );
646    }
647
648    #[rstest]
649    #[case(
650        "foo-bar-1:1.0.0-1-any",
651        Ok(InstalledPackage{
652            name: Name::new("foo-bar").unwrap(),
653            version: Version::from_str("1:1.0.0-1").unwrap(),
654            architecture: Architecture::Any,
655        }),
656    )]
657    #[case("foo-bar-1:1.0.0-1", Err(strum::ParseError::VariantNotFound.into()))]
658    #[case("1:1.0.0-1-any", Err(Error::MissingComponent { component: "name" }))]
659    fn installed_new(#[case] s: &str, #[case] result: Result<InstalledPackage, Error>) {
660        assert_eq!(InstalledPackage::from_str(s), result);
661    }
662
663    #[rstest]
664    #[case("foo-1:1.0.0-bar-any", "invalid package release")]
665    #[case("packagename-30-0.1oops-any", "expected end of package release value")]
666    #[case("package$with$dollars-30-0.1-any", "invalid character in package name")]
667    fn installed_new_parse_error(#[case] input: &str, #[case] error_snippet: &str) {
668        let result = InstalledPackage::from_str(input);
669        assert!(result.is_err(), "Expected InstalledPackage parsing to fail");
670        let err = result.unwrap_err();
671        let pretty_error = err.to_string();
672        assert!(
673            pretty_error.contains(error_snippet),
674            "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
675        );
676    }
677}