use crate::message::{ChatMessage, Role};
use crate::session::Session;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HumanExportFormat {
#[default]
Text,
Html,
}
impl HumanExportFormat {
pub fn parse(s: &str) -> Option<HumanExportFormat> {
match s {
"text" => Some(HumanExportFormat::Text),
"html" => Some(HumanExportFormat::Html),
_ => None,
}
}
}
pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
render_messages(
&session.messages,
session.meta.session_id.as_deref(),
session.meta.model.as_deref(),
format,
)
}
pub fn render_messages(
messages: &[ChatMessage],
session_id: Option<&str>,
model: Option<&str>,
format: HumanExportFormat,
) -> String {
match format {
HumanExportFormat::Text => render_text(messages, session_id, model),
HumanExportFormat::Html => render_html(messages, session_id, model),
}
}
fn role_label(role: Role) -> &'static str {
match role {
Role::System => "System",
Role::User => "User",
Role::Assistant => "Assistant",
Role::Tool => "Tool",
}
}
fn message_body(msg: &ChatMessage) -> String {
let mut parts = Vec::new();
if let Some(content) = &msg.content {
if !content.is_empty() {
parts.push(content.clone());
}
}
if let Some(calls) = &msg.tool_calls {
for call in calls {
parts.push(format!(
"[tool call: {}({})]",
call.function.name, call.function.arguments
));
}
}
if parts.is_empty() {
parts.push("(empty)".to_string());
}
parts.join("\n")
}
fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
let mut out = String::new();
if let Some(id) = session_id {
out.push_str(&format!("Session: {id}\n"));
}
if let Some(model) = model {
out.push_str(&format!("Model: {model}\n"));
}
if !out.is_empty() {
out.push('\n');
}
for (i, msg) in messages.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!("## {}\n", role_label(msg.role)));
out.push_str(&message_body(msg));
out.push('\n');
}
out
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
let mut out = String::new();
out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
out.push_str("<title>supercode session export</title>\n");
out.push_str(
"<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
.msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
.role{font-weight:bold}</style>\n",
);
out.push_str("</head><body>\n");
if let Some(id) = session_id {
out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
}
if let Some(model) = model {
out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
}
for msg in messages {
out.push_str("<div class=\"msg\"><div class=\"role\">");
out.push_str(role_label(msg.role));
out.push_str("</div><div class=\"body\">");
out.push_str(&html_escape(&message_body(msg)));
out.push_str("</div></div>\n");
}
out.push_str("</body></html>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{FunctionCall, ToolCall};
use crate::session::{Session, SessionSource};
fn sample_session() -> Session {
let mut session = Session::from_native_messages(vec![
ChatMessage::system("you are helpful".to_string()),
ChatMessage::user("list files".to_string()),
ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![ToolCall {
id: "call-1".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "bash".to_string(),
arguments: r#"{"command":"ls"}"#.to_string(),
},
}]),
tool_call_id: None,
name: None,
metadata: Default::default(),
},
ChatMessage::tool_result("call-1", "bash", "a.txt\nb.txt".to_string()),
]);
session.meta.source = SessionSource::ClaudeCode;
session.meta.session_id = Some("sess-1".to_string());
session.meta.model = Some("anthropic/claude-opus-4-8".to_string());
session
}
#[test]
fn default_export_format_is_text() {
assert_eq!(HumanExportFormat::default(), HumanExportFormat::Text);
}
#[test]
fn parse_round_trips_known_strings() {
assert_eq!(
HumanExportFormat::parse("text"),
Some(HumanExportFormat::Text)
);
assert_eq!(
HumanExportFormat::parse("html"),
Some(HumanExportFormat::Html)
);
assert_eq!(HumanExportFormat::parse("xml"), None);
}
#[test]
fn text_render_includes_every_message() {
let session = sample_session();
let text = render_transcript(&session, HumanExportFormat::Text);
assert!(text.contains("Session: sess-1"));
assert!(text.contains("Model: anthropic/claude-opus-4-8"));
assert!(text.contains("## System"));
assert!(text.contains("you are helpful"));
assert!(text.contains("## User"));
assert!(text.contains("list files"));
assert!(text.contains("## Assistant"));
assert!(text.contains("[tool call: bash({\"command\":\"ls\"})]"));
assert!(text.contains("## Tool"));
assert!(text.contains("a.txt\nb.txt"));
}
#[test]
fn text_render_handles_empty_session() {
let mut session = sample_session();
session.messages.clear();
let text = render_transcript(&session, HumanExportFormat::Text);
assert!(text.contains("Session: sess-1"));
assert!(!text.contains("##"));
}
#[test]
fn html_render_escapes_message_content() {
let mut session = sample_session();
session
.messages
.push(ChatMessage::user("<script>alert(1)</script>".to_string()));
let html = render_transcript(&session, HumanExportFormat::Html);
assert!(!html.contains("<script>alert(1)</script>"));
assert!(html.contains("<script>alert(1)</script>"));
assert!(html.contains("<!doctype html>"));
}
#[test]
fn render_is_read_only_and_idempotent() {
let session = sample_session();
let before_len = session.messages.len();
let first = render_transcript(&session, HumanExportFormat::Text);
let second = render_transcript(&session, HumanExportFormat::Text);
assert_eq!(first, second);
assert_eq!(session.messages.len(), before_len);
}
#[test]
fn render_messages_agrees_with_render_transcript() {
let session = sample_session();
let via_session = render_transcript(&session, HumanExportFormat::Text);
let via_messages = render_messages(
&session.messages,
session.meta.session_id.as_deref(),
session.meta.model.as_deref(),
HumanExportFormat::Text,
);
assert_eq!(via_session, via_messages);
}
}