memlay 0.1.3

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
pub mod mrf;
pub mod store;
pub mod validate;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

pub const RECORD_FORMAT_VERSION: u32 = 1;
pub const KEY_MAX_CHARS: usize = 160;
pub const SUMMARY_MAX_CHARS: usize = 500;
pub const RATIONALE_MAX_CHARS: usize = 4_000;
pub const DETAIL_LINE_MAX_CHARS: usize = 2_000;
pub const DETAILS_COMBINED_MAX_CHARS: usize = 12_000;
/// Generic per-line byte cap enforced by the parser before field-specific limits.
pub const LINE_MAX_BYTES: usize = 32_768;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
    Change,
    Decision,
    Constraint,
    Convention,
    Interface,
    DomainFact,
    Architecture,
    Workstream,
    Incident,
    KnownIssue,
    KeyAlias,
}

impl Kind {
    pub fn as_str(self) -> &'static str {
        match self {
            Kind::Change => "change",
            Kind::Decision => "decision",
            Kind::Constraint => "constraint",
            Kind::Convention => "convention",
            Kind::Interface => "interface",
            Kind::DomainFact => "domain-fact",
            Kind::Architecture => "architecture",
            Kind::Workstream => "workstream",
            Kind::Incident => "incident",
            Kind::KnownIssue => "known-issue",
            Kind::KeyAlias => "key-alias",
        }
    }

    pub fn parse(s: &str) -> Option<Kind> {
        Some(match s {
            "change" => Kind::Change,
            "decision" => Kind::Decision,
            "constraint" => Kind::Constraint,
            "convention" => Kind::Convention,
            "interface" => Kind::Interface,
            "domain-fact" => Kind::DomainFact,
            "architecture" => Kind::Architecture,
            "workstream" => Kind::Workstream,
            "incident" => Kind::Incident,
            "known-issue" => Kind::KnownIssue,
            "key-alias" => Kind::KeyAlias,
            _ => return None,
        })
    }

    /// Event kinds describe chronology; state kinds describe current truth (PRD §8.5).
    pub fn is_event(self) -> bool {
        matches!(self, Kind::Change | Kind::Incident)
    }

    pub fn requires_rationale(self) -> bool {
        matches!(
            self,
            Kind::Decision | Kind::Architecture | Kind::Constraint | Kind::Interface | Kind::Change
        )
    }

    #[cfg(test)]
    pub fn all() -> &'static [Kind] {
        &[
            Kind::Change,
            Kind::Decision,
            Kind::Constraint,
            Kind::Convention,
            Kind::Interface,
            Kind::DomainFact,
            Kind::Architecture,
            Kind::Workstream,
            Kind::Incident,
            Kind::KnownIssue,
            Kind::KeyAlias,
        ]
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Op {
    Assert,
    Retract,
}

