Skip to main content

agentics_domain/models/
github.rs

1//! GitHub-specific typed scalar values shared across creation flows.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use nutype::nutype;
7use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10/// User-facing validation message for GitHub pull request numbers.
11pub const GITHUB_PULL_REQUEST_NUMBER_ERROR_MESSAGE: &str = "pr_number must be a positive integer";
12
13/// Validation failure for [`GithubPullRequestNumber`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct GithubPullRequestNumberError;
16
17impl fmt::Display for GithubPullRequestNumberError {
18    /// Formats the validation error as the public contract message.
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        f.write_str(GITHUB_PULL_REQUEST_NUMBER_ERROR_MESSAGE)
21    }
22}
23
24impl std::error::Error for GithubPullRequestNumberError {}
25
26#[nutype(
27    sanitize(trim),
28    validate(
29        with = validate_github_pull_request_number,
30        error = GithubPullRequestNumberError
31    ),
32    derive(
33        Debug,
34        Clone,
35        PartialEq,
36        Eq,
37        PartialOrd,
38        Ord,
39        Hash,
40        AsRef,
41        Deref,
42        Display,
43        FromStr,
44        TryFrom,
45    ),
46)]
47/// Validated GitHub pull request number.
48pub struct GithubPullRequestNumber(String);
49
50impl GithubPullRequestNumber {
51    /// Borrow the canonical decimal pull request number.
52    pub fn as_str(&self) -> &str {
53        self.as_ref()
54    }
55
56    /// Return the numeric pull request number for database/API boundaries.
57    pub fn as_i32(&self) -> Result<i32, GithubPullRequestNumberError> {
58        self.as_str()
59            .parse::<i32>()
60            .map_err(|_| GithubPullRequestNumberError)
61    }
62}
63
64impl Serialize for GithubPullRequestNumber {
65    /// Serializes the validated pull request number as a JSON number.
66    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
67    where
68        S: Serializer,
69    {
70        let value = self.as_i32().map_err(serde::ser::Error::custom)?;
71        serializer.serialize_i32(value)
72    }
73}
74
75impl<'de> Deserialize<'de> for GithubPullRequestNumber {
76    /// Deserializes a JSON number into the validated pull request number type.
77    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
78    where
79        D: Deserializer<'de>,
80    {
81        let value = i64::deserialize(deserializer)?;
82        let value = i32::try_from(value).map_err(serde::de::Error::custom)?;
83        Self::try_new(value.to_string()).map_err(serde::de::Error::custom)
84    }
85}
86
87impl JsonSchema for GithubPullRequestNumber {
88    /// Keeps this scalar inline in generated JSON schemas.
89    fn inline_schema() -> bool {
90        true
91    }
92
93    /// Names the generated schema for this GitHub scalar.
94    fn schema_name() -> Cow<'static, str> {
95        "GithubPullRequestNumber".into()
96    }
97
98    /// Describes the public JSON contract for pull request numbers.
99    fn json_schema(_: &mut SchemaGenerator) -> Schema {
100        json_schema!({
101            "type": "integer",
102            "minimum": 1,
103            "maximum": i32::MAX
104        })
105    }
106}
107
108/// Validates that a pull request number is a canonical positive decimal integer.
109fn validate_github_pull_request_number(value: &str) -> Result<(), GithubPullRequestNumberError> {
110    if value.is_empty() || value.starts_with('+') || value.starts_with('0') && value != "0" {
111        return Err(GithubPullRequestNumberError);
112    }
113    let Ok(number) = value.parse::<i32>() else {
114        return Err(GithubPullRequestNumberError);
115    };
116    if number > 0 {
117        Ok(())
118    } else {
119        Err(GithubPullRequestNumberError)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::GithubPullRequestNumber;
126
127    /// Verifies that pull request numbers trim and validate CLI strings.
128    #[test]
129    fn validates_pull_request_numbers() {
130        let number = GithubPullRequestNumber::try_new(" 42 ".to_string())
131            .expect("positive PR number should parse");
132        assert_eq!(number.as_str(), "42");
133        assert_eq!(number.as_i32().expect("number should fit i32"), 42);
134        for value in ["", "0", "-1", "42abc", "42.9", "01"] {
135            assert!(GithubPullRequestNumber::try_new(value.to_string()).is_err());
136        }
137    }
138
139    /// Verifies that JSON remains numeric for pull request numbers.
140    #[test]
141    fn serde_uses_numeric_json() {
142        let number: GithubPullRequestNumber =
143            serde_json::from_str("42").expect("numeric PR number should deserialize");
144        assert_eq!(number.as_str(), "42");
145        assert_eq!(
146            serde_json::to_string(&number).expect("PR number should serialize"),
147            "42"
148        );
149        assert!(serde_json::from_str::<GithubPullRequestNumber>("\"42\"").is_err());
150    }
151}