supercode_harness/
human_export.rs1use crate::message::{ChatMessage, Role};
21use crate::session::Session;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum HumanExportFormat {
27 #[default]
30 Text,
31 Html,
34}
35
36impl HumanExportFormat {
37 pub fn parse(s: &str) -> Option<HumanExportFormat> {
42 match s {
43 "text" => Some(HumanExportFormat::Text),
44 "html" => Some(HumanExportFormat::Html),
45 _ => None,
46 }
47 }
48}
49
50pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
56 render_messages(
57 &session.messages,
58 session.meta.session_id.as_deref(),
59 session.meta.model.as_deref(),
60 format,
61 )
62}
63
64pub fn render_messages(
71 messages: &[ChatMessage],
72 session_id: Option<&str>,
73 model: Option<&str>,
74 format: HumanExportFormat,
75) -> String {
76 match format {
77 HumanExportFormat::Text => render_text(messages, session_id, model),
78 HumanExportFormat::Html => render_html(messages, session_id, model),
79 }
80}
81
82fn role_label(role: Role) -> &'static str {
83 match role {
84 Role::System => "System",
85 Role::User => "User",
86 Role::Assistant => "Assistant",
87 Role::Tool => "Tool",
88 }
89}
90
91fn message_body(msg: &ChatMessage) -> String {
96 let mut parts = Vec::new();
97 if let Some(content) = &msg.content {
98 if !content.is_empty() {
99 parts.push(content.clone());
100 }
101 }
102 if let Some(calls) = &msg.tool_calls {
103 for call in calls {
104 parts.push(format!(
105 "[tool call: {}({})]",
106 call.function.name, call.function.arguments
107 ));
108 }
109 }
110 if parts.is_empty() {
111 parts.push("(empty)".to_string());
112 }
113 parts.join("\n")
114}
115
116fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
117 let mut out = String::new();
118 if let Some(id) = session_id {
119 out.push_str(&format!("Session: {id}\n"));
120 }
121 if let Some(model) = model {
122 out.push_str(&format!("Model: {model}\n"));
123 }
124 if !out.is_empty() {
125 out.push('\n');
126 }
127 for (i, msg) in messages.iter().enumerate() {
128 if i > 0 {
129 out.push('\n');
130 }
131 out.push_str(&format!("## {}\n", role_label(msg.role)));
132 out.push_str(&message_body(msg));
133 out.push('\n');
134 }
135 out
136}
137
138fn html_escape(s: &str) -> String {
139 s.replace('&', "&")
140 .replace('<', "<")
141 .replace('>', ">")
142 .replace('"', """)
143}
144
145fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
146 let mut out = String::new();
147 out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
148 out.push_str("<title>supercode session export</title>\n");
149 out.push_str(
150 "<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
151 .msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
152 .role{font-weight:bold}</style>\n",
153 );
154 out.push_str("</head><body>\n");
155 if let Some(id) = session_id {
156 out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
157 }
158 if let Some(model) = model {
159 out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
160 }
161 for msg in messages {
162 out.push_str("<div class=\"msg\"><div class=\"role\">");
163 out.push_str(role_label(msg.role));
164 out.push_str("</div><div class=\"body\">");
165 out.push_str(&html_escape(&message_body(msg)));
166 out.push_str("</div></div>\n");
167 }
168 out.push_str("</body></html>\n");
169 out
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::message::{FunctionCall, ToolCall};
176 use crate::session::{Session, SessionSource};
177
178 fn sample_session() -> Session {
179 let mut session = Session::from_native_messages(vec![
180 ChatMessage::system("you are helpful".to_string()),
181 ChatMessage::user("list files".to_string()),
182 ChatMessage {
183 role: Role::Assistant,
184 content: None,
185 content_parts: None,
186 tool_calls: Some(vec![ToolCall {
187 id: "call-1".to_string(),
188 kind: "function".to_string(),
189 function: FunctionCall {
190 name: "bash".to_string(),
191 arguments: r#"{"command":"ls"}"#.to_string(),
192 },
193 }]),
194 tool_call_id: None,
195 name: None,
196 metadata: Default::default(),
197 },
198 ChatMessage::tool_result("call-1", "bash", "a.txt\nb.txt".to_string()),
199 ]);
200 session.meta.source = SessionSource::ClaudeCode;
201 session.meta.session_id = Some("sess-1".to_string());
202 session.meta.model = Some("anthropic/claude-opus-4-8".to_string());
203 session
204 }
205
206 #[test]
210 fn default_export_format_is_text() {
211 assert_eq!(HumanExportFormat::default(), HumanExportFormat::Text);
212 }
213
214 #[test]
215 fn parse_round_trips_known_strings() {
216 assert_eq!(
217 HumanExportFormat::parse("text"),
218 Some(HumanExportFormat::Text)
219 );
220 assert_eq!(
221 HumanExportFormat::parse("html"),
222 Some(HumanExportFormat::Html)
223 );
224 assert_eq!(HumanExportFormat::parse("xml"), None);
225 }
226
227 #[test]
231 fn text_render_includes_every_message() {
232 let session = sample_session();
233 let text = render_transcript(&session, HumanExportFormat::Text);
234 assert!(text.contains("Session: sess-1"));
235 assert!(text.contains("Model: anthropic/claude-opus-4-8"));
236 assert!(text.contains("## System"));
237 assert!(text.contains("you are helpful"));
238 assert!(text.contains("## User"));
239 assert!(text.contains("list files"));
240 assert!(text.contains("## Assistant"));
241 assert!(text.contains("[tool call: bash({\"command\":\"ls\"})]"));
242 assert!(text.contains("## Tool"));
243 assert!(text.contains("a.txt\nb.txt"));
244 }
245
246 #[test]
249 fn text_render_handles_empty_session() {
250 let mut session = sample_session();
251 session.messages.clear();
252 let text = render_transcript(&session, HumanExportFormat::Text);
253 assert!(text.contains("Session: sess-1"));
254 assert!(!text.contains("##"));
255 }
256
257 #[test]
261 fn html_render_escapes_message_content() {
262 let mut session = sample_session();
263 session
264 .messages
265 .push(ChatMessage::user("<script>alert(1)</script>".to_string()));
266 let html = render_transcript(&session, HumanExportFormat::Html);
267 assert!(!html.contains("<script>alert(1)</script>"));
268 assert!(html.contains("<script>alert(1)</script>"));
269 assert!(html.contains("<!doctype html>"));
270 }
271
272 #[test]
277 fn render_is_read_only_and_idempotent() {
278 let session = sample_session();
279 let before_len = session.messages.len();
280 let first = render_transcript(&session, HumanExportFormat::Text);
281 let second = render_transcript(&session, HumanExportFormat::Text);
282 assert_eq!(first, second);
283 assert_eq!(session.messages.len(), before_len);
284 }
285
286 #[test]
291 fn render_messages_agrees_with_render_transcript() {
292 let session = sample_session();
293 let via_session = render_transcript(&session, HumanExportFormat::Text);
294 let via_messages = render_messages(
295 &session.messages,
296 session.meta.session_id.as_deref(),
297 session.meta.model.as_deref(),
298 HumanExportFormat::Text,
299 );
300 assert_eq!(via_session, via_messages);
301 }
302}