use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::index::{AnnotationId, CoveredRegion, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpecHeader {
pub annotation_id: AnnotationId,
pub annotation_text_hash: Sha256,
pub source_body_hash: Sha256,
pub covered_region: CoveredRegion,
pub covered_region_path: String,
pub mined_at: String,
pub mined_by: String,
pub human_reviewed: bool,
pub notes: String,
pub staleness: StalenessState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StalenessState {
Fresh,
Stale {
detected_at: String,
current_body_hash: Sha256,
},
}
impl StalenessState {
pub fn try_from_fields(
stale_at: Option<String>,
current_body_hash: Option<Sha256>,
) -> Result<Self, StalenessFieldsError> {
match (stale_at, current_body_hash) {
(None, None) => Ok(Self::Fresh),
(Some(detected_at), Some(current_body_hash)) => Ok(Self::Stale {
detected_at,
current_body_hash,
}),
(Some(_), None) => Err(StalenessFieldsError::StaleAtWithoutCurrentBodyHash),
(None, Some(_)) => Err(StalenessFieldsError::CurrentBodyHashWithoutStaleAt),
}
}
pub fn is_stale(&self) -> bool {
matches!(self, Self::Stale { .. })
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum StalenessFieldsError {
#[error("`stale_at` is present but `current_body_hash` is missing")]
StaleAtWithoutCurrentBodyHash,
#[error("`current_body_hash` is present but `stale_at` is missing")]
CurrentBodyHashWithoutStaleAt,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpecHeaderWire {
annotation_id: AnnotationId,
annotation_text_hash: Sha256,
source_body_hash: Sha256,
covered_region: CoveredRegion,
covered_region_path: String,
mined_at: String,
mined_by: String,
human_reviewed: bool,
#[serde(default)]
notes: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
stale_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_body_hash: Option<Sha256>,
}
impl Serialize for SpecHeader {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
SpecHeaderWire::from(self.clone()).serialize(s)
}
}
impl<'de> Deserialize<'de> for SpecHeader {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let wire = SpecHeaderWire::deserialize(d)?;
Self::try_from(wire).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for SpecHeader {
fn schema_name() -> String {
"SpecHeader".to_owned()
}
fn json_schema(generator: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
SpecHeaderWire::json_schema(generator)
}
}
impl From<SpecHeader> for SpecHeaderWire {
fn from(h: SpecHeader) -> Self {
let (stale_at, current_body_hash) = match h.staleness {
StalenessState::Fresh => (None, None),
StalenessState::Stale {
detected_at,
current_body_hash,
} => (Some(detected_at), Some(current_body_hash)),
};
Self {
annotation_id: h.annotation_id,
annotation_text_hash: h.annotation_text_hash,
source_body_hash: h.source_body_hash,
covered_region: h.covered_region,
covered_region_path: h.covered_region_path,
mined_at: h.mined_at,
mined_by: h.mined_by,
human_reviewed: h.human_reviewed,
notes: h.notes,
stale_at,
current_body_hash,
}
}
}
impl TryFrom<SpecHeaderWire> for SpecHeader {
type Error = StalenessFieldsError;
fn try_from(w: SpecHeaderWire) -> Result<Self, Self::Error> {
let staleness = StalenessState::try_from_fields(w.stale_at, w.current_body_hash)?;
Ok(Self {
annotation_id: w.annotation_id,
annotation_text_hash: w.annotation_text_hash,
source_body_hash: w.source_body_hash,
covered_region: w.covered_region,
covered_region_path: w.covered_region_path,
mined_at: w.mined_at,
mined_by: w.mined_by,
human_reviewed: w.human_reviewed,
notes: w.notes,
staleness,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sha(c: char) -> Sha256 {
Sha256::parse(&format!("sha256:{}", c.to_string().repeat(64))).unwrap()
}
fn fresh_header() -> SpecHeader {
SpecHeader {
annotation_id: AnnotationId::parse("insert_into_cell_postcondition").unwrap(),
annotation_text_hash: sha('a'),
source_body_hash: sha('b'),
covered_region: CoveredRegion::Function,
covered_region_path: "src/btree.rs::insert_into_cell".into(),
mined_at: "2026-05-13T14:23:00Z".into(),
mined_by: "aristo verify (skill=aristo-mine-assertions)".into(),
human_reviewed: false,
notes: String::new(),
staleness: StalenessState::Fresh,
}
}
#[test]
fn staleness_fresh_round_trip() {
let s = StalenessState::try_from_fields(None, None).unwrap();
assert_eq!(s, StalenessState::Fresh);
assert!(!s.is_stale());
}
#[test]
fn staleness_stale_round_trip() {
let s =
StalenessState::try_from_fields(Some("2026-05-15T09:14:22Z".into()), Some(sha('c')))
.unwrap();
assert!(matches!(s, StalenessState::Stale { .. }));
assert!(s.is_stale());
}
#[test]
fn staleness_stale_at_alone_rejected() {
assert_eq!(
StalenessState::try_from_fields(Some("ts".into()), None),
Err(StalenessFieldsError::StaleAtWithoutCurrentBodyHash),
);
}
#[test]
fn staleness_current_hash_alone_rejected() {
assert_eq!(
StalenessState::try_from_fields(None, Some(sha('c'))),
Err(StalenessFieldsError::CurrentBodyHashWithoutStaleAt),
);
}
#[test]
fn fresh_header_round_trips_through_toml() {
let h = fresh_header();
let toml_text = toml::to_string(&h).unwrap();
assert!(!toml_text.contains("stale_at"));
assert!(!toml_text.contains("current_body_hash"));
let back: SpecHeader = toml::from_str(&toml_text).unwrap();
assert_eq!(back, h);
}
#[test]
fn stale_header_round_trips_through_toml() {
let mut h = fresh_header();
h.staleness = StalenessState::Stale {
detected_at: "2026-05-15T09:14:22Z".into(),
current_body_hash: sha('c'),
};
let toml_text = toml::to_string(&h).unwrap();
assert!(toml_text.contains("stale_at"));
assert!(toml_text.contains("current_body_hash"));
let back: SpecHeader = toml::from_str(&toml_text).unwrap();
assert_eq!(back, h);
}
#[test]
fn header_deserialize_rejects_stale_at_alone() {
let toml_text = format!(
r#"
annotation_id = "x"
annotation_text_hash = "sha256:{a}"
source_body_hash = "sha256:{b}"
covered_region = "function"
covered_region_path = "src/x.rs::y"
mined_at = "2026-05-13T14:23:00Z"
mined_by = "aristo verify"
human_reviewed = false
notes = ""
stale_at = "2026-05-15T09:14:22Z"
"#,
a = "a".repeat(64),
b = "b".repeat(64)
);
let result: Result<SpecHeader, _> = toml::from_str(&toml_text);
let err = result.unwrap_err().to_string();
assert!(err.contains("stale_at"), "got: {err}");
}
#[test]
fn header_deserialize_rejects_unknown_field() {
let toml_text = format!(
r#"
annotation_id = "x"
annotation_text_hash = "sha256:{a}"
source_body_hash = "sha256:{b}"
covered_region = "function"
covered_region_path = "src/x.rs::y"
mined_at = "2026-05-13T14:23:00Z"
mined_by = "aristo verify"
human_reviewed = false
notes = ""
this_is_not_a_field = "rejected"
"#,
a = "a".repeat(64),
b = "b".repeat(64)
);
let result: Result<SpecHeader, _> = toml::from_str(&toml_text);
assert!(result.is_err());
}
#[test]
fn human_reviewed_field_round_trips_both_values() {
let mut h = fresh_header();
for value in [true, false] {
h.human_reviewed = value;
let toml_text = toml::to_string(&h).unwrap();
let back: SpecHeader = toml::from_str(&toml_text).unwrap();
assert_eq!(back.human_reviewed, value);
}
}
}