memlay 0.1.3

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Logical record validation (PRD §8.4, §8.5, §10.3).

use super::{
    validate_key, validate_repo_relative_path, validate_tag, Kind, Op, Record,
    DETAILS_COMBINED_MAX_CHARS, DETAIL_LINE_MAX_CHARS, RATIONALE_MAX_CHARS, SUMMARY_MAX_CHARS,
};

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Issue {
    pub severity: Severity,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Error,
    Warning,
}

fn error(issues: &mut Vec<Issue>, message: impl Into<String>) {
    issues.push(Issue {
        severity: Severity::Error,
        message: message.into(),
    });
}

fn warning(issues: &mut Vec<Issue>, message: impl Into<String>) {
    issues.push(Issue {
        severity: Severity::Warning,
        message: message.into(),
    });
}

fn no_edge_spaces(issues: &mut Vec<Issue>, field: &str, value: &str) {
    if value != value.trim() {
        error(
            issues,
            format!("field '{field}' must not have leading or trailing spaces (breaks canonical round-trip)"),
        );
    }
}

/// Validate one record in isolation. Graph-level rules (missing supersedes
/// targets, cycles, cross-key supersession) live in `memgraph`.
pub fn validate_record(r: &Record) -> Vec<Issue> {
    let mut issues = Vec::new();

    if let Err(m) = validate_key(&r.key) {
        error(&mut issues, format!("invalid key: {m}"));
    }

    let summary_chars = r.summary.chars().count();
    if summary_chars == 0 {
        error(&mut issues, "summary is required");
    } else if summary_chars > SUMMARY_MAX_CHARS {
        error(
            &mut issues,
            format!("summary exceeds {SUMMARY_MAX_CHARS} characters ({summary_chars})"),
        );
    }
    no_edge_spaces(&mut issues, "summary", &r.summary);

    if let Some(rat) = &r.rationale {
        let n = rat.chars().count();
        if n > RATIONALE_MAX_CHARS {
            error(
                &mut issues,
                format!("rationale exceeds {RATIONALE_MAX_CHARS} characters ({n})"),
            );
        }
        no_edge_spaces(&mut issues, "rationale", rat);
    }

    if r.kind.requires_rationale()
        && r.rationale.is_none()
        && r.extension("x-rationale-not-applicable").is_none()
    {
        error(
            &mut issues,
            format!(
                "kind '{}' requires rationale (or an explicit x-rationale-not-applicable extension)",
                r.kind.as_str()
            ),
        );
    }

    let mut expanded_total = 0usize;
    for (name, list) in [
        ("detail", &r.details),
        ("alternative", &r.alternatives),
        ("consequence", &r.consequences),
    ] {
        for v in list {
            let n = v.chars().count();
            expanded_total += n;
            if n > DETAIL_LINE_MAX_CHARS {
                error(
                    &mut issues,
                    format!("{name} line exceeds {DETAIL_LINE_MAX_CHARS} characters"),
                );
            }
            no_edge_spaces(&mut issues, name, v);
        }
    }
    if expanded_total > DETAILS_COMBINED_MAX_CHARS {
        error(
            &mut issues,
            format!("combined detail/alternative/consequence content exceeds {DETAILS_COMBINED_MAX_CHARS} characters"),
        );
    }

    for p in &r.paths {
        if let Err(m) = validate_repo_relative_path(p) {
            error(&mut issues, format!("invalid path: {m}"));
        }
    }
    for t in &r.tags {
        if let Err(m) = validate_tag(t) {
            error(&mut issues, format!("invalid tag: {m}"));
        }
    }
    for e in &r.evidence {
        if e.etype == super::EvidenceType::Path {
            // Evidence values may carry a `@blake3:<hash>` suffix.
            let path_part = e.value.split('@').next().unwrap_or(&e.value);
            let path_part = path_part.split('#').next().unwrap_or(path_part);
            if let Err(m) = validate_repo_relative_path(path_part) {
                error(&mut issues, format!("invalid evidence path: {m}"));
            }
        }
    }

    // Operation semantics (PRD §8.5 op).
    if r.op == Op::Retract {
        if r.supersedes.is_empty() {
            error(
                &mut issues,
                "a retract record must supersede at least one record",
            );
        }
        if r.rationale.is_none() && r.details.is_empty() {
            error(
                &mut issues,
                "a retract record must explain the reason in rationale or detail",
            );
        }
    }

    if r.supersedes.contains(&r.id) {
        error(&mut issues, "a record cannot supersede itself");
    }
    {
        let mut seen = std::collections::HashSet::new();
        for u in &r.supersedes {
            if !seen.insert(u) {
                error(&mut issues, format!("duplicate supersedes entry {u}"));
            }
        }
    }

    // key-alias structural rules (PRD §8.5).
    if r.kind == Kind::KeyAlias {
        match (&r.alias_key, &r.canonical_key) {
            (Some(alias), Some(canonical)) => {
                if let Err(m) = validate_key(alias) {
                    error(&mut issues, format!("invalid alias-key: {m}"));
                }
                if let Err(m) = validate_key(canonical) {
                    error(&mut issues, format!("invalid canonical-key: {m}"));
                }
                if alias == canonical {
                    error(
                        &mut issues,
                        "alias-key must differ from canonical-key (self-alias)",
                    );
                }
                let expected = format!("key-alias.{alias}");
                if r.key != expected {
                    error(
                        &mut issues,
                        format!(
                            "key of a key-alias record must be '{expected}', found '{}'",
                            r.key
                        ),
                    );
                }
            }
            _ => error(
                &mut issues,
                "kind 'key-alias' requires alias-key and canonical-key",
            ),
        }
    } else if r.alias_key.is_some() || r.canonical_key.is_some() {
        error(
            &mut issues,
            "alias-key/canonical-key are only valid on kind 'key-alias'",
        );
    }

    // Event-kind key conventions (PRD §8.5 kind).
    if r.kind.is_event() {
        let prefix = format!("{}.", r.kind.as_str());
        if !r.key.starts_with(&prefix) {
            warning(
                &mut issues,
                format!("event record key should normally be '{}<uuid-v7>'", prefix),
            );
        }
    }

    // Scope expectation (PRD §8.4): at least one path, symbol, or tag unless
    // the record is repository-wide (marked by extension or tag convention).
    if r.paths.is_empty()
        && r.symbols.is_empty()
        && r.tags.is_empty()
        && r.kind != Kind::KeyAlias
        && r.extension("x-repository-wide").is_none()
    {
        warning(
            &mut issues,
            "record has no path, symbol, or tag scope; add one or mark it repository-wide with x-repository-wide true",
        );
    }

    // Scalar values used in single-line contexts must not carry edge spaces.
    for (name, v) in [("key", Some(&r.key)), ("writer", Some(&r.writer))] {
        if let Some(v) = v {
            no_edge_spaces(&mut issues, name, v);
        }
    }

    for x in &r.extensions {
        if !x.name.starts_with("x-") || x.name.len() <= 2 {
            error(
                &mut issues,
                format!("extension field '{}' must begin with 'x-'", x.name),
            );
        }
    }

    issues
}

