1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::{BvrError, Result};
5
6const KNOWN_STATUSES: &[&str] = &[
7 "open",
8 "in_progress",
9 "blocked",
10 "deferred",
11 "pinned",
12 "hooked",
13 "review",
14 "closed",
15 "tombstone",
16];
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct Issue {
20 #[serde(default)]
21 pub id: String,
22 #[serde(default)]
23 pub title: String,
24 #[serde(default)]
25 pub description: String,
26 #[serde(default)]
27 pub design: String,
28 #[serde(default)]
29 pub acceptance_criteria: String,
30 #[serde(default)]
31 pub notes: String,
32 #[serde(default)]
33 pub status: String,
34 #[serde(default = "default_priority")]
35 pub priority: i32,
36 #[serde(default)]
37 pub issue_type: String,
38 #[serde(default)]
39 pub assignee: String,
40 #[serde(default)]
41 pub estimated_minutes: Option<i32>,
42 #[serde(default)]
43 pub created_at: Option<DateTime<Utc>>,
44 #[serde(default)]
45 pub updated_at: Option<DateTime<Utc>>,
46 #[serde(default)]
47 pub due_date: Option<DateTime<Utc>>,
48 #[serde(default)]
49 pub closed_at: Option<DateTime<Utc>>,
50 #[serde(default)]
51 pub labels: Vec<String>,
52 #[serde(default)]
53 pub comments: Vec<Comment>,
54 #[serde(default)]
55 pub dependencies: Vec<Dependency>,
56 #[serde(default)]
57 pub source_repo: String,
58 #[serde(skip)]
61 pub workspace_prefix: Option<String>,
62 #[serde(default, skip_serializing)]
64 pub content_hash: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub external_ref: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct Dependency {
72 #[serde(default)]
73 pub issue_id: String,
74 #[serde(default)]
75 pub depends_on_id: String,
76 #[serde(default, rename = "type")]
77 pub dep_type: String,
78 #[serde(default)]
79 pub created_by: String,
80 #[serde(default)]
81 pub created_at: Option<DateTime<Utc>>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct Comment {
86 #[serde(default)]
87 pub id: i64,
88 #[serde(default)]
89 pub issue_id: String,
90 #[serde(default)]
91 pub author: String,
92 #[serde(default)]
93 pub text: String,
94 #[serde(default)]
95 pub created_at: Option<DateTime<Utc>>,
96}
97
98impl Dependency {
99 #[must_use]
100 pub fn is_blocking(&self) -> bool {
101 let t = self.dep_type.trim().to_ascii_lowercase();
107 matches!(
108 t.as_str(),
109 "" | "blocks" | "waits-for" | "conditional-blocks"
110 )
111 }
112
113 #[must_use]
114 pub fn is_parent_child(&self) -> bool {
115 let t = self.dep_type.trim().to_ascii_lowercase();
116 t == "parent-child"
117 }
118}
119
120pub fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
124 DateTime::parse_from_rfc3339(s)
125 .ok()
126 .map(|dt| dt.with_timezone(&Utc))
127}
128
129pub fn ts(s: &str) -> Option<DateTime<Utc>> {
132 Some(
133 DateTime::parse_from_rfc3339(s)
134 .unwrap_or_else(|e| panic!("invalid timestamp {s:?}: {e}"))
135 .with_timezone(&Utc),
136 )
137}
138
139pub fn days_ago(n: i64) -> Option<DateTime<Utc>> {
142 Some(Utc::now() - chrono::Duration::days(n))
143}
144
145impl Issue {
146 #[must_use]
147 pub fn normalized_status(&self) -> String {
148 self.status.trim().to_ascii_lowercase()
149 }
150
151 #[must_use]
153 pub fn is_closed_like(&self) -> bool {
154 matches!(self.normalized_status().as_str(), "closed" | "tombstone")
155 }
156
157 #[must_use]
159 pub fn is_closed(&self) -> bool {
160 self.normalized_status() == "closed"
161 }
162
163 #[must_use]
165 pub fn is_tombstone(&self) -> bool {
166 self.normalized_status() == "tombstone"
167 }
168
169 #[must_use]
171 pub fn is_in_progress(&self) -> bool {
172 self.normalized_status() == "in_progress"
173 }
174
175 #[must_use]
176 pub fn is_open_like(&self) -> bool {
177 !self.is_closed_like()
178 }
179
180 #[must_use]
181 pub fn priority_normalized(&self) -> f64 {
182 let p = self.priority.clamp(0, 4);
183 (5_i32.saturating_sub(p)) as f64 / 5.0
185 }
186
187 pub fn validate(&self) -> Result<()> {
188 if self.id.trim().is_empty() {
189 return Err(BvrError::InvalidIssue(
190 "issue id cannot be empty".to_string(),
191 ));
192 }
193 if self.title.trim().is_empty() {
194 return Err(BvrError::InvalidIssue(format!(
195 "issue {} title cannot be empty",
196 self.id
197 )));
198 }
199 if self.issue_type.trim().is_empty() {
200 return Err(BvrError::InvalidIssue(format!(
201 "issue {} issue_type cannot be empty",
202 self.id
203 )));
204 }
205
206 let status = self.normalized_status();
207 if status.is_empty() {
208 return Err(BvrError::InvalidIssue(format!(
209 "issue {} status cannot be empty",
210 self.id
211 )));
212 }
213 if !KNOWN_STATUSES.contains(&status.as_str()) {
214 return Err(BvrError::InvalidIssue(format!(
215 "issue {} has unknown status: {}",
216 self.id, self.status
217 )));
218 }
219
220 if let (Some(created_at), Some(updated_at)) = (self.created_at, self.updated_at)
221 && updated_at < created_at
222 {
223 return Err(BvrError::InvalidIssue(format!(
224 "issue {} updated_at cannot be earlier than created_at",
225 self.id
226 )));
227 }
228
229 if let (Some(created_at), Some(closed_at)) = (self.created_at, self.closed_at)
230 && closed_at < created_at
231 {
232 return Err(BvrError::InvalidIssue(format!(
233 "issue {} closed_at cannot be earlier than created_at",
234 self.id
235 )));
236 }
237
238 Ok(())
239 }
240}
241
242const fn default_priority() -> i32 {
243 3
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, Default)]
247pub struct Sprint {
248 #[serde(default)]
249 pub id: String,
250 #[serde(default)]
251 pub name: String,
252 #[serde(default)]
253 pub start_date: Option<DateTime<Utc>>,
254 #[serde(default)]
255 pub end_date: Option<DateTime<Utc>>,
256 #[serde(default)]
257 pub bead_ids: Vec<String>,
258}
259
260impl Sprint {
261 #[must_use]
262 pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
263 let Some(start_date) = self.start_date else {
264 return false;
265 };
266 let Some(end_date) = self.end_date else {
267 return false;
268 };
269
270 now >= start_date && now <= end_date
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct BurndownPoint {
276 pub date: DateTime<Utc>,
277 pub remaining: i32,
278 pub completed: i32,
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
288 fn dependency_is_blocking_for_empty_type() {
289 let dep = Dependency {
290 dep_type: String::new(),
291 ..Default::default()
292 };
293 assert!(dep.is_blocking());
294 }
295
296 #[test]
297 fn dependency_is_blocking_for_blocks_type() {
298 let dep = Dependency {
299 dep_type: "blocks".to_string(),
300 ..Default::default()
301 };
302 assert!(dep.is_blocking());
303 let dep2 = Dependency {
305 dep_type: " Blocks ".to_string(),
306 ..Default::default()
307 };
308 assert!(dep2.is_blocking());
309 }
310
311 #[test]
312 fn dependency_is_blocking_for_waits_for_and_conditional_blocks() {
313 for t in ["waits-for", "conditional-blocks"] {
317 let dep = Dependency {
318 dep_type: t.to_string(),
319 ..Default::default()
320 };
321 assert!(dep.is_blocking(), "{t} should be blocking");
322 }
323 let dep = Dependency {
325 dep_type: " Waits-For ".to_string(),
326 ..Default::default()
327 };
328 assert!(dep.is_blocking());
329 let dep = Dependency {
330 dep_type: "Conditional-Blocks".to_string(),
331 ..Default::default()
332 };
333 assert!(dep.is_blocking());
334 }
335
336 #[test]
337 fn dependency_not_blocking_for_other_types() {
338 for t in [
343 "parent-child",
344 "related",
345 "mentions",
346 "discovered-from",
347 "unknown",
348 ] {
349 let dep = Dependency {
350 dep_type: t.to_string(),
351 ..Default::default()
352 };
353 assert!(!dep.is_blocking(), "{t} should not be blocking");
354 }
355 }
356
357 #[test]
358 fn dependency_is_parent_child() {
359 let dep = Dependency {
360 dep_type: "parent-child".to_string(),
361 ..Default::default()
362 };
363 assert!(dep.is_parent_child());
364 let dep2 = Dependency {
366 dep_type: " Parent-Child ".to_string(),
367 ..Default::default()
368 };
369 assert!(dep2.is_parent_child());
370 let dep3 = Dependency {
372 dep_type: "blocks".to_string(),
373 ..Default::default()
374 };
375 assert!(!dep3.is_parent_child());
376 }
377
378 #[test]
381 fn normalized_status_lowercases_and_trims() {
382 let issue = Issue {
383 status: " OPEN ".to_string(),
384 ..Default::default()
385 };
386 assert_eq!(issue.normalized_status(), "open");
387 }
388
389 #[test]
390 fn is_closed_like_detects_closed_and_tombstone() {
391 for status in ["closed", "Closed", "CLOSED", "tombstone", "Tombstone"] {
392 let issue = Issue {
393 status: status.to_string(),
394 ..Default::default()
395 };
396 assert!(issue.is_closed_like(), "{status} should be closed-like");
397 assert!(!issue.is_open_like(), "{status} should not be open-like");
398 }
399 }
400
401 #[test]
402 fn is_closed_vs_tombstone_distinction() {
403 let closed = Issue {
404 status: "closed".to_string(),
405 ..Default::default()
406 };
407 assert!(closed.is_closed());
408 assert!(!closed.is_tombstone());
409 assert!(closed.is_closed_like());
410
411 let tombstone = Issue {
412 status: "tombstone".to_string(),
413 ..Default::default()
414 };
415 assert!(!tombstone.is_closed());
416 assert!(tombstone.is_tombstone());
417 assert!(tombstone.is_closed_like());
418
419 let open = Issue {
420 status: "open".to_string(),
421 ..Default::default()
422 };
423 assert!(!open.is_closed());
424 assert!(!open.is_tombstone());
425 assert!(!open.is_closed_like());
426 }
427
428 #[test]
429 fn is_open_like_for_all_open_statuses() {
430 for status in [
431 "open",
432 "in_progress",
433 "blocked",
434 "deferred",
435 "pinned",
436 "hooked",
437 "review",
438 ] {
439 let issue = Issue {
440 status: status.to_string(),
441 ..Default::default()
442 };
443 assert!(issue.is_open_like(), "{status} should be open-like");
444 assert!(
445 !issue.is_closed_like(),
446 "{status} should not be closed-like"
447 );
448 }
449 }
450
451 #[test]
452 fn content_hash_and_external_ref_defaults() {
453 let issue = Issue::default();
454 assert!(issue.workspace_prefix.is_none());
455 assert!(issue.content_hash.is_none());
456 assert!(issue.external_ref.is_none());
457
458 let issue_with_ref = Issue {
459 external_ref: Some("https://github.com/org/repo/issues/42".to_string()),
460 ..Default::default()
461 };
462 assert_eq!(
463 issue_with_ref.external_ref.as_deref(),
464 Some("https://github.com/org/repo/issues/42")
465 );
466 }
467
468 #[test]
469 fn workspace_prefix_is_never_serialized_or_deserialized() {
470 let issue = Issue {
471 id: "api-1".to_string(),
472 workspace_prefix: Some("api-".to_string()),
473 ..Default::default()
474 };
475 let json = serde_json::to_value(&issue).unwrap();
476 assert!(
477 json.get("workspace_prefix").is_none(),
478 "internal workspace_prefix must not be serialized"
479 );
480
481 let parsed: Issue = serde_json::from_str(
482 r#"{"id":"api-1","title":"T","status":"open","issue_type":"task","workspace_prefix":"evil-"}"#,
483 )
484 .unwrap();
485 assert!(
486 parsed.workspace_prefix.is_none(),
487 "internal workspace_prefix must not be accepted from external JSON"
488 );
489 }
490
491 #[test]
494 fn priority_normalized_maps_p0_to_highest_and_p4_to_lowest() {
495 let p0 = Issue {
496 priority: 0,
497 ..Default::default()
498 };
499 assert!((p0.priority_normalized() - 1.0).abs() < f64::EPSILON);
500
501 let p4 = Issue {
502 priority: 4,
503 ..Default::default()
504 };
505 assert!((p4.priority_normalized() - 0.2).abs() < f64::EPSILON);
506 }
507
508 #[test]
509 fn priority_normalized_distinguishes_p0_from_p1() {
510 let p0 = Issue {
511 priority: 0,
512 ..Default::default()
513 };
514 let p1 = Issue {
515 priority: 1,
516 ..Default::default()
517 };
518
519 assert!(p0.priority_normalized() > p1.priority_normalized());
520 assert!((p1.priority_normalized() - 0.8).abs() < f64::EPSILON);
521 }
522
523 #[test]
524 fn priority_normalized_clamps_out_of_range() {
525 let too_low = Issue {
526 priority: -10,
527 ..Default::default()
528 };
529 assert!((too_low.priority_normalized() - 1.0).abs() < f64::EPSILON);
531
532 let too_high = Issue {
533 priority: 100,
534 ..Default::default()
535 };
536 assert!((too_high.priority_normalized() - 0.2).abs() < f64::EPSILON);
538 }
539
540 #[test]
541 fn priority_normalized_default_treats_zero_as_p0() {
542 let issue = Issue::default();
544 assert_eq!(issue.priority, 0);
545 assert!((issue.priority_normalized() - 1.0).abs() < f64::EPSILON);
546 }
547
548 #[test]
549 fn priority_normalized_serde_default_is_3() {
550 let json = r#"{"id":"X","title":"T"}"#;
551 let issue: Issue = serde_json::from_str(json).unwrap();
552 assert_eq!(issue.priority, 3);
553 assert!((issue.priority_normalized() - 0.4).abs() < f64::EPSILON);
555 }
556
557 #[test]
560 fn validate_rejects_empty_id() {
561 let issue = Issue {
562 id: " ".to_string(),
563 title: "T".to_string(),
564 issue_type: "task".to_string(),
565 status: "open".to_string(),
566 ..Default::default()
567 };
568 let err = issue.validate().unwrap_err();
569 assert!(err.to_string().contains("id cannot be empty"));
570 }
571
572 #[test]
573 fn validate_rejects_empty_title() {
574 let issue = Issue {
575 id: "X-1".to_string(),
576 title: String::new(),
577 issue_type: "task".to_string(),
578 status: "open".to_string(),
579 ..Default::default()
580 };
581 let err = issue.validate().unwrap_err();
582 assert!(err.to_string().contains("title cannot be empty"));
583 }
584
585 #[test]
586 fn validate_rejects_empty_type() {
587 let issue = Issue {
588 id: "X-1".to_string(),
589 title: "Test".to_string(),
590 issue_type: String::new(),
591 status: "open".to_string(),
592 ..Default::default()
593 };
594 let err = issue.validate().unwrap_err();
595 assert!(err.to_string().contains("issue_type cannot be empty"));
596 }
597
598 #[test]
599 fn validate_rejects_empty_status() {
600 let issue = Issue {
601 id: "X-1".to_string(),
602 title: "Test".to_string(),
603 issue_type: "task".to_string(),
604 status: String::new(),
605 ..Default::default()
606 };
607 let err = issue.validate().unwrap_err();
608 assert!(err.to_string().contains("status cannot be empty"));
609 }
610
611 #[test]
612 fn validate_rejects_unknown_status() {
613 let issue = Issue {
614 id: "X-1".to_string(),
615 title: "Test".to_string(),
616 issue_type: "task".to_string(),
617 status: "banana".to_string(),
618 ..Default::default()
619 };
620 let err = issue.validate().unwrap_err();
621 assert!(err.to_string().contains("unknown status"));
622 }
623
624 #[test]
625 fn validate_accepts_all_known_statuses() {
626 for status in KNOWN_STATUSES {
627 let issue = Issue {
628 id: "X-1".to_string(),
629 title: "Test".to_string(),
630 issue_type: "task".to_string(),
631 status: status.to_string(),
632 ..Default::default()
633 };
634 assert!(issue.validate().is_ok(), "status {status} should be valid");
635 }
636 }
637
638 #[test]
639 fn validate_rejects_updated_at_before_created_at() {
640 let issue = Issue {
641 id: "X-1".to_string(),
642 title: "Test".to_string(),
643 issue_type: "task".to_string(),
644 status: "open".to_string(),
645 created_at: ts("2025-01-02T00:00:00Z"),
646 updated_at: ts("2025-01-01T00:00:00Z"),
647 ..Default::default()
648 };
649
650 let err = issue.validate().unwrap_err();
651 assert!(
652 err.to_string()
653 .contains("updated_at cannot be earlier than created_at")
654 );
655 }
656
657 #[test]
658 fn validate_accepts_equal_created_and_updated_timestamps() {
659 let issue = Issue {
660 id: "X-1".to_string(),
661 title: "Test".to_string(),
662 issue_type: "task".to_string(),
663 status: "open".to_string(),
664 created_at: ts("2025-01-01T00:00:00Z"),
665 updated_at: ts("2025-01-01T00:00:00Z"),
666 ..Default::default()
667 };
668
669 assert!(issue.validate().is_ok());
670 }
671
672 #[test]
673 fn validate_rejects_closed_at_before_created_at() {
674 let issue = Issue {
675 id: "X-1".to_string(),
676 title: "Test".to_string(),
677 issue_type: "task".to_string(),
678 status: "closed".to_string(),
679 created_at: ts("2025-01-02T00:00:00Z"),
680 closed_at: ts("2025-01-01T00:00:00Z"),
681 ..Default::default()
682 };
683
684 let err = issue.validate().unwrap_err();
685 assert!(
686 err.to_string()
687 .contains("closed_at cannot be earlier than created_at")
688 );
689 }
690
691 #[test]
692 fn validate_accepts_equal_created_and_closed_timestamps() {
693 let issue = Issue {
694 id: "X-1".to_string(),
695 title: "Test".to_string(),
696 issue_type: "task".to_string(),
697 status: "closed".to_string(),
698 created_at: ts("2025-01-01T00:00:00Z"),
699 closed_at: ts("2025-01-01T00:00:00Z"),
700 ..Default::default()
701 };
702
703 assert!(issue.validate().is_ok());
704 }
705
706 #[test]
709 fn sprint_is_active_at_within_range() {
710 let sprint = Sprint {
711 id: "s1".to_string(),
712 name: "Sprint 1".to_string(),
713 start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
714 end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
715 bead_ids: Vec::new(),
716 };
717 let mid: DateTime<Utc> = "2026-01-07T12:00:00Z".parse().unwrap();
718 assert!(sprint.is_active_at(mid));
719 }
720
721 #[test]
722 fn sprint_not_active_outside_range() {
723 let sprint = Sprint {
724 id: "s1".to_string(),
725 name: "Sprint 1".to_string(),
726 start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
727 end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
728 bead_ids: Vec::new(),
729 };
730 let before: DateTime<Utc> = "2025-12-31T00:00:00Z".parse().unwrap();
731 let after: DateTime<Utc> = "2026-01-15T00:00:00Z".parse().unwrap();
732 assert!(!sprint.is_active_at(before));
733 assert!(!sprint.is_active_at(after));
734 }
735
736 #[test]
737 fn sprint_not_active_without_dates() {
738 let sprint = Sprint {
739 start_date: None,
740 end_date: None,
741 ..Default::default()
742 };
743 let now: DateTime<Utc> = "2026-01-07T00:00:00Z".parse().unwrap();
744 assert!(!sprint.is_active_at(now));
745 }
746
747 #[test]
748 fn sprint_active_at_boundary() {
749 let sprint = Sprint {
750 start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
751 end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
752 ..Default::default()
753 };
754 let at_start: DateTime<Utc> = "2026-01-01T00:00:00Z".parse().unwrap();
755 let at_end: DateTime<Utc> = "2026-01-14T00:00:00Z".parse().unwrap();
756 assert!(sprint.is_active_at(at_start), "active at start boundary");
757 assert!(sprint.is_active_at(at_end), "active at end boundary");
758 }
759
760 #[test]
763 fn issue_deserializes_with_defaults() {
764 let json = r#"{"id":"X-1","title":"Test"}"#;
765 let issue: Issue = serde_json::from_str(json).unwrap();
766 assert_eq!(issue.id, "X-1");
767 assert_eq!(issue.priority, 3); assert_eq!(issue.status, "");
769 assert!(issue.labels.is_empty());
770 assert!(issue.dependencies.is_empty());
771 }
772
773 #[test]
774 fn dependency_deserializes_type_field() {
775 let json = r#"{"issue_id":"A","depends_on_id":"B","type":"blocks"}"#;
776 let dep: Dependency = serde_json::from_str(json).unwrap();
777 assert_eq!(dep.dep_type, "blocks");
778 assert!(dep.is_blocking());
779 }
780}