1use std::{
9 borrow::Borrow,
10 fmt,
11 path::{Component, Path},
12 str::FromStr,
13};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum DomainTypeError {
21 #[error("repository path must not be empty")]
23 EmptyRepoPath,
24 #[error("repository path `{value}` must remain relative to the workspace")]
26 AbsoluteRepoPath {
27 value: String,
29 },
30 #[error("repository path `{value}` must not contain parent-directory traversal")]
32 TraversingRepoPath {
33 value: String,
35 },
36 #[error("{kind} must not be empty")]
38 EmptySlug {
39 kind: &'static str,
41 },
42 #[error("{kind} `{value}` may contain only lowercase ascii letters, digits, `-`, and `_`")]
44 InvalidSlug {
45 kind: &'static str,
47 value: String,
49 },
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(try_from = "String", into = "String")]
55pub struct RepoPath(String);
56
57impl RepoPath {
58 pub fn new(value: impl Into<String>) -> Result<Self, DomainTypeError> {
80 let value = value.into();
81 if value.is_empty() {
82 return Err(DomainTypeError::EmptyRepoPath);
83 }
84
85 let path = Path::new(&value);
86 if path.is_absolute() {
87 return Err(DomainTypeError::AbsoluteRepoPath { value });
88 }
89
90 if path
91 .components()
92 .any(|component| matches!(component, Component::ParentDir))
93 {
94 return Err(DomainTypeError::TraversingRepoPath { value });
95 }
96
97 Ok(Self(value))
98 }
99
100 #[must_use]
102 pub fn as_str(&self) -> &str {
103 &self.0
104 }
105
106 #[must_use]
108 pub fn into_inner(self) -> String {
109 self.0
110 }
111}
112
113impl AsRef<str> for RepoPath {
114 fn as_ref(&self) -> &str {
115 self.as_str()
116 }
117}
118
119impl Borrow<str> for RepoPath {
120 fn borrow(&self) -> &str {
121 self.as_str()
122 }
123}
124
125impl fmt::Display for RepoPath {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(self.as_str())
128 }
129}
130
131impl FromStr for RepoPath {
132 type Err = DomainTypeError;
133
134 fn from_str(value: &str) -> Result<Self, Self::Err> {
135 Self::new(value)
136 }
137}
138
139impl TryFrom<&str> for RepoPath {
140 type Error = DomainTypeError;
141
142 fn try_from(value: &str) -> Result<Self, Self::Error> {
143 Self::new(value)
144 }
145}
146
147impl TryFrom<String> for RepoPath {
148 type Error = DomainTypeError;
149
150 fn try_from(value: String) -> Result<Self, Self::Error> {
151 Self::new(value)
152 }
153}
154
155impl From<RepoPath> for String {
156 fn from(value: RepoPath) -> Self {
157 value.into_inner()
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
163#[serde(try_from = "String", into = "String")]
164pub struct ModeSlug(String);
165
166impl ModeSlug {
167 pub fn new(value: impl Into<String>) -> Result<Self, DomainTypeError> {
190 let value = value.into();
191 validate_slug("mode slug", &value)?;
192 Ok(Self(value))
193 }
194
195 #[must_use]
197 pub fn as_str(&self) -> &str {
198 &self.0
199 }
200
201 #[must_use]
203 pub fn into_inner(self) -> String {
204 self.0
205 }
206}
207
208impl AsRef<str> for ModeSlug {
209 fn as_ref(&self) -> &str {
210 self.as_str()
211 }
212}
213
214impl Borrow<str> for ModeSlug {
215 fn borrow(&self) -> &str {
216 self.as_str()
217 }
218}
219
220impl fmt::Display for ModeSlug {
221 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222 formatter.write_str(self.as_str())
223 }
224}
225
226impl FromStr for ModeSlug {
227 type Err = DomainTypeError;
228
229 fn from_str(value: &str) -> Result<Self, Self::Err> {
230 Self::new(value)
231 }
232}
233
234impl TryFrom<&str> for ModeSlug {
235 type Error = DomainTypeError;
236
237 fn try_from(value: &str) -> Result<Self, Self::Error> {
238 Self::new(value)
239 }
240}
241
242impl TryFrom<String> for ModeSlug {
243 type Error = DomainTypeError;
244
245 fn try_from(value: String) -> Result<Self, Self::Error> {
246 Self::new(value)
247 }
248}
249
250impl From<ModeSlug> for String {
251 fn from(value: ModeSlug) -> Self {
252 value.into_inner()
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
258#[serde(try_from = "String", into = "String")]
259pub struct ApprovalProfileSlug(String);
260
261impl ApprovalProfileSlug {
262 pub fn new(value: impl Into<String>) -> Result<Self, DomainTypeError> {
284 let value = value.into();
285 validate_slug("approval profile slug", &value)?;
286 Ok(Self(value))
287 }
288
289 #[must_use]
291 pub fn as_str(&self) -> &str {
292 &self.0
293 }
294
295 #[must_use]
297 pub fn into_inner(self) -> String {
298 self.0
299 }
300}
301
302impl AsRef<str> for ApprovalProfileSlug {
303 fn as_ref(&self) -> &str {
304 self.as_str()
305 }
306}
307
308impl Borrow<str> for ApprovalProfileSlug {
309 fn borrow(&self) -> &str {
310 self.as_str()
311 }
312}
313
314impl fmt::Display for ApprovalProfileSlug {
315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316 formatter.write_str(self.as_str())
317 }
318}
319
320impl FromStr for ApprovalProfileSlug {
321 type Err = DomainTypeError;
322
323 fn from_str(value: &str) -> Result<Self, Self::Err> {
324 Self::new(value)
325 }
326}
327
328impl TryFrom<&str> for ApprovalProfileSlug {
329 type Error = DomainTypeError;
330
331 fn try_from(value: &str) -> Result<Self, Self::Error> {
332 Self::new(value)
333 }
334}
335
336impl TryFrom<String> for ApprovalProfileSlug {
337 type Error = DomainTypeError;
338
339 fn try_from(value: String) -> Result<Self, Self::Error> {
340 Self::new(value)
341 }
342}
343
344impl From<ApprovalProfileSlug> for String {
345 fn from(value: ApprovalProfileSlug) -> Self {
346 value.into_inner()
347 }
348}
349
350fn validate_slug(kind: &'static str, value: &str) -> Result<(), DomainTypeError> {
351 if value.is_empty() {
352 return Err(DomainTypeError::EmptySlug { kind });
353 }
354
355 if value.chars().all(|character| {
356 character.is_ascii_lowercase()
357 || character.is_ascii_digit()
358 || matches!(character, '-' | '_')
359 }) {
360 Ok(())
361 } else {
362 Err(DomainTypeError::InvalidSlug {
363 kind,
364 value: value.to_owned(),
365 })
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use serde_json::{from_str, to_string};
372
373 use super::{ApprovalProfileSlug, DomainTypeError, ModeSlug, RepoPath};
374
375 #[test]
376 fn repo_path_rejects_workspace_escape() {
377 assert_eq!(
378 RepoPath::new("../secrets.txt"),
379 Err(DomainTypeError::TraversingRepoPath {
380 value: "../secrets.txt".to_owned(),
381 })
382 );
383 }
384
385 #[test]
386 fn mode_slug_rejects_uppercase_characters() {
387 assert_eq!(
388 ModeSlug::new("Architect"),
389 Err(DomainTypeError::InvalidSlug {
390 kind: "mode slug",
391 value: "Architect".to_owned(),
392 })
393 );
394 }
395
396 #[test]
397 fn approval_profile_slug_roundtrips_through_serde() {
398 let slug = ApprovalProfileSlug::new("default_profile")
399 .unwrap_or_else(|error| panic!("default_profile should be valid: {error}"));
400 let encoded = to_string(&slug)
401 .unwrap_or_else(|error| panic!("approval profile slug should serialize: {error}"));
402 let decoded: ApprovalProfileSlug = from_str(&encoded)
403 .unwrap_or_else(|error| panic!("approval profile slug should deserialize: {error}"));
404
405 assert_eq!(decoded, slug);
406 }
407}