use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Critique {
pub id: String,
pub title: String,
pub solution_id: String,
pub status: CritiqueStatus,
pub severity: CritiqueSeverity,
pub author: Option<String>,
pub reviewer: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub argument: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_context: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
#[serde(default)]
pub replies: Vec<Reply>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_review_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reply {
pub id: String,
pub author: String,
pub body: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CritiqueStatus {
#[default]
Open,
Addressed,
Valid,
Dismissed,
}
impl std::fmt::Display for CritiqueStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CritiqueStatus::Open => write!(f, "open"),
CritiqueStatus::Addressed => write!(f, "addressed"),
CritiqueStatus::Valid => write!(f, "valid"),
CritiqueStatus::Dismissed => write!(f, "dismissed"),
}
}
}
impl std::str::FromStr for CritiqueStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"open" => Ok(CritiqueStatus::Open),
"addressed" => Ok(CritiqueStatus::Addressed),
"valid" => Ok(CritiqueStatus::Valid),
"dismissed" => Ok(CritiqueStatus::Dismissed),
_ => Err(format!(
"Unknown critique status: '{}'. Valid values: open, addressed, valid, dismissed",
s
)),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum CritiqueSeverity {
Low,
#[default]
Medium,
High,
Critical,
}
impl std::fmt::Display for CritiqueSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CritiqueSeverity::Low => write!(f, "low"),
CritiqueSeverity::Medium => write!(f, "medium"),
CritiqueSeverity::High => write!(f, "high"),
CritiqueSeverity::Critical => write!(f, "critical"),
}
}
}
impl std::str::FromStr for CritiqueSeverity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"low" => Ok(CritiqueSeverity::Low),
"medium" => Ok(CritiqueSeverity::Medium),
"high" => Ok(CritiqueSeverity::High),
"critical" => Ok(CritiqueSeverity::Critical),
_ => Err(format!(
"Unknown critique severity: '{}'. Valid values: low, medium, high, critical",
s
)),
}
}
}
impl Critique {
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
solution_id: impl Into<String>,
) -> Self {
let now = Utc::now();
Self {
id: id.into(),
title: title.into(),
solution_id: solution_id.into(),
status: CritiqueStatus::Open,
severity: CritiqueSeverity::Medium,
author: None,
reviewer: None,
created_at: now,
updated_at: now,
argument: String::new(),
file_path: None,
line_start: None,
line_end: None,
code_context: Vec::new(),
context_before: Vec::new(),
context_after: Vec::new(),
replies: Vec::new(),
github_review_id: None,
}
}
pub(crate) fn set_status(&mut self, status: CritiqueStatus) {
self.status = status;
self.updated_at = Utc::now();
}
pub fn can_transition_to(&self, target: &CritiqueStatus) -> bool {
matches!(
(&self.status, target),
(CritiqueStatus::Open, CritiqueStatus::Addressed)
| (CritiqueStatus::Open, CritiqueStatus::Valid)
| (CritiqueStatus::Open, CritiqueStatus::Dismissed)
| (CritiqueStatus::Addressed, CritiqueStatus::Open)
| (CritiqueStatus::Addressed, CritiqueStatus::Valid)
| (CritiqueStatus::Addressed, CritiqueStatus::Dismissed)
| (CritiqueStatus::Valid, CritiqueStatus::Addressed)
| (CritiqueStatus::Valid, CritiqueStatus::Dismissed)
| (CritiqueStatus::Dismissed, CritiqueStatus::Open)
)
}
pub fn try_set_status(&mut self, status: CritiqueStatus) -> Result<(), String> {
if !self.can_transition_to(&status) {
return Err(format!(
"Invalid status transition: {} -> {}",
self.status, status
));
}
self.set_status(status);
Ok(())
}
pub fn set_severity(&mut self, severity: CritiqueSeverity) {
self.severity = severity;
self.updated_at = Utc::now();
}
pub fn address(&mut self) -> Result<(), String> {
self.try_set_status(CritiqueStatus::Addressed)
}
pub fn validate(&mut self) -> Result<(), String> {
self.try_set_status(CritiqueStatus::Valid)
}
pub fn dismiss(&mut self) -> Result<(), String> {
self.try_set_status(CritiqueStatus::Dismissed)
}
pub fn is_active(&self) -> bool {
matches!(self.status, CritiqueStatus::Open)
}
pub fn is_resolved(&self) -> bool {
matches!(
self.status,
CritiqueStatus::Addressed | CritiqueStatus::Valid | CritiqueStatus::Dismissed
)
}
pub fn invalidates_solution(&self) -> bool {
self.status == CritiqueStatus::Valid
}
pub fn add_reply(&mut self, author: impl Into<String>, body: impl Into<String>) {
let reply_num = self.replies.len() + 1;
let reply = Reply {
id: format!("{}-r{}", self.id, reply_num),
author: author.into(),
body: body.into(),
created_at: Utc::now(),
};
self.replies.push(reply);
self.updated_at = Utc::now();
}
pub fn set_location(
&mut self,
file_path: impl Into<String>,
line_start: usize,
line_end: Option<usize>,
code_context: Vec<String>,
context_before: Vec<String>,
context_after: Vec<String>,
) {
self.file_path = Some(file_path.into());
self.line_start = Some(line_start);
self.line_end = line_end.or(Some(line_start));
self.code_context = code_context;
self.context_before = context_before;
self.context_after = context_after;
self.updated_at = Utc::now();
}
pub fn has_location(&self) -> bool {
self.file_path.is_some() && self.line_start.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritiqueFrontmatter {
pub id: String,
pub title: String,
pub solution_id: String,
pub status: CritiqueStatus,
pub severity: CritiqueSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_context: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_review_id: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Reply>,
}
impl From<&Critique> for CritiqueFrontmatter {
fn from(c: &Critique) -> Self {
Self {
id: c.id.clone(),
title: c.title.clone(),
solution_id: c.solution_id.clone(),
status: c.status.clone(),
severity: c.severity.clone(),
author: c.author.clone(),
reviewer: c.reviewer.clone(),
file_path: c.file_path.clone(),
line_start: c.line_start,
line_end: c.line_end,
code_context: c.code_context.clone(),
context_before: c.context_before.clone(),
context_after: c.context_after.clone(),
created_at: c.created_at,
updated_at: c.updated_at,
github_review_id: c.github_review_id,
replies: c.replies.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_critique() {
let critique = Critique::new(
"c1".to_string(),
"JWT vulnerable to XSS".to_string(),
"s1".to_string(),
);
assert_eq!(critique.id, "c1");
assert_eq!(critique.title, "JWT vulnerable to XSS");
assert_eq!(critique.solution_id, "s1");
assert_eq!(critique.status, CritiqueStatus::Open);
assert_eq!(critique.severity, CritiqueSeverity::Medium);
assert!(critique.is_active());
}
#[test]
fn test_status_transitions() {
let mut critique = Critique::new("c1".to_string(), "Test".to_string(), "s1".to_string());
assert_eq!(critique.status, CritiqueStatus::Open);
assert!(critique.is_active());
critique.address().unwrap();
assert_eq!(critique.status, CritiqueStatus::Addressed);
assert!(critique.is_resolved());
assert!(!critique.invalidates_solution());
}
#[test]
fn test_valid_critique() {
let mut critique = Critique::new(
"c1".to_string(),
"Critical flaw".to_string(),
"s1".to_string(),
);
critique.validate().unwrap();
assert_eq!(critique.status, CritiqueStatus::Valid);
assert!(critique.is_resolved());
assert!(critique.invalidates_solution());
}
#[test]
fn test_dismissed_critique() {
let mut critique = Critique::new(
"c1".to_string(),
"Not actually a problem".to_string(),
"s1".to_string(),
);
critique.dismiss().unwrap();
assert_eq!(critique.status, CritiqueStatus::Dismissed);
assert!(critique.is_resolved());
assert!(!critique.invalidates_solution());
}
#[test]
fn test_severity_ordering() {
assert!(CritiqueSeverity::Critical > CritiqueSeverity::High);
assert!(CritiqueSeverity::High > CritiqueSeverity::Medium);
assert!(CritiqueSeverity::Medium > CritiqueSeverity::Low);
}
#[test]
fn test_status_parsing() {
assert_eq!(
"open".parse::<CritiqueStatus>().unwrap(),
CritiqueStatus::Open
);
assert_eq!(
"addressed".parse::<CritiqueStatus>().unwrap(),
CritiqueStatus::Addressed
);
assert_eq!(
"valid".parse::<CritiqueStatus>().unwrap(),
CritiqueStatus::Valid
);
assert_eq!(
"dismissed".parse::<CritiqueStatus>().unwrap(),
CritiqueStatus::Dismissed
);
}
#[test]
fn test_severity_parsing() {
assert_eq!(
"low".parse::<CritiqueSeverity>().unwrap(),
CritiqueSeverity::Low
);
assert_eq!(
"medium".parse::<CritiqueSeverity>().unwrap(),
CritiqueSeverity::Medium
);
assert_eq!(
"high".parse::<CritiqueSeverity>().unwrap(),
CritiqueSeverity::High
);
assert_eq!(
"critical".parse::<CritiqueSeverity>().unwrap(),
CritiqueSeverity::Critical
);
}
#[test]
fn test_add_reply() {
let mut critique = Critique::new(
"c1".to_string(),
"Test critique".to_string(),
"s1".to_string(),
);
critique.add_reply("alice".to_string(), "I disagree".to_string());
assert_eq!(critique.replies.len(), 1);
assert_eq!(critique.replies[0].author, "alice");
assert_eq!(critique.replies[0].body, "I disagree");
assert!(critique.replies[0].id.starts_with("c1-r"));
}
#[test]
fn test_critique_with_location() {
let mut critique = Critique::new(
"c1".to_string(),
"SQL injection".to_string(),
"s1".to_string(),
);
critique.set_location(
"src/db.rs".to_string(),
42,
Some(45),
vec!["let query = format!(...)".to_string()],
vec![],
vec![],
);
assert_eq!(critique.file_path, Some("src/db.rs".to_string()));
assert_eq!(critique.line_start, Some(42));
assert_eq!(critique.line_end, Some(45));
assert!(critique.has_location());
}
#[test]
fn test_critique_without_location() {
let critique = Critique::new(
"c1".to_string(),
"Conceptual critique".to_string(),
"s1".to_string(),
);
assert!(!critique.has_location());
}
#[test]
fn test_critique_with_reviewer() {
let mut critique = Critique::new(
"c1".to_string(),
"Awaiting review from @bob".to_string(),
"s1".to_string(),
);
critique.reviewer = Some("bob".to_string());
assert_eq!(critique.reviewer, Some("bob".to_string()));
}
}