use crate::agent::AgentId;
use crate::message::MessageKind;
use crate::transcript::Transcript;
use std::cmp::Reverse;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum Severity {
Info,
Minor,
Major,
Blocker,
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub category: Option<String>,
pub summary: String,
pub location: Option<String>,
pub quote: Option<String>,
pub suggested_change: Option<String>,
supporting_critics: Vec<AgentId>,
}
impl Finding {
pub fn new(severity: Severity, summary: impl Into<String>) -> Self {
Self {
severity,
category: None,
summary: summary.into(),
location: None,
quote: None,
suggested_change: None,
supporting_critics: Vec::new(),
}
}
#[must_use]
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
#[must_use]
pub fn with_category_opt(mut self, category: Option<String>) -> Self {
if let Some(c) = category {
self.category = Some(c);
}
self
}
#[must_use]
pub fn with_location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
#[must_use]
pub fn with_location_opt(mut self, location: Option<String>) -> Self {
if let Some(l) = location {
self.location = Some(l);
}
self
}
#[must_use]
pub fn with_quote(mut self, quote: impl Into<String>) -> Self {
self.quote = Some(quote.into());
self
}
#[must_use]
pub fn with_quote_opt(mut self, quote: Option<String>) -> Self {
if let Some(q) = quote {
self.quote = Some(q);
}
self
}
#[must_use]
pub fn with_suggested_change(mut self, change: impl Into<String>) -> Self {
self.suggested_change = Some(change.into());
self
}
#[must_use]
pub fn with_suggested_change_opt(mut self, change: Option<String>) -> Self {
if let Some(c) = change {
self.suggested_change = Some(c);
}
self
}
#[must_use]
pub fn with_supporting_critics(mut self, critics: Vec<AgentId>) -> Self {
self.supporting_critics = critics;
self
}
pub fn severity(&self) -> &Severity {
&self.severity
}
pub fn summary(&self) -> &str {
&self.summary
}
pub fn supporting_critics(&self) -> &[AgentId] {
&self.supporting_critics
}
pub fn author(&self) -> Option<&AgentId> {
self.supporting_critics.first()
}
}
#[derive(Debug, Clone, Default)]
pub struct Report {
findings: Vec<Finding>,
}
impl Report {
pub fn new() -> Self {
Self {
findings: Vec::new(),
}
}
pub fn push_finding(&mut self, finding: Finding) {
self.findings.push(finding);
}
pub fn from_transcript(transcript: &Transcript) -> Self {
let findings = transcript
.iter()
.filter(|m| matches!(m.kind(), MessageKind::Critique))
.map(|m| Finding {
severity: Severity::Major,
category: None,
summary: m.text().to_owned(),
location: None,
quote: None,
suggested_change: None,
supporting_critics: vec![m.sender().clone()],
})
.collect();
Self { findings }
}
pub fn findings(&self) -> &[Finding] {
&self.findings
}
pub fn to_markdown_with_source(&self, source: Option<&str>) -> String {
let mut out = String::from("# Critique Report\n\n");
if let Some(src) = source {
out.push_str(&format!("**Subject:** `{src}`\n\n"));
}
if self.findings.is_empty() {
out.push_str("No findings.\n");
return out;
}
out.push_str(&format!(
"**Findings:** {} ({} blocker, {} major, {} minor, {} info)\n\n",
self.findings.len(),
self.count(Severity::Blocker),
self.count(Severity::Major),
self.count(Severity::Minor),
self.count(Severity::Info),
));
let mut sorted: Vec<&Finding> = self.findings.iter().collect();
sorted.sort_by_key(|f| Reverse(f.severity));
for (i, finding) in sorted.iter().enumerate() {
out.push_str(&format!(
"## {}. [{}] {}\n\n",
i + 1,
finding.severity.label(),
finding.summary,
));
if let Some(cat) = &finding.category {
out.push_str(&format!("- **Category:** {cat}\n"));
}
if let Some(loc) = &finding.location {
out.push_str(&format!("- **Location:** {loc}\n"));
}
if let Some(quote) = &finding.quote {
out.push_str(&format!("- **Quote:** > {quote}\n"));
}
if let Some(change) = &finding.suggested_change {
out.push_str(&format!("- **Suggested change:** {change}\n"));
}
if !finding.supporting_critics.is_empty() {
let names: Vec<&str> = finding
.supporting_critics
.iter()
.map(|c| c.as_str())
.collect();
out.push_str(&format!("- **Raised by:** {}\n", names.join(", ")));
}
out.push('\n');
}
out
}
fn count(&self, severity: Severity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
pub fn to_markdown(&self) -> String {
self.to_markdown_with_source(None)
}
#[cfg(feature = "json")]
pub fn to_json(&self) -> String {
let mut sorted: Vec<&Finding> = self.findings.iter().collect();
sorted.sort_by_key(|f| Reverse(f.severity));
serde_json::to_string_pretty(&JsonReport {
findings: sorted.iter().map(|f| JsonFinding::from(*f)).collect(),
})
.unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_owned())
}
}
impl Severity {
pub fn label(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Minor => "minor",
Severity::Major => "major",
Severity::Blocker => "blocker",
}
}
}
#[cfg(feature = "json")]
#[derive(serde::Serialize)]
struct JsonReport<'a> {
findings: Vec<JsonFinding<'a>>,
}
#[cfg(feature = "json")]
#[derive(serde::Serialize)]
struct JsonFinding<'a> {
severity: &'a Severity,
category: &'a Option<String>,
summary: &'a str,
location: &'a Option<String>,
quote: &'a Option<String>,
suggested_change: &'a Option<String>,
supporting_critics: Vec<&'a str>,
}
#[cfg(feature = "json")]
impl<'a> From<&'a Finding> for JsonFinding<'a> {
fn from(f: &'a Finding) -> Self {
Self {
severity: &f.severity,
category: &f.category,
summary: &f.summary,
location: &f.location,
quote: &f.quote,
suggested_change: &f.suggested_change,
supporting_critics: f.supporting_critics.iter().map(|c| c.as_str()).collect(),
}
}
}