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 serde::{Deserialize, Serialize};
9use winnow::{
10    ModalResult,
11    Parser,
12    ascii::space1,
13    combinator::{alt, cut_err, eof, opt, separated_pair, seq, terminated},
14    error::{StrContext, StrContextValue},
15    token::{rest, take_till, take_until},
16};
17
18use crate::{Error, Name, VersionRequirement};
19
20/// A package relation
21///
22/// Describes a relation to a component.
23/// Package relations may either consist of only a [`Name`] *or* of a [`Name`] and a
24/// [`VersionRequirement`].
25///
26/// ## Note
27///
28/// A [`PackageRelation`] covers all [alpm-package-relations] *except* optional
29/// dependencies, as those behave differently.
30///
31/// [alpm-package-relations]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33pub struct PackageRelation {
34    /// The name of the package
35    pub name: Name,
36    /// The version requirement for the package
37    pub version_requirement: Option<VersionRequirement>,
38}
39
40impl PackageRelation {
41    /// Creates a new [`PackageRelation`]
42    ///
43    /// # Examples
44    ///
45    /// ```
46    /// use alpm_types::{PackageRelation, VersionComparison, VersionRequirement};
47    ///
48    /// # fn main() -> Result<(), alpm_types::Error> {
49    /// PackageRelation::new(
50    ///     "example".parse()?,
51    ///     Some(VersionRequirement {
52    ///         comparison: VersionComparison::Less,
53    ///         version: "1.0.0".parse()?,
54    ///     }),
55    /// );
56    ///
57    /// PackageRelation::new("example".parse()?, None);
58    /// # Ok(())
59    /// # }
60    /// ```
61    pub fn new(name: Name, version_requirement: Option<VersionRequirement>) -> Self {
62        Self {
63            name,
64            version_requirement,
65        }
66    }
67
68    /// Parses a [`PackageRelation`] from a string slice.
69    ///
70    /// Consumes all of its input.
71    ///
72    /// # Examples
73    ///
74    /// See [`Self::from_str`] for code examples.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if `input` is not a valid _package-relation_.
79    pub fn parser(input: &mut &str) -> ModalResult<Self> {
80        seq!(Self {
81            name: take_till(1.., ('<', '>', '=')).and_then(Name::parser).context(StrContext::Label("package name")),
82            version_requirement: opt(VersionRequirement::parser),
83            _: eof.context(StrContext::Expected(StrContextValue::Description("end of relation version requirement"))),
84        })
85        .parse_next(input)
86    }
87}
88
89impl Display for PackageRelation {
90    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91        if let Some(version_requirement) = self.version_requirement.as_ref() {
92            write!(f, "{}{}", self.name, version_requirement)
93        } else {
94            write!(f, "{}", self.name)
95        }
96    }
97}
98
99impl FromStr for PackageRelation {
100    type Err = Error;
101    /// Parses a [`PackageRelation`] from a string slice.
102    ///
103    /// Delegates to [`PackageRelation::parser`].
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if [`PackageRelation::parser`] fails.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use std::str::FromStr;
113    ///
114    /// use alpm_types::{PackageRelation, VersionComparison, VersionRequirement};
115    ///
116    /// # fn main() -> Result<(), alpm_types::Error> {
117    /// assert_eq!(
118    ///     PackageRelation::from_str("example<1.0.0")?,
119    ///     PackageRelation::new(
120    ///         "example".parse()?,
121    ///         Some(VersionRequirement {
122    ///             comparison: VersionComparison::Less,
123    ///             version: "1.0.0".parse()?
124    ///         })
125    ///     ),
126    /// );
127    ///
128    /// assert_eq!(
129    ///     PackageRelation::from_str("example<=1.0.0")?,
130    ///     PackageRelation::new(
131    ///         "example".parse()?,
132    ///         Some(VersionRequirement {
133    ///             comparison: VersionComparison::LessOrEqual,
134    ///             version: "1.0.0".parse()?
135    ///         })
136    ///     ),
137    /// );
138    ///
139    /// assert_eq!(
140    ///     PackageRelation::from_str("example=1.0.0")?,
141    ///     PackageRelation::new(
142    ///         "example".parse()?,
143    ///         Some(VersionRequirement {
144    ///             comparison: VersionComparison::Equal,
145    ///             version: "1.0.0".parse()?
146    ///         })
147    ///     ),
148    /// );
149    ///
150    /// assert_eq!(
151    ///     PackageRelation::from_str("example>1.0.0")?,
152    ///     PackageRelation::new(
153    ///         "example".parse()?,
154    ///         Some(VersionRequirement {
155    ///             comparison: VersionComparison::Greater,
156    ///             version: "1.0.0".parse()?
157    ///         })
158    ///     ),
159    /// );
160    ///
161    /// assert_eq!(
162    ///     PackageRelation::from_str("example>=1.0.0")?,
163    ///     PackageRelation::new(
164    ///         "example".parse()?,
165    ///         Some(VersionRequirement {
166    ///             comparison: VersionComparison::GreaterOrEqual,
167    ///             version: "1.0.0".parse()?
168    ///         })
169    ///     ),
170    /// );
171    ///
172    /// assert_eq!(
173    ///     PackageRelation::from_str("example")?,
174    ///     PackageRelation::new("example".parse()?, None),
175    /// );
176    ///
177    /// assert!(PackageRelation::from_str("example<").is_err());
178    /// # Ok(())
179    /// # }
180    /// ```
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Ok(Self::parser.parse(s)?)
183    }
184}
185
186/// An optional dependency for a package.
187///
188/// This type is used for representing dependencies that are not essential for base functionality
189/// of a package, but may be necessary to make use of certain features of a package.
190///
191/// An [`OptionalDependency`] consists of a package relation and an optional description separated
192/// by a colon (`:`).
193///
194/// - The package relation component must be a valid [`PackageRelation`].
195/// - If a description is provided it must be at least one character long.
196///
197/// Refer to [alpm-package-relation] of type [optional dependency] for details on the format.
198/// ## Examples
199///
200/// ```
201/// use std::str::FromStr;
202///
203/// use alpm_types::{Name, OptionalDependency};
204///
205/// # fn main() -> Result<(), alpm_types::Error> {
206/// // Create OptionalDependency from &str
207/// let opt_depend = OptionalDependency::from_str("example: this is an example dependency")?;
208///
209/// // Get the name
210/// assert_eq!("example", opt_depend.name().as_ref());
211///
212/// // Get the description
213/// assert_eq!(
214///     Some("this is an example dependency"),
215///     opt_depend.description().as_deref()
216/// );
217///
218/// // Format as String
219/// assert_eq!(
220///     "example: this is an example dependency",
221///     format!("{opt_depend}")
222/// );
223/// # Ok(())
224/// # }
225/// ```
226///
227/// [alpm-package-relation]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html
228/// [optional dependency]: https://alpm.archlinux.page/specifications/alpm-package-relation.7.html#optional-dependency
229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
230pub struct OptionalDependency {
231    package_relation: PackageRelation,
232    description: Option<String>,
233}
234
235impl OptionalDependency {
236    /// Create a new OptionalDependency in a Result
237    pub fn new(
238        package_relation: PackageRelation,
239        description: Option<String>,
240    ) -> OptionalDependency {
241        OptionalDependency {
242            package_relation,
243            description,
244        }
245    }
246
247    /// Return the name of the optional dependency
248    pub fn name(&self) -> &Name {
249        &self.package_relation.name
250    }
251
252    /// Return the version requirement of the optional dependency
253    pub fn version_requirement(&self) -> &Option<VersionRequirement> {
254        &self.package_relation.version_requirement
255    }
256
257    /// Return the description for the optional dependency, if it exists
258    pub fn description(&self) -> &Option<String> {
259        &self.description
260    }
261
262    /// Returns a reference to the tracked [`PackageRelation`].
263    pub fn package_relation(&self) -> &PackageRelation {
264        &self.package_relation
265    }
266
267    /// Recognizes an [`OptionalDependency`] in a string slice.
268    ///
269    /// Consumes all of its input.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if `input` is not a valid _alpm-package-relation_ of type _optional
274    /// dependency_.
275    pub fn parser(input: &mut &str) -> ModalResult<Self> {
276        let description_parser = terminated(
277            // Descriptions may consist of any character except '\n' and '\r'.
278            // Descriptions are a also at the end of a `OptionalDependency`.
279            // We enforce forbidding `\n` and `\r` by only taking until either of them
280            // is hit and checking for `eof` afterwards.
281            // This will **always** succeed unless `\n` and `\r` are hit, in which case an
282            // error is thrown.
283            take_till(0.., ('\n', '\r')),
284            eof,
285        )
286        .context(StrContext::Label("optional dependency description"))
287        .context(StrContext::Expected(StrContextValue::Description(
288            r"no carriage returns or newlines",
289        )))
290        .map(|d: &str| match d.trim_ascii() {
291            "" => None,
292            t => Some(t.to_string()),
293        });
294
295        let (package_relation, description) = alt((
296            // look for a ":" followed by at least one whitespace, then dispatch either side to the
297            // relevant parser without allowing backtracking.
298            separated_pair(
299                take_until(1.., ":").and_then(cut_err(PackageRelation::parser)),
300                (":", space1),
301                rest.and_then(cut_err(description_parser)),
302            ),
303            // if we can't find ": ", then assume it's all PackageRelation
304            // and assert we've reached the end of input
305            (rest.and_then(PackageRelation::parser), eof.value(None)),
306        ))
307        .parse_next(input)?;
308
309        Ok(Self {
310            package_relation,
311            description,
312        })
313    }
314}
315
316impl FromStr for OptionalDependency {
317    type Err = Error;
318
319    /// Creates a new [`OptionalDependency`] from a string slice.
320    ///
321    /// Delegates to [`OptionalDependency::parser`].
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if [`OptionalDependency::parser`] fails.
326    fn from_str(s: &str) -> Result<Self, Self::Err> {
327        Ok(Self::parser.parse(s)?)
328    }
329}
330
331impl Display for OptionalDependency {
332    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
333        match self.description {
334            Some(ref description) => write!(fmt, "{}: {}", self.package_relation, description),
335            None => write!(fmt, "{}", self.package_relation),
336        }
337    }
338}
339
340/// Group of a package
341///
342/// Represents an arbitrary collection of packages that share a common
343/// characteristic or functionality.
344///
345/// While group names can be any valid UTF-8 string, it is recommended to follow
346/// the format of [`Name`] (`[a-z\d\-._@+]` but must not start with `[-.]`)
347/// to ensure consistency and ease of use.
348///
349/// This is a type alias for [`String`].
350///
351/// ## Examples
352/// ```
353/// use alpm_types::Group;
354///
355/// // Create a Group
356/// let group: Group = "package-group".to_string();
357/// ```
358pub type Group = String;
359
360#[cfg(test)]
361mod tests {
362    use proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
363    use rstest::rstest;
364
365    use super::*;
366    use crate::VersionComparison;
367
368    const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
369    /// NOTE: [`Epoch`][alpm_types::Epoch] is implicitly constrained by [`std::usize::MAX`].
370    /// However, it's unrealistic to ever reach that many forced downgrades for a package, hence
371    /// we don't test that fully
372    const EPOCH_REGEX: &str = r"[1-9]{1}[0-9]{0,10}";
373    const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
374    const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
375    const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
376    const DESCRIPTION_REGEX: &str = "[^\n\r]*";
377
378    proptest! {
379        #![proptest_config(ProptestConfig::with_cases(1000))]
380
381
382        #[test]
383        fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
384            println!("s: {s}");
385            let name = PackageRelation::from_str(&s).unwrap();
386            prop_assert_eq!(s, format!("{}", name));
387        }
388    }
389
390    proptest! {
391        #[test]
392        fn opt_depend_from_str(
393            name in NAME_REGEX,
394            desc in DESCRIPTION_REGEX,
395            use_desc in proptest::bool::ANY
396        ) {
397            let desc_trimmed = desc.trim_ascii();
398            let desc_is_blank = desc_trimmed.is_empty();
399
400            let (raw_in, formatted_expected) = if use_desc {
401                // Raw input and expected formatted output.
402                // These are different because `desc` will be trimmed by the parser;
403                // if it is *only* ascii whitespace then it will be skipped altogether.
404                (
405                    format!("{name}: {desc}"),
406                    if !desc_is_blank {
407                        format!("{name}: {desc_trimmed}")
408                    } else {
409                        name.clone()
410                    }
411                )
412            } else {
413                (name.clone(), name.clone())
414            };
415
416            println!("input string: {raw_in}");
417            let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
418            let formatted_actual = format!("{opt_depend}");
419            prop_assert_eq!(
420                formatted_expected,
421                formatted_actual,
422                "Formatted output doesn't match input"
423            );
424        }
425    }
426
427    #[rstest]
428    #[case(
429        "python>=3",
430        Ok(PackageRelation {
431            name: Name::new("python").unwrap(),
432            version_requirement: Some(VersionRequirement {
433                comparison: VersionComparison::GreaterOrEqual,
434                version: "3".parse().unwrap(),
435            }),
436        }),
437    )]
438    #[case(
439        "java-environment>=17",
440        Ok(PackageRelation {
441            name: Name::new("java-environment").unwrap(),
442            version_requirement: Some(VersionRequirement {
443                comparison: VersionComparison::GreaterOrEqual,
444                version: "17".parse().unwrap(),
445            }),
446        }),
447    )]
448    fn valid_package_relation(
449        #[case] input: &str,
450        #[case] expected: Result<PackageRelation, Error>,
451    ) {
452        assert_eq!(PackageRelation::from_str(input), expected);
453    }
454
455    #[rstest]
456    #[case(
457        "example: this is an example dependency",
458        OptionalDependency {
459            package_relation: PackageRelation {
460                name: Name::new("example").unwrap(),
461                version_requirement: None,
462            },
463            description: Some("this is an example dependency".to_string()),
464        },
465    )]
466    #[case(
467        "example-two:     a description with lots of whitespace padding     ",
468        OptionalDependency {
469            package_relation: PackageRelation {
470                name: Name::new("example-two").unwrap(),
471                version_requirement: None,
472            },
473            description: Some("a description with lots of whitespace padding".to_string())
474        },
475    )]
476    #[case(
477        "dep_name",
478        OptionalDependency {
479            package_relation: PackageRelation {
480                name: Name::new("dep_name").unwrap(),
481                version_requirement: None,
482            },
483            description: None,
484        },
485    )]
486    #[case(
487        "dep_name: ",
488        OptionalDependency {
489            package_relation: PackageRelation {
490                name: Name::new("dep_name").unwrap(),
491                version_requirement: None,
492            },
493            description: None,
494        },
495    )]
496    #[case(
497        "dep_name_with_special_chars-123: description with !@#$%^&*",
498        OptionalDependency {
499            package_relation: PackageRelation {
500                name: Name::new("dep_name_with_special_chars-123").unwrap(),
501                version_requirement: None,
502            },
503            description: Some("description with !@#$%^&*".to_string()),
504        },
505    )]
506    // versioned optional dependencies
507    #[case(
508        "elfutils=0.192: for translations",
509        OptionalDependency {
510            package_relation: PackageRelation {
511                name: Name::new("elfutils").unwrap(),
512                version_requirement: Some(VersionRequirement {
513                    comparison: VersionComparison::Equal,
514                    version: "0.192".parse().unwrap(),
515                }),
516            },
517            description: Some("for translations".to_string()),
518        },
519    )]
520    #[case(
521        "python>=3: For Python bindings",
522        OptionalDependency {
523            package_relation: PackageRelation {
524                name: Name::new("python").unwrap(),
525                version_requirement: Some(VersionRequirement {
526                    comparison: VersionComparison::GreaterOrEqual,
527                    version: "3".parse().unwrap(),
528                }),
529            },
530            description: Some("For Python bindings".to_string()),
531        },
532    )]
533    #[case(
534        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
535        OptionalDependency {
536            package_relation: PackageRelation {
537                name: Name::new("java-environment").unwrap(),
538                version_requirement: Some(VersionRequirement {
539                    comparison: VersionComparison::GreaterOrEqual,
540                    version: "17".parse().unwrap(),
541                }),
542            },
543            description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
544        },
545    )]
546    fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
547        let opt_depend_result = OptionalDependency::from_str(input);
548        let Ok(optional_dependency) = opt_depend_result else {
549            panic!(
550                "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
551            )
552        };
553
554        assert_eq!(
555            expected, optional_dependency,
556            "Optional dependency has not been correctly parsed."
557        );
558    }
559
560    #[rstest]
561    #[case(
562        "example: this is an example dependency",
563        "example: this is an example dependency"
564    )]
565    #[case(
566        "example-two:     a description with lots of whitespace padding     ",
567        "example-two: a description with lots of whitespace padding"
568    )]
569    #[case(
570        "tabs:    a description with a tab directly after the colon",
571        "tabs: a description with a tab directly after the colon"
572    )]
573    #[case("dep_name", "dep_name")]
574    #[case("dep_name: ", "dep_name")]
575    #[case(
576        "dep_name_with_special_chars-123: description with !@#$%^&*",
577        "dep_name_with_special_chars-123: description with !@#$%^&*"
578    )]
579    // versioned optional dependencies
580    #[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
581    #[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
582    #[case(
583        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
584        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
585    )]
586    fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
587        let opt_depend_result = OptionalDependency::from_str(input);
588        let Ok(optional_dependency) = opt_depend_result else {
589            panic!(
590                "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
591            )
592        };
593        assert_eq!(
594            expected,
595            optional_dependency.to_string(),
596            "OptionalDependency to_string is erroneous."
597        );
598    }
599
600    #[rstest]
601    #[case(
602        "#invalid-name: this is an example dependency",
603        "invalid first character of package name"
604    )]
605    #[case(": no_name_colon", "invalid first character of package name")]
606    #[case(
607        "name:description with no leading whitespace",
608        "invalid character in package name"
609    )]
610    #[case(
611        "dep-name>=10: \n\ndescription with\rnewlines",
612        "expected no carriage returns or newlines"
613    )]
614    fn opt_depend_invalid_string_parse_error(#[case] input: &str, #[case] err_snippet: &str) {
615        let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
616            panic!("'{input}' did not fail to parse as expected")
617        };
618        assert!(
619            err_msg.contains(err_snippet),
620            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
621        );
622    }
623}