mod entry;
mod enums;
mod strings;
pub use entry::{
AssumeEntry, BindingFieldsError, BindingState, IndexEntry, IntentEntry, ParentLink,
};
pub use enums::{AnnotationKind, CoveredRegion, Status, VerifyLevel, VerifyMethod};
pub use strings::{
AnnotationId, ArtaId, CommitHash, IdNamespace, ParseError, Sha256, VerifiedOutcome,
};
use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct IndexFile {
#[serde(rename = "__meta__")]
pub meta: Meta,
#[serde(flatten)]
pub entries: BTreeMap<AnnotationId, IndexEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Meta {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_root: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ValidationError {
#[error(
"id `{id}` is in the `aristos:` namespace but the entry is not server-bound; \
server-bound entries require a `linked` field (B5a-revised)"
)]
AristosIdNotBound { id: String },
#[error(
"id `{id}` is not in the `aristos:` namespace but the entry has a `linked` field; \
the namespace prefix and the binding must agree (B5a-revised)"
)]
NonAristosIdIsBound { id: String },
}
pub fn index_file_schema_json() -> String {
let schema = schemars::schema_for!(IndexFile);
serde_json::to_string_pretty(&schema)
.expect("serializing a schemars-derived schema cannot fail")
}
impl IndexFile {
pub fn validate(&self) -> Result<(), ValidationError> {
for (id, entry) in &self.entries {
let id_is_aristos = matches!(id.namespace(), IdNamespace::Aristos);
let entry_is_bound = match entry {
IndexEntry::Intent(e) => e.binding.is_bound(),
IndexEntry::Assume(e) => e.linked.is_some(),
};
match (id_is_aristos, entry_is_bound) {
(true, false) => {
return Err(ValidationError::AristosIdNotBound { id: id.to_string() });
}
(false, true) => {
return Err(ValidationError::NonAristosIdIsBound { id: id.to_string() });
}
_ => {}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sha(byte: char) -> Sha256 {
Sha256::parse(&format!("sha256:{}", byte.to_string().repeat(64))).unwrap()
}
fn arta() -> ArtaId {
ArtaId::parse("arta_op4q3z9NbV").unwrap()
}
fn outcome() -> VerifiedOutcome {
VerifiedOutcome::parse(&format!("v1:{}", "A".repeat(86))).unwrap()
}
fn commit() -> CommitHash {
CommitHash::parse(&"a".repeat(40)).unwrap()
}
fn local_intent() -> IntentEntry {
IntentEntry {
text: "stub".into(),
verify: VerifyLevel::Method(VerifyMethod::Test),
status: Status::Tested,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "fn foo".into(),
covered_region: CoveredRegion::Function,
binding: BindingState::Local,
parent: None,
last_critiqued_at_text_hash: None,
last_critique_finding_count: None,
}
}
fn certified_intent() -> IntentEntry {
IntentEntry {
binding: BindingState::Certified {
linked: arta(),
verified_outcome: outcome(),
last_verified_at_commit: commit(),
},
status: Status::Verified,
..local_intent()
}
}
fn local_assume() -> AssumeEntry {
AssumeEntry {
text: "external".into(),
status: Status::Unknown,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "mod storage".into(),
covered_region: CoveredRegion::ModuleInlineBody,
linked: None,
parent: None,
}
}
fn meta_minimal() -> Meta {
Meta {
schema_version: 1,
generated_by: None,
generated_at: None,
source_root: None,
}
}
#[test]
fn empty_index_validates() {
let file = IndexFile {
meta: meta_minimal(),
entries: BTreeMap::new(),
};
file.validate().unwrap();
}
#[test]
fn meta_optional_fields_default_to_none() {
let json = serde_json::json!({ "schema_version": 1 });
let meta: Meta = serde_json::from_value(json).unwrap();
assert_eq!(meta.schema_version, 1);
assert!(meta.generated_by.is_none());
assert!(meta.generated_at.is_none());
assert!(meta.source_root.is_none());
}
#[test]
fn meta_optional_fields_round_trip() {
let meta = Meta {
schema_version: 1,
generated_by: Some("aristo index v0.1.0".into()),
generated_at: Some("2026-05-13T14:23:00Z".into()),
source_root: Some(".".into()),
};
let json = serde_json::to_string(&meta).unwrap();
let back: Meta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
#[test]
fn meta_rejects_unknown_field() {
let json = serde_json::json!({ "schema_version": 1, "bogus": 42 });
let result: Result<Meta, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[test]
fn aristos_id_with_certified_intent_validates() {
let mut entries = BTreeMap::new();
entries.insert(
AnnotationId::parse("aristos:foo").unwrap(),
IndexEntry::Intent(certified_intent()),
);
let file = IndexFile {
meta: meta_minimal(),
entries,
};
file.validate().unwrap();
}
#[test]
fn aristos_id_with_local_intent_rejected() {
let mut entries = BTreeMap::new();
entries.insert(
AnnotationId::parse("aristos:foo").unwrap(),
IndexEntry::Intent(local_intent()),
);
let file = IndexFile {
meta: meta_minimal(),
entries,
};
assert!(matches!(
file.validate(),
Err(ValidationError::AristosIdNotBound { .. })
));
}
#[test]
fn non_aristos_id_with_certified_intent_rejected() {
let mut entries = BTreeMap::new();
entries.insert(
AnnotationId::parse("foo").unwrap(),
IndexEntry::Intent(certified_intent()),
);
let file = IndexFile {
meta: meta_minimal(),
entries,
};
assert!(matches!(
file.validate(),
Err(ValidationError::NonAristosIdIsBound { .. })
));
}
#[test]
fn aristos_id_with_bound_assume_validates() {
let mut a = local_assume();
a.linked = Some(arta());
let mut entries = BTreeMap::new();
entries.insert(
AnnotationId::parse("aristos:atomic_writes").unwrap(),
IndexEntry::Assume(a),
);
let file = IndexFile {
meta: meta_minimal(),
entries,
};
file.validate().unwrap();
}
#[test]
fn aristos_id_with_local_assume_rejected() {
let mut entries = BTreeMap::new();
entries.insert(
AnnotationId::parse("aristos:atomic_writes").unwrap(),
IndexEntry::Assume(local_assume()),
);
let file = IndexFile {
meta: meta_minimal(),
entries,
};
assert!(matches!(
file.validate(),
Err(ValidationError::AristosIdNotBound { .. })
));
}
}