Skip to main content

hj_render/
lib.rs

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}