Skip to main content

chainlink/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct Issue {
6    pub id: i64,
7    pub title: String,
8    pub description: Option<String>,
9    pub status: String,
10    pub priority: String,
11    pub parent_id: Option<i64>,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub closed_at: Option<DateTime<Utc>>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct Comment {
19    pub id: i64,
20    pub issue_id: i64,
21    pub content: String,
22    pub created_at: DateTime<Utc>,
23    #[serde(default = "default_comment_kind")]
24    pub kind: String,
25}
26
27fn default_comment_kind() -> String {
28    "note".to_string()
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct Session {
33    pub id: i64,
34    pub started_at: DateTime<Utc>,
35    pub ended_at: Option<DateTime<Utc>>,
36    pub active_issue_id: Option<i64>,
37    pub handoff_notes: Option<String>,
38    pub last_action: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub agent_id: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct TokenUsage {
45    pub id: i64,
46    pub agent_id: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub session_id: Option<i64>,
49    pub timestamp: DateTime<Utc>,
50    pub input_tokens: i64,
51    pub output_tokens: i64,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub cache_read_tokens: Option<i64>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub cache_creation_tokens: Option<i64>,
56    pub model: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub cost_estimate: Option<f64>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct Milestone {
63    pub id: i64,
64    pub name: String,
65    pub description: Option<String>,
66    pub status: String,
67    pub created_at: DateTime<Utc>,
68    pub closed_at: Option<DateTime<Utc>>,
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use proptest::prelude::*;
75
76    // ==================== Issue Tests ====================
77
78    #[test]
79    fn test_issue_serialization_json() {
80        let issue = Issue {
81            id: 1,
82            title: "Test issue".to_string(),
83            description: Some("A description".to_string()),
84            status: "open".to_string(),
85            priority: "high".to_string(),
86            parent_id: None,
87            created_at: Utc::now(),
88            updated_at: Utc::now(),
89            closed_at: None,
90        };
91
92        let json = serde_json::to_string(&issue).unwrap();
93        let deserialized: Issue = serde_json::from_str(&json).unwrap();
94
95        assert_eq!(issue.id, deserialized.id);
96        assert_eq!(issue.title, deserialized.title);
97        assert_eq!(issue.description, deserialized.description);
98        assert_eq!(issue.status, deserialized.status);
99        assert_eq!(issue.priority, deserialized.priority);
100        assert_eq!(issue.parent_id, deserialized.parent_id);
101    }
102
103    #[test]
104    fn test_issue_with_parent() {
105        let issue = Issue {
106            id: 2,
107            title: "Child issue".to_string(),
108            description: None,
109            status: "open".to_string(),
110            priority: "medium".to_string(),
111            parent_id: Some(1),
112            created_at: Utc::now(),
113            updated_at: Utc::now(),
114            closed_at: None,
115        };
116
117        let json = serde_json::to_string(&issue).unwrap();
118        let deserialized: Issue = serde_json::from_str(&json).unwrap();
119
120        assert_eq!(deserialized.parent_id, Some(1));
121    }
122
123    #[test]
124    fn test_issue_closed_at() {
125        let now = Utc::now();
126        let issue = Issue {
127            id: 1,
128            title: "Closed issue".to_string(),
129            description: None,
130            status: "closed".to_string(),
131            priority: "low".to_string(),
132            parent_id: None,
133            created_at: now,
134            updated_at: now,
135            closed_at: Some(now),
136        };
137
138        let json = serde_json::to_string(&issue).unwrap();
139        let deserialized: Issue = serde_json::from_str(&json).unwrap();
140
141        assert_eq!(deserialized.closed_at, Some(now));
142    }
143
144    #[test]
145    fn test_issue_unicode_fields() {
146        let issue = Issue {
147            id: 1,
148            title: "测试 🐛 αβγ".to_string(),
149            description: Some("Description with émojis 🎉".to_string()),
150            status: "open".to_string(),
151            priority: "high".to_string(),
152            parent_id: None,
153            created_at: Utc::now(),
154            updated_at: Utc::now(),
155            closed_at: None,
156        };
157
158        let json = serde_json::to_string(&issue).unwrap();
159        let deserialized: Issue = serde_json::from_str(&json).unwrap();
160
161        assert_eq!(deserialized.title, "测试 🐛 αβγ");
162        assert_eq!(
163            deserialized.description,
164            Some("Description with émojis 🎉".to_string())
165        );
166    }
167
168    // ==================== Comment Tests ====================
169
170    #[test]
171    fn test_comment_serialization() {
172        let comment = Comment {
173            id: 1,
174            issue_id: 42,
175            content: "A comment".to_string(),
176            created_at: Utc::now(),
177            kind: "note".to_string(),
178        };
179
180        let json = serde_json::to_string(&comment).unwrap();
181        let deserialized: Comment = serde_json::from_str(&json).unwrap();
182
183        assert_eq!(comment.id, deserialized.id);
184        assert_eq!(comment.issue_id, deserialized.issue_id);
185        assert_eq!(comment.content, deserialized.content);
186    }
187
188    #[test]
189    fn test_comment_empty_content() {
190        let comment = Comment {
191            id: 1,
192            issue_id: 1,
193            content: "".to_string(),
194            created_at: Utc::now(),
195            kind: "note".to_string(),
196        };
197
198        let json = serde_json::to_string(&comment).unwrap();
199        let deserialized: Comment = serde_json::from_str(&json).unwrap();
200
201        assert_eq!(deserialized.content, "");
202    }
203
204    // ==================== Session Tests ====================
205
206    #[test]
207    fn test_session_serialization() {
208        let session = Session {
209            id: 1,
210            started_at: Utc::now(),
211            ended_at: None,
212            active_issue_id: Some(5),
213            handoff_notes: Some("Notes here".to_string()),
214            last_action: None,
215            agent_id: None,
216        };
217
218        let json = serde_json::to_string(&session).unwrap();
219        let deserialized: Session = serde_json::from_str(&json).unwrap();
220
221        assert_eq!(session.id, deserialized.id);
222        assert_eq!(session.active_issue_id, deserialized.active_issue_id);
223        assert_eq!(session.handoff_notes, deserialized.handoff_notes);
224    }
225
226    #[test]
227    fn test_session_ended() {
228        let now = Utc::now();
229        let session = Session {
230            id: 1,
231            started_at: now,
232            ended_at: Some(now),
233            active_issue_id: None,
234            handoff_notes: Some("Final notes".to_string()),
235            last_action: None,
236            agent_id: None,
237        };
238
239        let json = serde_json::to_string(&session).unwrap();
240        let deserialized: Session = serde_json::from_str(&json).unwrap();
241
242        assert_eq!(deserialized.ended_at, Some(now));
243        assert_eq!(deserialized.handoff_notes, Some("Final notes".to_string()));
244    }
245
246    // ==================== Milestone Tests ====================
247
248    #[test]
249    fn test_milestone_serialization() {
250        let milestone = Milestone {
251            id: 1,
252            name: "v1.0".to_string(),
253            description: Some("First release".to_string()),
254            status: "open".to_string(),
255            created_at: Utc::now(),
256            closed_at: None,
257        };
258
259        let json = serde_json::to_string(&milestone).unwrap();
260        let deserialized: Milestone = serde_json::from_str(&json).unwrap();
261
262        assert_eq!(milestone.id, deserialized.id);
263        assert_eq!(milestone.name, deserialized.name);
264        assert_eq!(milestone.description, deserialized.description);
265        assert_eq!(milestone.status, deserialized.status);
266    }
267
268    #[test]
269    fn test_milestone_closed() {
270        let now = Utc::now();
271        let milestone = Milestone {
272            id: 1,
273            name: "v1.0".to_string(),
274            description: None,
275            status: "closed".to_string(),
276            created_at: now,
277            closed_at: Some(now),
278        };
279
280        let json = serde_json::to_string(&milestone).unwrap();
281        let deserialized: Milestone = serde_json::from_str(&json).unwrap();
282
283        assert_eq!(deserialized.closed_at, Some(now));
284        assert_eq!(deserialized.status, "closed");
285    }
286
287    // ==================== Property-Based Tests ====================
288
289    proptest! {
290        #[test]
291        fn prop_issue_json_roundtrip(
292            id in 1i64..10000,
293            title in "[a-zA-Z0-9 ]{1,100}",
294            status in "open|closed",
295            priority in "low|medium|high|critical"
296        ) {
297            let issue = Issue {
298                id,
299                title: title.clone(),
300                description: None,
301                status: status.clone(),
302                priority: priority.clone(),
303                parent_id: None,
304                created_at: Utc::now(),
305                updated_at: Utc::now(),
306                closed_at: None,
307            };
308
309            let json = serde_json::to_string(&issue).unwrap();
310            let deserialized: Issue = serde_json::from_str(&json).unwrap();
311
312            prop_assert_eq!(deserialized.id, id);
313            prop_assert_eq!(deserialized.title, title);
314            prop_assert_eq!(deserialized.status, status);
315            prop_assert_eq!(deserialized.priority, priority);
316        }
317
318        #[test]
319        fn prop_comment_json_roundtrip(
320            id in 1i64..10000,
321            issue_id in 1i64..10000,
322            content in "[a-zA-Z0-9 ]{0,500}"
323        ) {
324            let comment = Comment {
325                id,
326                issue_id,
327                content: content.clone(),
328                created_at: Utc::now(),
329                kind: "note".to_string(),
330            };
331
332            let json = serde_json::to_string(&comment).unwrap();
333            let deserialized: Comment = serde_json::from_str(&json).unwrap();
334
335            prop_assert_eq!(deserialized.id, id);
336            prop_assert_eq!(deserialized.issue_id, issue_id);
337            prop_assert_eq!(deserialized.content, content);
338        }
339
340        #[test]
341        fn prop_session_json_roundtrip(
342            id in 1i64..10000,
343            active_issue_id in prop::option::of(1i64..10000),
344            handoff_notes in prop::option::of("[a-zA-Z0-9 ]{0,200}")
345        ) {
346            let session = Session {
347                id,
348                started_at: Utc::now(),
349                ended_at: None,
350                active_issue_id,
351                handoff_notes: handoff_notes.clone(),
352                last_action: None,
353                agent_id: None,
354            };
355
356            let json = serde_json::to_string(&session).unwrap();
357            let deserialized: Session = serde_json::from_str(&json).unwrap();
358
359            prop_assert_eq!(deserialized.id, id);
360            prop_assert_eq!(deserialized.active_issue_id, active_issue_id);
361            prop_assert_eq!(deserialized.handoff_notes, handoff_notes);
362        }
363
364        #[test]
365        fn prop_milestone_json_roundtrip(
366            id in 1i64..10000,
367            name in "[a-zA-Z0-9.]{1,50}",
368            status in "open|closed"
369        ) {
370            let milestone = Milestone {
371                id,
372                name: name.clone(),
373                description: None,
374                status: status.clone(),
375                created_at: Utc::now(),
376                closed_at: None,
377            };
378
379            let json = serde_json::to_string(&milestone).unwrap();
380            let deserialized: Milestone = serde_json::from_str(&json).unwrap();
381
382            prop_assert_eq!(deserialized.id, id);
383            prop_assert_eq!(deserialized.name, name);
384            prop_assert_eq!(deserialized.status, status);
385        }
386
387        #[test]
388        fn prop_issue_with_optional_fields(
389            has_desc in proptest::bool::ANY,
390            has_parent in proptest::bool::ANY,
391            is_closed in proptest::bool::ANY
392        ) {
393            let now = Utc::now();
394            let issue = Issue {
395                id: 1,
396                title: "Test".to_string(),
397                description: if has_desc { Some("Desc".to_string()) } else { None },
398                status: if is_closed { "closed".to_string() } else { "open".to_string() },
399                priority: "medium".to_string(),
400                parent_id: if has_parent { Some(99) } else { None },
401                created_at: now,
402                updated_at: now,
403                closed_at: if is_closed { Some(now) } else { None },
404            };
405
406            let json = serde_json::to_string(&issue).unwrap();
407            let deserialized: Issue = serde_json::from_str(&json).unwrap();
408
409            prop_assert_eq!(deserialized.description.is_some(), has_desc);
410            prop_assert_eq!(deserialized.parent_id.is_some(), has_parent);
411            prop_assert_eq!(deserialized.closed_at.is_some(), is_closed);
412        }
413    }
414}