use crate::cli::App;
use crate::errors::{err, ErrorCode};
use crate::memgraph::{self, simulate_alias};
use crate::records::store::{self, LoadedRecord};
use crate::records::{Extension, Kind, Op, Record};
use crate::retrieval::query::split_ident;
use crate::team::Layer;
use anyhow::Result;
use chrono::Utc;
use std::collections::BTreeSet;
use uuid::Uuid;
fn tokens_of_key(key: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for segment in key.split('.') {
out.insert(segment.to_string());
for part in split_ident(segment) {
out.insert(part);
}
}
out
}
fn keyword_tokens(text: &str) -> BTreeSet<String> {
text.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| w.len() >= 3)
.map(|w| w.to_ascii_lowercase())
.collect()
}
fn trigrams(text: &str) -> BTreeSet<String> {
let chars: Vec<char> = text.chars().collect();
if chars.len() < 3 {
return BTreeSet::from([text.to_string()]);
}
chars.windows(3).map(|w| w.iter().collect()).collect()
}
fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let inter = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
inter / union
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct KeyCandidate {
pub key: String,
pub score: f64,
pub summary: String,
pub reason: String,
}
pub fn similar_keys(
records: &[LoadedRecord],
graph: &memgraph::MemoryGraph,
proposed_key: &str,
proposed_summary: &str,
proposed_paths: &[String],
proposed_tags: &[String],
) -> Vec<KeyCandidate> {
let proposed_tokens = tokens_of_key(proposed_key);
let proposed_words = keyword_tokens(proposed_summary);
let proposed_scope: BTreeSet<String> = proposed_paths
.iter()
.chain(proposed_tags.iter())
.cloned()
.collect();
let canonical_proposed = graph.resolve_key(proposed_key);
let mut out: Vec<KeyCandidate> = Vec::new();
for (key, state) in &graph.keys {
if state.kind.is_event() || *key == canonical_proposed {
continue;
}
let head = records
.iter()
.filter(|r| state.head_ids.contains(&r.record.id))
.min_by_key(|r| r.record.id);
let Some(head) = head else { continue };
let key_sim = jaccard(&proposed_tokens, &tokens_of_key(key))
.max(jaccard(&trigrams(proposed_key), &trigrams(key)));
let word_sim = jaccard(&proposed_words, &keyword_tokens(&head.record.summary));
let head_scope: BTreeSet<String> = head
.record
.paths
.iter()
.chain(head.record.tags.iter())
.cloned()
.collect();
let scope_sim = jaccard(&proposed_scope, &head_scope);
let score = key_sim * 0.6 + word_sim * 0.25 + scope_sim * 0.15;
if score > 0.15 {
let mut reasons = Vec::new();
if key_sim > 0.3 {
reasons.push("similar key tokens");
}
if word_sim > 0.2 {
reasons.push("similar summary");
}
if scope_sim > 0.2 {
reasons.push("overlapping scope");
}
out.push(KeyCandidate {
key: key.clone(),
score: (score * 1000.0).round() / 1000.0,
summary: head.record.summary.clone(),
reason: reasons.join(", "),
});
}
}
out.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.key.cmp(&b.key))
});
out.truncate(5);
out
}
pub fn check_duplicate_key(
app: &App,
records: &[LoadedRecord],
graph: &memgraph::MemoryGraph,
record: &Record,
resolution: Option<&str>,
justification: Option<&str>,
) -> Result<Vec<KeyCandidate>> {
if record.kind.is_event() || record.kind == Kind::KeyAlias || !record.supersedes.is_empty() {
return Ok(vec![]);
}
let canonical = graph.resolve_key(&record.key);
if graph.keys.contains_key(&canonical) {
return Ok(vec![]);
}
let candidates = similar_keys(
records,
graph,
&record.key,
&record.summary,
&record.paths,
&record.tags,
);
let block = app.config.memory.duplicate_key_block_score;
let strongest = candidates.first().map(|c| c.score).unwrap_or(0.0);
if strongest >= block {
match resolution {
Some("create-distinct") => {
if justification.map(|j| j.trim().is_empty()).unwrap_or(true) {
return Err(err(
ErrorCode::DuplicateKeyConfirmationRequired,
"create-distinct requires a non-empty justification",
));
}
Ok(candidates)
}
Some(other) => Err(err(
ErrorCode::DuplicateKeyConfirmationRequired,
format!(
"resolution '{other}' must reuse the candidate key or supersede its heads directly; candidates: {}",
candidates.iter().map(|c| c.key.as_str()).collect::<Vec<_>>().join(", ")
),
)),
None => Err(err(
ErrorCode::DuplicateKeyConfirmationRequired,
format!(
"proposed key '{}' strongly matches existing key '{}' (score {:.2}). Choose: reuse the existing key, supersede its heads, alias it (memlay keys alias), or pass --create-distinct with --justification.",
record.key, candidates[0].key, strongest
),
)),
}
} else {
Ok(candidates)
}
}
pub fn keys_alias(
app: &App,
alias_key: &str,
canonical_key: &str,
rationale: Option<String>,
confirm_conflicts: bool,
) -> Result<()> {
let (loaded, graph, _) = app.load_memory()?;
crate::records::validate_key(alias_key)
.map_err(|m| err(ErrorCode::InvalidRecord, format!("alias-key: {m}")))?;
crate::records::validate_key(canonical_key)
.map_err(|m| err(ErrorCode::InvalidRecord, format!("canonical-key: {m}")))?;
if graph.alias_map.contains_key(alias_key) {
return Err(err(
ErrorCode::KeyAliasConflict,
format!(
"alias '{alias_key}' already maps to '{}'; supersede that key-alias record to change it",
graph.alias_map[alias_key]
),
));
}
let impact = simulate_alias(&loaded.records, alias_key, canonical_key);
if app.json {
println!("{}", serde_json::to_string_pretty(&impact)?);
} else {
println!(
"impact: '{alias_key}' ({} head(s)) + '{canonical_key}' ({} head(s)) -> {} head(s) after fusion",
impact.before_alias_heads, impact.before_canonical_heads, impact.after_heads
);
}
if impact.introduces_conflict && !confirm_conflicts {
return Err(err(
ErrorCode::SemanticConflict,
format!(
"aliasing would introduce a semantic conflict ({} heads after fusion); re-run with --confirm-conflicts to proceed and then resolve the fused key",
impact.after_heads
),
));
}
let record = Record {
id: Uuid::now_v7(),
key: format!("key-alias.{alias_key}"),
kind: Kind::KeyAlias,
op: Op::Assert,
summary: format!("'{alias_key}' is the same concept as '{canonical_key}'."),
rationale,
confidence: crate::records::Confidence::Verified,
created_at: Utc::now(),
writer: app.writer_id()?,
human: app.repo.user_email(),
agent: None,
session: None,
pr: None,
issue: None,
alias_key: Some(alias_key.to_string()),
canonical_key: Some(canonical_key.to_string()),
details: vec![],
alternatives: vec![],
consequences: if impact.introduces_conflict {
vec![format!(
"Fusing these keys creates {} competing heads that must be resolved.",
impact.after_heads
)]
} else {
vec![]
},
paths: vec![],
symbols: vec![],
tags: vec![],
evidence: vec![],
supersedes: vec![],
related: vec![],
extensions: vec![Extension {
name: "x-alias-impact".into(),
value: format!(
"before={}+{} after={}",
impact.before_alias_heads, impact.before_canonical_heads, impact.after_heads
),
}],
};
let rel = store::create(&app.repo.root, &record)?;
if !app.json {
println!("Created alias record {rel}");
if impact.introduces_conflict {
println!("warning: resolve the fused key with 'memlay resolve {canonical_key} ...'");
}
}
Ok(())
}
pub fn keys_catalog(app: &App, scope: Option<&str>) -> Result<()> {
let (loaded, graph, _) = app.load_memory()?;
let mut rows: Vec<serde_json::Value> = Vec::new();
for (key, state) in &graph.keys {
if state.kind.is_event() {
continue;
}
if let Some(s) = scope {
let head_scopes: Vec<&LoadedRecord> = loaded
.records
.iter()
.filter(|r| state.head_ids.contains(&r.record.id))
.collect();
let matches = key.contains(s)
|| head_scopes.iter().any(|r| {
r.record.paths.iter().any(|p| p.starts_with(s))
|| r.record.tags.iter().any(|t| t == s)
});
if !matches {
continue;
}
}
let head_summary = loaded
.records
.iter()
.find(|r| state.head_ids.first() == Some(&r.record.id))
.map(|r| r.record.summary.clone())
.unwrap_or_default();
let aliases: Vec<&String> = graph
.alias_map
.iter()
.filter(|(_, canonical)| *canonical == key)
.map(|(alias, _)| alias)
.collect();
rows.push(serde_json::json!({
"key": key,
"kind": state.kind.as_str(),
"active": state.active,
"conflicted": state.conflicted,
"heads": state.head_ids.len(),
"aliases": aliases,
"summary": head_summary,
}));
}
if app.json {
println!("{}", serde_json::json!({ "keys": rows }));
} else {
for r in &rows {
let mark = if r["conflicted"].as_bool().unwrap_or(false) {
" [CONFLICT]"
} else if !r["active"].as_bool().unwrap_or(true) {
" [inactive]"
} else {
""
};
println!(
"{} ({}){} :: {}",
r["key"].as_str().unwrap_or(""),
r["kind"].as_str().unwrap_or(""),
mark,
r["summary"].as_str().unwrap_or("")
);
}
}
Ok(())
}
pub fn keys_show(app: &App, key: &str) -> Result<()> {
let (loaded, graph, layers) = app.load_memory()?;
let canonical = graph.resolve_key(key);
let state = graph
.keys
.get(&canonical)
.ok_or_else(|| err(ErrorCode::InvalidRecord, format!("unknown key '{key}'")))?;
let aliases: Vec<&String> = graph
.alias_map
.iter()
.filter(|(_, c)| **c == canonical)
.map(|(a, _)| a)
.collect();
let versions: Vec<&LoadedRecord> = loaded
.records
.iter()
.filter(|r| {
graph.resolve_key(&r.record.key) == canonical && r.record.kind != Kind::KeyAlias
})
.collect();
if app.json {
println!(
"{}",
serde_json::json!({
"key": canonical,
"kind": state.kind.as_str(),
"active": state.active,
"conflicted": state.conflicted,
"aliases": aliases,
"versions": versions.len(),
"heads": state.head_ids.iter().map(|u| u.to_string()).collect::<Vec<_>>(),
})
);
return Ok(());
}
println!("key {canonical} ({})", state.kind.as_str());
if !aliases.is_empty() {
println!(
"aliases {}",
aliases
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
println!(
"state {}{}",
if state.active { "active" } else { "inactive" },
if state.conflicted { " CONFLICTED" } else { "" }
);
for r in &versions {
let head = if state.head_ids.contains(&r.record.id) {
" [head]"
} else {
""
};
println!(
" {} {}{head} [{}] :: {}",
r.record.created_at.format("%Y-%m-%d"),
r.record.id,
layers.layer_of(&r.rel_path).as_str(),
r.record.summary
);
}
Ok(())
}
pub fn keys_conflicts(app: &App) -> Result<()> {
let (loaded, graph, _) = app.load_memory()?;
let mut findings: Vec<String> = Vec::new();
for c in graph.conflicts.iter().filter(|c| c.kind == Kind::KeyAlias) {
findings.push(format!(
"alias conflict: '{}' has {} competing mappings",
c.canonical_key,
c.head_ids.len()
));
}
let warn = app.config.memory.duplicate_key_warn_score;
let keys: Vec<&String> = graph.keys.keys().collect();
for key in &keys {
let state = &graph.keys[key.as_str()];
if state.kind.is_event() {
continue;
}
let Some(head) = loaded
.records
.iter()
.find(|r| state.head_ids.first() == Some(&r.record.id))
else {
continue;
};
for cand in similar_keys(
&loaded.records,
&graph,
key,
&head.record.summary,
&head.record.paths,
&head.record.tags,
) {
if cand.score >= warn && key.as_str() < cand.key.as_str() {
findings.push(format!(
"suspected duplicates ({:.2}): '{key}' and '{}' — join with 'memlay keys alias' or justify with create-distinct",
cand.score, cand.key
));
}
}
}
if app.json {
println!("{}", serde_json::json!({ "findings": findings }));
} else if findings.is_empty() {
println!("No alias conflicts or suspected duplicates.");
} else {
for f in &findings {
println!("{f}");
}
}
Ok(())
}
pub fn keys_similar(app: &App, key_or_text: &str) -> Result<()> {
let (loaded, graph, _) = app.load_memory()?;
let candidates = similar_keys(&loaded.records, &graph, key_or_text, key_or_text, &[], &[]);
if app.json {
println!("{}", serde_json::json!({ "candidates": candidates }));
} else if candidates.is_empty() {
println!("No similar keys.");
} else {
for c in &candidates {
println!("{:.2} {} :: {} ({})", c.score, c.key, c.summary, c.reason);
}
}
Ok(())
}
pub fn diff(app: &App, base: &str, format: &str) -> Result<()> {
let (loaded, graph, layers) = app.load_memory()?;
let mut new_changes: Vec<&LoadedRecord> = Vec::new();
let mut new_state: Vec<&LoadedRecord> = Vec::new();
let mut supersessions: Vec<&LoadedRecord> = Vec::new();
let mut retractions: Vec<&LoadedRecord> = Vec::new();
let mut aliases: Vec<&LoadedRecord> = Vec::new();
let mut overrides: Vec<&LoadedRecord> = Vec::new();
for r in loaded.records.iter().filter(|r| r.is_valid()) {
if layers.layer_of(&r.rel_path) == Layer::Team {
continue; }
if r.record.kind == Kind::KeyAlias {
aliases.push(r);
} else if r.record.op == Op::Retract {
retractions.push(r);
} else if !r.record.supersedes.is_empty() {
supersessions.push(r);
} else if r.record.kind.is_event() {
new_changes.push(r);
} else {
new_state.push(r);
}
if r.record.extension("x-key-resolution") == Some("create-distinct") {
overrides.push(r);
}
}
let alias_conflicts: Vec<_> = graph.conflicts.iter().filter(|c| c.alias_induced).collect();
let plain_conflicts: Vec<_> = graph
.conflicts
.iter()
.filter(|c| !c.alias_induced)
.collect();
let mut immutable_violations = 0usize;
for (status, path) in app.repo.name_status_since(base).unwrap_or_default() {
if path.starts_with(".memlay/records/")
&& path.ends_with(".mly")
&& matches!(status.chars().next(), Some('M' | 'D' | 'R'))
{
immutable_violations += 1;
}
}
let md = format == "markdown";
let h = |s: &str| {
if md {
format!("### {s}")
} else {
s.to_string()
}
};
let mut out = String::new();
if md {
out.push_str("<!-- memlay-diff -->\n## Memlay memory changes\n\n");
out.push_str(&format!("Base: `{base}`\n\n"));
}
let section = |title: &str, items: &[&LoadedRecord], out: &mut String| {
if items.is_empty() {
return;
}
out.push_str(&h(title));
out.push('\n');
let mut sorted: Vec<&&LoadedRecord> = items.iter().collect();
sorted.sort_by_key(|r| r.record.id);
for r in sorted {
let bullet = if md { "- " } else { " " };
out.push_str(&format!(
"{bullet}`{}` ({}) :: {}\n",
r.record.key,
r.record.kind.as_str(),
r.record.summary
));
}
out.push('\n');
};
section("New change records", &new_changes, &mut out);
section("New decisions and state", &new_state, &mut out);
section("Superseded state", &supersessions, &mut out);
section("Retractions", &retractions, &mut out);
section("Key aliases", &aliases, &mut out);
section("create-distinct overrides", &overrides, &mut out);
if !alias_conflicts.is_empty() || !plain_conflicts.is_empty() {
out.push_str(&h("Unresolved semantic conflicts"));
out.push('\n');
for c in plain_conflicts.iter().chain(alias_conflicts.iter()) {
let origin = if c.alias_induced {
" (alias-induced)"
} else {
""
};
out.push_str(&format!(
"- `{}`{origin}: {} competing heads\n",
c.canonical_key,
c.head_ids.len()
));
}
out.push('\n');
}
if immutable_violations > 0 {
out.push_str(&format!(
"**{immutable_violations} immutable record(s) modified or deleted — this branch fails `memlay check`.**\n\n"
));
}
if out.trim().is_empty() || (md && out.lines().count() <= 4) {
out.push_str("No memory changes on this branch.\n");
}
print!("{out}");
Ok(())
}
const EXEMPT_SUFFIXES: &[&str] = &[
".md",
".txt",
".lock",
"Cargo.lock",
"package-lock.json",
"yarn.lock",
".gitignore",
".gitattributes",
];
fn is_exempt(path: &str) -> bool {
path.starts_with(".memlay/")
|| path.starts_with(".github/")
|| path.starts_with(".codex/")
|| path.starts_with(".claude/")
|| path == ".mcp.json"
|| path == "AGENTS.md"
|| path == "CLAUDE.md"
|| EXEMPT_SUFFIXES.iter().any(|s| path.ends_with(s))
}
pub fn check_change_coverage(app: &App, base: &str) -> Result<Vec<String>> {
let changed: Vec<String> = app
.repo
.name_status_since(base)?
.into_iter()
.map(|(_, p)| p)
.filter(|p| !is_exempt(p))
.collect();
if changed.is_empty() {
return Ok(vec![]);
}
let (loaded, _, layers) = app.load_memory()?;
let change_records: Vec<&LoadedRecord> = loaded
.records
.iter()
.filter(|r| {
r.is_valid() && r.record.kind.is_event() && layers.layer_of(&r.rel_path) != Layer::Team
})
.collect();
let covered = changed.iter().all(|path| {
change_records.iter().any(|r| {
r.record
.paths
.iter()
.any(|scope| path == scope || path.starts_with(&format!("{scope}/")))
|| r.record
.evidence
.iter()
.any(|e| e.value.starts_with(path.as_str()))
})
});
let any_change_record = !change_records.is_empty();
if !any_change_record {
return Err(err(
ErrorCode::ChangeRecordRequired,
format!(
"{} nontrivial source path(s) changed but no change record exists on this branch. Create one: memlay record --kind change --summary \"...\" --rationale \"...\" --scope <dir>",
changed.len()
),
));
}
if !covered {
let uncovered: Vec<&String> = changed
.iter()
.filter(|path| {
!change_records.iter().any(|r| {
r.record
.paths
.iter()
.any(|scope| *path == scope || path.starts_with(&format!("{scope}/")))
})
})
.take(5)
.collect();
return Ok(vec![format!(
"change records exist but do not cover: {}",
uncovered
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)]);
}
Ok(vec![])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_token_similarity_orders_sensibly() {
let a = tokens_of_key("auth.refresh-token-storage");
let b = tokens_of_key("auth.token-storage");
let c = tokens_of_key("payments.webhook.retry");
assert!(jaccard(&a, &b) > jaccard(&a, &c));
}
#[test]
fn exempt_paths() {
assert!(is_exempt(".memlay/records/2026/x.mly"));
assert!(is_exempt("Cargo.lock"));
assert!(is_exempt("README.md"));
assert!(!is_exempt("src/main.rs"));
}
}