use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{CollaborationError, Label, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueState {
Open,
Closed,
}
impl std::fmt::Display for IssueState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IssueState::Open => write!(f, "open"),
IssueState::Closed => write!(f, "closed"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: u64,
pub repo_key: String,
pub number: u32,
pub title: String,
pub description: String,
pub author: String,
pub state: IssueState,
pub labels: Vec<Label>,
pub created_at: u64,
pub updated_at: u64,
pub closed_at: Option<u64>,
pub closed_by: Option<String>,
}
impl Issue {
pub fn new(
id: u64,
repo_key: impl Into<String>,
number: u32,
title: impl Into<String>,
description: impl Into<String>,
author: impl Into<String>,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id,
repo_key: repo_key.into(),
number,
title: title.into(),
description: description.into(),
author: author.into(),
state: IssueState::Open,
labels: Vec::new(),
created_at: now,
updated_at: now,
closed_at: None,
closed_by: None,
}
}
pub fn is_open(&self) -> bool {
self.state == IssueState::Open
}
pub fn is_closed(&self) -> bool {
self.state == IssueState::Closed
}
pub fn close(&mut self, closed_by: impl Into<String>) -> Result<()> {
if self.state == IssueState::Closed {
return Err(CollaborationError::InvalidStateTransition {
action: "close".to_string(),
current_state: self.state.to_string(),
});
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
self.state = IssueState::Closed;
self.closed_at = Some(now);
self.closed_by = Some(closed_by.into());
self.updated_at = now;
Ok(())
}
pub fn reopen(&mut self) -> Result<()> {
if self.state == IssueState::Open {
return Err(CollaborationError::InvalidStateTransition {
action: "reopen".to_string(),
current_state: self.state.to_string(),
});
}
self.state = IssueState::Open;
self.closed_at = None;
self.closed_by = None;
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(())
}
pub fn update_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
}
pub fn update_description(&mut self, description: impl Into<String>) {
self.description = description.into();
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
}
pub fn add_label(&mut self, label: Label) {
if !self.labels.iter().any(|l| l.name == label.name) {
self.labels.push(label);
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
}
}
pub fn remove_label(&mut self, name: &str) {
let before = self.labels.len();
self.labels.retain(|l| l.name != name);
if self.labels.len() != before {
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_issue() -> Issue {
Issue::new(
1,
"alice/repo",
1,
"Bug: Something is broken",
"Steps to reproduce...",
"alice_pubkey",
)
}
#[test]
fn test_issue_creation() {
let issue = create_test_issue();
assert_eq!(issue.number, 1);
assert_eq!(issue.title, "Bug: Something is broken");
assert!(issue.is_open());
assert!(!issue.is_closed());
}
#[test]
fn test_issue_close_and_reopen() {
let mut issue = create_test_issue();
issue.close("bob_pubkey").unwrap();
assert!(issue.is_closed());
assert!(!issue.is_open());
assert!(issue.closed_at.is_some());
assert_eq!(issue.closed_by, Some("bob_pubkey".to_string()));
issue.reopen().unwrap();
assert!(issue.is_open());
assert!(!issue.is_closed());
assert!(issue.closed_at.is_none());
assert!(issue.closed_by.is_none());
}
#[test]
fn test_cannot_close_closed_issue() {
let mut issue = create_test_issue();
issue.close("bob").unwrap();
let result = issue.close("alice");
assert!(result.is_err());
}
#[test]
fn test_cannot_reopen_open_issue() {
let mut issue = create_test_issue();
let result = issue.reopen();
assert!(result.is_err());
}
#[test]
fn test_labels() {
let mut issue = create_test_issue();
issue.add_label(Label::bug());
issue.add_label(Label::help_wanted());
assert_eq!(issue.labels.len(), 2);
issue.add_label(Label::bug());
assert_eq!(issue.labels.len(), 2);
issue.remove_label("bug");
assert_eq!(issue.labels.len(), 1);
assert_eq!(issue.labels[0].name, "help wanted");
}
#[test]
fn test_update_title_and_description() {
let mut issue = create_test_issue();
let original_updated = issue.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10));
issue.update_title("New title");
assert_eq!(issue.title, "New title");
assert!(issue.updated_at >= original_updated);
issue.update_description("New description");
assert_eq!(issue.description, "New description");
}
}