memlay 0.1.4

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! P0 epoch compatibility contract (PRD §8.9): the `.mle` archive-index
//! format is defined and validated now so P1 archival cannot require a
//! format break. Creating/fetching archives is P1 and intentionally absent.

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}"),
            );
        }
    }
}

/// Validate every committed `.mle` under `.memlay/epochs/` (used by check).
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());
    }
}