use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct IssueId(String);
impl IssueId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for IssueId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for IssueId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for IssueId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: IssueId,
pub title: String,
pub description: String,
pub status: IssueStatus,
pub priority: u8,
pub issue_type: IssueType,
pub assignee: Option<String>,
pub labels: Vec<String>,
pub design: Option<String>,
pub acceptance_criteria: Option<String>,
pub notes: Option<String>,
pub external_ref: Option<String>,
pub dependencies: Vec<Dependency>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
}
impl Issue {
pub fn validate(&self) -> Result<(), String> {
validate_title_and_priority(&self.title, self.priority)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueStatus {
Open,
#[serde(rename = "in_progress")]
InProgress,
Blocked,
Closed,
}
impl fmt::Display for IssueStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => write!(f, "open"),
Self::InProgress => write!(f, "in_progress"),
Self::Blocked => write!(f, "blocked"),
Self::Closed => write!(f, "closed"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueType {
Bug,
Feature,
Task,
Epic,
Chore,
}
impl fmt::Display for IssueType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bug => write!(f, "bug"),
Self::Feature => write!(f, "feature"),
Self::Task => write!(f, "task"),
Self::Epic => write!(f, "epic"),
Self::Chore => write!(f, "chore"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Dependency {
pub depends_on_id: IssueId,
pub dep_type: DependencyType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DependencyType {
Blocks,
Related,
ParentChild,
DiscoveredFrom,
}
impl fmt::Display for DependencyType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Blocks => write!(f, "blocks"),
Self::Related => write!(f, "related"),
Self::ParentChild => write!(f, "parent-child"),
Self::DiscoveredFrom => write!(f, "discovered-from"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortPolicy {
#[default]
Hybrid,
Priority,
Oldest,
}
pub const MAX_TITLE_LENGTH: usize = 200;
pub const MIN_PRIORITY: u8 = 0;
pub const MAX_PRIORITY: u8 = 4;
fn validate_title_and_priority(title: &str, priority: u8) -> Result<(), String> {
let trimmed = title.trim();
if trimmed.is_empty() {
return Err("Title cannot be empty".to_string());
}
if trimmed.len() > MAX_TITLE_LENGTH {
return Err(format!(
"Title cannot exceed {} characters (got {})",
MAX_TITLE_LENGTH,
trimmed.len()
));
}
if priority > MAX_PRIORITY {
return Err(format!(
"Priority must be in range {}-{} (got {})",
MIN_PRIORITY, MAX_PRIORITY, priority
));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct NewIssue {
pub title: String,
pub description: String,
pub priority: u8,
pub issue_type: IssueType,
pub assignee: Option<String>,
pub labels: Vec<String>,
pub design: Option<String>,
pub acceptance_criteria: Option<String>,
pub notes: Option<String>,
pub external_ref: Option<String>,
pub dependencies: Vec<(IssueId, DependencyType)>,
}
impl NewIssue {
pub fn validate(&self) -> Result<(), String> {
validate_title_and_priority(&self.title, self.priority)
}
}
impl Default for NewIssue {
fn default() -> Self {
Self {
title: "Untitled Issue".to_string(),
description: String::new(),
priority: 2,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IssueUpdate {
pub title: Option<String>,
pub description: Option<String>,
pub status: Option<IssueStatus>,
pub priority: Option<u8>,
pub assignee: Option<Option<String>>,
pub design: Option<String>,
pub acceptance_criteria: Option<String>,
pub notes: Option<String>,
pub external_ref: Option<String>,
pub labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct IssueFilter {
pub status: Option<IssueStatus>,
pub priority: Option<u8>,
pub issue_type: Option<IssueType>,
pub assignee: Option<String>,
pub label: Option<String>,
pub limit: Option<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_id_display() {
let id = IssueId::new("test-123");
assert_eq!(format!("{}", id), "test-123");
}
#[test]
fn test_issue_id_from_string() {
let id = IssueId::from("test-456".to_string());
assert_eq!(id.as_str(), "test-456");
}
#[test]
fn test_issue_id_from_str() {
let id = IssueId::from("test-789");
assert_eq!(id.as_str(), "test-789");
}
#[test]
fn test_issue_id_as_str() {
let id = IssueId::new("proj-abc");
assert_eq!(id.as_str(), "proj-abc");
}
#[test]
fn test_issue_id_equality() {
let id1 = IssueId::new("same-id");
let id2 = IssueId::new("same-id");
let id3 = IssueId::new("different-id");
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_validate_valid_issue() {
let issue = NewIssue {
title: "Valid Title".to_string(),
priority: 2,
..Default::default()
};
assert!(issue.validate().is_ok());
}
#[test]
fn test_validate_empty_title() {
let issue = NewIssue {
title: "".to_string(),
..Default::default()
};
let result = issue.validate();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Title cannot be empty");
}
#[test]
fn test_validate_whitespace_only_title() {
let issue = NewIssue {
title: " \t\n ".to_string(),
..Default::default()
};
let result = issue.validate();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Title cannot be empty");
}
#[test]
fn test_validate_title_too_long() {
let long_title = "x".repeat(MAX_TITLE_LENGTH + 1);
let issue = NewIssue {
title: long_title.clone(),
..Default::default()
};
let result = issue.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains(&format!("cannot exceed {}", MAX_TITLE_LENGTH)));
}
#[test]
fn test_validate_title_exactly_max_length() {
let max_title = "x".repeat(MAX_TITLE_LENGTH);
let issue = NewIssue {
title: max_title,
..Default::default()
};
assert!(issue.validate().is_ok());
}
#[test]
fn test_validate_title_with_whitespace() {
let issue = NewIssue {
title: " Valid Title ".to_string(),
..Default::default()
};
assert!(issue.validate().is_ok());
}
#[test]
fn test_validate_invalid_priority_low() {
let issue = NewIssue {
title: "Valid Title".to_string(),
priority: 5,
..Default::default()
};
let result = issue.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Priority must be in range 0-4"));
}
#[test]
fn test_validate_invalid_priority_high() {
let issue = NewIssue {
title: "Valid Title".to_string(),
priority: 255,
..Default::default()
};
let result = issue.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Priority must be in range 0-4"));
}
#[test]
fn test_validate_priority_boundaries() {
for priority in 0..=4 {
let issue = NewIssue {
title: "Valid Title".to_string(),
priority,
..Default::default()
};
assert!(
issue.validate().is_ok(),
"Priority {} should be valid",
priority
);
}
}
mod validate_title_and_priority_tests {
use super::super::{
validate_title_and_priority, MAX_PRIORITY, MAX_TITLE_LENGTH, MIN_PRIORITY,
};
use rstest::rstest;
#[rstest]
#[case::valid_title_and_priority("Valid Title", 2, true)]
#[case::empty_title("", 2, false)]
#[case::whitespace_only_title(" ", 2, false)]
#[case::priority_zero("Valid", 0, true)]
#[case::priority_max("Valid", MAX_PRIORITY, true)]
#[case::priority_too_high("Valid", MAX_PRIORITY + 1, false)]
fn test_validate_title_and_priority(
#[case] title: &str,
#[case] priority: u8,
#[case] should_pass: bool,
) {
let result = validate_title_and_priority(title, priority);
assert_eq!(result.is_ok(), should_pass);
}
#[test]
fn test_title_exactly_max_length() {
let title = "x".repeat(MAX_TITLE_LENGTH);
assert!(validate_title_and_priority(&title, 2).is_ok());
}
#[test]
fn test_title_exceeds_max_length() {
let title = "x".repeat(MAX_TITLE_LENGTH + 1);
let result = validate_title_and_priority(&title, 2);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot exceed"));
}
#[test]
fn test_priority_error_message_includes_range() {
let result = validate_title_and_priority("Valid", MAX_PRIORITY + 1);
let err = result.unwrap_err();
assert!(err.contains(&format!("{}-{}", MIN_PRIORITY, MAX_PRIORITY)));
}
}
#[test]
fn test_new_issue_default() {
let issue = NewIssue::default();
assert_eq!(issue.title, "Untitled Issue");
assert_eq!(issue.description, "");
assert_eq!(issue.priority, 2);
assert_eq!(issue.issue_type, IssueType::Task);
assert!(issue.assignee.is_none());
assert!(issue.labels.is_empty());
assert!(issue.dependencies.is_empty());
}
#[test]
fn test_new_issue_default_validates() {
let issue = NewIssue::default();
assert!(issue.validate().is_ok());
}
#[test]
fn test_issue_status_display() {
assert_eq!(format!("{}", IssueStatus::Open), "open");
assert_eq!(format!("{}", IssueStatus::InProgress), "in_progress");
assert_eq!(format!("{}", IssueStatus::Blocked), "blocked");
assert_eq!(format!("{}", IssueStatus::Closed), "closed");
}
#[test]
fn test_issue_type_display() {
assert_eq!(format!("{}", IssueType::Bug), "bug");
assert_eq!(format!("{}", IssueType::Feature), "feature");
assert_eq!(format!("{}", IssueType::Task), "task");
assert_eq!(format!("{}", IssueType::Epic), "epic");
assert_eq!(format!("{}", IssueType::Chore), "chore");
}
#[test]
fn test_dependency_type_display() {
assert_eq!(format!("{}", DependencyType::Blocks), "blocks");
assert_eq!(format!("{}", DependencyType::Related), "related");
assert_eq!(format!("{}", DependencyType::ParentChild), "parent-child");
assert_eq!(
format!("{}", DependencyType::DiscoveredFrom),
"discovered-from"
);
}
}