use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
pub id: String,
pub title: String,
pub target_date: Option<DateTime<Utc>>,
pub status: MilestoneStatus,
#[serde(default)]
pub problem_ids: Vec<String>,
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MilestoneStatus {
#[default]
Planning,
Active,
Completed,
Cancelled,
}
impl std::fmt::Display for MilestoneStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MilestoneStatus::Planning => write!(f, "planning"),
MilestoneStatus::Active => write!(f, "active"),
MilestoneStatus::Completed => write!(f, "completed"),
MilestoneStatus::Cancelled => write!(f, "cancelled"),
}
}
}
impl std::str::FromStr for MilestoneStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"planning" => Ok(MilestoneStatus::Planning),
"active" => Ok(MilestoneStatus::Active),
"completed" => Ok(MilestoneStatus::Completed),
"cancelled" => Ok(MilestoneStatus::Cancelled),
_ => Err(format!("Unknown milestone status: '{}'. Valid values: planning, active, completed, cancelled", s)),
}
}
}
impl Milestone {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: id.into(),
title: title.into(),
target_date: None,
status: MilestoneStatus::Planning,
problem_ids: Vec::new(),
assignee: None,
created_at: now,
updated_at: now,
description: String::new(),
}
}
pub fn add_problem(&mut self, problem_id: impl Into<String>) {
let problem_id = problem_id.into();
if !self.problem_ids.contains(&problem_id) {
self.problem_ids.push(problem_id);
self.updated_at = Utc::now();
}
}
pub fn remove_problem(&mut self, problem_id: &str) -> bool {
if let Some(pos) = self.problem_ids.iter().position(|id| id == problem_id) {
self.problem_ids.remove(pos);
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn set_target_date(&mut self, date: Option<DateTime<Utc>>) {
self.target_date = date;
self.updated_at = Utc::now();
}
pub fn set_status(&mut self, status: MilestoneStatus) {
self.status = status;
self.updated_at = Utc::now();
}
pub fn is_overdue(&self) -> bool {
if let Some(target) = self.target_date {
target < Utc::now() && self.status != MilestoneStatus::Completed
} else {
false
}
}
pub fn days_until_target(&self) -> Option<i64> {
self.target_date
.map(|target| (target - Utc::now()).num_days())
}
pub fn is_active(&self) -> bool {
matches!(
self.status,
MilestoneStatus::Planning | MilestoneStatus::Active
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MilestoneFrontmatter {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_date: Option<DateTime<Utc>>,
pub status: MilestoneStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub problem_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<&Milestone> for MilestoneFrontmatter {
fn from(m: &Milestone) -> Self {
Self {
id: m.id.clone(),
title: m.title.clone(),
target_date: m.target_date,
status: m.status.clone(),
problem_ids: m.problem_ids.clone(),
assignee: m.assignee.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_milestone() {
let milestone = Milestone::new("M-1".to_string(), "v1.0 Release".to_string());
assert_eq!(milestone.id, "M-1");
assert_eq!(milestone.title, "v1.0 Release");
assert_eq!(milestone.status, MilestoneStatus::Planning);
assert!(milestone.problem_ids.is_empty());
}
#[test]
fn test_add_problem() {
let mut milestone = Milestone::new("M-1".to_string(), "v1.0".to_string());
milestone.add_problem("P-1".to_string());
milestone.add_problem("P-2".to_string());
assert_eq!(milestone.problem_ids.len(), 2);
assert!(milestone.problem_ids.contains(&"P-1".to_string()));
}
#[test]
fn test_remove_problem() {
let mut milestone = Milestone::new("M-1".to_string(), "v1.0".to_string());
milestone.add_problem("P-1".to_string());
milestone.add_problem("P-2".to_string());
let removed = milestone.remove_problem("P-1");
assert!(removed);
assert_eq!(milestone.problem_ids.len(), 1);
assert!(!milestone.problem_ids.contains(&"P-1".to_string()));
}
#[test]
fn test_add_duplicate_problem() {
let mut milestone = Milestone::new("M-1".to_string(), "v1.0".to_string());
milestone.add_problem("P-1".to_string());
milestone.add_problem("P-1".to_string());
assert_eq!(milestone.problem_ids.len(), 1);
}
#[test]
fn test_status_transitions() {
let mut milestone = Milestone::new("M-1".to_string(), "v1.0".to_string());
assert_eq!(milestone.status, MilestoneStatus::Planning);
assert!(milestone.is_active());
milestone.set_status(MilestoneStatus::Active);
assert_eq!(milestone.status, MilestoneStatus::Active);
assert!(milestone.is_active());
milestone.set_status(MilestoneStatus::Completed);
assert_eq!(milestone.status, MilestoneStatus::Completed);
assert!(!milestone.is_active());
}
#[test]
fn test_status_parsing() {
assert_eq!(
"planning".parse::<MilestoneStatus>().unwrap(),
MilestoneStatus::Planning
);
assert_eq!(
"active".parse::<MilestoneStatus>().unwrap(),
MilestoneStatus::Active
);
assert_eq!(
"completed".parse::<MilestoneStatus>().unwrap(),
MilestoneStatus::Completed
);
assert_eq!(
"cancelled".parse::<MilestoneStatus>().unwrap(),
MilestoneStatus::Cancelled
);
}
}