1use crate::lifecycle_format;
2use crate::lifecycle_service::{LifecycleAction, LifecycleWorkbenchSnapshot};
3use crate::lifecycle_store::LedgerEntry;
4use serde::Serialize;
5use serde_json::{Value, json};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct LifecycleQueuePayload {
9 pub entries: Vec<LedgerEntry>,
10 pub summaries: Vec<LifecycleSummary>,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct LifecycleRecordPayload {
15 pub record: LedgerEntry,
16 pub summary: LifecycleSummary,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct LifecycleHistoryPayload {
21 pub record_id: String,
22 pub history: Vec<LedgerEntry>,
23 pub summaries: Vec<LifecycleSummary>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct LifecycleSummary {
28 pub record_id: String,
29 pub state: &'static str,
30 pub title: String,
31 pub pending_review: bool,
32 pub wakeup_ready: bool,
33 pub actor: Option<String>,
34 pub reason: Option<String>,
35 pub evidence_refs: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct LifecycleCreateSummary {
40 pub kind: String,
41 #[serde(flatten)]
42 pub summary: LifecycleSummary,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct LifecycleActionSummary {
47 pub action: String,
48 #[serde(flatten)]
49 pub summary: LifecycleSummary,
50}
51
52impl LifecycleSummary {
53 pub fn from_entry(entry: &LedgerEntry) -> Self {
54 Self {
55 record_id: entry.record_id.clone(),
56 state: lifecycle_format::state_label(entry),
57 title: entry.record.title.clone(),
58 pending_review: entry.record.requires_review(),
59 wakeup_ready: entry.record.can_be_returned_in_wakeup(),
60 actor: entry.metadata.actor.clone(),
61 reason: entry.metadata.reason.clone(),
62 evidence_refs: entry.metadata.evidence_refs.clone(),
63 }
64 }
65
66 pub fn to_json(&self) -> Value {
67 serde_json::to_value(self).expect("lifecycle summary should serialize")
68 }
69
70 pub fn metadata_lines(&self) -> String {
71 let mut lines = String::new();
72 if let Some(actor) = self.actor.as_deref() {
73 lines.push_str(&format!("- actor: {}\n", actor));
74 }
75 if let Some(reason) = self.reason.as_deref() {
76 lines.push_str(&format!("- reason: {}\n", reason));
77 }
78 if !self.evidence_refs.is_empty() {
79 lines.push_str(&format!(
80 "- evidence_refs: {}\n",
81 self.evidence_refs.join(", ")
82 ));
83 }
84 lines
85 }
86}
87
88impl LifecycleCreateSummary {
89 pub fn new(kind: &str, entry: &LedgerEntry) -> Self {
90 Self {
91 kind: kind.to_string(),
92 summary: LifecycleSummary::from_entry(entry),
93 }
94 }
95
96 pub fn to_json(&self) -> Value {
97 serde_json::to_value(self).expect("lifecycle create summary should serialize")
98 }
99
100 pub fn render_markdown(&self) -> String {
101 format!(
102 "# Lifecycle create\n\n- kind: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
103 self.kind,
104 self.summary.record_id,
105 self.summary.state,
106 self.summary.title,
107 self.summary.pending_review,
108 self.summary.wakeup_ready,
109 self.summary.metadata_lines()
110 )
111 }
112}
113
114impl LifecycleActionSummary {
115 pub fn new(action: LifecycleAction, entry: &LedgerEntry) -> Self {
116 Self {
117 action: action.label().to_string(),
118 summary: LifecycleSummary::from_entry(entry),
119 }
120 }
121
122 pub fn to_json(&self) -> Value {
123 serde_json::to_value(self).expect("lifecycle action summary should serialize")
124 }
125
126 pub fn render_markdown(&self) -> String {
127 format!(
128 "# Lifecycle action\n\n- action: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
129 self.action,
130 self.summary.record_id,
131 self.summary.state,
132 self.summary.title,
133 self.summary.pending_review,
134 self.summary.wakeup_ready,
135 self.summary.metadata_lines()
136 )
137 }
138}
139
140impl LifecycleQueuePayload {
141 pub fn new(entries: &[LedgerEntry]) -> Self {
142 Self {
143 entries: entries.to_vec(),
144 summaries: entries.iter().map(LifecycleSummary::from_entry).collect(),
145 }
146 }
147
148 pub fn to_json_with_field_name(&self, field_name: &str) -> Value {
149 json!({
150 field_name: self.entries,
151 "summaries": self.summaries,
152 })
153 }
154}
155
156impl LifecycleRecordPayload {
157 pub fn new(entry: &LedgerEntry) -> Self {
158 Self {
159 record: entry.clone(),
160 summary: LifecycleSummary::from_entry(entry),
161 }
162 }
163
164 pub fn to_json(&self) -> Value {
165 serde_json::to_value(self).expect("lifecycle record payload should serialize")
166 }
167
168 pub fn render_markdown(&self, quote_record_id: bool, include_summary_section: bool) -> String {
169 lifecycle_format::render_detail(&self.record, quote_record_id, include_summary_section)
170 }
171}
172
173impl LifecycleHistoryPayload {
174 pub fn new(record_id: &str, history: &[LedgerEntry]) -> Self {
175 Self {
176 record_id: record_id.to_string(),
177 history: history.to_vec(),
178 summaries: history.iter().map(LifecycleSummary::from_entry).collect(),
179 }
180 }
181
182 pub fn to_json(&self) -> Value {
183 serde_json::to_value(self).expect("lifecycle history payload should serialize")
184 }
185
186 pub fn render_markdown(&self, quote_record_id: bool) -> String {
187 lifecycle_format::render_history(&self.record_id, &self.history, quote_record_id)
188 }
189}
190
191pub fn create_payload(
192 kind: &str,
193 entry: &LedgerEntry,
194 snapshot: &LifecycleWorkbenchSnapshot,
195) -> Value {
196 json!({
197 "entry": entry,
198 "summary": LifecycleCreateSummary::new(kind, entry).to_json(),
199 "snapshot": {
200 "pending_review": snapshot.pending_review,
201 "wakeup_ready": snapshot.wakeup_ready
202 }
203 })
204}
205
206pub fn action_payload(
207 entry: &LedgerEntry,
208 snapshot: &LifecycleWorkbenchSnapshot,
209 action: LifecycleAction,
210) -> Value {
211 json!({
212 "action": action.label(),
213 "entry": entry,
214 "summary": LifecycleActionSummary::new(action, entry).to_json(),
215 "snapshot": {
216 "pending_review": snapshot.pending_review,
217 "wakeup_ready": snapshot.wakeup_ready
218 }
219 })
220}
221
222pub fn queue_payload(entries: &[LedgerEntry], field_name: &str) -> Value {
223 LifecycleQueuePayload::new(entries).to_json_with_field_name(field_name)
224}
225
226pub fn record_payload(entry: &LedgerEntry) -> Value {
227 LifecycleRecordPayload::new(entry).to_json()
228}
229
230pub fn history_payload(record_id: &str, history: &[LedgerEntry]) -> Value {
231 LifecycleHistoryPayload::new(record_id, history).to_json()
232}
233
234pub fn render_record_text(
235 entry: &LedgerEntry,
236 quote_record_id: bool,
237 include_summary_section: bool,
238) -> String {
239 LifecycleRecordPayload::new(entry).render_markdown(quote_record_id, include_summary_section)
240}
241
242pub fn render_history_text(
243 record_id: &str,
244 history: &[LedgerEntry],
245 quote_record_id: bool,
246) -> String {
247 LifecycleHistoryPayload::new(record_id, history).render_markdown(quote_record_id)
248}
249
250pub fn render_queue_text(
251 title: &str,
252 entries: &[LedgerEntry],
253 include_record_id: bool,
254 markdown_heading: bool,
255) -> String {
256 lifecycle_format::render_list(title, entries, include_record_id, markdown_heading)
257}
258
259pub fn not_found_payload(record_id: &str) -> Value {
260 json!({
261 "record_id": record_id,
262 "summary": Value::Null,
263 })
264}
265
266pub fn render_create_text(kind: &str, entry: &LedgerEntry) -> String {
267 LifecycleCreateSummary::new(kind, entry).render_markdown()
268}
269
270pub fn render_action_text(action: LifecycleAction, entry: &LedgerEntry) -> String {
271 LifecycleActionSummary::new(action, entry).render_markdown()
272}
273
274#[cfg(test)]
275mod tests {
276 use super::{
277 LifecycleActionSummary, LifecycleCreateSummary, LifecycleHistoryPayload,
278 LifecycleQueuePayload, LifecycleRecordPayload, LifecycleSummary, render_action_text,
279 render_create_text, render_history_text, render_queue_text, render_record_text,
280 };
281 use crate::domain::{MemoryLedgerAction, MemoryRecord, MemoryScope, MemorySourceKind};
282 use crate::lifecycle_service::LifecycleAction;
283 use crate::lifecycle_store::{LedgerEntry, TransitionMetadata};
284
285 fn sample_entry() -> LedgerEntry {
286 LedgerEntry {
287 schema_version: "memory-ledger.v1".to_string(),
288 record_id: "record-1".to_string(),
289 action: MemoryLedgerAction::Accept,
290 recorded_at: "2026-04-13T00:00:00Z".to_string(),
291 source_kind: MemorySourceKind::AiProposal,
292 scope_key: "user:long".to_string(),
293 metadata: TransitionMetadata {
294 actor: Some("long".to_string()),
295 reason: Some("approved after review".to_string()),
296 evidence_refs: vec!["session:1".to_string()],
297 },
298 record: MemoryRecord::new_ai_proposal(
299 "测试偏好",
300 "先 smoke 再收口",
301 "workflow",
302 MemoryScope::User,
303 "session:1",
304 )
305 .apply(crate::domain::MemoryPromotionAction::Accept)
306 .with_user_id("long"),
307 }
308 }
309
310 #[test]
311 fn summary_should_capture_cli_and_mcp_shared_fields() {
312 let summary = LifecycleSummary::from_entry(&sample_entry());
313 assert_eq!(summary.record_id, "record-1");
314 assert_eq!(summary.state, "accepted");
315 assert_eq!(summary.title, "测试偏好");
316 assert!(!summary.pending_review);
317 assert!(summary.wakeup_ready);
318 assert_eq!(summary.actor.as_deref(), Some("long"));
319 assert_eq!(summary.reason.as_deref(), Some("approved after review"));
320 assert_eq!(summary.evidence_refs, vec!["session:1"]);
321 }
322
323 #[test]
324 fn structured_summaries_should_include_create_and_action_specific_fields() {
325 let entry = sample_entry();
326 let create = LifecycleCreateSummary::new("propose", &entry).to_json();
327 let action = LifecycleActionSummary::new(LifecycleAction::Accept, &entry).to_json();
328 assert_eq!(create["kind"], "propose");
329 assert_eq!(create["record_id"], "record-1");
330 assert_eq!(action["action"], "accept");
331 assert_eq!(action["reason"], "approved after review");
332 }
333
334 #[test]
335 fn text_renderers_should_use_summary_fields() {
336 let entry = sample_entry();
337 let create = render_create_text("propose", &entry);
338 let action = render_action_text(LifecycleAction::Accept, &entry);
339 assert!(create.contains("- state: accepted"));
340 assert!(create.contains("- actor: long"));
341 assert!(action.contains("- action: accept"));
342 assert!(action.contains("- reason: approved after review"));
343 }
344
345 #[test]
346 fn read_side_payloads_should_share_summary_generation() {
347 let entry = sample_entry();
348 let queue = LifecycleQueuePayload::new(std::slice::from_ref(&entry));
349 let record = LifecycleRecordPayload::new(&entry);
350 let history = LifecycleHistoryPayload::new("record-1", std::slice::from_ref(&entry));
351 assert_eq!(queue.entries.len(), 1);
352 assert_eq!(queue.summaries[0].record_id, "record-1");
353 assert_eq!(record.summary.state, "accepted");
354 assert_eq!(history.record_id, "record-1");
355 assert_eq!(history.summaries[0].title, "测试偏好");
356 }
357
358 #[test]
359 fn read_side_text_renderers_should_match_existing_markdown_contracts() {
360 let entry = sample_entry();
361 let list = render_queue_text("Pending review", std::slice::from_ref(&entry), true, true);
362 let detail = render_record_text(&entry, true, true);
363 let history = render_history_text("record-1", std::slice::from_ref(&entry), true);
364 assert!(list.contains("# Pending review"));
365 assert!(list.contains("record-1"));
366 assert!(detail.contains("# Memory record"));
367 assert!(detail.contains("## Summary"));
368 assert!(history.contains("# Memory history"));
369 assert!(history.contains("events: 1"));
370 }
371}