pub fn has_errors(issues: &[Issue]) -> bool {
    issues.iter().any(|i| i.severity == Severity::Error)
}

#[cfg(test)]
mod tests {
    use super::super::{Confidence, Evidence, EvidenceType, Extension, Op, Record};
    use super::*;
    use chrono::TimeZone;
    use chrono::Utc;
    use uuid::Uuid;

    fn base(kind: Kind) -> Record {
        Record {
            id: Uuid::now_v7(),
            key: "payments.webhook.retry-policy".into(),
            kind,
            op: Op::Assert,
            summary: "Something true.".into(),
            rationale: Some("Because reasons.".into()),
            confidence: Confidence::Verified,
            created_at: Utc.with_ymd_and_hms(2026, 7, 27, 0, 0, 0).unwrap(),
            writer: "w1".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!["apps/worker".into()],
            symbols: vec![],
            tags: vec![],
            evidence: vec![],
            supersedes: vec![],
            related: vec![],
            extensions: vec![],
        }
    }

    #[test]
    fn valid_decision_passes() {
        let issues = validate_record(&base(Kind::Decision));
        assert!(!has_errors(&issues), "{issues:?}");
    }

    #[test]
    fn decision_without_rationale_fails() {
        let mut r = base(Kind::Decision);
        r.rationale = None;
        assert!(has_errors(&validate_record(&r)));
        r.extensions.push(Extension {
            name: "x-rationale-not-applicable".into(),
            value: "true".into(),
        });
        assert!(!has_errors(&validate_record(&r)));
    }

    #[test]
    fn retract_needs_supersedes_and_reason() {
        let mut r = base(Kind::Decision);
        r.op = Op::Retract;
        r.supersedes = vec![];
        assert!(has_errors(&validate_record(&r)));
        r.supersedes = vec![Uuid::now_v7()];
        assert!(!has_errors(&validate_record(&r)));
    }

    #[test]
    fn key_alias_shape_enforced() {
        let mut r = base(Kind::KeyAlias);
        r.rationale = Some("Concept fused.".into());
        assert!(has_errors(&validate_record(&r))); // missing alias fields
        r.alias_key = Some("auth.tokens".into());
        r.canonical_key = Some("auth.token-storage".into());
        r.key = "key-alias.auth.tokens".into();
        assert!(
            !has_errors(&validate_record(&r)),
            "{:?}",
            validate_record(&r)
        );
        r.canonical_key = Some("auth.tokens".into());
        assert!(has_errors(&validate_record(&r))); // self alias
    }

    #[test]
    fn self_supersession_rejected() {
        let mut r = base(Kind::Decision);
        r.supersedes = vec![r.id];
        assert!(has_errors(&validate_record(&r)));
    }

    #[test]
    fn bad_evidence_path_rejected() {
        let mut r = base(Kind::Decision);
        r.evidence = vec![Evidence {
            etype: EvidenceType::Path,
            value: "../outside".into(),
        }];
        assert!(has_errors(&validate_record(&r)));
    }
}