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 #[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 #[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 #[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 #[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 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}