radicle/identity/
project.rs

1use std::{fmt, str::FromStr};
2
3use serde::{
4    de::{self, MapAccess, Visitor},
5    Deserialize, Serialize,
6};
7use thiserror::Error;
8
9use crate::crypto;
10use crate::identity::doc;
11use crate::identity::doc::Payload;
12use crate::storage::BranchName;
13
14pub use crypto::PublicKey;
15
16/// A project-related error.
17#[derive(Debug, Error)]
18pub enum ProjectError {
19    #[error("invalid name: {0}")]
20    Name(&'static str),
21    #[error("invalid description: {0}")]
22    Description(&'static str),
23    #[error("invalid default branch: {0}")]
24    DefaultBranch(&'static str),
25}
26
27/// A valid project name.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(try_from = "String", into = "String")]
30pub struct ProjectName(String);
31
32impl std::fmt::Display for ProjectName {
33    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
34        self.0.fmt(f)
35    }
36}
37
38impl From<ProjectName> for String {
39    fn from(value: ProjectName) -> Self {
40        value.0
41    }
42}
43
44impl ProjectName {
45    /// List of allowed special characters.
46    pub const ALLOWED_CHARS: &'static [char] = &['-', '_', '.'];
47
48    /// Return a string reference to the name.
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl TryFrom<&str> for ProjectName {
55    type Error = ProjectError;
56
57    fn try_from(s: &str) -> Result<Self, Self::Error> {
58        ProjectName::from_str(s)
59    }
60}
61
62impl TryFrom<String> for ProjectName {
63    type Error = ProjectError;
64
65    fn try_from(s: String) -> Result<Self, Self::Error> {
66        if s.is_empty() {
67            return Err(ProjectError::Name("name cannot be empty"));
68        } else if s.len() > doc::MAX_STRING_LENGTH {
69            return Err(ProjectError::Name("name cannot exceed 255 bytes"));
70        }
71        // Nb. We avoid characters that need to be quoted by shells, such as `$`,
72        // `!` etc., since repository names are used for naming folders during clone.
73        if !s
74            .chars()
75            .all(|c| c.is_alphanumeric() || Self::ALLOWED_CHARS.contains(&c))
76        {
77            return Err(ProjectError::Name(
78                "invalid repository name, only alphanumeric characters, '-', '_' and '.' are allowed",
79            ));
80        }
81        Ok(Self(s))
82    }
83}
84
85impl FromStr for ProjectName {
86    type Err = ProjectError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        Self::try_from(s.to_owned())
90    }
91}
92
93/// A "project" payload in an identity document.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Project {
97    /// Project name.
98    name: ProjectName,
99    /// Project description.
100    description: String,
101    /// Project default branch.
102    default_branch: BranchName,
103}
104
105impl<'de> Deserialize<'de> for Project {
106    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
107    where
108        D: serde::Deserializer<'de>,
109    {
110        #[derive(Deserialize)]
111        #[serde(field_identifier, rename_all = "camelCase")]
112        enum Field {
113            Name,
114            Description,
115            DefaultBranch,
116            /// A catch-all variant to allow for unknown fields
117            Unknown(#[allow(dead_code)] String),
118        }
119
120        struct ProjectVisitor;
121
122        impl<'de> Visitor<'de> for ProjectVisitor {
123            type Value = Project;
124
125            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
126                formatter.write_str("xyz.radicle.project")
127            }
128
129            fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
130            where
131                V: MapAccess<'de>,
132            {
133                let mut name = None;
134                let mut description = None;
135                let mut default_branch = None;
136
137                while let Some(key) = map.next_key()? {
138                    match key {
139                        Field::Name => {
140                            if name.is_some() {
141                                return Err(de::Error::duplicate_field("name"));
142                            }
143                            name = Some(map.next_value()?);
144                        }
145                        Field::Description => {
146                            if description.is_some() {
147                                return Err(de::Error::duplicate_field("description"));
148                            }
149                            description = Some(map.next_value()?);
150                        }
151                        Field::DefaultBranch => {
152                            if default_branch.is_some() {
153                                return Err(de::Error::duplicate_field("defaultBranch"));
154                            }
155                            default_branch = Some(map.next_value()?);
156                        }
157                        Field::Unknown(_) => continue,
158                    }
159                }
160                let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
161                let description =
162                    description.ok_or_else(|| de::Error::missing_field("description"))?;
163                let default_branch =
164                    default_branch.ok_or_else(|| de::Error::missing_field("defaultBranch"))?;
165                Project::new(name, description, default_branch).map_err(|errs| {
166                    de::Error::custom(
167                        errs.into_iter()
168                            .map(|err| err.to_string())
169                            .collect::<Vec<_>>()
170                            .join(", "),
171                    )
172                })
173            }
174        }
175        const FIELDS: &[&str] = &["name", "descrption", "defaultBranch"];
176        deserializer.deserialize_struct("Project", FIELDS, ProjectVisitor)
177    }
178}
179
180impl Project {
181    /// Create a new `Project` payload with the given values.
182    ///
183    /// These values are subject to validation and any errors are returned in a vector.
184    ///
185    /// # Validation Rules
186    ///
187    ///   * `name`'s length must not be empty and must not exceed 255.
188    ///   * `description`'s length must not exceed 255.
189    ///   * `default_branch`'s length must not be empty and must not exceed 255.
190    pub fn new(
191        name: ProjectName,
192        description: String,
193        default_branch: BranchName,
194    ) -> Result<Self, Vec<ProjectError>> {
195        let mut errs = Vec::new();
196
197        if description.len() > doc::MAX_STRING_LENGTH {
198            errs.push(ProjectError::Description(
199                "description cannot exceed 255 bytes",
200            ));
201        }
202
203        if default_branch.is_empty() {
204            errs.push(ProjectError::DefaultBranch(
205                "default branch cannot be empty",
206            ))
207        } else if default_branch.len() > doc::MAX_STRING_LENGTH {
208            errs.push(ProjectError::DefaultBranch(
209                "default branch cannot exceed 255 bytes",
210            ))
211        }
212
213        if errs.is_empty() {
214            Ok(Self {
215                name,
216                description,
217                default_branch,
218            })
219        } else {
220            Err(errs)
221        }
222    }
223
224    /// Update the `Project` payload with new values, if provided.
225    ///
226    /// When any of the values are set to `None` then the original
227    /// value will be used, and so the value will pass validation.
228    ///
229    /// Otherwise, the new value is used and will be subject to the
230    /// original validation rules (see [`Project::new`]).
231    pub fn update(
232        self,
233        name: impl Into<Option<ProjectName>>,
234        description: impl Into<Option<String>>,
235        default_branch: impl Into<Option<BranchName>>,
236    ) -> Result<Self, Vec<ProjectError>> {
237        let name = name.into().unwrap_or(self.name);
238        let description = description.into().unwrap_or(self.description);
239        let default_branch = default_branch.into().unwrap_or(self.default_branch);
240        Self::new(name, description, default_branch)
241    }
242
243    #[inline]
244    pub fn name(&self) -> &str {
245        self.name.as_str()
246    }
247
248    #[inline]
249    pub fn description(&self) -> &str {
250        &self.description
251    }
252
253    #[inline]
254    pub fn default_branch(&self) -> &BranchName {
255        &self.default_branch
256    }
257}
258
259impl From<Project> for Payload {
260    fn from(proj: Project) -> Self {
261        let value = serde_json::to_value(proj)
262            .expect("Payload::from: could not convert project into value");
263
264        Self::from(value)
265    }
266}
267
268#[cfg(test)]
269#[allow(clippy::unwrap_used)]
270mod test {
271    use super::*;
272    use crate::assert_matches;
273
274    #[test]
275    fn test_project_name() {
276        assert_matches!(serde_json::from_str::<ProjectName>("\"\""), Err(_));
277        assert_matches!(
278            serde_json::from_str::<ProjectName>("\"invalid name\""),
279            Err(_)
280        );
281        assert_matches!(
282            serde_json::from_str::<ProjectName>("\"invalid%name\""),
283            Err(_)
284        );
285        assert_eq!(
286            serde_json::from_str::<ProjectName>("\"valid-name\"").unwrap(),
287            ProjectName("valid-name".to_owned())
288        );
289    }
290}