memlay 0.1.3

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Loading and writing canonical `.mly` record files under `.memlay/records/`.
//!
//! Records are immutable: files are only ever created with exclusive-create
//! semantics and never modified or deleted (PRD §8.7).

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";

/// A record loaded from disk together with its provenance-relevant location
/// and any validation issues. Invalid records are retained so one malformed
/// file never hides valid memory (PRD §23).
#[derive(Debug, Clone)]
pub struct LoadedRecord {
    pub record: Record,
    /// Repository-relative path with `/` separators.
    pub rel_path: String,
    pub issues: Vec<Issue>,
}

impl LoadedRecord {
    pub fn is_valid(&self) -> bool {
        !has_errors(&self.issues)
    }
}

/// A file that failed MRF parsing entirely.
#[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('\\', "/")
}

/// Recursively load every `.mly` file under `<root>/.memlay/records`.
/// Ordering is normalized by record ID afterwards, never by traversal order
/// (PRD §9.9).
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(),
                });
            }
        }
    }

    // Deterministic order by stable record ID (PRD §9.9).
    result.records.sort_by_key(|r| r.record.id);
    result.malformed.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
    Ok(result)
}

/// Atomically create one new immutable record file. Fails if the record is
/// invalid or the file already exists. Returns the repository-relative path.
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);
    // Exclusive create: never overwrite an existing canonical 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/"));
        // Second create of the same record must fail (exclusive create).
        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());
    }
}