Skip to main content

exomonad_core/
domain.rs

1//! Domain types with parse-at-edge validation.
2//!
3//! This module provides newtype wrappers for string domain concepts
4//! with validation at construction time. All parsing happens at the
5//! boundary (deserialization, construction) to ensure invalid values
6//! never enter the system.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11// ============================================================================
12// Error Types
13// ============================================================================
14
15/// Domain validation errors.
16#[derive(Debug, thiserror::Error)]
17pub enum DomainError {
18    /// Empty field value.
19    #[error("empty {field}")]
20    Empty { field: &'static str },
21
22    /// Invalid field value.
23    #[error("invalid {field}: {value}")]
24    Invalid { field: &'static str, value: String },
25
26    /// Parse error for numeric types.
27    #[error("parse error for {field}: {value}")]
28    ParseError { field: &'static str, value: String },
29}
30
31// ============================================================================
32// Validated String Macro
33// ============================================================================
34
35/// Generate a validated non-empty string newtype with standard impls.
36///
37/// Provides: TryFrom<String>, From<&str>, From<T> for String, Display,
38/// as_str(), and serde support via try_from/into.
39macro_rules! validated_string {
40    ($(#[doc = $doc:expr])* $name:ident, $field:expr) => {
41        $(#[doc = $doc])*
42        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43        #[serde(try_from = "String", into = "String")]
44        pub struct $name(String);
45
46        impl TryFrom<String> for $name {
47            type Error = DomainError;
48
49            fn try_from(s: String) -> Result<Self, Self::Error> {
50                if s.is_empty() {
51                    return Err(DomainError::Empty { field: $field });
52                }
53                Ok(Self(s))
54            }
55        }
56
57        impl From<$name> for String {
58            fn from(val: $name) -> String {
59                val.0
60            }
61        }
62
63        impl From<&str> for $name {
64            fn from(s: &str) -> Self {
65                Self(s.to_string())
66            }
67        }
68
69        impl $name {
70            /// Get the value as a string slice.
71            pub fn as_str(&self) -> &str {
72                &self.0
73            }
74        }
75
76        impl fmt::Display for $name {
77            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78                write!(f, "{}", self.0)
79            }
80        }
81    };
82}
83
84// ============================================================================
85// String Newtypes
86// ============================================================================
87
88validated_string!(
89    #[doc = "Session identifier (non-empty string)."]
90    SessionId,
91    "session_id"
92);
93validated_string!(
94    #[doc = "Tool name identifier (non-empty string)."]
95    ToolName,
96    "tool_name"
97);
98validated_string!(
99    #[doc = "GitHub repository owner (non-empty string)."]
100    GithubOwner,
101    "github_owner"
102);
103validated_string!(
104    #[doc = "GitHub repository name (non-empty string)."]
105    GithubRepo,
106    "github_repo"
107);
108
109#[cfg(test)]
110impl SessionId {
111    /// Create from &str (unchecked, for tests).
112    pub fn from_str_unchecked(s: &str) -> Self {
113        Self(s.to_string())
114    }
115}
116
117// ============================================================================
118// GitHub Issue Number
119// ============================================================================
120
121/// GitHub issue number (positive integer).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123#[serde(try_from = "u64", into = "u64")]
124pub struct IssueNumber(u64);
125
126impl TryFrom<u64> for IssueNumber {
127    type Error = DomainError;
128
129    fn try_from(n: u64) -> Result<Self, Self::Error> {
130        if n == 0 {
131            return Err(DomainError::Invalid {
132                field: "issue_number",
133                value: "0".to_string(),
134            });
135        }
136        Ok(Self(n))
137    }
138}
139
140impl TryFrom<String> for IssueNumber {
141    type Error = DomainError;
142
143    fn try_from(s: String) -> Result<Self, Self::Error> {
144        let n = s.parse::<u64>().map_err(|_| DomainError::ParseError {
145            field: "issue_number",
146            value: s,
147        })?;
148        Self::try_from(n)
149    }
150}
151
152impl From<IssueNumber> for u64 {
153    fn from(num: IssueNumber) -> u64 {
154        num.0
155    }
156}
157
158impl IssueNumber {
159    /// Get the issue number as a u64.
160    pub fn as_u64(&self) -> u64 {
161        self.0
162    }
163}
164
165impl fmt::Display for IssueNumber {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "{}", self.0)
168    }
169}
170
171// ============================================================================
172// Tool Permission
173// ============================================================================
174
175/// Tool execution permission for PreToolUse hooks.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "lowercase")]
178pub enum ToolPermission {
179    /// Allow the tool to execute.
180    Allow,
181    /// Deny tool execution.
182    Deny,
183    /// Ask the user for permission.
184    Ask,
185}
186
187impl fmt::Display for ToolPermission {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::Allow => write!(f, "allow"),
191            Self::Deny => write!(f, "deny"),
192            Self::Ask => write!(f, "ask"),
193        }
194    }
195}
196
197impl TryFrom<String> for ToolPermission {
198    type Error = DomainError;
199
200    fn try_from(s: String) -> Result<Self, Self::Error> {
201        match s.to_lowercase().as_str() {
202            "allow" => Ok(Self::Allow),
203            "deny" => Ok(Self::Deny),
204            "ask" => Ok(Self::Ask),
205            _ => Err(DomainError::Invalid {
206                field: "tool_permission",
207                value: s,
208            }),
209        }
210    }
211}
212
213// ============================================================================
214// Role
215// ============================================================================
216
217/// Agent role (dev, tl, pm).
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "lowercase")]
220pub enum Role {
221    /// Developer role.
222    #[default]
223    Dev,
224    /// Tech lead role.
225    TL,
226    /// Product manager role.
227    PM,
228}
229
230impl fmt::Display for Role {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::Dev => write!(f, "dev"),
234            Self::TL => write!(f, "tl"),
235            Self::PM => write!(f, "pm"),
236        }
237    }
238}
239
240impl TryFrom<String> for Role {
241    type Error = DomainError;
242
243    fn try_from(s: String) -> Result<Self, Self::Error> {
244        match s.to_lowercase().as_str() {
245            "dev" => Ok(Self::Dev),
246            "tl" => Ok(Self::TL),
247            "pm" => Ok(Self::PM),
248            _ => Err(DomainError::Invalid {
249                field: "role",
250                value: s,
251            }),
252        }
253    }
254}
255
256// ============================================================================
257// GitHub States
258// ============================================================================
259
260/// State of an item (Issue/PR) - Open, Closed, or Unknown.
261///
262/// Deserializes case-insensitively to handle both lowercase API responses
263/// and SCREAMING_SNAKE_CASE from GitHub's GraphQL API.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
265pub enum ItemState {
266    /// Item is currently open.
267    Open,
268    /// Item is closed or merged.
269    Closed,
270    /// State could not be determined.
271    Unknown,
272}
273
274impl fmt::Display for ItemState {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Open => write!(f, "open"),
278            Self::Closed => write!(f, "closed"),
279            Self::Unknown => write!(f, "unknown"),
280        }
281    }
282}
283
284impl<'de> Deserialize<'de> for ItemState {
285    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
286    where
287        D: serde::Deserializer<'de>,
288    {
289        let s = String::deserialize(deserializer)?;
290        match s.to_lowercase().as_str() {
291            "open" => Ok(Self::Open),
292            "closed" => Ok(Self::Closed),
293            _ => Ok(Self::Unknown),
294        }
295    }
296}
297
298/// State of a GitHub Review.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
301pub enum ReviewState {
302    /// Review is pending.
303    Pending,
304    /// Review is approved.
305    Approved,
306    /// Changes were requested.
307    ChangesRequested,
308    /// Review was dismissed.
309    Dismissed,
310    /// Comment was left without explicit approval/request.
311    Commented,
312}
313
314impl fmt::Display for ReviewState {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        let s = match self {
317            ReviewState::Pending => "PENDING",
318            ReviewState::Approved => "APPROVED",
319            ReviewState::ChangesRequested => "CHANGES_REQUESTED",
320            ReviewState::Dismissed => "DISMISSED",
321            ReviewState::Commented => "COMMENTED",
322        };
323        write!(f, "{}", s)
324    }
325}
326
327// ============================================================================
328// Path Types
329// ============================================================================
330
331use std::path::{Path, PathBuf};
332
333/// Path validation errors.
334#[derive(Debug, thiserror::Error)]
335pub enum PathError {
336    #[error("path must be absolute: {path}")]
337    NotAbsolute { path: PathBuf },
338
339    #[error("path does not exist: {path}")]
340    NotFound { path: PathBuf },
341
342    #[error("I/O error for path {path}: {source}")]
343    Io {
344        path: PathBuf,
345        source: std::io::Error,
346    },
347}
348
349/// Absolute path (must be absolute).
350#[derive(Debug, Clone, PartialEq, Eq, Hash)]
351pub struct AbsolutePath(PathBuf);
352
353impl TryFrom<PathBuf> for AbsolutePath {
354    type Error = PathError;
355
356    fn try_from(p: PathBuf) -> Result<Self, Self::Error> {
357        if !p.is_absolute() {
358            return Err(PathError::NotAbsolute { path: p });
359        }
360        Ok(Self(p))
361    }
362}
363
364impl From<AbsolutePath> for PathBuf {
365    fn from(p: AbsolutePath) -> PathBuf {
366        p.0
367    }
368}
369
370impl AbsolutePath {
371    /// Get the path as a Path reference.
372    pub fn as_path(&self) -> &Path {
373        &self.0
374    }
375
376    /// Convert to PathBuf (consumes self).
377    pub fn into_path_buf(self) -> PathBuf {
378        self.0
379    }
380}
381
382impl AsRef<Path> for AbsolutePath {
383    fn as_ref(&self) -> &Path {
384        &self.0
385    }
386}
387
388impl fmt::Display for AbsolutePath {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        write!(f, "{}", self.0.display())
391    }
392}
393
394// ============================================================================
395// Tests
396// ============================================================================
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_session_id_validation() {
404        // Valid
405        let id = SessionId::try_from("session-123".to_string()).unwrap();
406        assert_eq!(id.as_str(), "session-123");
407
408        // Empty
409        let result = SessionId::try_from("".to_string());
410        assert!(matches!(result, Err(DomainError::Empty { .. })));
411    }
412
413    #[test]
414    fn test_tool_name_validation() {
415        // Valid
416        let name = ToolName::try_from("Write".to_string()).unwrap();
417        assert_eq!(name.as_str(), "Write");
418
419        // Empty
420        let result = ToolName::try_from("".to_string());
421        assert!(matches!(result, Err(DomainError::Empty { .. })));
422    }
423
424    #[test]
425    fn test_github_identifiers() {
426        // Valid owner
427        let owner = GithubOwner::try_from("anthropics".to_string()).unwrap();
428        assert_eq!(owner.as_str(), "anthropics");
429
430        // Valid repo
431        let repo = GithubRepo::try_from("claude-code".to_string()).unwrap();
432        assert_eq!(repo.as_str(), "claude-code");
433
434        // Empty owner
435        let result = GithubOwner::try_from("".to_string());
436        assert!(matches!(result, Err(DomainError::Empty { .. })));
437
438        // Empty repo
439        let result = GithubRepo::try_from("".to_string());
440        assert!(matches!(result, Err(DomainError::Empty { .. })));
441    }
442
443    #[test]
444    fn test_issue_number_validation() {
445        // Valid
446        let num = IssueNumber::try_from(123u64).unwrap();
447        assert_eq!(num.as_u64(), 123);
448
449        // Zero
450        let result = IssueNumber::try_from(0u64);
451        assert!(matches!(result, Err(DomainError::Invalid { .. })));
452
453        // From string
454        let num = IssueNumber::try_from("456".to_string()).unwrap();
455        assert_eq!(num.as_u64(), 456);
456
457        // Invalid string
458        let result = IssueNumber::try_from("not-a-number".to_string());
459        assert!(matches!(result, Err(DomainError::ParseError { .. })));
460    }
461
462    #[test]
463    fn test_tool_permission() {
464        assert_eq!(
465            ToolPermission::try_from("allow".to_string()).unwrap(),
466            ToolPermission::Allow
467        );
468        assert_eq!(
469            ToolPermission::try_from("deny".to_string()).unwrap(),
470            ToolPermission::Deny
471        );
472        assert_eq!(
473            ToolPermission::try_from("ask".to_string()).unwrap(),
474            ToolPermission::Ask
475        );
476
477        // Case insensitive
478        assert_eq!(
479            ToolPermission::try_from("ALLOW".to_string()).unwrap(),
480            ToolPermission::Allow
481        );
482
483        // Invalid
484        let result = ToolPermission::try_from("invalid".to_string());
485        assert!(matches!(result, Err(DomainError::Invalid { .. })));
486    }
487
488    #[test]
489    fn test_role() {
490        assert_eq!(Role::try_from("dev".to_string()).unwrap(), Role::Dev);
491        assert_eq!(Role::try_from("tl".to_string()).unwrap(), Role::TL);
492        assert_eq!(Role::try_from("pm".to_string()).unwrap(), Role::PM);
493
494        // Case insensitive
495        assert_eq!(Role::try_from("DEV".to_string()).unwrap(), Role::Dev);
496
497        // Invalid
498        let result = Role::try_from("invalid".to_string());
499        assert!(matches!(result, Err(DomainError::Invalid { .. })));
500
501        // Default
502        assert_eq!(Role::default(), Role::Dev);
503    }
504
505    #[test]
506    fn test_item_state_case_insensitive() {
507        // lowercase
508        let s: ItemState = serde_json::from_str("\"open\"").unwrap();
509        assert_eq!(s, ItemState::Open);
510
511        // UPPERCASE
512        let s: ItemState = serde_json::from_str("\"OPEN\"").unwrap();
513        assert_eq!(s, ItemState::Open);
514
515        let s: ItemState = serde_json::from_str("\"CLOSED\"").unwrap();
516        assert_eq!(s, ItemState::Closed);
517
518        // Unknown
519        let s: ItemState = serde_json::from_str("\"something_else\"").unwrap();
520        assert_eq!(s, ItemState::Unknown);
521    }
522
523    #[test]
524    fn test_serde_roundtrip() {
525        // SessionId
526        let id = SessionId::try_from("test-session".to_string()).unwrap();
527        let json = serde_json::to_string(&id).unwrap();
528        let deserialized: SessionId = serde_json::from_str(&json).unwrap();
529        assert_eq!(id, deserialized);
530
531        // IssueNumber
532        let num = IssueNumber::try_from(42u64).unwrap();
533        let json = serde_json::to_string(&num).unwrap();
534        let deserialized: IssueNumber = serde_json::from_str(&json).unwrap();
535        assert_eq!(num, deserialized);
536
537        // ToolPermission
538        let permission = ToolPermission::Allow;
539        let json = serde_json::to_string(&permission).unwrap();
540        let deserialized: ToolPermission = serde_json::from_str(&json).unwrap();
541        assert_eq!(permission, deserialized);
542    }
543
544    #[test]
545    fn test_absolute_path() {
546        // Valid absolute path
547        let abs = AbsolutePath::try_from(PathBuf::from("/tmp/test")).unwrap();
548        assert_eq!(abs.as_path(), Path::new("/tmp/test"));
549
550        // Relative path should fail
551        let result = AbsolutePath::try_from(PathBuf::from("relative/path"));
552        assert!(matches!(result, Err(PathError::NotAbsolute { .. })));
553
554        // Test as_ref
555        let _: &Path = abs.as_ref();
556    }
557}