use anyhow::Result;
use std::path::Path;
use uuid::Uuid;
pub const EPOCHS_DIR: &str = ".memlay/epochs";
pub const MLE_GRAMMAR_TEXT: &str = r#"Memlay Epoch Index (MLE) v1 — file extension .mle, committed under .memlay/epochs/
- UTF-8, line-oriented, one immutable epoch index per file.
- First non-comment line: exactly `memlay-epoch 1`.
- Blank lines and lines starting with `#` are ignored.
- Scalar fields (each exactly once):
epoch-id <uuid-v7> (must match the filename)
created-at <rfc3339-utc>
archive-ref refs/memlay/archive/<epoch-id>
archive-tree <git-tree-oid>
- Repeated field (one per archived record, at least one):
record <uuid-v7> <key> <kind> <YYYY-MM-DD> <blake3-hex-16> <summary...>
- The archive ref holds the original immutable record bytes; the index keeps
searchable summaries and hashes so history stays discoverable without
fetching archived bodies.
"#;
#[derive(Debug)]
pub struct EpochIssue {
pub file: String,
pub message: String,
}
fn validate_one(path: &Path, rel: &str, issues: &mut Vec<EpochIssue>) {
let push = |issues: &mut Vec<EpochIssue>, message: String| {
issues.push(EpochIssue {
file: rel.to_string(),
message,
});
};
let Ok(text) = std::fs::read_to_string(path) else {
push(issues, "unreadable epoch index".into());
return;
};
let mut header = false;
let mut epoch_id: Option<String> = None;
let mut seen: Vec<&str> = Vec::new();
let mut records = 0usize;
for (idx, raw) in text.lines().enumerate() {
let line = raw.trim_end_matches('\r');
if line.is_empty() || line.starts_with('#') {
continue;
}
if !header {
if line == "memlay-epoch 1" {
header = true;
continue;
}
push(
issues,
format!("line {}: first line must be 'memlay-epoch 1'", idx + 1),
);
return;
}
let Some((field, value)) = line.split_once(' ') else {
push(
issues,
format!("line {}: expected '<field> <value>'", idx + 1),
);
continue;
};
match field {
"epoch-id" | "created-at" | "archive-ref" | "archive-tree" => {
if seen.contains(&field) {
push(
issues,
format!("line {}: duplicate scalar '{field}'", idx + 1),
);
}
seen.push(field);
match field {
"epoch-id" => {
match Uuid::parse_str(value) {
Ok(u) if u.get_version_num() == 7 => epoch_id = Some(value.into()),
_ => push(issues, format!("line {}: epoch-id must be UUIDv7", idx + 1)),
};
}
"created-at" => {
if chrono::DateTime::parse_from_rfc3339(value).is_err() {
push(
issues,
format!("line {}: created-at is not RFC3339", idx + 1),
);
}
}
"archive-ref" => {
if !value.starts_with("refs/memlay/archive/") {
push(
issues,
format!(
"line {}: archive-ref must be under refs/memlay/archive/",
idx + 1
),
);
}
}
"archive-tree" => {
if value.len() < 40 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
push(
issues,
format!("line {}: archive-tree is not a tree OID", idx + 1),
);
}
}
_ => unreachable!(),
}
}
"record" => {
records += 1;
let parts: Vec<&str> = value.splitn(6, ' ').collect();
if parts.len() < 6 {
push(
issues,
format!(
"line {}: record needs '<uuid> <key> <kind> <date> <hash16> <summary>'",
idx + 1
),
);
continue;
}
if Uuid::parse_str(parts[0])
.map(|u| u.get_version_num() != 7)
.unwrap_or(true)
{
push(
issues,
format!("line {}: record id must be UUIDv7", idx + 1),
);
}
if crate::records::validate_key(parts[1]).is_err() {
push(issues, format!("line {}: invalid record key", idx + 1));
}
if crate::records::Kind::parse(parts[2]).is_none() {
push(issues, format!("line {}: unknown record kind", idx + 1));
}
if parts[4].len() != 16 || !parts[4].chars().all(|c| c.is_ascii_hexdigit()) {
push(
issues,
format!("line {}: record hash must be 16 hex chars", idx + 1),
);
}
}
other => push(issues, format!("line {}: unknown field '{other}'", idx + 1)),
}
}
if !header {
push(issues, "missing 'memlay-epoch 1' header".into());
return;
}
for required in ["epoch-id", "created-at", "archive-ref", "archive-tree"] {
if !seen.contains(&required) {
push(issues, format!("missing required field '{required}'"));
}
}
if records == 0 {
push(issues, "epoch index lists no records".into());
}
if let Some(id) = epoch_id {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if stem != id {
push(
issues,
format!("filename '{stem}.mle' does not match epoch-id {id}"),
);
}
}
}
pub fn validate_all(repo_root: &Path) -> Result<Vec<EpochIssue>> {
let dir = repo_root.join(EPOCHS_DIR.replace('/', std::path::MAIN_SEPARATOR_STR));
let mut issues = Vec::new();
if !dir.exists() {
return Ok(issues);
}
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "mle") {
let rel = format!(
"{EPOCHS_DIR}/{}",
path.file_name().unwrap_or_default().to_string_lossy()
);
validate_one(&path, &rel, &mut issues);
}
}
Ok(issues)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_epoch_passes_and_bad_ones_fail() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".memlay/epochs");
std::fs::create_dir_all(&dir).unwrap();
let id = Uuid::now_v7();
std::fs::write(
dir.join(format!("{id}.mle")),
format!(
"memlay-epoch 1\nepoch-id {id}\ncreated-at 2026-07-27T00:00:00Z\narchive-ref refs/memlay/archive/{id}\narchive-tree {}\nrecord {} auth.tokens decision 2026-01-01 0123456789abcdef Tokens are hashed.\n",
"a".repeat(40),
Uuid::now_v7()
),
)
.unwrap();
assert!(validate_all(tmp.path()).unwrap().is_empty());
std::fs::write(dir.join(format!("{}.mle", Uuid::now_v7())), "not an epoch").unwrap();
assert!(!validate_all(tmp.path()).unwrap().is_empty());
}
}