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;
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,
})
}
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,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extension {
pub name: String,
pub value: String,
}
#[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 {
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())
}
}
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(())
}
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(())
}
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);
}
}