use std::path::Path;
use serde::Serialize;
use crate::backend::BackendError;
use crate::entity::EntityId;
use crate::provenance::ProvenanceKind;
pub const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
pub const RENAME_SIMILARITY_DEFAULT: f32 = 0.6;
pub const RENAME_SIMILARITY_MIN: f32 = 0.1;
pub const RENAME_SIMILARITY_MAX: f32 = 1.0;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "action", rename_all = "lowercase")]
pub enum ChangeEnvelope {
Added {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
},
Updated {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
},
Removed {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
},
Renamed {
from_id: EntityId,
to_id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
},
}
impl ChangeEnvelope {
pub fn primary_id(&self) -> &str {
match self {
ChangeEnvelope::Added { id, .. }
| ChangeEnvelope::Updated { id, .. }
| ChangeEnvelope::Removed { id, .. } => id.as_ref(),
ChangeEnvelope::Renamed { to_id, .. } => to_id.as_ref(),
}
}
pub fn action(&self) -> &'static str {
match self {
ChangeEnvelope::Added { .. } => "added",
ChangeEnvelope::Updated { .. } => "updated",
ChangeEnvelope::Removed { .. } => "removed",
ChangeEnvelope::Renamed { .. } => "renamed",
}
}
pub fn entity_type(&self) -> Option<&str> {
match self {
ChangeEnvelope::Added { entity_type, .. }
| ChangeEnvelope::Updated { entity_type, .. }
| ChangeEnvelope::Removed { entity_type, .. }
| ChangeEnvelope::Renamed { entity_type, .. } => entity_type.as_deref(),
}
}
fn without_metadata(&self) -> Self {
match self {
ChangeEnvelope::Added { id, .. } => ChangeEnvelope::Added {
id: id.clone(),
title: None,
entity_type: None,
},
ChangeEnvelope::Updated { id, .. } => ChangeEnvelope::Updated {
id: id.clone(),
title: None,
entity_type: None,
},
ChangeEnvelope::Removed { id, .. } => ChangeEnvelope::Removed {
id: id.clone(),
title: None,
entity_type: None,
},
ChangeEnvelope::Renamed { from_id, to_id, .. } => ChangeEnvelope::Renamed {
from_id: from_id.clone(),
to_id: to_id.clone(),
title: None,
entity_type: None,
},
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BackendChanges {
pub since: String,
pub head: String,
pub changes: Vec<ChangeEnvelope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<crate::ops::agent_notes::CommitNote>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memstead_ref: Option<String>,
}
impl BackendChanges {
pub fn empty_at(since: &str) -> Self {
Self {
since: since.to_string(),
head: since.to_string(),
changes: Vec::new(),
notes: Vec::new(),
memstead_ref: None,
}
}
}
pub fn folder_changes_since(
mem_root: &Path,
mem: &str,
since: &str,
) -> Result<BackendChanges, BackendError> {
let log_path = crate::filesystem::changelog::changelog_path(mem_root);
let raw = match std::fs::read_to_string(&log_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(BackendChanges::empty_at(since));
}
Err(e) => return Err(BackendError::Io(e)),
};
struct Aggregate {
first_kind: ProvenanceKind,
last_kind: ProvenanceKind,
}
let mut by_entity: std::collections::BTreeMap<String, Aggregate> =
std::collections::BTreeMap::new();
let mut max_ts: Option<String> = None;
let cursor_opt: Option<&str> = if since.is_empty() || since == EMPTY_TREE_SHA {
None
} else {
Some(since)
};
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
if let Some(c) = cursor_opt
&& ts_str <= c
{
continue;
}
let kind = match value
.get("kind")
.and_then(|v| v.as_str())
.and_then(ProvenanceKind::parse)
{
Some(k) => k,
None => continue,
};
let entity_id = match value.get("entity").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
if max_ts.as_deref().is_none_or(|m| ts_str > m) {
max_ts = Some(ts_str.to_string());
}
by_entity
.entry(entity_id)
.and_modify(|agg| {
agg.last_kind = kind;
})
.or_insert(Aggregate {
first_kind: kind,
last_kind: kind,
});
}
let mut changes: Vec<ChangeEnvelope> = Vec::with_capacity(by_entity.len());
for (entity_str, agg) in by_entity {
let id = match entity_str.split_once("--") {
Some((v, slug)) if v == mem => EntityId::new(v, slug),
_ => continue,
};
let envelope = match (agg.first_kind, agg.last_kind) {
(_, ProvenanceKind::Delete) => ChangeEnvelope::Removed {
id,
title: None,
entity_type: None,
},
(ProvenanceKind::Create, _) => ChangeEnvelope::Added {
id,
title: None,
entity_type: None,
},
_ => ChangeEnvelope::Updated {
id,
title: None,
entity_type: None,
},
};
changes.push(envelope);
}
Ok(BackendChanges {
since: since.to_string(),
head: max_ts.unwrap_or_else(|| since.to_string()),
changes,
notes: Vec::new(),
memstead_ref: None,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangesReport {
pub mem: String,
pub since: String,
pub head: String,
pub changes: Vec<ChangeEnvelope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<crate::ops::WarningHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memstead_ref: Option<String>,
}
const NOTICE_DETAILED_MAX: usize = 50;
const NOTICE_IDS_MAX: usize = 500;
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
pub struct NoticeByChange {
pub added: usize,
pub updated: usize,
pub removed: usize,
pub renamed: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum NoticeChanges {
Detailed { entries: Vec<ChangeEnvelope> },
Ids { entries: Vec<ChangeEnvelope> },
Counts {
counts: std::collections::BTreeMap<String, usize>,
by_change: NoticeByChange,
self_inform: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct MemChangedNotice {
pub mem: String,
pub from_head: String,
pub to_head: String,
pub changes: NoticeChanges,
}
impl MemChangedNotice {
pub fn from_delta(
mem: String,
from_head: String,
to_head: String,
mut changes: Vec<ChangeEnvelope>,
) -> Self {
changes.sort_by(|a, b| a.primary_id().cmp(b.primary_id()));
let n = changes.len();
let body = if n <= NOTICE_DETAILED_MAX {
NoticeChanges::Detailed { entries: changes }
} else if n <= NOTICE_IDS_MAX {
NoticeChanges::Ids {
entries: changes
.iter()
.map(ChangeEnvelope::without_metadata)
.collect(),
}
} else {
let mut counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
let mut by_change = NoticeByChange::default();
for env in &changes {
match env {
ChangeEnvelope::Added { .. } => by_change.added += 1,
ChangeEnvelope::Updated { .. } => by_change.updated += 1,
ChangeEnvelope::Removed { .. } => by_change.removed += 1,
ChangeEnvelope::Renamed { .. } => by_change.renamed += 1,
}
if let Some(t) = env.entity_type() {
*counts.entry(t.to_string()).or_default() += 1;
}
}
NoticeChanges::Counts {
counts,
by_change,
self_inform: format!("call memstead_changes_since(since={from_head})"),
}
};
Self {
mem,
from_head,
to_head,
changes: body,
}
}
pub fn entity_count(&self) -> usize {
match &self.changes {
NoticeChanges::Detailed { entries } | NoticeChanges::Ids { entries } => entries.len(),
NoticeChanges::Counts { by_change, .. } => {
by_change.added + by_change.updated + by_change.removed + by_change.renamed
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_at_echoes_cursor() {
let r = BackendChanges::empty_at("abc");
assert_eq!(r.since, "abc");
assert_eq!(r.head, "abc");
assert!(r.changes.is_empty());
}
#[test]
fn changes_report_omits_notes_and_memstead_ref_when_none() {
let r = ChangesReport {
mem: "specs".to_string(),
since: "abc".to_string(),
head: "def".to_string(),
changes: Vec::new(),
warnings: Vec::new(),
notes: None,
memstead_ref: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(
!json.contains("\"notes\""),
"notes must be omitted when None: {json}"
);
assert!(
!json.contains("\"memstead_ref\""),
"memstead_ref must be omitted when None: {json}"
);
}
#[test]
fn changes_report_emits_notes_and_memstead_ref_when_some() {
let r = ChangesReport {
mem: "specs".to_string(),
since: "abc".to_string(),
head: "def".to_string(),
changes: Vec::new(),
warnings: Vec::new(),
notes: Some(Vec::new()),
memstead_ref: Some("aabbccdd".to_string()),
};
let json = serde_json::to_string(&r).unwrap();
assert!(
json.contains("\"notes\":["),
"notes must be present: {json}"
);
assert!(
json.contains("\"memstead_ref\":\"aabbccdd\""),
"memstead_ref must be present and carry the SHA: {json}"
);
}
#[test]
fn change_envelope_serializes_action_tag() {
let env = ChangeEnvelope::Added {
id: EntityId::new("specs", "hello"),
title: Some("Hello".to_string()),
entity_type: Some("spec".to_string()),
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains(r#""action":"added""#));
assert!(json.contains(r#""title":"Hello""#));
assert!(json.contains(r#""entity_type":"spec""#));
}
#[test]
fn change_envelope_skips_none_metadata_fields() {
let env = ChangeEnvelope::Removed {
id: EntityId::new("specs", "gone"),
title: None,
entity_type: None,
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains(r#""action":"removed""#));
assert!(!json.contains("\"title\""));
assert!(!json.contains("\"entity_type\""));
}
#[test]
fn change_envelope_renamed_carries_both_ids() {
let env = ChangeEnvelope::Renamed {
from_id: EntityId::new("specs", "old"),
to_id: EntityId::new("specs", "new"),
title: Some("New".to_string()),
entity_type: Some("spec".to_string()),
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains(r#""action":"renamed""#));
assert!(json.contains(r#""from_id":"specs--old""#));
assert!(json.contains(r#""to_id":"specs--new""#));
}
fn added_envelopes(n: usize) -> Vec<ChangeEnvelope> {
(0..n)
.map(|i| ChangeEnvelope::Added {
id: EntityId::new("specs", &format!("e-{i:04}")),
title: Some(format!("Entity {i}")),
entity_type: Some("spec".to_string()),
})
.collect()
}
#[test]
fn notice_small_delta_is_detailed_ordered_and_typed() {
let changes = vec![
ChangeEnvelope::Updated {
id: EntityId::new("specs", "bbb"),
title: Some("Bee".to_string()),
entity_type: Some("spec".to_string()),
},
ChangeEnvelope::Added {
id: EntityId::new("specs", "aaa"),
title: Some("Ay".to_string()),
entity_type: Some("spec".to_string()),
},
];
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
changes,
);
match ¬ice.changes {
NoticeChanges::Detailed { entries } => {
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].primary_id(), "specs--aaa");
assert_eq!(entries[0].action(), "added");
assert_eq!(entries[1].primary_id(), "specs--bbb");
assert_eq!(entries[1].action(), "updated");
assert_eq!(entries[1].entity_type(), Some("spec"));
}
other => panic!("expected detailed, got {other:?}"),
}
let json = serde_json::to_string(¬ice).unwrap();
assert!(json.contains(r#""mode":"detailed""#));
assert!(json.contains(r#""action":"updated""#));
assert!(json.contains(r#""entity_type":"spec""#));
assert!(
!json.contains(r#""change":"#),
"no legacy `change` key: {json}"
);
}
#[test]
fn notice_entry_is_byte_identical_to_changes_since_envelope() {
let env = ChangeEnvelope::Updated {
id: EntityId::new("specs", "x"),
title: Some("X".to_string()),
entity_type: Some("spec".to_string()),
};
let changes_since_json = serde_json::to_value(&env).unwrap();
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
vec![env],
);
let notice_json = serde_json::to_value(¬ice).unwrap();
let entry = ¬ice_json["changes"]["entries"][0];
assert_eq!(
entry, &changes_since_json,
"notice entry must equal the changes_since envelope verbatim",
);
}
#[test]
fn notice_renamed_carries_both_ids_and_sorts_under_to_id() {
let changes = vec![ChangeEnvelope::Renamed {
from_id: EntityId::new("specs", "old"),
to_id: EntityId::new("specs", "new"),
title: Some("New".to_string()),
entity_type: Some("spec".to_string()),
}];
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
changes,
);
match ¬ice.changes {
NoticeChanges::Detailed { entries } => {
assert_eq!(entries[0].primary_id(), "specs--new");
assert_eq!(entries[0].action(), "renamed");
}
other => panic!("expected detailed, got {other:?}"),
}
let json = serde_json::to_string(¬ice).unwrap();
assert!(
json.contains(r#""from_id":"specs--old""#),
"rename carries from_id: {json}"
);
assert!(
json.contains(r#""to_id":"specs--new""#),
"rename carries to_id: {json}"
);
}
#[test]
fn notice_ids_tier_preserves_rename_both_ids() {
let mut changes = added_envelopes(NOTICE_DETAILED_MAX);
changes.push(ChangeEnvelope::Renamed {
from_id: EntityId::new("specs", "zzz-old"),
to_id: EntityId::new("specs", "zzz-new"),
title: Some("Z".to_string()),
entity_type: Some("spec".to_string()),
});
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
changes,
);
let json = serde_json::to_string(¬ice).unwrap();
assert!(
json.contains(r#""mode":"ids""#),
"expected ids tier: {json}"
);
assert!(json.contains(r#""from_id":"specs--zzz-old""#));
assert!(json.contains(r#""to_id":"specs--zzz-new""#));
assert!(!json.contains(r#""title""#), "ids tier drops title: {json}");
}
#[test]
fn notice_medium_delta_degrades_to_ids_with_every_id_inline() {
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
added_envelopes(60),
);
match ¬ice.changes {
NoticeChanges::Ids { entries } => {
assert_eq!(entries.len(), 60, "every changed id stays inline");
assert_eq!(entries[0].primary_id(), "specs--e-0000");
}
other => panic!("expected ids, got {other:?}"),
}
let json = serde_json::to_string(¬ice).unwrap();
assert!(json.contains(r#""mode":"ids""#));
assert!(!json.contains(r#""title""#));
assert!(!json.contains(r#""entity_type""#));
}
#[test]
fn notice_id_list_outlives_rich_detail() {
let just_over_detail = NOTICE_DETAILED_MAX + 1;
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
added_envelopes(just_over_detail),
);
match ¬ice.changes {
NoticeChanges::Ids { entries } => {
assert_eq!(entries.len(), just_over_detail);
}
other => panic!("expected ids at {just_over_detail}, got {other:?}"),
}
}
#[test]
fn notice_mass_delta_degrades_to_counts_with_self_inform() {
let n = NOTICE_IDS_MAX + 1;
let notice = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
added_envelopes(n),
);
match ¬ice.changes {
NoticeChanges::Counts {
counts,
by_change,
self_inform,
} => {
assert_eq!(by_change.added, n);
assert_eq!(counts.get("spec").copied(), Some(n));
assert_eq!(self_inform, "call memstead_changes_since(since=H0)");
}
other => panic!("expected counts, got {other:?}"),
}
let json = serde_json::to_string(¬ice).unwrap();
assert!(json.contains(r#""mode":"counts""#));
assert!(!json.contains("specs--e-"));
}
#[test]
fn notice_is_deterministic_regardless_of_input_order() {
let forward = added_envelopes(20);
let mut reversed = forward.clone();
reversed.reverse();
let a = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
forward,
);
let b = MemChangedNotice::from_delta(
"specs".to_string(),
"H0".to_string(),
"H1".to_string(),
reversed,
);
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap(),
);
}
}