buffrs/package/
name.rs

1// Copyright 2023 Helsing GmbH
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{fmt, ops::Deref, str::FromStr};
16
17use miette::IntoDiagnostic;
18use serde::{Deserialize, Serialize};
19
20/// A `buffrs` package name for parsing and type safety
21#[derive(Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug)]
22#[serde(try_from = "String", into = "String")]
23pub struct PackageName(String);
24
25/// Errors that can be generated parsing [`PackageName`][], see [`PackageName::new()`][].
26#[derive(thiserror::Error, Debug, PartialEq)]
27pub enum PackageNameError {
28    /// Empty package name.
29    #[error("package name must be at least one character long, but was empty")]
30    Empty,
31    /// Too long.
32    #[error("package names must be at most 128 characters long, but was {0:}")]
33    TooLong(usize),
34    /// Invalid start character.
35    #[error("package name must start with alphabetic character, but was {0:}")]
36    InvalidStart(char),
37    /// Invalid character.
38    #[error(
39        "package name must consist of only ASCII lowercase and dashes, but contains {0:} at position {1:}"
40    )]
41    InvalidCharacter(char, usize),
42}
43
44impl PackageName {
45    const MIN_LENGTH: usize = 1;
46    const MAX_LENGTH: usize = 128;
47
48    /// New package name from string.
49    pub fn new<S: Into<String>>(value: S) -> Result<Self, PackageNameError> {
50        let value = value.into();
51        Self::validate(&value)?;
52        Ok(Self(value))
53    }
54
55    /// New package name from an unchecked string.
56    pub(crate) fn unchecked<S: Into<String>>(value: S) -> Self {
57        Self(value.into())
58    }
59
60    /// Determine if this character is allowed at the start of a package name.
61    fn is_allowed_start(c: char) -> bool {
62        c.is_alphabetic()
63    }
64
65    /// Determine if this character is allowed anywhere in a package name.
66    fn is_allowed(c: char) -> bool {
67        let is_ascii_lowercase_alphanumeric =
68            |c: char| c.is_ascii_alphanumeric() && !c.is_ascii_uppercase();
69        match c {
70            '-' => true,
71            c if is_ascii_lowercase_alphanumeric(c) => true,
72            _ => false,
73        }
74    }
75
76    /// Validate a package name.
77    pub fn validate(name: impl AsRef<str>) -> Result<(), PackageNameError> {
78        let name = name.as_ref();
79
80        // validate length
81        if name.len() < Self::MIN_LENGTH {
82            return Err(PackageNameError::Empty);
83        }
84
85        if name.len() > Self::MAX_LENGTH {
86            return Err(PackageNameError::TooLong(name.len()));
87        }
88
89        // validate first character
90        match name.chars().next() {
91            Some(c) if Self::is_allowed_start(c) => {}
92            Some(c) => return Err(PackageNameError::InvalidStart(c)),
93            None => unreachable!(),
94        }
95
96        // validate all characters
97        let illegal = name
98            .chars()
99            .enumerate()
100            .find(|(_, c)| !Self::is_allowed(*c));
101
102        if let Some((index, c)) = illegal {
103            return Err(PackageNameError::InvalidCharacter(c, index));
104        }
105
106        Ok(())
107    }
108}
109
110impl TryFrom<String> for PackageName {
111    type Error = PackageNameError;
112
113    fn try_from(value: String) -> Result<Self, Self::Error> {
114        Self::new(value)
115    }
116}
117
118impl FromStr for PackageName {
119    type Err = miette::Report;
120
121    fn from_str(input: &str) -> Result<Self, Self::Err> {
122        Self::new(input).into_diagnostic()
123    }
124}
125
126impl From<PackageName> for String {
127    fn from(s: PackageName) -> Self {
128        s.to_string()
129    }
130}
131
132impl Deref for PackageName {
133    type Target = str;
134
135    fn deref(&self) -> &Self::Target {
136        &self.0
137    }
138}
139
140impl fmt::Display for PackageName {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        self.0.fmt(f)
143    }
144}
145
146#[cfg(test)]
147mod test {
148    use super::*;
149
150    #[test]
151    fn ascii_lowercase() {
152        assert_eq!(PackageName::new("abc"), Ok(PackageName("abc".into())));
153        assert_eq!(PackageName::new("abc"), Ok(PackageName("abc".into())));
154    }
155
156    #[test]
157    fn short() {
158        assert_eq!(PackageName::new("a"), Ok(PackageName("a".into())));
159        assert_eq!(PackageName::new("ab"), Ok(PackageName("ab".into())));
160    }
161
162    #[test]
163    fn long() {
164        assert_eq!(
165            PackageName::new("a".repeat(128)),
166            Ok(PackageName("a".repeat(128)))
167        );
168
169        assert_eq!(
170            PackageName::new("a".repeat(129)),
171            Err(PackageNameError::TooLong(129))
172        );
173    }
174
175    #[test]
176    fn empty() {
177        assert_eq!(PackageName::new(""), Err(PackageNameError::Empty));
178    }
179
180    #[test]
181    fn numeric_start() {
182        assert_eq!(
183            PackageName::new("4abc"),
184            Err(PackageNameError::InvalidStart('4'))
185        );
186    }
187
188    #[test]
189    fn snake_case() {
190        assert_eq!(
191            PackageName::new("with_underscore"),
192            Err(PackageNameError::InvalidCharacter('_', 4))
193        );
194    }
195}