From 335d13ca335817277f379b3e77de0cd06ecaa17b Mon Sep 17 00:00:00 2001
From: David Runge <dvzrv@archlinux.org>
Date: Wed, 22 Mar 2023 16:45:02 +0100
Subject: [PATCH] feat: Implement Name type

Add a Name type to describe package names.
Use proptest to define generic test scenarios for valid and invalid
package name strings.
---
 .ci/check.sh  |  2 +-
 Cargo.toml    |  3 ++
 README.md     | 11 +++++++
 src/error.rs  |  4 +++
 src/lib.rs    | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/macros.rs | 23 ++++++++++++++
 6 files changed, 125 insertions(+), 1 deletion(-)
 create mode 100644 src/macros.rs

diff --git a/.ci/check.sh b/.ci/check.sh
index 32df9f8..32df5ca 100755
--- a/.ci/check.sh
+++ b/.ci/check.sh
@@ -8,5 +8,5 @@ set -euxo pipefail
 cargo fmt -- --check
 cargo clippy --all -- -D warnings
 cargo test --all
-codespell --skip '.cargo,.git,target'
+codespell --skip '.cargo,.git,target' --ignore-words-list 'crate'
 reuse lint
diff --git a/Cargo.toml b/Cargo.toml
index 8458bf7..206e377 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,9 +17,12 @@ version = "0.0.0"
 
 [dependencies]
 chrono = "0.4.23"
+once_cell = "1.17.1"
+regex = "1.7.2"
 strum = "0.24.1"
 strum_macros = "0.24.3"
 thiserror = "1.0.40"
 
 [dev-dependencies]
+proptest = "1.1.0"
 rstest = "0.17.0"
diff --git a/README.md b/README.md
index 5f17c5a..d90c96c 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,17 @@ use std::str::FromStr;
 assert_eq!(InstalledSize::from_str("1"), Ok(InstalledSize::new(1)));
 ```
 
+The name for a package is restricted to a specific set of characters.
+You can create `Name` directly or from str, which yields a Result:
+
+```rust
+use std::str::FromStr;
+use alpm_types::{Error, Name};
+
+assert_eq!(Name::from_str("test-123@.foo_+"), Ok(Name::new("test-123@.foo_+")));
+assert_eq!(Name::from_str(".foo"), Err(Error::InvalidName(".foo".to_string())));
+```
+
 Package types are distinguished using the `PkgType` enum. Its variants can be constructed from str:
 
 ```rust
diff --git a/src/error.rs b/src/error.rs
index f66ca42..bdefa21 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -15,6 +15,9 @@ pub enum Error {
     /// An invalid installed package size (in bytes)
     #[error("Invalid installed size: {0}")]
     InvalidInstalledSize(String),
+    /// An invalid package name
+    #[error("Invalid package name: {0}")]
+    InvalidName(String),
 }
 
 #[cfg(test)]
@@ -32,6 +35,7 @@ mod tests {
         "Invalid installed size: -1",
         Error::InvalidInstalledSize(String::from("-1"))
     )]
+    #[case("Invalid package name: -1", Error::InvalidName(String::from("-1")))]
     fn error_format_string(#[case] error_str: &str, #[case] error: Error) {
         assert_eq!(error_str, format!("{}", error));
     }
diff --git a/src/lib.rs b/src/lib.rs
index cde989d..f352409 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,6 +14,9 @@ use strum_macros::EnumString;
 mod error;
 pub use error::Error;
 
+mod macros;
+use macros::regex_once;
+
 /// CPU architecture
 ///
 /// Members of the Architecture enum can be created from `&str`.
@@ -229,6 +232,69 @@ impl Display for InstalledSize {
     }
 }
 
+/// A package name
+///
+/// Package names may contain the characters `[a-z\d\-._@+]`, but must not
+/// start with `[-.]`.
+///
+/// ## Examples
+/// ```
+/// use alpm_types::{Name, Error};
+/// use std::str::FromStr;
+///
+/// // create Name from &str
+/// assert_eq!(
+///     Name::from_str("test-123@.foo_+"),
+///     Ok(Name::new("test-123@.foo_+").unwrap())
+/// );
+/// assert_eq!(
+///     Name::from_str(".test"),
+///     Err(Error::InvalidName(".test".to_string()))
+/// );
+///
+/// // format as String
+/// assert_eq!("foo", format!("{}", Name::new("foo").unwrap()));
+/// ```
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Name {
+    name: String,
+}
+
+impl Name {
+    /// Create a new Name in a Result
+    pub fn new(name: &str) -> Result<Name, Error> {
+        Name::validate(name)
+    }
+
+    /// Validate a string and return a Name in a Result
+    ///
+    /// The validation happens on the basis of the allowed characters as
+    /// defined by the Name type.
+    pub fn validate(name: &str) -> Result<Name, Error> {
+        if regex_once!(r"^[a-z\d_@+]+[a-z\d\-._@+]*$").is_match(name) {
+            Ok(Name {
+                name: name.to_string(),
+            })
+        } else {
+            Err(Error::InvalidName(name.to_string()))
+        }
+    }
+}
+
+impl FromStr for Name {
+    type Err = Error;
+    /// Create a Name from a string
+    fn from_str(input: &str) -> Result<Name, Self::Err> {
+        Name::validate(input)
+    }
+}
+
+impl Display for Name {
+    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
+        write!(fmt, "{}", self.name)
+    }
+}
+
 /// The type of a package
 ///
 /// ## Examples
@@ -266,6 +332,7 @@ pub enum PkgType {
 mod tests {
     use super::*;
     use chrono::NaiveDateTime;
+    use proptest::prelude::*;
     use rstest::rstest;
     use strum::ParseError;
 
@@ -361,6 +428,22 @@ mod tests {
         assert_eq!("1", format!("{}", InstalledSize::new(1)));
     }
 
+    proptest! {
+        #![proptest_config(ProptestConfig::with_cases(1000))]
+
+        #[test]
+        fn valid_name_from_string(name_str in r"[a-z\d_@+]+[a-z\d\-._@+]*") {
+            let name = Name::from_str(&name_str).unwrap();
+            prop_assert_eq!(name_str, format!("{}", name));
+        }
+
+        #[test]
+        fn invalid_name_from_string_start(name_str in r"[\-.]+[a-z\d\-._@+]*") {
+            let error = Name::from_str(&name_str).unwrap_err();
+            assert!(format!("{}", error).ends_with(&name_str));
+        }
+    }
+
     #[rstest]
     #[case("debug", Ok(PkgType::Debug))]
     #[case("pkg", Ok(PkgType::Package))]
diff --git a/src/macros.rs b/src/macros.rs
new file mode 100644
index 0000000..472daa2
--- /dev/null
+++ b/src/macros.rs
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: 2023 David Runge <dvzrv@archlinux.org>
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+/// A convenient way to create a regular expression only once
+///
+/// A string literal as input is used to define the regular expression.
+/// With the help of OnceCell the regular expression is created only once.
+///
+/// ## Examples
+/// ```
+/// #[macro_use] extern crate alpm_types;
+///
+/// let re = regex_once!("^(foo)$");
+/// assert!(re.is_match("foo"));
+/// ```
+macro_rules! regex_once {
+    ($re:literal $(,)?) => {{
+        static RE: once_cell::sync::OnceCell<regex::Regex> = once_cell::sync::OnceCell::new();
+        RE.get_or_init(|| regex::Regex::new($re).unwrap())
+    }};
+}
+
+pub(crate) use regex_once;
-- 
GitLab