use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("empty {field}")]
Empty { field: &'static str },
#[error("invalid {field}: {value}")]
Invalid { field: &'static str, value: String },
#[error("parse error for {field}: {value}")]
ParseError { field: &'static str, value: String },
}
macro_rules! validated_string {
($(#[doc = $doc:expr])* $name:ident, $field:expr) => {
$(#[doc = $doc])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct $name(String);
impl TryFrom<String> for $name {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.is_empty() {
return Err(DomainError::Empty { field: $field });
}
Ok(Self(s))
}
}
impl From<$name> for String {
fn from(val: $name) -> String {
val.0
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl $name {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
validated_string!(
#[doc = "Session identifier (non-empty string)."]
SessionId,
"session_id"
);
validated_string!(
#[doc = "Tool name identifier (non-empty string)."]
ToolName,
"tool_name"
);
validated_string!(
#[doc = "GitHub repository owner (non-empty string)."]
GithubOwner,
"github_owner"
);
validated_string!(
#[doc = "GitHub repository name (non-empty string)."]
GithubRepo,
"github_repo"
);
#[cfg(test)]
impl SessionId {
pub fn from_str_unchecked(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "u64", into = "u64")]
pub struct IssueNumber(u64);
impl TryFrom<u64> for IssueNumber {
type Error = DomainError;
fn try_from(n: u64) -> Result<Self, Self::Error> {
if n == 0 {
return Err(DomainError::Invalid {
field: "issue_number",
value: "0".to_string(),
});
}
Ok(Self(n))
}
}
impl TryFrom<String> for IssueNumber {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
let n = s.parse::<u64>().map_err(|_| DomainError::ParseError {
field: "issue_number",
value: s,
})?;
Self::try_from(n)
}
}
impl From<IssueNumber> for u64 {
fn from(num: IssueNumber) -> u64 {
num.0
}
}
impl IssueNumber {
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl fmt::Display for IssueNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolPermission {
Allow,
Deny,
Ask,
}
impl fmt::Display for ToolPermission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Allow => write!(f, "allow"),
Self::Deny => write!(f, "deny"),
Self::Ask => write!(f, "ask"),
}
}
}
impl TryFrom<String> for ToolPermission {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"allow" => Ok(Self::Allow),
"deny" => Ok(Self::Deny),
"ask" => Ok(Self::Ask),
_ => Err(DomainError::Invalid {
field: "tool_permission",
value: s,
}),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
#[default]
Dev,
TL,
PM,
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dev => write!(f, "dev"),
Self::TL => write!(f, "tl"),
Self::PM => write!(f, "pm"),
}
}
}
impl TryFrom<String> for Role {
type Error = DomainError;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"dev" => Ok(Self::Dev),
"tl" => Ok(Self::TL),
"pm" => Ok(Self::PM),
_ => Err(DomainError::Invalid {
field: "role",
value: s,
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ItemState {
Open,
Closed,
Unknown,
}
impl fmt::Display for ItemState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => write!(f, "open"),
Self::Closed => write!(f, "closed"),
Self::Unknown => write!(f, "unknown"),
}
}
}
impl<'de> Deserialize<'de> for ItemState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"open" => Ok(Self::Open),
"closed" => Ok(Self::Closed),
_ => Ok(Self::Unknown),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReviewState {
Pending,
Approved,
ChangesRequested,
Dismissed,
Commented,
}
impl fmt::Display for ReviewState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ReviewState::Pending => "PENDING",
ReviewState::Approved => "APPROVED",
ReviewState::ChangesRequested => "CHANGES_REQUESTED",
ReviewState::Dismissed => "DISMISSED",
ReviewState::Commented => "COMMENTED",
};
write!(f, "{}", s)
}
}
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum PathError {
#[error("path must be absolute: {path}")]
NotAbsolute { path: PathBuf },
#[error("path does not exist: {path}")]
NotFound { path: PathBuf },
#[error("I/O error for path {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AbsolutePath(PathBuf);
impl TryFrom<PathBuf> for AbsolutePath {
type Error = PathError;
fn try_from(p: PathBuf) -> Result<Self, Self::Error> {
if !p.is_absolute() {
return Err(PathError::NotAbsolute { path: p });
}
Ok(Self(p))
}
}
impl From<AbsolutePath> for PathBuf {
fn from(p: AbsolutePath) -> PathBuf {
p.0
}
}
impl AbsolutePath {
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn into_path_buf(self) -> PathBuf {
self.0
}
}
impl AsRef<Path> for AbsolutePath {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl fmt::Display for AbsolutePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.display())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_id_validation() {
let id = SessionId::try_from("session-123".to_string()).unwrap();
assert_eq!(id.as_str(), "session-123");
let result = SessionId::try_from("".to_string());
assert!(matches!(result, Err(DomainError::Empty { .. })));
}
#[test]
fn test_tool_name_validation() {
let name = ToolName::try_from("Write".to_string()).unwrap();
assert_eq!(name.as_str(), "Write");
let result = ToolName::try_from("".to_string());
assert!(matches!(result, Err(DomainError::Empty { .. })));
}
#[test]
fn test_github_identifiers() {
let owner = GithubOwner::try_from("anthropics".to_string()).unwrap();
assert_eq!(owner.as_str(), "anthropics");
let repo = GithubRepo::try_from("claude-code".to_string()).unwrap();
assert_eq!(repo.as_str(), "claude-code");
let result = GithubOwner::try_from("".to_string());
assert!(matches!(result, Err(DomainError::Empty { .. })));
let result = GithubRepo::try_from("".to_string());
assert!(matches!(result, Err(DomainError::Empty { .. })));
}
#[test]
fn test_issue_number_validation() {
let num = IssueNumber::try_from(123u64).unwrap();
assert_eq!(num.as_u64(), 123);
let result = IssueNumber::try_from(0u64);
assert!(matches!(result, Err(DomainError::Invalid { .. })));
let num = IssueNumber::try_from("456".to_string()).unwrap();
assert_eq!(num.as_u64(), 456);
let result = IssueNumber::try_from("not-a-number".to_string());
assert!(matches!(result, Err(DomainError::ParseError { .. })));
}
#[test]
fn test_tool_permission() {
assert_eq!(
ToolPermission::try_from("allow".to_string()).unwrap(),
ToolPermission::Allow
);
assert_eq!(
ToolPermission::try_from("deny".to_string()).unwrap(),
ToolPermission::Deny
);
assert_eq!(
ToolPermission::try_from("ask".to_string()).unwrap(),
ToolPermission::Ask
);
assert_eq!(
ToolPermission::try_from("ALLOW".to_string()).unwrap(),
ToolPermission::Allow
);
let result = ToolPermission::try_from("invalid".to_string());
assert!(matches!(result, Err(DomainError::Invalid { .. })));
}
#[test]
fn test_role() {
assert_eq!(Role::try_from("dev".to_string()).unwrap(), Role::Dev);
assert_eq!(Role::try_from("tl".to_string()).unwrap(), Role::TL);
assert_eq!(Role::try_from("pm".to_string()).unwrap(), Role::PM);
assert_eq!(Role::try_from("DEV".to_string()).unwrap(), Role::Dev);
let result = Role::try_from("invalid".to_string());
assert!(matches!(result, Err(DomainError::Invalid { .. })));
assert_eq!(Role::default(), Role::Dev);
}
#[test]
fn test_item_state_case_insensitive() {
let s: ItemState = serde_json::from_str("\"open\"").unwrap();
assert_eq!(s, ItemState::Open);
let s: ItemState = serde_json::from_str("\"OPEN\"").unwrap();
assert_eq!(s, ItemState::Open);
let s: ItemState = serde_json::from_str("\"CLOSED\"").unwrap();
assert_eq!(s, ItemState::Closed);
let s: ItemState = serde_json::from_str("\"something_else\"").unwrap();
assert_eq!(s, ItemState::Unknown);
}
#[test]
fn test_serde_roundtrip() {
let id = SessionId::try_from("test-session".to_string()).unwrap();
let json = serde_json::to_string(&id).unwrap();
let deserialized: SessionId = serde_json::from_str(&json).unwrap();
assert_eq!(id, deserialized);
let num = IssueNumber::try_from(42u64).unwrap();
let json = serde_json::to_string(&num).unwrap();
let deserialized: IssueNumber = serde_json::from_str(&json).unwrap();
assert_eq!(num, deserialized);
let permission = ToolPermission::Allow;
let json = serde_json::to_string(&permission).unwrap();
let deserialized: ToolPermission = serde_json::from_str(&json).unwrap();
assert_eq!(permission, deserialized);
}
#[test]
fn test_absolute_path() {
let abs = AbsolutePath::try_from(PathBuf::from("/tmp/test")).unwrap();
assert_eq!(abs.as_path(), Path::new("/tmp/test"));
let result = AbsolutePath::try_from(PathBuf::from("relative/path"));
assert!(matches!(result, Err(PathError::NotAbsolute { .. })));
let _: &Path = abs.as_ref();
}
}