use crate::lifecycle_format;
use crate::lifecycle_service::{LifecycleAction, LifecycleWorkbenchSnapshot};
use crate::lifecycle_store::LedgerEntry;
use serde::Serialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleQueuePayload {
pub entries: Vec<LedgerEntry>,
pub summaries: Vec<LifecycleSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleRecordPayload {
pub record: LedgerEntry,
pub summary: LifecycleSummary,
}
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleHistoryPayload {
pub record_id: String,
pub history: Vec<LedgerEntry>,
pub summaries: Vec<LifecycleSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleSummary {
pub record_id: String,
pub state: &'static str,
pub title: String,
pub pending_review: bool,
pub wakeup_ready: bool,
pub actor: Option<String>,
pub reason: Option<String>,
pub evidence_refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleCreateSummary {
pub kind: String,
#[serde(flatten)]
pub summary: LifecycleSummary,
}
#[derive(Debug, Clone, Serialize)]
pub struct LifecycleActionSummary {
pub action: String,
#[serde(flatten)]
pub summary: LifecycleSummary,
}
impl LifecycleSummary {
pub fn from_entry(entry: &LedgerEntry) -> Self {
Self {
record_id: entry.record_id.clone(),
state: lifecycle_format::state_label(entry),
title: entry.record.title.clone(),
pending_review: entry.record.requires_review(),
wakeup_ready: entry.record.can_be_returned_in_wakeup(),
actor: entry.metadata.actor.clone(),
reason: entry.metadata.reason.clone(),
evidence_refs: entry.metadata.evidence_refs.clone(),
}
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("lifecycle summary should serialize")
}
pub fn metadata_lines(&self) -> String {
let mut lines = String::new();
if let Some(actor) = self.actor.as_deref() {
lines.push_str(&format!("- actor: {}\n", actor));
}
if let Some(reason) = self.reason.as_deref() {
lines.push_str(&format!("- reason: {}\n", reason));
}
if !self.evidence_refs.is_empty() {
lines.push_str(&format!(
"- evidence_refs: {}\n",
self.evidence_refs.join(", ")
));
}
lines
}
}
impl LifecycleCreateSummary {
pub fn new(kind: &str, entry: &LedgerEntry) -> Self {
Self {
kind: kind.to_string(),
summary: LifecycleSummary::from_entry(entry),
}
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("lifecycle create summary should serialize")
}
pub fn render_markdown(&self) -> String {
format!(
"# Lifecycle create\n\n- kind: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
self.kind,
self.summary.record_id,
self.summary.state,
self.summary.title,
self.summary.pending_review,
self.summary.wakeup_ready,
self.summary.metadata_lines()
)
}
}
impl LifecycleActionSummary {
pub fn new(action: LifecycleAction, entry: &LedgerEntry) -> Self {
Self {
action: action.label().to_string(),
summary: LifecycleSummary::from_entry(entry),
}
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("lifecycle action summary should serialize")
}
pub fn render_markdown(&self) -> String {
format!(
"# Lifecycle action\n\n- action: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
self.action,
self.summary.record_id,
self.summary.state,
self.summary.title,
self.summary.pending_review,
self.summary.wakeup_ready,
self.summary.metadata_lines()
)
}
}
impl LifecycleQueuePayload {
pub fn new(entries: &[LedgerEntry]) -> Self {
Self {
entries: entries.to_vec(),
summaries: entries.iter().map(LifecycleSummary::from_entry).collect(),
}
}
pub fn to_json_with_field_name(&self, field_name: &str) -> Value {
json!({
field_name: self.entries,
"summaries": self.summaries,
})
}
}
impl LifecycleRecordPayload {
pub fn new(entry: &LedgerEntry) -> Self {
Self {
record: entry.clone(),
summary: LifecycleSummary::from_entry(entry),
}
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("lifecycle record payload should serialize")
}
pub fn render_markdown(&self, quote_record_id: bool, include_summary_section: bool) -> String {
lifecycle_format::render_detail(&self.record, quote_record_id, include_summary_section)
}
}
impl LifecycleHistoryPayload {
pub fn new(record_id: &str, history: &[LedgerEntry]) -> Self {
Self {
record_id: record_id.to_string(),
history: history.to_vec(),
summaries: history.iter().map(LifecycleSummary::from_entry).collect(),
}
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).expect("lifecycle history payload should serialize")
}
pub fn render_markdown(&self, quote_record_id: bool) -> String {
lifecycle_format::render_history(&self.record_id, &self.history, quote_record_id)
}
}
pub fn create_payload(
kind: &str,
entry: &LedgerEntry,
snapshot: &LifecycleWorkbenchSnapshot,
) -> Value {
json!({
"entry": entry,
"summary": LifecycleCreateSummary::new(kind, entry).to_json(),
"snapshot": {
"pending_review": snapshot.pending_review,
"wakeup_ready": snapshot.wakeup_ready
}
})
}
pub fn action_payload(
entry: &LedgerEntry,
snapshot: &LifecycleWorkbenchSnapshot,
action: LifecycleAction,
) -> Value {
json!({
"action": action.label(),
"entry": entry,
"summary": LifecycleActionSummary::new(action, entry).to_json(),
"snapshot": {
"pending_review": snapshot.pending_review,
"wakeup_ready": snapshot.wakeup_ready
}
})
}
pub fn queue_payload(entries: &[LedgerEntry], field_name: &str) -> Value {
LifecycleQueuePayload::new(entries).to_json_with_field_name(field_name)
}
pub fn record_payload(entry: &LedgerEntry) -> Value {
LifecycleRecordPayload::new(entry).to_json()
}
pub fn history_payload(record_id: &str, history: &[LedgerEntry]) -> Value {
LifecycleHistoryPayload::new(record_id, history).to_json()
}
pub fn render_record_text(
entry: &LedgerEntry,
quote_record_id: bool,
include_summary_section: bool,
) -> String {
LifecycleRecordPayload::new(entry).render_markdown(quote_record_id, include_summary_section)
}
pub fn render_history_text(
record_id: &str,
history: &[LedgerEntry],
quote_record_id: bool,
) -> String {
LifecycleHistoryPayload::new(record_id, history).render_markdown(quote_record_id)
}
pub fn render_queue_text(
title: &str,
entries: &[LedgerEntry],
include_record_id: bool,
markdown_heading: bool,
) -> String {
lifecycle_format::render_list(title, entries, include_record_id, markdown_heading)
}
pub fn not_found_payload(record_id: &str) -> Value {
json!({
"record_id": record_id,
"summary": Value::Null,
})
}
pub fn render_create_text(kind: &str, entry: &LedgerEntry) -> String {
LifecycleCreateSummary::new(kind, entry).render_markdown()
}
pub fn render_action_text(action: LifecycleAction, entry: &LedgerEntry) -> String {
LifecycleActionSummary::new(action, entry).render_markdown()
}
#[cfg(test)]
mod tests {
use super::{
LifecycleActionSummary, LifecycleCreateSummary, LifecycleHistoryPayload,
LifecycleQueuePayload, LifecycleRecordPayload, LifecycleSummary, render_action_text,
render_create_text, render_history_text, render_queue_text, render_record_text,
};
use crate::domain::{MemoryLedgerAction, MemoryRecord, MemoryScope, MemorySourceKind};
use crate::lifecycle_service::LifecycleAction;
use crate::lifecycle_store::{LedgerEntry, TransitionMetadata};
fn sample_entry() -> LedgerEntry {
LedgerEntry {
schema_version: "memory-ledger.v1".to_string(),
record_id: "record-1".to_string(),
action: MemoryLedgerAction::Accept,
recorded_at: "2026-04-13T00:00:00Z".to_string(),
source_kind: MemorySourceKind::AiProposal,
scope_key: "user:long".to_string(),
metadata: TransitionMetadata {
actor: Some("long".to_string()),
reason: Some("approved after review".to_string()),
evidence_refs: vec!["session:1".to_string()],
},
record: MemoryRecord::new_ai_proposal(
"测试偏好",
"先 smoke 再收口",
"workflow",
MemoryScope::User,
"session:1",
)
.apply(crate::domain::MemoryPromotionAction::Accept)
.with_user_id("long"),
}
}
#[test]
fn summary_should_capture_cli_and_mcp_shared_fields() {
let summary = LifecycleSummary::from_entry(&sample_entry());
assert_eq!(summary.record_id, "record-1");
assert_eq!(summary.state, "accepted");
assert_eq!(summary.title, "测试偏好");
assert!(!summary.pending_review);
assert!(summary.wakeup_ready);
assert_eq!(summary.actor.as_deref(), Some("long"));
assert_eq!(summary.reason.as_deref(), Some("approved after review"));
assert_eq!(summary.evidence_refs, vec!["session:1"]);
}
#[test]
fn structured_summaries_should_include_create_and_action_specific_fields() {
let entry = sample_entry();
let create = LifecycleCreateSummary::new("propose", &entry).to_json();
let action = LifecycleActionSummary::new(LifecycleAction::Accept, &entry).to_json();
assert_eq!(create["kind"], "propose");
assert_eq!(create["record_id"], "record-1");
assert_eq!(action["action"], "accept");
assert_eq!(action["reason"], "approved after review");
}
#[test]
fn text_renderers_should_use_summary_fields() {
let entry = sample_entry();
let create = render_create_text("propose", &entry);
let action = render_action_text(LifecycleAction::Accept, &entry);
assert!(create.contains("- state: accepted"));
assert!(create.contains("- actor: long"));
assert!(action.contains("- action: accept"));
assert!(action.contains("- reason: approved after review"));
}
#[test]
fn read_side_payloads_should_share_summary_generation() {
let entry = sample_entry();
let queue = LifecycleQueuePayload::new(std::slice::from_ref(&entry));
let record = LifecycleRecordPayload::new(&entry);
let history = LifecycleHistoryPayload::new("record-1", std::slice::from_ref(&entry));
assert_eq!(queue.entries.len(), 1);
assert_eq!(queue.summaries[0].record_id, "record-1");
assert_eq!(record.summary.state, "accepted");
assert_eq!(history.record_id, "record-1");
assert_eq!(history.summaries[0].title, "测试偏好");
}
#[test]
fn read_side_text_renderers_should_match_existing_markdown_contracts() {
let entry = sample_entry();
let list = render_queue_text("Pending review", std::slice::from_ref(&entry), true, true);
let detail = render_record_text(&entry, true, true);
let history = render_history_text("record-1", std::slice::from_ref(&entry), true);
assert!(list.contains("# Pending review"));
assert!(list.contains("record-1"));
assert!(detail.contains("# Memory record"));
assert!(detail.contains("## Summary"));
assert!(history.contains("# Memory history"));
assert!(history.contains("events: 1"));
}
}