use std::collections::BTreeMap;
use std::path::Path;
use std::{fs, io};
use serde::{Deserialize, Serialize};
use crate::index::AnnotationId;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ExpectationsFile {
#[serde(rename = "__meta__", default)]
pub meta: ExpectationsMeta,
#[serde(flatten)]
pub entries: BTreeMap<AnnotationId, Expectation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExpectationsMeta {
pub schema_version: u32,
}
impl Default for ExpectationsMeta {
fn default() -> Self {
Self { schema_version: 1 }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Expectation {
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tracking: Option<String>,
pub accepted_at: String,
}
impl ExpectationsFile {
pub fn read(path: &Path) -> io::Result<Self> {
match fs::read_to_string(path) {
Ok(raw) => toml::from_str(&raw)
.map_err(|e| io::Error::other(format!("parse {}: {e}", path.display()))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e),
}
}
pub fn write_atomic(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let toml_text = toml::to_string_pretty(self)
.map_err(|e| io::Error::other(format!("serialize expectations: {e}")))?;
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, toml_text.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
pub fn get(&self, id: &AnnotationId) -> Option<&Expectation> {
self.entries.get(id)
}
pub fn is_waived(&self, id: &AnnotationId) -> bool {
self.entries.contains_key(id)
}
pub fn accept(
&mut self,
id: AnnotationId,
reason: String,
tracking: Option<String>,
accepted_at: String,
) {
self.entries.insert(
id,
Expectation {
reason,
tracking,
accepted_at,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn id(s: &str) -> AnnotationId {
AnnotationId::parse(s).unwrap()
}
#[test]
fn missing_file_reads_as_empty_default() {
let dir = tempdir().unwrap();
let path = dir.path().join(".aristo/expectations.toml");
let f = ExpectationsFile::read(&path).unwrap();
assert_eq!(f, ExpectationsFile::default());
assert!(f.entries.is_empty());
assert_eq!(f.meta.schema_version, 1);
}
#[test]
fn accept_then_write_then_read_round_trips() {
let dir = tempdir().unwrap();
let path = dir.path().join(".aristo/expectations.toml");
let mut f = ExpectationsFile::default();
f.accept(
id("aristos:wal_initialized_reflects_sync_outcome"),
"turso uses a file-existence proxy".into(),
Some("https://example/issues/1".into()),
"2026-06-01T12:34:56Z".into(),
);
f.write_atomic(&path).unwrap();
let back = ExpectationsFile::read(&path).unwrap();
assert_eq!(back, f);
let exp = back
.get(&id("aristos:wal_initialized_reflects_sync_outcome"))
.expect("entry present");
assert_eq!(exp.reason, "turso uses a file-existence proxy");
assert_eq!(exp.tracking.as_deref(), Some("https://example/issues/1"));
assert!(back.is_waived(&id("aristos:wal_initialized_reflects_sync_outcome")));
}
#[test]
fn serialized_form_has_meta_header_and_prefixed_key() {
let mut f = ExpectationsFile::default();
f.accept(
id("aristos:foo"),
"because reasons".into(),
None,
"2026-06-01T00:00:00Z".into(),
);
let text = toml::to_string_pretty(&f).unwrap();
assert!(text.contains("[__meta__]"), "meta header; got:\n{text}");
assert!(text.contains("schema_version = 1"), "version; got:\n{text}");
assert!(
text.contains("[\"aristos:foo\"]"),
"prefixed key; got:\n{text}"
);
assert!(text.contains("because reasons"), "reason; got:\n{text}");
assert!(
!text.contains("tracking"),
"no tracking key when None; got:\n{text}"
);
}
#[test]
fn accept_is_idempotent_overwrite() {
let mut f = ExpectationsFile::default();
f.accept(id("aristos:foo"), "first".into(), None, "t1".into());
f.accept(id("aristos:foo"), "second".into(), None, "t2".into());
assert_eq!(f.entries.len(), 1);
assert_eq!(f.get(&id("aristos:foo")).unwrap().reason, "second");
}
#[test]
fn malformed_file_surfaces_a_parse_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("expectations.toml");
fs::write(&path, "this is not = = valid toml [[[").unwrap();
assert!(ExpectationsFile::read(&path).is_err());
}
}