use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Problem {
pub id: String,
pub title: String,
pub parent_id: Option<String>,
pub status: ProblemStatus,
pub priority: Priority,
#[serde(default, skip_serializing_if = "is_confidence_unknown")]
pub confidence: Confidence,
#[serde(default)]
pub solution_ids: Vec<String>,
#[serde(default)]
pub child_ids: Vec<String>,
pub milestone_id: Option<String>,
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dissolved_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_issue: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
#[default]
Unknown,
Red,
Amber,
Green,
}
impl std::fmt::Display for Confidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Confidence::Unknown => write!(f, "unknown"),
Confidence::Red => write!(f, "red"),
Confidence::Amber => write!(f, "amber"),
Confidence::Green => write!(f, "green"),
}
}
}
impl std::str::FromStr for Confidence {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"unknown" => Ok(Confidence::Unknown),
"red" => Ok(Confidence::Red),
"amber" | "yellow" => Ok(Confidence::Amber),
"green" => Ok(Confidence::Green),
_ => Err(format!(
"Invalid confidence: '{}'. Use unknown, red, amber, or green",
s
)),
}
}
}
impl Confidence {
pub fn next(&self) -> Self {
match self {
Confidence::Unknown => Confidence::Red,
Confidence::Red => Confidence::Amber,
Confidence::Amber => Confidence::Green,
Confidence::Green => Confidence::Unknown,
}
}
}
fn is_confidence_unknown(c: &Confidence) -> bool {
matches!(c, Confidence::Unknown)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
#[default]
Medium,
High,
Critical,
}
impl std::fmt::Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Priority::Low => write!(f, "low"),
Priority::Medium => write!(f, "medium"),
Priority::High => write!(f, "high"),
Priority::Critical => write!(f, "critical"),
}
}
}
impl std::str::FromStr for Priority {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"critical" => Ok(Priority::Critical),
"high" => Ok(Priority::High),
"medium" => Ok(Priority::Medium),
"low" => Ok(Priority::Low),
_ => Err(format!(
"Invalid priority: '{}'. Use critical, high, medium, or low",
s
)),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ProblemStatus {
#[default]
Open,
InProgress,
Solved,
Dissolved,
}
impl std::fmt::Display for ProblemStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProblemStatus::Open => write!(f, "open"),
ProblemStatus::InProgress => write!(f, "in_progress"),
ProblemStatus::Solved => write!(f, "solved"),
ProblemStatus::Dissolved => write!(f, "dissolved"),
}
}
}
impl std::str::FromStr for ProblemStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"open" => Ok(ProblemStatus::Open),
"in_progress" | "inprogress" => Ok(ProblemStatus::InProgress),
"solved" => Ok(ProblemStatus::Solved),
"dissolved" => Ok(ProblemStatus::Dissolved),
_ => Err(format!(
"Unknown problem status: '{}'. Valid values: open, in_progress, solved, dissolved",
s
)),
}
}
}
impl Problem {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: id.into(),
title: title.into(),
parent_id: None,
status: ProblemStatus::Open,
priority: Priority::default(),
confidence: Confidence::default(),
solution_ids: Vec::new(),
child_ids: Vec::new(),
milestone_id: None,
assignee: None,
created_at: now,
updated_at: now,
description: String::new(),
dissolved_reason: None,
github_issue: None,
tags: Vec::new(),
}
}
pub fn set_parent(&mut self, parent_id: Option<String>) {
self.parent_id = parent_id;
self.updated_at = Utc::now();
}
pub fn add_solution(&mut self, solution_id: impl Into<String>) {
let solution_id = solution_id.into();
if !self.solution_ids.contains(&solution_id) {
self.solution_ids.push(solution_id);
self.updated_at = Utc::now();
}
}
pub fn remove_solution(&mut self, solution_id: &str) -> bool {
if let Some(pos) = self.solution_ids.iter().position(|id| id == solution_id) {
self.solution_ids.remove(pos);
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn add_child(&mut self, child_id: impl Into<String>) {
let child_id = child_id.into();
if !self.child_ids.contains(&child_id) {
self.child_ids.push(child_id);
self.updated_at = Utc::now();
}
}
pub fn remove_child(&mut self, child_id: &str) -> bool {
if let Some(pos) = self.child_ids.iter().position(|id| id == child_id) {
self.child_ids.remove(pos);
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn set_milestone(&mut self, milestone_id: Option<String>) {
self.milestone_id = milestone_id;
self.updated_at = Utc::now();
}
pub(crate) fn set_status(&mut self, status: ProblemStatus) {
self.status = status;
self.updated_at = Utc::now();
}
pub fn try_set_status(&mut self, status: ProblemStatus) -> Result<(), String> {
if !self.can_transition_to(&status) {
return Err(format!(
"Invalid status transition: {} -> {}",
self.status, status
));
}
self.set_status(status);
Ok(())
}
pub fn can_transition_to(&self, target: &ProblemStatus) -> bool {
matches!(
(&self.status, target),
(ProblemStatus::Open, ProblemStatus::InProgress)
| (ProblemStatus::Open, ProblemStatus::Solved)
| (ProblemStatus::Open, ProblemStatus::Dissolved)
| (ProblemStatus::InProgress, ProblemStatus::Solved)
| (ProblemStatus::InProgress, ProblemStatus::Open)
| (ProblemStatus::InProgress, ProblemStatus::Dissolved)
| (ProblemStatus::Solved, ProblemStatus::Open)
| (ProblemStatus::Dissolved, ProblemStatus::Open)
)
}
pub fn dissolve(&mut self, reason: impl Into<String>) {
self.status = ProblemStatus::Dissolved;
self.dissolved_reason = Some(reason.into());
self.updated_at = Utc::now();
}
pub fn is_open(&self) -> bool {
matches!(self.status, ProblemStatus::Open | ProblemStatus::InProgress)
}
pub fn is_resolved(&self) -> bool {
matches!(
self.status,
ProblemStatus::Solved | ProblemStatus::Dissolved
)
}
pub fn is_in_progress(&self) -> bool {
self.status == ProblemStatus::InProgress
}
pub fn is_subproblem(&self) -> bool {
self.parent_id.is_some()
}
pub fn is_root(&self) -> bool {
self.parent_id.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemFrontmatter {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub status: ProblemStatus,
pub priority: Priority,
#[serde(default, skip_serializing_if = "is_confidence_unknown")]
pub confidence: Confidence,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub solution_ids: Vec<String>,
#[serde(skip)]
pub child_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub milestone_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dissolved_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_issue: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl From<&Problem> for ProblemFrontmatter {
fn from(p: &Problem) -> Self {
Self {
id: p.id.clone(),
title: p.title.clone(),
parent_id: p.parent_id.clone(),
status: p.status.clone(),
priority: p.priority.clone(),
confidence: p.confidence.clone(),
solution_ids: p.solution_ids.clone(),
child_ids: p.child_ids.clone(),
milestone_id: p.milestone_id.clone(),
assignee: p.assignee.clone(),
created_at: p.created_at,
updated_at: p.updated_at,
dissolved_reason: p.dissolved_reason.clone(),
github_issue: p.github_issue,
tags: p.tags.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_problem() {
let problem = Problem::new("P-1".to_string(), "User auth is unreliable".to_string());
assert_eq!(problem.id, "P-1");
assert_eq!(problem.title, "User auth is unreliable");
assert_eq!(problem.status, ProblemStatus::Open);
assert!(problem.is_root());
assert!(problem.is_open());
assert!(problem.tags.is_empty());
}
#[test]
fn test_subproblem() {
let mut problem = Problem::new("P-2".to_string(), "Token expiry handling".to_string());
problem.set_parent(Some("P-1".to_string()));
assert!(problem.is_subproblem());
assert!(!problem.is_root());
assert_eq!(problem.parent_id, Some("P-1".to_string()));
}
#[test]
fn test_status_transitions() {
let mut problem = Problem::new("P-1".to_string(), "Test".to_string());
assert_eq!(problem.status, ProblemStatus::Open);
assert!(problem.is_open());
problem.set_status(ProblemStatus::InProgress);
assert_eq!(problem.status, ProblemStatus::InProgress);
assert!(problem.is_open());
problem.set_status(ProblemStatus::Solved);
assert_eq!(problem.status, ProblemStatus::Solved);
assert!(problem.is_resolved());
}
#[test]
fn test_add_solution() {
let mut problem = Problem::new("P-1".to_string(), "Test".to_string());
problem.add_solution("S-1".to_string());
problem.add_solution("S-2".to_string());
assert_eq!(problem.solution_ids.len(), 2);
assert!(problem.solution_ids.contains(&"S-1".to_string()));
}
#[test]
fn test_add_child() {
let mut problem = Problem::new("P-1".to_string(), "Parent".to_string());
problem.add_child("P-2".to_string());
problem.add_child("P-3".to_string());
assert_eq!(problem.child_ids.len(), 2);
assert!(problem.child_ids.contains(&"P-2".to_string()));
}
#[test]
fn test_status_parsing() {
assert_eq!(
"open".parse::<ProblemStatus>().unwrap(),
ProblemStatus::Open
);
assert_eq!(
"in_progress".parse::<ProblemStatus>().unwrap(),
ProblemStatus::InProgress
);
assert_eq!(
"solved".parse::<ProblemStatus>().unwrap(),
ProblemStatus::Solved
);
assert_eq!(
"dissolved".parse::<ProblemStatus>().unwrap(),
ProblemStatus::Dissolved
);
}
#[test]
fn test_priority_from_str() {
assert_eq!("critical".parse::<Priority>().unwrap(), Priority::Critical);
assert_eq!("Critical".parse::<Priority>().unwrap(), Priority::Critical);
assert_eq!("high".parse::<Priority>().unwrap(), Priority::High);
assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
assert_eq!("medium".parse::<Priority>().unwrap(), Priority::Medium);
assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
assert!("p0".parse::<Priority>().is_err());
assert!("p1".parse::<Priority>().is_err());
assert!("p2".parse::<Priority>().is_err());
assert!("p3".parse::<Priority>().is_err());
}
#[test]
fn test_priority_display() {
assert_eq!(format!("{}", Priority::Critical), "critical");
assert_eq!(format!("{}", Priority::High), "high");
assert_eq!(format!("{}", Priority::Medium), "medium");
assert_eq!(format!("{}", Priority::Low), "low");
}
#[test]
fn test_priority_ordering() {
assert!(Priority::Critical > Priority::High);
assert!(Priority::High > Priority::Medium);
assert!(Priority::Medium > Priority::Low);
}
#[test]
fn test_problem_priority_default() {
let p = Problem::new("P-1".to_string(), "Test".to_string());
assert_eq!(p.priority, Priority::Medium);
}
#[test]
fn test_dissolved_reason() {
let mut p = Problem::new("P-1".to_string(), "Test".to_string());
assert_eq!(p.dissolved_reason, None);
p.dissolve("The data was correct; our test was wrong".to_string());
assert_eq!(p.status, ProblemStatus::Dissolved);
assert_eq!(
p.dissolved_reason.as_deref(),
Some("The data was correct; our test was wrong")
);
}
#[test]
fn test_problem_tags() {
let mut p = Problem::new("P-1".to_string(), "Test".to_string());
assert!(p.tags.is_empty());
p.tags = vec![
"backend".to_string(),
"auth".to_string(),
"size:L".to_string(),
];
let fm = ProblemFrontmatter::from(&p);
assert_eq!(
fm.tags,
vec![
"backend".to_string(),
"auth".to_string(),
"size:L".to_string()
]
);
let yaml = serde_yml::to_string(&fm).unwrap();
assert!(yaml.contains("tags:"));
assert!(yaml.contains("backend"));
let parsed: ProblemFrontmatter = serde_yml::from_str(&yaml).unwrap();
assert_eq!(parsed.tags, p.tags);
}
#[test]
fn test_problem_tags_serde_default() {
let yaml = "id: P-1\ntitle: Test\nstatus: open\npriority: medium\nsolution_ids: []\ncreated_at: '2025-01-01T00:00:00Z'\nupdated_at: '2025-01-01T00:00:00Z'\n";
let fm: ProblemFrontmatter = serde_yml::from_str(yaml).unwrap();
assert!(fm.tags.is_empty());
}
}