alpm_repo_db/desc/
v1.rs

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