alpm_repo_db/desc/
v2.rs

1//! Representation of the package repository desc file v2 ([alpm-repo-descv2]).
2//!
3//! [alpm-repo-descv2]: https://alpm.archlinux.page/specifications/alpm-repo-descv2.5.html
4
5use std::{
6    fmt::{Display, Formatter, Result as FmtResult, Write},
7    str::FromStr,
8};
9
10use alpm_types::{
11    Architecture,
12    Base64OpenPGPSignature,
13    BuildDate,
14    CompressedSize,
15    FullVersion,
16    Group,
17    InstalledSize,
18    License,
19    Name,
20    OptionalDependency,
21    PackageBaseName,
22    PackageDescription,
23    PackageFileName,
24    PackageRelation,
25    Packager,
26    RelationOrSoname,
27    Sha256Checksum,
28    Url,
29};
30use winnow::Parser;
31
32use crate::{
33    Error,
34    desc::{
35        RepoDescFileV1,
36        Section,
37        parser::{SectionKeyword, sections},
38    },
39};
40
41/// Representation of files following the [alpm-repo-descv2] specification.
42///
43/// This file format is used to describe a single package entry within an [alpm-repo-db].
44///
45/// It includes information such as the package's name, version, architecture,
46/// and dependency relationships.
47///
48/// ## Examples
49///
50/// ```
51/// use std::str::FromStr;
52///
53/// use alpm_repo_db::desc::RepoDescFileV2;
54///
55/// # fn main() -> Result<(), alpm_repo_db::Error> {
56/// let desc_data = r#"%FILENAME%
57/// example-meta-1.0.0-1-any.pkg.tar.zst
58///
59/// %NAME%
60/// example-meta
61///
62/// %BASE%
63/// example-meta
64///
65/// %VERSION%
66/// 1.0.0-1
67///
68/// %DESC%
69/// An example meta package
70///
71/// %CSIZE%
72/// 4634
73///
74/// %ISIZE%
75/// 0
76///
77/// %SHA256SUM%
78/// b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
79///
80/// %URL%
81/// https://example.org/
82///
83/// %LICENSE%
84/// GPL-3.0-or-later
85///
86/// %ARCH%
87/// any
88///
89/// %BUILDDATE%
90/// 1729181726
91///
92/// %PACKAGER%
93/// Foobar McFooface <foobar@mcfooface.org>
94///
95/// "#;
96///
97/// // Parse a REPO DESC file in version 2 format.
98/// let repo_desc = RepoDescFileV2::from_str(desc_data)?;
99/// // Convert back to its canonical string representation.
100/// assert_eq!(repo_desc.to_string(), desc_data);
101/// # Ok(())
102/// # }
103/// ```
104///
105/// [alpm-repo-db]: https://alpm.archlinux.page/specifications/alpm-repo-db.7.html
106/// [alpm-repo-descv2]: https://alpm.archlinux.page/specifications/alpm-repo-descv2.5.html
107#[derive(Clone, Debug, serde::Deserialize, PartialEq, serde::Serialize)]
108#[serde(deny_unknown_fields)]
109#[serde(rename_all = "lowercase")]
110pub struct RepoDescFileV2 {
111    /// The file name of the package.
112    pub file_name: PackageFileName,
113
114    /// The name of the package.
115    pub name: Name,
116
117    /// The name of the package base, from which this package originates.
118    pub base: PackageBaseName,
119
120    /// The version of the package.
121    pub version: FullVersion,
122
123    /// The description of the package.
124    ///
125    /// Can be 0 or more characters.
126    pub description: PackageDescription,
127
128    /// The groups this package belongs to.
129    ///
130    /// If the package does not belong to any group, this will be an empty list.
131    pub groups: Vec<Group>,
132
133    /// The compressed size of the package in bytes.
134    pub compressed_size: CompressedSize,
135
136    /// The size of the uncompressed and unpacked package contents in bytes.
137    ///
138    /// Multiple hard-linked files are only counted once.
139    pub installed_size: InstalledSize,
140
141    /// The SHA256 checksum of the package file.
142    pub sha256_checksum: Sha256Checksum,
143
144    /// The base64 encoded OpenPGP detached signature of the package file.
145    ///
146    /// Optional in version 2.
147    pub pgp_signature: Option<Base64OpenPGPSignature>,
148
149    /// The optional URL associated with the package.
150    pub url: Option<Url>,
151
152    /// Set of licenses under which the package is distributed.
153    ///
154    /// Can be empty.
155    pub license: Vec<License>,
156
157    /// The architecture of the package.
158    pub arch: Architecture,
159
160    /// The date at wchich the build of the package started.
161    pub build_date: BuildDate,
162
163    /// The User ID of the entity, that built the package.
164    pub packager: Packager,
165
166    /// Virtual components or packages that this package replaces upon installation.
167    ///
168    /// Can be empty.
169    pub replaces: Vec<PackageRelation>,
170
171    /// Virtual components or packages that this package conflicts with.
172    ///
173    /// Can be empty.
174    pub conflicts: Vec<PackageRelation>,
175
176    /// Virtual components or packages that this package provides.
177    ///
178    /// Can be empty.
179    pub provides: Vec<RelationOrSoname>,
180
181    /// Run-time dependencies required by the package.
182    ///
183    /// Can be empty.
184    pub dependencies: Vec<RelationOrSoname>,
185
186    /// Optional dependencies that are not strictly required by the package.
187    ///
188    /// Can be empty.
189    pub optional_dependencies: Vec<OptionalDependency>,
190
191    /// Dependencies for building the upstream software of the package.
192    ///
193    /// Can be empty.
194    pub make_dependencies: Vec<PackageRelation>,
195
196    /// A dependency for running tests of the package's upstream project.
197    ///
198    /// Can be empty.
199    pub check_dependencies: Vec<PackageRelation>,
200}
201
202impl Display for RepoDescFileV2 {
203    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
204        // Helper function to write a single value section
205        fn single<T: Display, W: Write>(f: &mut W, key: &str, val: &T) -> FmtResult {
206            writeln!(f, "%{key}%\n{val}\n")
207        }
208
209        // Helper function to write a multi-value section
210        fn section<T: Display, W: Write>(f: &mut W, key: &str, vals: &[T]) -> FmtResult {
211            if vals.is_empty() {
212                return Ok(());
213            }
214            writeln!(f, "%{key}%")?;
215            for v in vals {
216                writeln!(f, "{v}")?;
217            }
218            writeln!(f)
219        }
220
221        single(f, "FILENAME", &self.file_name)?;
222        single(f, "NAME", &self.name)?;
223        single(f, "BASE", &self.base)?;
224        single(f, "VERSION", &self.version)?;
225        if !&self.description.as_ref().is_empty() {
226            single(f, "DESC", &self.description)?;
227        }
228        section(f, "GROUPS", &self.groups)?;
229        single(f, "CSIZE", &self.compressed_size)?;
230        single(f, "ISIZE", &self.installed_size)?;
231        single(f, "SHA256SUM", &self.sha256_checksum)?;
232        if let Some(pgpsig) = &self.pgp_signature {
233            single(f, "PGPSIG", pgpsig)?;
234        }
235        if let Some(url) = &self.url {
236            single(f, "URL", url)?;
237        }
238        section(f, "LICENSE", &self.license)?;
239        single(f, "ARCH", &self.arch)?;
240        single(f, "BUILDDATE", &self.build_date)?;
241        single(f, "PACKAGER", &self.packager)?;
242        section(f, "REPLACES", &self.replaces)?;
243        section(f, "CONFLICTS", &self.conflicts)?;
244        section(f, "PROVIDES", &self.provides)?;
245        section(f, "DEPENDS", &self.dependencies)?;
246        section(f, "OPTDEPENDS", &self.optional_dependencies)?;
247        section(f, "MAKEDEPENDS", &self.make_dependencies)?;
248        section(f, "CHECKDEPENDS", &self.check_dependencies)?;
249        Ok(())
250    }
251}
252
253impl FromStr for RepoDescFileV2 {
254    type Err = Error;
255
256    /// Creates a [`RepoDescFileV2`] from a string slice.
257    ///
258    /// Parses the input according to the [alpm-repo-descv2] specification and constructs a
259    /// structured [`RepoDescFileV2`] representation.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use std::str::FromStr;
265    ///
266    /// use alpm_repo_db::desc::RepoDescFileV2;
267    ///
268    /// # fn main() -> Result<(), alpm_repo_db::Error> {
269    /// let desc_data = r#"%FILENAME%
270    /// example-meta-1.0.0-1-any.pkg.tar.zst
271    ///
272    /// %NAME%
273    /// example-meta
274    ///
275    /// %BASE%
276    /// example-meta
277    ///
278    /// %VERSION%
279    /// 1.0.0-1
280    ///
281    /// %DESC%
282    /// An example meta package
283    ///
284    /// %CSIZE%
285    /// 4634
286    ///
287    /// %ISIZE%
288    /// 0
289    ///
290    /// %SHA256SUM%
291    /// b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
292    ///
293    /// %URL%
294    /// https://example.org/
295    ///
296    /// %LICENSE%
297    /// GPL-3.0-or-later
298    ///
299    /// %ARCH%
300    /// any
301    ///
302    /// %BUILDDATE%
303    /// 1729181726
304    ///
305    /// %PACKAGER%
306    /// Foobar McFooface <foobar@mcfooface.org>
307    ///
308    /// "#;
309    ///
310    /// let repo_desc = RepoDescFileV2::from_str(desc_data)?;
311    /// assert_eq!(repo_desc.name.to_string(), "example-meta");
312    /// # Ok(())
313    /// # }
314    /// ```
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if:
319    ///
320    /// - the input cannot be parsed into valid sections,
321    /// - or required fields are missing or malformed.
322    ///
323    /// [alpm-repo-descv2]: https://alpm.archlinux.page/specifications/alpm-repo-descv2.5.html
324    fn from_str(s: &str) -> Result<Self, Self::Err> {
325        let sections = sections.parse(s)?;
326        Self::try_from(sections)
327    }
328}
329
330impl TryFrom<Vec<Section>> for RepoDescFileV2 {
331    type Error = Error;
332
333    /// Tries to create a [`RepoDescFileV2`] from a list of parsed [`Section`]s.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if:
338    ///
339    /// - any required field is missing,
340    /// - a section appears more than once,
341    /// - or a section violates the expected format for version 2.
342    fn try_from(sections: Vec<Section>) -> Result<Self, Self::Error> {
343        let mut file_name = None;
344        let mut name = None;
345        let mut base = None;
346        let mut version = None;
347        let mut description = None;
348        let mut groups: Vec<Group> = Vec::new();
349        let mut compressed_size = None;
350        let mut installed_size = None;
351        let mut sha256_checksum = None;
352        let mut pgp_signature = None;
353        let mut url = None;
354        let mut license: Vec<License> = Vec::new();
355        let mut arch = None;
356        let mut build_date = None;
357        let mut packager = None;
358        let mut replaces: Vec<PackageRelation> = Vec::new();
359        let mut conflicts: Vec<PackageRelation> = Vec::new();
360        let mut provides: Vec<RelationOrSoname> = Vec::new();
361        let mut dependencies: Vec<RelationOrSoname> = Vec::new();
362        let mut optional_dependencies: Vec<OptionalDependency> = Vec::new();
363        let mut make_dependencies: Vec<PackageRelation> = Vec::new();
364        let mut check_dependencies: Vec<PackageRelation> = Vec::new();
365
366        /// Helper macro to set a field only once, returning an error if it was already set.
367        macro_rules! set_once {
368            ($field:ident, $val:expr, $kw:expr) => {{
369                if $field.is_some() {
370                    return Err(Error::DuplicateSection($kw));
371                }
372                $field = Some($val);
373            }};
374        }
375
376        /// Helper macro to set a vector field only once, returning an error if it was already set.
377        /// Additionally, ensures that the provided value is not empty.
378        macro_rules! set_vec_once {
379            ($field:ident, $val:expr, $kw:expr) => {{
380                if !$field.is_empty() {
381                    return Err(Error::DuplicateSection($kw));
382                }
383                if $val.is_empty() {
384                    return Err(Error::EmptySection($kw));
385                }
386                $field = $val;
387            }};
388        }
389
390        for section in sections {
391            match section {
392                Section::Filename(val) => set_once!(file_name, val, SectionKeyword::Filename),
393                Section::Name(val) => set_once!(name, val, SectionKeyword::Name),
394                Section::Base(val) => set_once!(base, val, SectionKeyword::Base),
395                Section::Version(val) => set_once!(version, val, SectionKeyword::Version),
396                Section::Desc(val) => set_once!(description, val, SectionKeyword::Desc),
397                Section::Groups(val) => set_vec_once!(groups, val, SectionKeyword::Groups),
398                Section::CSize(val) => set_once!(compressed_size, val, SectionKeyword::CSize),
399                Section::ISize(val) => set_once!(installed_size, val, SectionKeyword::ISize),
400                Section::Sha256Sum(val) => {
401                    set_once!(sha256_checksum, val, SectionKeyword::Sha256Sum)
402                }
403                Section::PgpSig(val) => set_once!(pgp_signature, val, SectionKeyword::PgpSig),
404                Section::Url(val) => set_once!(url, val, SectionKeyword::Url),
405                Section::License(val) => set_vec_once!(license, val, SectionKeyword::License),
406                Section::Arch(val) => set_once!(arch, val, SectionKeyword::Arch),
407                Section::BuildDate(val) => set_once!(build_date, val, SectionKeyword::BuildDate),
408                Section::Packager(val) => set_once!(packager, val, SectionKeyword::Packager),
409                Section::Replaces(val) => set_vec_once!(replaces, val, SectionKeyword::Replaces),
410                Section::Conflicts(val) => set_vec_once!(conflicts, val, SectionKeyword::Conflicts),
411                Section::Provides(val) => set_vec_once!(provides, val, SectionKeyword::Provides),
412                Section::Depends(val) => set_vec_once!(dependencies, val, SectionKeyword::Depends),
413                Section::OptDepends(val) => {
414                    set_vec_once!(optional_dependencies, val, SectionKeyword::OptDepends)
415                }
416                Section::MakeDepends(val) => {
417                    set_vec_once!(make_dependencies, val, SectionKeyword::MakeDepends)
418                }
419                Section::CheckDepends(val) => {
420                    set_vec_once!(check_dependencies, val, SectionKeyword::CheckDepends)
421                }
422                Section::Md5Sum(_) => {
423                    return Err(Error::InvalidSectionForVersion {
424                        section: SectionKeyword::Md5Sum,
425                        version: 2,
426                    });
427                }
428            }
429        }
430
431        Ok(RepoDescFileV2 {
432            file_name: file_name.ok_or(Error::MissingSection(SectionKeyword::Filename))?,
433            name: name.ok_or(Error::MissingSection(SectionKeyword::Name))?,
434            base: base.ok_or(Error::MissingSection(SectionKeyword::Base))?,
435            version: version.ok_or(Error::MissingSection(SectionKeyword::Version))?,
436            description: description.unwrap_or_default(),
437            groups,
438            compressed_size: compressed_size.ok_or(Error::MissingSection(SectionKeyword::CSize))?,
439            installed_size: installed_size.ok_or(Error::MissingSection(SectionKeyword::ISize))?,
440            sha256_checksum: sha256_checksum
441                .ok_or(Error::MissingSection(SectionKeyword::Sha256Sum))?,
442            pgp_signature,
443            url: url.unwrap_or(None),
444            license,
445            arch: arch.ok_or(Error::MissingSection(SectionKeyword::Arch))?,
446            build_date: build_date.ok_or(Error::MissingSection(SectionKeyword::BuildDate))?,
447            packager: packager.ok_or(Error::MissingSection(SectionKeyword::Packager))?,
448            replaces,
449            conflicts,
450            provides,
451            dependencies,
452            optional_dependencies,
453            make_dependencies,
454            check_dependencies,
455        })
456    }
457}
458
459impl From<RepoDescFileV1> for RepoDescFileV2 {
460    /// Converts a [`RepoDescFileV1`] into a [`RepoDescFileV2`].
461    ///
462    /// # Note
463    ///
464    /// This drops the `md5_checksum` field of the [`RepoDescFileV1`].
465    fn from(v1: RepoDescFileV1) -> Self {
466        RepoDescFileV2 {
467            file_name: v1.file_name,
468            name: v1.name,
469            base: v1.base,
470            version: v1.version,
471            description: v1.description,
472            groups: v1.groups,
473            compressed_size: v1.compressed_size,
474            installed_size: v1.installed_size,
475            sha256_checksum: v1.sha256_checksum,
476            pgp_signature: Some(v1.pgp_signature),
477            url: v1.url,
478            license: v1.license,
479            arch: v1.arch,
480            build_date: v1.build_date,
481            packager: v1.packager,
482            replaces: v1.replaces,
483            conflicts: v1.conflicts,
484            provides: v1.provides,
485            dependencies: v1.dependencies,
486            optional_dependencies: v1.optional_dependencies,
487            make_dependencies: v1.make_dependencies,
488            check_dependencies: v1.check_dependencies,
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use pretty_assertions::assert_eq;
496    use rstest::*;
497    use testresult::TestResult;
498
499    use super::*;
500
501    const VALID_DESC_FILE: &str = r#"%FILENAME%
502example-1.0.0-1-any.pkg.tar.zst
503
504%NAME%
505example
506
507%BASE%
508example
509
510%VERSION%
5111.0.0-1
512
513%DESC%
514An example package
515
516%GROUPS%
517example-group
518other-group
519
520%CSIZE%
5211818463
522
523%ISIZE%
52418184634
525
526%SHA256SUM%
527b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
528
529%URL%
530https://example.org/
531
532%LICENSE%
533MIT
534Apache-2.0
535
536%ARCH%
537x86_64
538
539%BUILDDATE%
5401729181726
541
542%PACKAGER%
543Foobar McFooface <foobar@mcfooface.org>
544
545%REPLACES%
546other-pkg-replaced
547
548%CONFLICTS%
549other-pkg-conflicts
550
551%PROVIDES%
552example-component
553lib:libexample.so.1
554
555%DEPENDS%
556glibc
557gcc-libs
558libdep.so=1-64
559
560%OPTDEPENDS%
561bash: for a script
562
563%MAKEDEPENDS%
564cmake
565
566%CHECKDEPENDS%
567bats
568
569"#;
570
571    const VALID_DESC_FILE_MINIMAL: &str = r#"%FILENAME%
572example-1.0.0-1-any.pkg.tar.zst
573
574%NAME%
575example
576
577%BASE%
578example
579
580%VERSION%
5811.0.0-1
582
583%CSIZE%
5841818463
585
586%ISIZE%
58718184634
588
589%SHA256SUM%
590b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
591
592%ARCH%
593x86_64
594
595%BUILDDATE%
5961729181726
597
598%PACKAGER%
599Foobar McFooface <foobar@mcfooface.org>
600
601"#;
602
603    const VALID_DESC_FILE_EMPTY_FIELDS: &str = r#"%FILENAME%
604example-1.0.0-1-any.pkg.tar.zst
605
606%NAME%
607example
608
609%BASE%
610example
611
612%VERSION%
6131.0.0-1
614
615%DESC%
616
617%CSIZE%
6181818463
619
620%ISIZE%
62118184634
622
623%SHA256SUM%
624b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
625
626%URL%
627
628%ARCH%
629x86_64
630
631%BUILDDATE%
6321729181726
633
634%PACKAGER%
635Foobar McFooface <foobar@mcfooface.org>
636
637"#;
638
639    #[test]
640    fn parse_valid_v2_desc() -> TestResult {
641        let actual = RepoDescFileV2::from_str(VALID_DESC_FILE)?;
642        let expected = RepoDescFileV2 {
643            file_name: PackageFileName::from_str("example-1.0.0-1-any.pkg.tar.zst")?,
644            name: Name::from_str("example")?,
645            base: PackageBaseName::from_str("example")?,
646            version: FullVersion::from_str("1.0.0-1")?,
647            description: PackageDescription::from("An example package"),
648            groups: vec![
649                Group::from_str("example-group")?,
650                Group::from_str("other-group")?,
651            ],
652            compressed_size: CompressedSize::from_str("1818463")?,
653            installed_size: InstalledSize::from_str("18184634")?,
654            sha256_checksum: Sha256Checksum::from_str(
655                "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
656            )?,
657            pgp_signature: None,
658            url: Some(Url::from_str("https://example.org")?),
659            license: vec![License::from_str("MIT")?, License::from_str("Apache-2.0")?],
660            arch: Architecture::from_str("x86_64")?,
661            build_date: BuildDate::from_str("1729181726")?,
662            packager: Packager::from_str("Foobar McFooface <foobar@mcfooface.org>")?,
663            replaces: vec![PackageRelation::from_str("other-pkg-replaced")?],
664            conflicts: vec![PackageRelation::from_str("other-pkg-conflicts")?],
665            provides: vec![
666                RelationOrSoname::from_str("example-component")?,
667                RelationOrSoname::from_str("lib:libexample.so.1")?,
668            ],
669            dependencies: vec![
670                RelationOrSoname::from_str("glibc")?,
671                RelationOrSoname::from_str("gcc-libs")?,
672                RelationOrSoname::from_str("libdep.so=1-64")?,
673            ],
674            optional_dependencies: vec![OptionalDependency::from_str("bash: for a script")?],
675            make_dependencies: vec![PackageRelation::from_str("cmake")?],
676            check_dependencies: vec![PackageRelation::from_str("bats")?],
677        };
678        assert_eq!(actual, expected);
679        assert_eq!(VALID_DESC_FILE, actual.to_string());
680        Ok(())
681    }
682
683    #[test]
684    fn parse_valid_v2_desc_minimal() -> TestResult {
685        let actual = RepoDescFileV2::from_str(VALID_DESC_FILE_MINIMAL)?;
686        let expected = RepoDescFileV2 {
687            file_name: PackageFileName::from_str("example-1.0.0-1-any.pkg.tar.zst")?,
688            name: Name::from_str("example")?,
689            base: PackageBaseName::from_str("example")?,
690            version: FullVersion::from_str("1.0.0-1")?,
691            description: PackageDescription::from(""),
692            groups: vec![],
693            compressed_size: CompressedSize::from_str("1818463")?,
694            installed_size: InstalledSize::from_str("18184634")?,
695            sha256_checksum: Sha256Checksum::from_str(
696                "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
697            )?,
698            pgp_signature: None,
699            url: None,
700            license: vec![],
701            arch: Architecture::from_str("x86_64")?,
702            build_date: BuildDate::from_str("1729181726")?,
703            packager: Packager::from_str("Foobar McFooface <foobar@mcfooface.org>")?,
704            replaces: vec![],
705            conflicts: vec![],
706            provides: vec![],
707            dependencies: vec![],
708            optional_dependencies: vec![],
709            make_dependencies: vec![],
710            check_dependencies: vec![],
711        };
712        assert_eq!(actual, expected);
713        assert_eq!(VALID_DESC_FILE_MINIMAL, actual.to_string());
714        Ok(())
715    }
716
717    #[rstest]
718    #[case(VALID_DESC_FILE, VALID_DESC_FILE)]
719    #[case(VALID_DESC_FILE_MINIMAL, VALID_DESC_FILE_MINIMAL)]
720    // Empty optional fields are omitted in output
721    #[case(VALID_DESC_FILE_EMPTY_FIELDS, VALID_DESC_FILE_MINIMAL)]
722    fn parser_roundtrip(#[case] input: &str, #[case] expected: &str) -> TestResult {
723        let desc = RepoDescFileV2::from_str(input)?;
724        let output = desc.to_string();
725        assert_eq!(output, expected);
726        let desc_roundtrip = RepoDescFileV2::from_str(&output)?;
727        assert_eq!(desc, desc_roundtrip);
728        Ok(())
729    }
730
731    #[rstest]
732    #[case("%UNKNOWN%\nvalue", "invalid section name")]
733    #[case("%VERSION%\n1.0.0-1\n", "Missing section: %FILENAME%")]
734    fn invalid_desc_parser(#[case] input: &str, #[case] error_snippet: &str) {
735        let result = RepoDescFileV2::from_str(input);
736        assert!(result.is_err());
737        let err = result.unwrap_err();
738        let pretty_error = err.to_string();
739        assert!(
740            pretty_error.contains(error_snippet),
741            "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
742        );
743    }
744
745    #[test]
746    fn missing_required_section_should_fail() {
747        let input = "%VERSION%\n1.0.0-1\n";
748        let result = RepoDescFileV2::from_str(input);
749        assert!(matches!(result, Err(Error::MissingSection(s)) if s == SectionKeyword::Filename));
750    }
751}