1use serde::{Deserialize, Serialize};
9use std::fmt;
10
11#[derive(Debug, thiserror::Error)]
17pub enum DomainError {
18 #[error("empty {field}")]
20 Empty { field: &'static str },
21
22 #[error("invalid {field}: {value}")]
24 Invalid { field: &'static str, value: String },
25
26 #[error("parse error for {field}: {value}")]
28 ParseError { field: &'static str, value: String },
29}
30
31macro_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 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
84validated_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 pub fn from_str_unchecked(s: &str) -> Self {
113 Self(s.to_string())
114 }
115}
116
117#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "lowercase")]
178pub enum ToolPermission {
179 Allow,
181 Deny,
183 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "lowercase")]
220pub enum Role {
221 #[default]
223 Dev,
224 TL,
226 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
265pub enum ItemState {
266 Open,
268 Closed,
270 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
301pub enum ReviewState {
302 Pending,
304 Approved,
306 ChangesRequested,
308 Dismissed,
310 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
327use std::path::{Path, PathBuf};
332
333#[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#[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 pub fn as_path(&self) -> &Path {
373 &self.0
374 }
375
376 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#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_session_id_validation() {
404 let id = SessionId::try_from("session-123".to_string()).unwrap();
406 assert_eq!(id.as_str(), "session-123");
407
408 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 let name = ToolName::try_from("Write".to_string()).unwrap();
417 assert_eq!(name.as_str(), "Write");
418
419 let result = ToolName::try_from("".to_string());
421 assert!(matches!(result, Err(DomainError::Empty { .. })));
422 }
423
424 #[test]
425 fn test_github_identifiers() {
426 let owner = GithubOwner::try_from("anthropics".to_string()).unwrap();
428 assert_eq!(owner.as_str(), "anthropics");
429
430 let repo = GithubRepo::try_from("claude-code".to_string()).unwrap();
432 assert_eq!(repo.as_str(), "claude-code");
433
434 let result = GithubOwner::try_from("".to_string());
436 assert!(matches!(result, Err(DomainError::Empty { .. })));
437
438 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 let num = IssueNumber::try_from(123u64).unwrap();
447 assert_eq!(num.as_u64(), 123);
448
449 let result = IssueNumber::try_from(0u64);
451 assert!(matches!(result, Err(DomainError::Invalid { .. })));
452
453 let num = IssueNumber::try_from("456".to_string()).unwrap();
455 assert_eq!(num.as_u64(), 456);
456
457 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 assert_eq!(
479 ToolPermission::try_from("ALLOW".to_string()).unwrap(),
480 ToolPermission::Allow
481 );
482
483 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 assert_eq!(Role::try_from("DEV".to_string()).unwrap(), Role::Dev);
496
497 let result = Role::try_from("invalid".to_string());
499 assert!(matches!(result, Err(DomainError::Invalid { .. })));
500
501 assert_eq!(Role::default(), Role::Dev);
503 }
504
505 #[test]
506 fn test_item_state_case_insensitive() {
507 let s: ItemState = serde_json::from_str("\"open\"").unwrap();
509 assert_eq!(s, ItemState::Open);
510
511 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 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 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 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 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 let abs = AbsolutePath::try_from(PathBuf::from("/tmp/test")).unwrap();
548 assert_eq!(abs.as_path(), Path::new("/tmp/test"));
549
550 let result = AbsolutePath::try_from(PathBuf::from("relative/path"));
552 assert!(matches!(result, Err(PathError::NotAbsolute { .. })));
553
554 let _: &Path = abs.as_ref();
556 }
557}