Skip to main content

ferrify_domain/
types.rs

1//! Strong domain types that replace meaning-bearing raw primitives.
2//!
3//! These value objects move critical invariants to construction time. Instead
4//! of asking every caller to remember that repository paths must stay relative
5//! or that mode names must be lowercase slugs, Ferrify encodes those rules in
6//! dedicated types and rejects invalid values at the boundary.
7
8use std::{
9    borrow::Borrow,
10    fmt,
11    path::{Component, Path},
12    str::FromStr,
13};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18/// Validation failures for domain value objects.
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum DomainTypeError {
21    /// The provided repository path was empty.
22    #[error("repository path must not be empty")]
23    EmptyRepoPath,
24    /// The provided repository path was absolute.
25    #[error("repository path `{value}` must remain relative to the workspace")]
26    AbsoluteRepoPath {
27        /// The rejected path value.
28        value: String,
29    },
30    /// The provided repository path attempted to escape the workspace root.
31    #[error("repository path `{value}` must not contain parent-directory traversal")]
32    TraversingRepoPath {
33        /// The rejected path value.
34        value: String,
35    },
36    /// The provided slug was empty.
37    #[error("{kind} must not be empty")]
38    EmptySlug {
39        /// The semantic kind of slug that failed validation.
40        kind: &'static str,
41    },
42    /// The provided slug contained unsupported characters.
43    #[error("{kind} `{value}` may contain only lowercase ascii letters, digits, `-`, and `_`")]
44    InvalidSlug {
45        /// The semantic kind of slug that failed validation.
46        kind: &'static str,
47        /// The rejected slug value.
48        value: String,
49    },
50}
51
52/// A repository-relative path that cannot escape the workspace root.
53#[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    /// Creates a validated repository-relative path.
59    ///
60    /// `RepoPath` rejects empty values, absolute paths, and any path that tries
61    /// to escape the workspace with `..`.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`DomainTypeError`] when the value is empty, absolute, or
66    /// contains parent-directory traversal.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use agent_domain::RepoPath;
72    ///
73    /// # fn main() -> Result<(), agent_domain::DomainTypeError> {
74    /// let path = RepoPath::new("crates/agent-cli/src/main.rs")?;
75    /// assert_eq!(path.as_str(), "crates/agent-cli/src/main.rs");
76    /// # Ok(())
77    /// # }
78    /// ```
79    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    /// Returns the path as a string slice.
101    #[must_use]
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105
106    /// Consumes the value object and returns the inner string.
107    #[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/// A stable slug identifying an execution mode.
162#[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    /// Creates a validated mode slug.
168    ///
169    /// Mode slugs are stable identifiers used in policy files and orchestration
170    /// logic. They are intentionally restricted to lowercase ASCII letters,
171    /// digits, `-`, and `_`.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`DomainTypeError`] when the value is empty or contains
176    /// unsupported characters.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use agent_domain::ModeSlug;
182    ///
183    /// # fn main() -> Result<(), agent_domain::DomainTypeError> {
184    /// let slug = ModeSlug::new("implementer")?;
185    /// assert_eq!(slug.as_str(), "implementer");
186    /// # Ok(())
187    /// # }
188    /// ```
189    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    /// Returns the slug as a string slice.
196    #[must_use]
197    pub fn as_str(&self) -> &str {
198        &self.0
199    }
200
201    /// Consumes the slug and returns the inner string.
202    #[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/// A stable slug identifying an approval profile.
257#[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    /// Creates a validated approval-profile slug.
263    ///
264    /// Approval-profile slugs identify policy bundles under
265    /// `.agent/approvals/*.yaml`.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`DomainTypeError`] when the value is empty or contains
270    /// unsupported characters.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use agent_domain::ApprovalProfileSlug;
276    ///
277    /// # fn main() -> Result<(), agent_domain::DomainTypeError> {
278    /// let slug = ApprovalProfileSlug::new("default")?;
279    /// assert_eq!(slug.as_str(), "default");
280    /// # Ok(())
281    /// # }
282    /// ```
283    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    /// Returns the slug as a string slice.
290    #[must_use]
291    pub fn as_str(&self) -> &str {
292        &self.0
293    }
294
295    /// Consumes the slug and returns the inner string.
296    #[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}