Skip to main content

alpm_types/relation/
base.rs

1//! Basic relation types used in metadata files.
2
3use std::{
4    fmt::{Display, Formatter},
5    str::FromStr,
6};
7
8use 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 insta::assert_snapshot;
363    use proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
364    use rstest::rstest;
365
366    use super::*;
367    use crate::{VersionComparison, configure_insta};
368
369    const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
370    /// NOTE: [`Epoch`][alpm_types::Epoch] is implicitly constrained by [`std::usize::MAX`].
371    /// However, it's unrealistic to ever reach that many forced downgrades for a package, hence
372    /// we don't test that fully
373    const EPOCH_REGEX: &str = r"[1-9]{1}[0-9]{0,10}";
374    const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
375    const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
376    const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
377    const DESCRIPTION_REGEX: &str = "[^\n\r]*";
378
379    proptest! {
380        #![proptest_config(ProptestConfig::with_cases(1000))]
381
382
383        #[test]
384        fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
385            println!("s: {s}");
386            let name = PackageRelation::from_str(&s).unwrap();
387            prop_assert_eq!(s, format!("{}", name));
388        }
389    }
390
391    proptest! {
392        #[test]
393        fn opt_depend_from_str(
394            name in NAME_REGEX,
395            desc in DESCRIPTION_REGEX,
396            use_desc in proptest::bool::ANY
397        ) {
398            let desc_trimmed = desc.trim_ascii();
399            let desc_is_blank = desc_trimmed.is_empty();
400
401            let (raw_in, formatted_expected) = if use_desc {
402                // Raw input and expected formatted output.
403                // These are different because `desc` will be trimmed by the parser;
404                // if it is *only* ascii whitespace then it will be skipped altogether.
405                (
406                    format!("{name}: {desc}"),
407                    if !desc_is_blank {
408                        format!("{name}: {desc_trimmed}")
409                    } else {
410                        name.clone()
411                    }
412                )
413            } else {
414                (name.clone(), name.clone())
415            };
416
417            println!("input string: {raw_in}");
418            let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
419            let formatted_actual = format!("{opt_depend}");
420            prop_assert_eq!(
421                formatted_expected,
422                formatted_actual,
423                "Formatted output doesn't match input"
424            );
425        }
426    }
427
428    #[rstest]
429    #[case(
430        "python>=3",
431        Ok(PackageRelation {
432            name: Name::new("python").unwrap(),
433            version_requirement: Some(VersionRequirement {
434                comparison: VersionComparison::GreaterOrEqual,
435                version: "3".parse().unwrap(),
436            }),
437        }),
438    )]
439    #[case(
440        "java-environment>=17",
441        Ok(PackageRelation {
442            name: Name::new("java-environment").unwrap(),
443            version_requirement: Some(VersionRequirement {
444                comparison: VersionComparison::GreaterOrEqual,
445                version: "17".parse().unwrap(),
446            }),
447        }),
448    )]
449    fn valid_package_relation(
450        #[case] input: &str,
451        #[case] expected: Result<PackageRelation, Error>,
452    ) {
453        assert_eq!(PackageRelation::from_str(input), expected);
454    }
455
456    #[rstest]
457    #[case(
458        "example: this is an example dependency",
459        OptionalDependency {
460            package_relation: PackageRelation {
461                name: Name::new("example").unwrap(),
462                version_requirement: None,
463            },
464            description: Some("this is an example dependency".to_string()),
465        },
466    )]
467    #[case(
468        "example-two:     a description with lots of whitespace padding     ",
469        OptionalDependency {
470            package_relation: PackageRelation {
471                name: Name::new("example-two").unwrap(),
472                version_requirement: None,
473            },
474            description: Some("a description with lots of whitespace padding".to_string())
475        },
476    )]
477    #[case(
478        "dep_name",
479        OptionalDependency {
480            package_relation: PackageRelation {
481                name: Name::new("dep_name").unwrap(),
482                version_requirement: None,
483            },
484            description: None,
485        },
486    )]
487    #[case(
488        "dep_name: ",
489        OptionalDependency {
490            package_relation: PackageRelation {
491                name: Name::new("dep_name").unwrap(),
492                version_requirement: None,
493            },
494            description: None,
495        },
496    )]
497    #[case(
498        "dep_name_with_special_chars-123: description with !@#$%^&*",
499        OptionalDependency {
500            package_relation: PackageRelation {
501                name: Name::new("dep_name_with_special_chars-123").unwrap(),
502                version_requirement: None,
503            },
504            description: Some("description with !@#$%^&*".to_string()),
505        },
506    )]
507    // versioned optional dependencies
508    #[case(
509        "elfutils=0.192: for translations",
510        OptionalDependency {
511            package_relation: PackageRelation {
512                name: Name::new("elfutils").unwrap(),
513                version_requirement: Some(VersionRequirement {
514                    comparison: VersionComparison::Equal,
515                    version: "0.192".parse().unwrap(),
516                }),
517            },
518            description: Some("for translations".to_string()),
519        },
520    )]
521    #[case(
522        "python>=3: For Python bindings",
523        OptionalDependency {
524            package_relation: PackageRelation {
525                name: Name::new("python").unwrap(),
526                version_requirement: Some(VersionRequirement {
527                    comparison: VersionComparison::GreaterOrEqual,
528                    version: "3".parse().unwrap(),
529                }),
530            },
531            description: Some("For Python bindings".to_string()),
532        },
533    )]
534    #[case(
535        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
536        OptionalDependency {
537            package_relation: PackageRelation {
538                name: Name::new("java-environment").unwrap(),
539                version_requirement: Some(VersionRequirement {
540                    comparison: VersionComparison::GreaterOrEqual,
541                    version: "17".parse().unwrap(),
542                }),
543            },
544            description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
545        },
546    )]
547    fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
548        let opt_depend_result = OptionalDependency::from_str(input);
549        let Ok(optional_dependency) = opt_depend_result else {
550            panic!(
551                "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
552            )
553        };
554
555        assert_eq!(
556            expected, optional_dependency,
557            "Optional dependency has not been correctly parsed."
558        );
559    }
560
561    #[rstest]
562    #[case(
563        "example: this is an example dependency",
564        "example: this is an example dependency"
565    )]
566    #[case(
567        "example-two:     a description with lots of whitespace padding     ",
568        "example-two: a description with lots of whitespace padding"
569    )]
570    #[case(
571        "tabs:    a description with a tab directly after the colon",
572        "tabs: a description with a tab directly after the colon"
573    )]
574    #[case("dep_name", "dep_name")]
575    #[case("dep_name: ", "dep_name")]
576    #[case(
577        "dep_name_with_special_chars-123: description with !@#$%^&*",
578        "dep_name_with_special_chars-123: description with !@#$%^&*"
579    )]
580    // versioned optional dependencies
581    #[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
582    #[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
583    #[case(
584        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
585        "java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
586    )]
587    fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
588        let opt_depend_result = OptionalDependency::from_str(input);
589        let Ok(optional_dependency) = opt_depend_result else {
590            panic!(
591                "Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
592            )
593        };
594        assert_eq!(
595            expected,
596            optional_dependency.to_string(),
597            "OptionalDependency to_string is erroneous."
598        );
599    }
600
601    #[rstest]
602    #[case("#invalid-name: this is an example dependency")]
603    #[case(": no_name_colon")]
604    #[case("name:description with no leading whitespace")]
605    #[case("dep-name>=10: \n\ndescription with\rnewlines")]
606    fn opt_depend_invalid_string_parse_error(#[case] input: &str) {
607        let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
608            panic!("'{input}' erroneously parsed as a OptionalDependency")
609        };
610
611        let (test_name, _guard) = configure_insta();
612        assert_snapshot!(test_name, err_msg.to_string());
613    }
614}