radicle/identity/
project.rs1use 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#[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#[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 pub const ALLOWED_CHARS: &'static [char] = &['-', '_', '.'];
47
48 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Project {
97 name: ProjectName,
99 description: String,
101 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 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 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 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}