use super::validate::{has_errors, validate_record, Issue, Severity};
use super::{mrf, Record};
use crate::errors::{err, ErrorCode};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const RECORDS_DIR: &str = ".memlay/records";
#[derive(Debug, Clone)]
pub struct LoadedRecord {
pub record: Record,
pub rel_path: String,
pub issues: Vec<Issue>,
}
impl LoadedRecord {
pub fn is_valid(&self) -> bool {
!has_errors(&self.issues)
}
}
#[derive(Debug, Clone)]
pub struct MalformedFile {
pub rel_path: String,
pub error: String,
}
#[derive(Debug, Default)]
pub struct LoadResult {
pub records: Vec<LoadedRecord>,
pub malformed: Vec<MalformedFile>,
}
impl LoadResult {
pub fn by_id(&self) -> HashMap<uuid::Uuid, &LoadedRecord> {
self.records.iter().map(|r| (r.record.id, r)).collect()
}
}
fn to_rel(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
pub fn load_all(root: &Path) -> Result<LoadResult> {
let dir = root.join(RECORDS_DIR);
let mut result = LoadResult::default();
if !dir.exists() {
return Ok(result);
}
let mut stack = vec![dir];
let mut files: Vec<PathBuf> = Vec::new();
while let Some(d) = stack.pop() {
for entry in fs::read_dir(&d).with_context(|| format!("reading {}", d.display()))? {
let entry = entry?;
let ft = entry.file_type()?;
let p = entry.path();
if ft.is_dir() {
stack.push(p);
} else if ft.is_file() && p.extension().is_some_and(|e| e == "mly") {
files.push(p);
}
}
}
let mut seen_ids: HashMap<uuid::Uuid, String> = HashMap::new();
for path in files {
let rel = to_rel(root, &path);
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(e) => {
result.malformed.push(MalformedFile {
rel_path: rel,
error: e.to_string(),
});
continue;
}
};
match mrf::parse(&bytes) {
Ok(record) => {
let mut issues = validate_record(&record);
let expected_stem = record.id.to_string();
let actual_stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if actual_stem != expected_stem {
issues.push(Issue {
severity: Severity::Error,
message: format!(
"filename '{actual_stem}.mly' does not match record id {expected_stem}"
),
});
}
if let Some(first) = seen_ids.get(&record.id) {
issues.push(Issue {
severity: Severity::Error,
message: format!("duplicate record id also present at {first}"),
});
} else {
seen_ids.insert(record.id, rel.clone());
}
result.records.push(LoadedRecord {
record,
rel_path: rel,
issues,
});
}
Err(e) => {
result.malformed.push(MalformedFile {
rel_path: rel,
error: e.to_string(),
});
}
}
}
result.records.sort_by_key(|r| r.record.id);
result.malformed.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(result)
}
pub fn create(root: &Path, record: &Record) -> Result<String> {
let issues = validate_record(record);
if has_errors(&issues) {
let details = issues
.iter()
.filter(|i| i.severity == Severity::Error)
.map(|i| i.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(err(
ErrorCode::InvalidRecord,
format!("record failed validation: {details}"),
));
}
let rel = record.relative_path();
let abs = root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let text = mrf::serialize(record);
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
let mut file = opts.open(&abs).map_err(|e| {
err(
ErrorCode::ImmutableRecordChanged,
format!("cannot create {rel}: {e}"),
)
})?;
use std::io::Write;
file.write_all(text.as_bytes())
.with_context(|| format!("writing {rel}"))?;
file.sync_all().ok();
Ok(rel)
}
#[cfg(test)]
mod tests {
use super::super::{Confidence, Kind, Op, Record};
use super::*;
use chrono::TimeZone;
use chrono::Utc;
use uuid::Uuid;
fn record() -> Record {
Record {
id: Uuid::now_v7(),
key: "a.b".into(),
kind: Kind::DomainFact,
op: Op::Assert,
summary: "Fact.".into(),
rationale: None,
confidence: Confidence::Verified,
created_at: Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap(),
writer: "w".into(),
human: None,
agent: None,
session: None,
pr: None,
issue: None,
alias_key: None,
canonical_key: None,
details: vec![],
alternatives: vec![],
consequences: vec![],
paths: vec![],
symbols: vec![],
tags: vec!["general".into()],
evidence: vec![],
supersedes: vec![],
related: vec![],
extensions: vec![],
}
}
#[test]
fn create_load_round_trip_and_immutability() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let r = record();
let rel = create(root, &r).unwrap();
assert!(rel.starts_with(".memlay/records/2026/07/27/"));
assert!(create(root, &r).is_err());
let loaded = load_all(root).unwrap();
assert_eq!(loaded.records.len(), 1);
assert!(loaded.records[0].is_valid());
assert_eq!(loaded.records[0].record, r);
}
#[test]
fn malformed_file_does_not_hide_valid_records() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
create(root, &record()).unwrap();
let bad_dir = root.join(".memlay/records/2026/07/27");
std::fs::write(bad_dir.join("broken.mly"), b"not a record").unwrap();
let loaded = load_all(root).unwrap();
assert_eq!(loaded.records.len(), 1);
assert_eq!(loaded.malformed.len(), 1);
}
#[test]
fn filename_mismatch_flagged() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let r = record();
let dir = root.join(".memlay/records/2026/07/27");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("wrong-name.mly"), super::super::mrf::serialize(&r)).unwrap();
let loaded = load_all(root).unwrap();
assert_eq!(loaded.records.len(), 1);
assert!(!loaded.records[0].is_valid());
}
}