1use hj_core::{Handoff, HandoffItem, HandoffState};
2
3pub fn render_markdown(handoff: &Handoff, state: Option<&HandoffState>) -> String {
4 let project = handoff.project.as_deref().unwrap_or("unknown");
5 let updated = handoff.updated.as_deref().unwrap_or("unknown");
6 let branch = state
7 .and_then(|value| value.branch.as_deref())
8 .unwrap_or("unknown");
9 let build = state
10 .and_then(|value| value.build.as_deref())
11 .unwrap_or("unknown");
12 let tests = state
13 .and_then(|value| value.tests.as_deref())
14 .unwrap_or("unknown");
15
16 let mut out = String::new();
17 out.push_str(&format!("# Handoff — {project} ({updated})\n\n"));
18 out.push_str(&format!(
19 "**Branch:** {branch} | **Build:** {build} | **Tests:** {tests}\n"
20 ));
21 if let Some(notes) = state
22 .and_then(|value| value.notes.as_deref())
23 .filter(|notes| !notes.is_empty())
24 {
25 out.push_str(&format!("{notes}\n"));
26 }
27
28 out.push_str("\n## Items\n\n");
29 out.push_str("| ID | P | Status | Title |\n");
30 out.push_str("|---|---|---|---|\n");
31
32 for item in sorted_active_items(handoff) {
33 out.push_str(&format!(
34 "| {} | {} | {} | {} |\n",
35 item.id,
36 item.priority.as_deref().unwrap_or("-"),
37 item.status.as_deref().unwrap_or("-"),
38 item.title
39 ));
40 }
41
42 out.push_str("\n## Log\n\n");
43 for entry in handoff.log.iter().take(5) {
44 let date = entry.date.as_deref().unwrap_or("unknown");
45 if entry.commits.is_empty() {
46 out.push_str(&format!("- {date}: {}\n", entry.summary));
47 } else {
48 out.push_str(&format!(
49 "- {date}: {} [{}]\n",
50 entry.summary,
51 entry.commits.join(", ")
52 ));
53 }
54 }
55
56 out
57}
58
59pub fn render_handover_markdown(handoff: &Handoff, state: Option<&HandoffState>) -> String {
60 let branch = state
61 .and_then(|value| value.branch.as_deref())
62 .unwrap_or("unknown");
63 let build = state
64 .and_then(|value| value.build.as_deref())
65 .unwrap_or("unknown");
66 let tests = state
67 .and_then(|value| value.tests.as_deref())
68 .unwrap_or("unknown");
69
70 let mut out = String::new();
71 out.push_str("## State\n\n");
72 out.push_str(&format!(
73 "Branch: {branch} | Build: {build} | Tests: {tests}\n"
74 ));
75 if let Some(notes) = state
76 .and_then(|value| value.notes.as_deref())
77 .filter(|notes| !notes.is_empty())
78 {
79 out.push_str(&format!("{notes}\n"));
80 }
81
82 out.push_str("\n## Items\n\n");
83 out.push_str("| ID | Priority | Status | Title |\n");
84 out.push_str("|---|---|---|---|\n");
85
86 for item in sorted_active_items(handoff) {
87 out.push_str(&format!(
88 "| {} | {} | {} | {} |\n",
89 item.id,
90 item.priority.as_deref().unwrap_or("-"),
91 item.status.as_deref().unwrap_or("-"),
92 item.title
93 ));
94 }
95
96 out.push_str("\n## Log\n\n");
97 for entry in handoff.log.iter().take(5) {
98 let date = entry.date.as_deref().unwrap_or("unknown");
99 if entry.commits.is_empty() {
100 out.push_str(&format!("- {date}: {}\n", entry.summary));
101 } else {
102 out.push_str(&format!(
103 "- {date}: {} [{}]\n",
104 entry.summary,
105 entry.commits.join(", ")
106 ));
107 }
108 }
109
110 out
111}
112
113fn sorted_active_items(handoff: &Handoff) -> Vec<&HandoffItem> {
114 let mut items: Vec<&HandoffItem> = handoff.active_items().collect();
115 items.sort_by_key(|item| {
116 (
117 priority_rank(item.priority.as_deref()),
118 status_rank(item.status.as_deref()),
119 item.id.as_str(),
120 )
121 });
122 items
123}
124
125fn priority_rank(priority: Option<&str>) -> u8 {
126 match priority {
127 Some("P0") => 0,
128 Some("P1") => 1,
129 Some("P2") => 2,
130 _ => 9,
131 }
132}
133
134fn status_rank(status: Option<&str>) -> u8 {
135 match status {
136 Some("open") => 0,
137 Some("blocked") => 1,
138 _ => 9,
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use hj_core::{Handoff, HandoffItem, HandoffState, LogEntry};
145
146 use super::{render_handover_markdown, render_markdown};
147
148 #[test]
149 fn renders_summary_markdown() {
150 let handoff = Handoff {
151 project: Some("hj".into()),
152 updated: Some("2026-04-15".into()),
153 items: vec![HandoffItem {
154 id: "hj-1".into(),
155 priority: Some("P1".into()),
156 status: Some("open".into()),
157 title: "Ship reconcile".into(),
158 ..HandoffItem::default()
159 }],
160 log: vec![LogEntry {
161 date: Some("2026-04-15".into()),
162 summary: "Scaffolded workspace".into(),
163 commits: vec!["abc1234".into()],
164 ..LogEntry::default()
165 }],
166 ..Handoff::default()
167 };
168 let state = HandoffState {
169 branch: Some("main".into()),
170 build: Some("clean".into()),
171 tests: Some("passing".into()),
172 ..HandoffState::default()
173 };
174
175 let rendered = render_markdown(&handoff, Some(&state));
176 assert!(rendered.contains("# Handoff — hj (2026-04-15)"));
177 assert!(rendered.contains("| hj-1 | P1 | open | Ship reconcile |"));
178 assert!(rendered.contains("- 2026-04-15: Scaffolded workspace [abc1234]"));
179 }
180
181 #[test]
182 fn renders_handover_markdown() {
183 let handoff = Handoff {
184 items: vec![HandoffItem {
185 id: "hj-1".into(),
186 priority: Some("P1".into()),
187 status: Some("open".into()),
188 title: "Ship reconcile".into(),
189 ..HandoffItem::default()
190 }],
191 log: vec![LogEntry {
192 date: Some("2026-04-15".into()),
193 summary: "Scaffolded workspace".into(),
194 commits: vec!["abc1234".into()],
195 ..LogEntry::default()
196 }],
197 ..Handoff::default()
198 };
199 let state = HandoffState {
200 branch: Some("main".into()),
201 build: Some("clean".into()),
202 tests: Some("passing".into()),
203 notes: Some("Ready for follow-up.".into()),
204 ..HandoffState::default()
205 };
206
207 let rendered = render_handover_markdown(&handoff, Some(&state));
208 assert!(rendered.contains("## State"));
209 assert!(rendered.contains("Branch: main | Build: clean | Tests: passing"));
210 assert!(rendered.contains("Ready for follow-up."));
211 assert!(rendered.contains("| hj-1 | P1 | open | Ship reconcile |"));
212 }
213}