use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::index::Sha256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CritiqueFile {
pub critique: CritiqueBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CritiqueBody {
pub critiqued_at_text_hash: Sha256,
pub produced_at_body_hash: Sha256,
pub produced_by: String,
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finding_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub highest_severity: Option<Severity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Finding {
pub category: Category,
pub severity: Severity,
pub rationale: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition: Option<Disposition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition_note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_at: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Disposition {
Accepted,
Rejected,
Deferred,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Category {
Rephrasing,
ParentShape,
Vocabulary,
Scope,
Clarity,
Canonicalize,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
Info,
Suggest,
StrongSuggest,
}
impl CritiqueFile {
pub fn parse(raw: &str) -> Result<Self, toml::de::Error> {
toml::from_str(raw)
}
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h(label: &str) -> Sha256 {
Sha256::from_bytes(label.as_bytes())
}
#[test]
fn round_trips_through_toml() {
let cf = CritiqueFile {
critique: CritiqueBody {
critiqued_at_text_hash: h("focal-text"),
produced_at_body_hash: h("focal-body"),
produced_by: "aristo-critique@v0.0.7".into(),
attempts: 1,
finding_count: Some(2),
highest_severity: Some(Severity::StrongSuggest),
findings: vec![
Finding {
category: Category::Rephrasing,
severity: Severity::StrongSuggest,
rationale: "double-negation".into(),
suggested_text: Some("For every B-tree balance ...".into()),
disposition: None,
disposition_note: None,
closed_at: None,
},
Finding {
category: Category::Vocabulary,
severity: Severity::Info,
rationale: "sibling drift".into(),
suggested_text: None,
disposition: Some(Disposition::Accepted),
disposition_note: Some("will tighten in follow-up".into()),
closed_at: Some("2026-05-18T13:05:00Z".into()),
},
],
},
};
let s = cf.to_toml().unwrap();
let back: CritiqueFile = CritiqueFile::parse(&s).unwrap();
assert_eq!(cf, back);
}
#[test]
fn category_serializes_as_kebab_case() {
let s = toml::to_string(&Finding {
category: Category::ParentShape,
severity: Severity::Suggest,
rationale: "x".into(),
suggested_text: None,
disposition: None,
disposition_note: None,
closed_at: None,
})
.unwrap();
assert!(s.contains("category = \"parent-shape\""), "got: {s}");
assert!(s.contains("severity = \"suggest\""), "got: {s}");
}
#[test]
fn severity_ordering_matches_intended_priority() {
assert!(Severity::Info < Severity::Suggest);
assert!(Severity::Suggest < Severity::StrongSuggest);
}
#[test]
fn empty_findings_serializes_without_findings_field() {
let cf = CritiqueFile {
critique: CritiqueBody {
critiqued_at_text_hash: h("x"),
produced_at_body_hash: h("y"),
produced_by: "a@v".into(),
attempts: 1,
finding_count: Some(0),
highest_severity: None,
findings: Vec::new(),
},
};
let s = cf.to_toml().unwrap();
assert!(
!s.contains("[[critique.findings]]"),
"empty findings must round-trip without an array; got:\n{s}"
);
}
#[test]
fn agent_may_omit_derived_fields() {
let raw = r#"
[critique]
critiqued_at_text_hash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
produced_at_body_hash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
produced_by = "agent@v0"
attempts = 1
[[critique.findings]]
category = "rephrasing"
severity = "suggest"
rationale = "x"
"#;
let cf: CritiqueFile = CritiqueFile::parse(raw).unwrap();
assert!(cf.critique.finding_count.is_none());
assert!(cf.critique.highest_severity.is_none());
assert_eq!(cf.critique.findings.len(), 1);
}
}