impl Op {
    pub fn as_str(self) -> &'static str {
        match self {
            Op::Assert => "assert",
            Op::Retract => "retract",
        }
    }
    pub fn parse(s: &str) -> Option<Op> {
        Some(match s {
            "assert" => Op::Assert,
            "retract" => Op::Retract,
            _ => return None,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Confidence {
    Verified,
    Inferred,
    Uncertain,
}

impl Confidence {
    pub fn as_str(self) -> &'static str {
        match self {
            Confidence::Verified => "verified",
            Confidence::Inferred => "inferred",
            Confidence::Uncertain => "uncertain",
        }
    }
    pub fn parse(s: &str) -> Option<Confidence> {
        Some(match s {
            "verified" => Confidence::Verified,
            "inferred" => Confidence::Inferred,
            "uncertain" => Confidence::Uncertain,
            _ => return None,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceType {
    Path,
    Symbol,
    Commit,
    Test,
    Issue,
    Pr,
    Url,
    AuditSession,
}

impl EvidenceType {
    pub fn as_str(self) -> &'static str {
        match self {
            EvidenceType::Path => "path",
            EvidenceType::Symbol => "symbol",
            EvidenceType::Commit => "commit",
            EvidenceType::Test => "test",
            EvidenceType::Issue => "issue",
            EvidenceType::Pr => "pr",
            EvidenceType::Url => "url",
            EvidenceType::AuditSession => "audit-session",
        }
    }
    pub fn parse(s: &str) -> Option<EvidenceType> {
        Some(match s {
            "path" => EvidenceType::Path,
            "symbol" => EvidenceType::Symbol,
            "commit" => EvidenceType::Commit,
            "test" => EvidenceType::Test,
            "issue" => EvidenceType::Issue,
            "pr" => EvidenceType::Pr,
            "url" => EvidenceType::Url,
            "audit-session" => EvidenceType::AuditSession,
            _ => return None,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Evidence {
    #[serde(rename = "type")]
    pub etype: EvidenceType,
    pub value: String,
}

/// An extension field (`x-*`), preserved in order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extension {
    pub name: String,
    pub value: String,
}

/// The logical memory record (PRD §8.4). The physical form is MRF (`.mly`);
/// this struct also serializes to the logical JSON interchange object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Record {
    pub id: Uuid,
    pub key: String,
    pub kind: Kind,
    pub op: Op,
    pub summary: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rationale: Option<String>,
    pub confidence: Confidence,
    pub created_at: DateTime<Utc>,
    pub writer: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub human: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pr: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issue: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub canonical_key: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub details: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub alternatives: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub consequences: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub paths: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub symbols: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub evidence: Vec<Evidence>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub supersedes: Vec<Uuid>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub related: Vec<Uuid>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extensions: Vec<Extension>,
}

impl Record {
    /// Repository-relative canonical path for this record file.
    pub fn relative_path(&self) -> String {
        format!(
            ".memlay/records/{}/{}.mly",
            self.created_at.format("%Y/%m/%d"),
            self.id
        )
    }

    pub fn extension(&self, name: &str) -> Option<&str> {
        self.extensions
            .iter()
            .find(|e| e.name == name)
            .map(|e| e.value.as_str())
    }
}

/// Validate a logical key (PRD §8.5): lowercase dot-separated segments of
/// `[a-z0-9-]`, non-empty, max 160 chars.
pub fn validate_key(key: &str) -> Result<(), String> {
    if key.is_empty() {
        return Err("key is empty".into());
    }
    if key.chars().count() > KEY_MAX_CHARS {
        return Err(format!("key exceeds {KEY_MAX_CHARS} characters"));
    }
    for segment in key.split('.') {
        if segment.is_empty() {
            return Err(format!("key '{key}' has an empty dot segment"));
        }
        for c in segment.chars() {
            if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
                return Err(format!(
                    "key '{key}' contains invalid character '{c}'; allowed: lowercase ASCII letters, digits, hyphens, dot separators"
                ));
            }
        }
    }
    Ok(())
}

/// Validate a lowercase kebab-case tag.
pub fn validate_tag(tag: &str) -> Result<(), String> {
    if tag.is_empty() {
        return Err("tag is empty".into());
    }
    for c in tag.chars() {
        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
            return Err(format!("tag '{tag}' must be lowercase kebab-case"));
        }
    }
    Ok(())
}

/// Validate a repository-relative path value: `/` separators, no traversal,
/// no absolute or drive-letter paths (PRD §8.5, §21).
pub fn validate_repo_relative_path(path: &str) -> Result<(), String> {
    if path.is_empty() {
        return Err("path is empty".into());
    }
    if path.contains('\\') {
        return Err(format!("path '{path}' must use '/' separators"));
    }
    if path.starts_with('/') || path.contains(':') {
        return Err(format!("path '{path}' must be repository-relative"));
    }
    for seg in path.split('/') {
        if seg == ".." {
            return Err(format!("path '{path}' must not contain '..'"));
        }
        if seg.is_empty() {
            return Err(format!("path '{path}' has an empty segment"));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn key_validation() {
        assert!(validate_key("payments.webhook.retry-policy").is_ok());
        assert!(validate_key("a").is_ok());
        assert!(validate_key("key-alias.auth.tokens").is_ok());
        assert!(validate_key("").is_err());
        assert!(validate_key("Payments.webhook").is_err());
        assert!(validate_key("a..b").is_err());
        assert!(validate_key("a.b_c").is_err());
        assert!(validate_key(&"a".repeat(161)).is_err());
    }

    #[test]
    fn path_validation() {
        assert!(validate_repo_relative_path("apps/api/payments").is_ok());
        assert!(validate_repo_relative_path("/etc/passwd").is_err());
        assert!(validate_repo_relative_path("..\\x").is_err());
        assert!(validate_repo_relative_path("a/../b").is_err());
        assert!(validate_repo_relative_path("C:/x").is_err());
    }

    #[test]
    fn kind_round_trip() {
        for k in Kind::all() {
            assert_eq!(Kind::parse(k.as_str()), Some(*k));
        }
        assert_eq!(Kind::parse("nonsense"), None);
    }
}