1use crate::domain::{
2 OutputFormat, TargetTool, WakeupMemoryItem, WakeupPacket, WakeupProfile, WakeupRecommendedNote,
3};
4
5pub fn render(packet: &WakeupPacket, format: OutputFormat) -> String {
6 match format {
7 OutputFormat::Json => render_json(packet),
8 OutputFormat::Markdown => render_markdown(packet),
9 OutputFormat::Prompt => render_prompt(packet),
10 }
11}
12
13pub fn render_json(packet: &WakeupPacket) -> String {
14 serde_json::to_string_pretty(packet).unwrap_or_else(|_| "{}".to_string())
15}
16
17pub fn render_markdown(packet: &WakeupPacket) -> String {
18 let mut output = String::new();
19 output.push_str("# wakeup packet\n\n");
20 output.push_str(&format!("- profile: {}\n", profile_label(packet.profile)));
21 output.push_str(&format!("- target: {}\n", target_label(packet.target)));
22 output.push_str(&format!("- task: {}\n", packet.query.task));
23 output.push_str(&format!("- cwd: {}\n", packet.query.cwd));
24 if let Some(project_name) = &packet.identity.project_name {
25 output.push_str(&format!("- project: {}\n", project_name));
26 }
27 if !packet.identity.developer_roots.is_empty() {
28 output.push_str(&format!(
29 "- developer_roots: {}\n",
30 packet.identity.developer_roots.join(", ")
31 ));
32 }
33 output.push('\n');
34
35 if let Some(index) = &packet.knowledge_index {
36 output.push_str("## Knowledge index\n\n");
37 output.push_str(index);
38 if !index.ends_with('\n') {
39 output.push('\n');
40 }
41 output.push('\n');
42 }
43
44 render_section(&mut output, "Working style", &packet.working_style.items);
45 render_section(&mut output, "Active context", &packet.active_context.items);
46 render_section(&mut output, "Constraints", &packet.constraints);
47 render_section(&mut output, "Decisions", &packet.decisions);
48 render_section(&mut output, "Incidents", &packet.incidents);
49 render_notes(&mut output, &packet.recommended_notes);
50
51 if !packet.maintenance_hints.is_empty() {
52 output.push_str("## Maintenance hints\n\n");
53 for hint in &packet.maintenance_hints {
54 output.push_str(&format!("- {}\n", hint));
55 }
56 output.push('\n');
57 }
58
59 output.push_str("## Policy\n\n");
60 output.push_str(&format!(
61 "- mode: {}\n- max_sensitivity_included: {}\n- redactions_applied: {}\n- suppressed_note_count: {}\n",
62 packet.policy.policy_mode,
63 packet
64 .policy
65 .max_sensitivity_included
66 .as_deref()
67 .unwrap_or("none"),
68 packet.policy.redactions_applied,
69 packet.policy.suppressed_note_count,
70 ));
71
72 output
73}
74
75pub fn render_prompt(packet: &WakeupPacket) -> String {
76 let mut output = String::new();
77 output.push_str(match packet.target {
78 TargetTool::Claude => "以下是给 Claude 使用的 wake-up packet。\n\n",
79 TargetTool::Codex => "以下是给 Codex 使用的 wake-up packet。\n\n",
80 TargetTool::Opencode => "以下是给 OpenCode 使用的 wake-up packet。\n\n",
81 });
82 output.push_str(&format!("Profile: {}\n", profile_label(packet.profile)));
83 if let Some(project_name) = &packet.identity.project_name {
84 output.push_str(&format!("Project: {}\n", project_name));
85 }
86 output.push_str(&format!("Task: {}\n", packet.query.task));
87
88 let total_items = packet.working_style.items.len()
89 + packet.active_context.items.len()
90 + packet.constraints.len()
91 + packet.decisions.len()
92 + packet.incidents.len();
93 if total_items > 0 {
94 output.push_str(&format!("Memories loaded: {total_items}\n"));
95 }
96 output.push('\n');
97
98 if let Some(index) = &packet.knowledge_index {
99 output.push_str("Knowledge index (auto-synthesized):\n");
100 output.push_str(index);
101 if !index.ends_with('\n') {
102 output.push('\n');
103 }
104 output.push('\n');
105 }
106
107 if !packet.working_style.items.is_empty() {
108 output.push_str("Working style:\n");
109 for item in &packet.working_style.items {
110 output.push_str(&format!("- {}\n", item.summary));
111 }
112 output.push('\n');
113 }
114
115 if !packet.active_context.items.is_empty() {
116 output.push_str("Active context:\n");
117 for item in &packet.active_context.items {
118 output.push_str(&format!("- {}\n", item.summary));
119 }
120 output.push('\n');
121 }
122
123 if !packet.recommended_notes.is_empty() {
124 output.push_str("Recommended notes:\n");
125 for note in &packet.recommended_notes {
126 output.push_str(&format!("- {} ({})\n", note.title, note.path));
127 }
128 output.push('\n');
129 }
130
131 if !packet.maintenance_hints.is_empty() {
132 output.push_str("Maintenance:\n");
133 for hint in &packet.maintenance_hints {
134 output.push_str(&format!("- {}\n", hint));
135 }
136 }
137
138 output
139}
140
141fn render_section(output: &mut String, heading: &str, items: &[WakeupMemoryItem]) {
142 output.push_str(&format!("## {}\n\n", heading));
143 if items.is_empty() {
144 output.push_str("- none\n\n");
145 return;
146 }
147 for item in items {
148 output.push_str(&format!("- {} — {}\n", item.title, item.summary));
149 }
150 output.push('\n');
151}
152
153fn render_notes(output: &mut String, notes: &[WakeupRecommendedNote]) {
154 output.push_str("## Recommended notes\n\n");
155 if notes.is_empty() {
156 output.push_str("- none\n\n");
157 return;
158 }
159 for note in notes {
160 output.push_str(&format!(
161 "- {} [{}] — {}\n",
162 note.title, note.path, note.why_relevant
163 ));
164 }
165 output.push('\n');
166}
167
168fn profile_label(profile: WakeupProfile) -> &'static str {
169 match profile {
170 WakeupProfile::Developer => "developer",
171 WakeupProfile::Project => "project",
172 }
173}
174
175fn target_label(target: TargetTool) -> &'static str {
176 match target {
177 TargetTool::Claude => "claude",
178 TargetTool::Codex => "codex",
179 TargetTool::Opencode => "opencode",
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::{render_markdown, render_prompt};
186 use crate::domain::{
187 ConfidenceTier, TargetTool, WakeupIdentity, WakeupPacket, WakeupPolicy, WakeupProfile,
188 WakeupProvenance, WakeupQuery, WakeupRecommendedNote, WakeupSection,
189 };
190
191 fn make_packet(target: TargetTool) -> WakeupPacket {
192 WakeupPacket {
193 version: "wakeup.v1".to_string(),
194 generated_at: "unix:1".to_string(),
195 target,
196 profile: WakeupProfile::Project,
197 query: WakeupQuery {
198 task: "demo task".to_string(),
199 cwd: "/tmp/repo".to_string(),
200 files: vec!["src/app.rs".to_string()],
201 },
202 identity: WakeupIdentity {
203 project_id: Some("spool".to_string()),
204 project_name: Some("spool".to_string()),
205 repo_paths: vec!["/tmp/repo".to_string()],
206 modules: Vec::new(),
207 scenes: Vec::new(),
208 active_profile: "project".to_string(),
209 developer_roots: Vec::new(),
210 },
211 knowledge_index: None,
212 working_style: WakeupSection::default(),
213 active_context: WakeupSection::default(),
214 priorities: Vec::new(),
215 constraints: Vec::new(),
216 decisions: Vec::new(),
217 incidents: Vec::new(),
218 recommended_notes: vec![WakeupRecommendedNote {
219 path: "10-Projects/demo.md".to_string(),
220 title: "Demo".to_string(),
221 memory_type: Some("project".to_string()),
222 why_relevant: "matched project token".to_string(),
223 score: 10,
224 confidence: ConfidenceTier::Medium,
225 }],
226 maintenance_hints: Vec::new(),
227 provenance: WakeupProvenance::default(),
228 policy: WakeupPolicy {
229 max_sensitivity_included: Some("internal".to_string()),
230 redactions_applied: false,
231 suppressed_note_count: 0,
232 policy_mode: "conservative_default".to_string(),
233 },
234 }
235 }
236
237 #[test]
238 fn prompt_renderer_should_include_target_specific_intro() {
239 let rendered = render_prompt(&make_packet(TargetTool::Codex));
240 assert!(rendered.contains("给 Codex 使用的 wake-up packet"));
241 }
242
243 #[test]
244 fn markdown_renderer_should_include_policy_block() {
245 let rendered = render_markdown(&make_packet(TargetTool::Claude));
246 assert!(rendered.contains("## Policy"));
247 assert!(rendered.contains("max_sensitivity_included: internal"));
248 }
249}