use super::{
Confidence, Evidence, EvidenceType, Extension, Kind, Op, Record, LINE_MAX_BYTES,
RECORD_FORMAT_VERSION,
};
use chrono::{DateTime, SecondsFormat, Utc};
use std::fmt::Write as _;
use uuid::Uuid;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum MrfError {
#[error("invalid UTF-8 in record file")]
InvalidUtf8,
#[error("record contains NUL byte")]
NulByte,
#[error("line {line}: exceeds {LINE_MAX_BYTES} bytes")]
OversizedLine { line: usize },
#[error("first non-comment line must be exactly 'memlay 1', found '{found}'")]
BadHeader { found: String },
#[error("missing 'memlay 1' header")]
MissingHeader,
#[error("line {line}: expected '<field> <value>'")]
BadLine { line: usize },
#[error("line {line}: field '{field}' must be lowercase ASCII")]
BadFieldName { line: usize, field: String },
#[error("line {line}: duplicate scalar field '{field}'")]
DuplicateScalar { line: usize, field: String },
#[error("line {line}: unknown field '{field}' (extension fields must begin with 'x-')")]
UnknownField { line: usize, field: String },
#[error("line {line}: field '{field}': {message}")]
BadValue {
line: usize,
field: String,
message: String,
},
#[error("missing required field '{field}'")]
MissingField { field: String },
#[error("value contains control character (field '{field}')")]
ControlChar { field: String },
}
const SCALAR_FIELDS: &[&str] = &[
"memlay",
"id",
"key",
"kind",
"op",
"summary",
"rationale",
"confidence",
"created-at",
"writer",
"human",
"agent",
"session",
"pr",
"issue",
"alias-key",
"canonical-key",
];
const REPEATED_FIELDS: &[&str] = &[
"detail",
"alternative",
"consequence",
"path",
"symbol",
"tag",
"evidence",
"supersedes",
"related",
];
fn field_name_ok(field: &str) -> bool {
!field.is_empty()
&& field
.bytes()
.all(|b| b.is_ascii_lowercase() || b == b'-' || b.is_ascii_digit())
}
fn value_ok(value: &str) -> bool {
!value
.chars()
.any(|c| (c.is_control() && c != '\t') || c == '\u{7f}')
}
pub fn parse(bytes: &[u8]) -> Result<Record, MrfError> {
if bytes.contains(&0) {
return Err(MrfError::NulByte);
}
let text = std::str::from_utf8(bytes).map_err(|_| MrfError::InvalidUtf8)?;
let mut header_seen = false;
let mut scalars: Vec<(String, String, usize)> = Vec::new();
let mut repeated: Vec<(String, String, usize)> = Vec::new();
let mut extensions: Vec<Extension> = Vec::new();
for (idx, raw_line) in text.split('\n').enumerate() {
let line_no = idx + 1;
if raw_line.len() > LINE_MAX_BYTES {
return Err(MrfError::OversizedLine { line: line_no });
}
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.is_empty() || line.starts_with('#') {
continue;
}
if !header_seen {
if line == "memlay 1" {
header_seen = true;
continue;
}
return Err(MrfError::BadHeader {
found: line.chars().take(80).collect(),
});
}
let (field, rest) = match line.split_once(' ') {
Some((f, r)) => (f, r),
None => return Err(MrfError::BadLine { line: line_no }),
};
if !field_name_ok(field) {
return Err(MrfError::BadFieldName {
line: line_no,
field: field.to_string(),
});
}
let value = rest.trim_start_matches(' ');
if value.is_empty() {
return Err(MrfError::BadLine { line: line_no });
}
if !value_ok(value) {
return Err(MrfError::ControlChar {
field: field.to_string(),
});
}
if field == "memlay" {
return Err(MrfError::DuplicateScalar {
line: line_no,
field: field.to_string(),
});
}
if SCALAR_FIELDS.contains(&field) {
if scalars.iter().any(|(f, _, _)| f == field) {
return Err(MrfError::DuplicateScalar {
line: line_no,
field: field.to_string(),
});
}
scalars.push((field.to_string(), value.to_string(), line_no));
} else if REPEATED_FIELDS.contains(&field) {
repeated.push((field.to_string(), value.to_string(), line_no));
} else if field.starts_with("x-") && field.len() > 2 {
extensions.push(Extension {
name: field.to_string(),
value: value.to_string(),
});
} else {
return Err(MrfError::UnknownField {
line: line_no,
field: field.to_string(),
});
}
}
if !header_seen {
return Err(MrfError::MissingHeader);
}
let get = |name: &str| -> Option<(String, usize)> {
scalars
.iter()
.find(|(f, _, _)| f == name)
.map(|(_, v, l)| (v.clone(), *l))
};
let require = |name: &str| -> Result<(String, usize), MrfError> {
get(name).ok_or_else(|| MrfError::MissingField {
field: name.to_string(),
})
};
let (id_s, id_line) = require("id")?;
let id = Uuid::parse_str(&id_s).map_err(|_| MrfError::BadValue {
line: id_line,
field: "id".into(),
message: "not a valid UUID".into(),
})?;
if id.get_version_num() != 7 {
return Err(MrfError::BadValue {
line: id_line,
field: "id".into(),
message: format!("must be UUIDv7, found version {}", id.get_version_num()),
});
}
let (key, _) = require("key")?;
let (kind_s, kind_line) = require("kind")?;
let kind = Kind::parse(&kind_s).ok_or_else(|| MrfError::BadValue {
line: kind_line,
field: "kind".into(),
message: format!("unknown kind '{kind_s}'"),
})?;
let (op_s, op_line) = require("op")?;
let op = Op::parse(&op_s).ok_or_else(|| MrfError::BadValue {
line: op_line,
field: "op".into(),
message: format!("unknown op '{op_s}'"),
})?;
let (summary, _) = require("summary")?;
let (conf_s, conf_line) = require("confidence")?;
let confidence = Confidence::parse(&conf_s).ok_or_else(|| MrfError::BadValue {
line: conf_line,
field: "confidence".into(),
message: format!("unknown confidence '{conf_s}'"),
})?;
let (ts_s, ts_line) = require("created-at")?;
let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&ts_s)
.map_err(|e| MrfError::BadValue {
line: ts_line,
field: "created-at".into(),
message: format!("not RFC3339: {e}"),
})?
.with_timezone(&Utc);
let (writer, _) = require("writer")?;
let parse_uuid_list = |name: &str| -> Result<Vec<Uuid>, MrfError> {
repeated
.iter()
.filter(|(f, _, _)| f == name)
.map(|(_, v, l)| {
Uuid::parse_str(v).map_err(|_| MrfError::BadValue {
line: *l,
field: name.to_string(),
message: format!("'{v}' is not a valid UUID"),
})
})
.collect()
};
let supersedes = parse_uuid_list("supersedes")?;
let related = parse_uuid_list("related")?;
let evidence = repeated
.iter()
.filter(|(f, _, _)| f == "evidence")
.map(|(_, v, l)| {
let (t, val) = v.split_once(' ').ok_or_else(|| MrfError::BadValue {
line: *l,
field: "evidence".into(),
message: "expected '<type> <value>'".into(),
})?;
let etype = EvidenceType::parse(t).ok_or_else(|| MrfError::BadValue {
line: *l,
field: "evidence".into(),
message: format!("unknown evidence type '{t}'"),
})?;
let val = val.trim_start_matches(' ');
if val.is_empty() {
return Err(MrfError::BadValue {
line: *l,
field: "evidence".into(),
message: "empty evidence value".into(),
});
}
Ok(Evidence {
etype,
value: val.to_string(),
})
})
.collect::<Result<Vec<_>, _>>()?;
let strings = |name: &str| -> Vec<String> {
repeated
.iter()
.filter(|(f, _, _)| f == name)
.map(|(_, v, _)| v.clone())
.collect()
};
Ok(Record {
id,
key,
kind,
op,
summary,
rationale: get("rationale").map(|(v, _)| v),
confidence,
created_at,
writer,
human: get("human").map(|(v, _)| v),
agent: get("agent").map(|(v, _)| v),
session: get("session").map(|(v, _)| v),
pr: get("pr").map(|(v, _)| v),
issue: get("issue").map(|(v, _)| v),
alias_key: get("alias-key").map(|(v, _)| v),
canonical_key: get("canonical-key").map(|(v, _)| v),
details: strings("detail"),
alternatives: strings("alternative"),
consequences: strings("consequence"),
paths: strings("path"),
symbols: strings("symbol"),
tags: strings("tag"),
evidence,
supersedes,
related,
extensions,
})
}
pub fn serialize(r: &Record) -> String {
let mut out = String::with_capacity(512);
let _ = writeln!(out, "memlay {RECORD_FORMAT_VERSION}");
let _ = writeln!(out, "id {}", r.id);
let _ = writeln!(out, "key {}", r.key);
let _ = writeln!(out, "kind {}", r.kind.as_str());
let _ = writeln!(out, "op {}", r.op.as_str());
let _ = writeln!(out, "summary {}", r.summary);
if let Some(v) = &r.rationale {
let _ = writeln!(out, "rationale {v}");
}
let _ = writeln!(out, "confidence {}", r.confidence.as_str());
let _ = writeln!(
out,
"created-at {}",
r.created_at.to_rfc3339_opts(SecondsFormat::AutoSi, true)
);
let _ = writeln!(out, "writer {}", r.writer);
for (name, v) in [
("human", &r.human),
("agent", &r.agent),
("session", &r.session),
("pr", &r.pr),
("issue", &r.issue),
("alias-key", &r.alias_key),
("canonical-key", &r.canonical_key),
] {
if let Some(v) = v {
let _ = writeln!(out, "{name} {v}");
}
}
for v in &r.details {
let _ = writeln!(out, "detail {v}");
}
for v in &r.alternatives {
let _ = writeln!(out, "alternative {v}");
}
for v in &r.consequences {
let _ = writeln!(out, "consequence {v}");
}
for v in &r.paths {
let _ = writeln!(out, "path {v}");
}
for v in &r.symbols {
let _ = writeln!(out, "symbol {v}");
}
for v in &r.tags {
let _ = writeln!(out, "tag {v}");
}
for e in &r.evidence {
let _ = writeln!(out, "evidence {} {}", e.etype.as_str(), e.value);
}
for u in &r.supersedes {
let _ = writeln!(out, "supersedes {u}");
}
for u in &r.related {
let _ = writeln!(out, "related {u}");
}
for x in &r.extensions {
let _ = writeln!(out, "{} {}", x.name, x.value);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample() -> Record {
Record {
id: Uuid::parse_str("019c001e-14a7-7e41-a91d-8b27c8f9ad92").unwrap(),
key: "auth.refresh-token-storage".into(),
kind: Kind::Decision,
op: Op::Assert,
summary: "Refresh tokens are stored only as keyed digests.".into(),
rationale: Some(
"Raw refresh tokens must not be recoverable from a database leak.".into(),
),
confidence: Confidence::Verified,
created_at: Utc.with_ymd_and_hms(2026, 7, 27, 17, 42, 11).unwrap(),
writer: "local-writer-id".into(),
human: Some("lucian@example.com".into()),
agent: Some("codex".into()),
session: Some("019c0000-1111-7222-8333-444444444444".into()),
pr: None,
issue: None,
alias_key: None,
canonical_key: None,
details: vec![],
alternatives: vec!["Store raw tokens encrypted at rest; rejected.".into()],
consequences: vec!["Existing raw-token sessions require migration.".into()],
paths: vec!["packages/auth".into()],
symbols: vec!["SessionService.rotateRefreshToken".into()],
tags: vec!["auth".into(), "security".into()],
evidence: vec![Evidence {
etype: EvidenceType::Test,
value: "packages/auth/src/session-service.test.ts#rotates_refresh_tokens".into(),
}],
supersedes: vec![Uuid::parse_str("019bffff-aaaa-7bbb-8ccc-dddddddddddd").unwrap()],
related: vec![],
extensions: vec![Extension {
name: "x-origin".into(),
value: "manual".into(),
}],
}
}
#[test]
fn round_trip() {
let r = sample();
let text = serialize(&r);
let parsed = parse(text.as_bytes()).unwrap();
assert_eq!(r, parsed);
assert_eq!(serialize(&parsed), text);
}
#[test]
fn header_required_and_strict() {
assert_eq!(parse(b"").unwrap_err(), MrfError::MissingHeader);
assert!(matches!(
parse(b"memlay 2\n").unwrap_err(),
MrfError::BadHeader { .. }
));
let ok = format!(
"# c\n\nmemlay 1\n{}",
&serialize(&sample())["memlay 1\n".len()..]
);
assert!(parse(ok.as_bytes()).is_ok());
}
#[test]
fn rejects_duplicates_unknowns_and_nul() {
let base = serialize(&sample());
let dup = format!("{base}summary again\n");
assert!(matches!(
parse(dup.as_bytes()).unwrap_err(),
MrfError::DuplicateScalar { .. }
));
let unknown = format!("{base}bogus value\n");
assert!(matches!(
parse(unknown.as_bytes()).unwrap_err(),
MrfError::UnknownField { .. }
));
let mut nul = base.clone().into_bytes();
nul.push(0);
assert_eq!(parse(&nul).unwrap_err(), MrfError::NulByte);
}
#[test]
fn extension_fields_allowed() {
let text = format!("{}x-custom-note something\n", serialize(&sample()));
let r = parse(text.as_bytes()).unwrap();
assert_eq!(r.extension("x-custom-note"), Some("something"));
}
#[test]
fn non_v7_uuid_rejected() {
let text = serialize(&sample()).replace("019c001e-14a7-7e41", "019c001e-14a7-4e41"); assert!(matches!(
parse(text.as_bytes()).unwrap_err(),
MrfError::BadValue { .. }
));
}
#[test]
fn multivalue_spaces_preserved_in_value() {
let r = parse(
b"memlay 1\nid 019c001e-14a7-7e41-a91d-8b27c8f9ad92\nkey a.b\nkind decision\nop assert\nsummary leading space kept after separator\nconfidence verified\ncreated-at 2026-07-27T00:00:00Z\nwriter w\nrationale why\n",
)
.unwrap();
assert_eq!(r.summary, "leading space kept after separator");
}
}