use super::{MessageRole, SharedSession};
use anyhow::Result;
#[derive(Debug, Clone, Copy)]
pub enum RenderFormat {
Markdown,
Json,
Html,
}
impl RenderFormat {
pub fn from_str_lossy(s: &str) -> Self {
match s.to_lowercase().as_str() {
"json" => RenderFormat::Json,
"html" => RenderFormat::Html,
_ => RenderFormat::Markdown,
}
}
pub fn content_type(&self) -> &'static str {
match self {
RenderFormat::Markdown => "text/markdown; charset=utf-8",
RenderFormat::Json => "application/json",
RenderFormat::Html => "text/html; charset=utf-8",
}
}
pub fn extension(&self) -> &'static str {
match self {
RenderFormat::Markdown => "md",
RenderFormat::Json => "json",
RenderFormat::Html => "html",
}
}
}
pub fn render(session: &SharedSession, format: RenderFormat) -> Result<String> {
match format {
RenderFormat::Markdown => Ok(render_markdown(session)),
RenderFormat::Json => Ok(serde_json::to_string_pretty(session)?),
RenderFormat::Html => Ok(render_html(session)),
}
}
fn render_markdown(session: &SharedSession) -> String {
let mut out = String::new();
out.push_str(&format!("# {} session — {}\n\n", session.provider, session.id));
if let Some(p) = &session.project_path {
out.push_str(&format!("**Project:** `{}`\n\n", p.display()));
}
if let Some(t) = session.started_at {
out.push_str(&format!("**Started:** {}\n\n", t.to_rfc3339()));
}
out.push_str(&format!("**Messages:** {}\n\n", session.messages.len()));
out.push_str("---\n\n");
for (i, msg) in session.messages.iter().enumerate() {
let header = match msg.role {
MessageRole::User => "👤 User".to_string(),
MessageRole::Assistant => {
if let Some(model) = msg.metadata.get("model") {
format!("🤖 Assistant ({})", model)
} else {
"🤖 Assistant".to_string()
}
}
MessageRole::System => "⚙️ System".to_string(),
MessageRole::ToolUse => {
if let Some(name) = msg.metadata.get("tool_name") {
format!("🔧 Tool call: `{}`", name)
} else {
"🔧 Tool call".to_string()
}
}
MessageRole::ToolResult => "📤 Tool result".to_string(),
};
let timestamp_suffix = msg
.timestamp
.map(|t| format!(" *— {}*", t.to_rfc3339()))
.unwrap_or_default();
out.push_str(&format!("## {}. {}{}\n\n", i + 1, header, timestamp_suffix));
match msg.role {
MessageRole::ToolUse => {
out.push_str("```\n");
out.push_str(&msg.content);
out.push_str("\n```\n\n");
}
MessageRole::ToolResult => {
out.push_str("```\n");
out.push_str(&msg.content);
out.push_str("\n```\n\n");
}
_ => {
out.push_str(&msg.content);
if !msg.content.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
}
out
}
fn render_html(session: &SharedSession) -> String {
let mut out = String::with_capacity(8 * 1024);
out.push_str("<!DOCTYPE html>\n<html lang=\"en\" data-theme=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n");
out.push_str(&format!(
"<title>{} session — {}</title>\n",
html_escape(&session.provider),
html_escape(&session.id)
));
out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
out.push_str("<style>\n");
out.push_str(EMBEDDED_CSS);
out.push_str("</style>\n</head>\n<body>\n<main>\n");
out.push_str(&format!(
"<header class=\"session-meta\">\n<h1>{} session</h1>\n",
html_escape(&session.provider)
));
out.push_str(&format!(
"<p class=\"session-id\"><code>{}</code></p>\n",
html_escape(&session.id)
));
if let Some(p) = &session.project_path {
out.push_str(&format!(
"<p><strong>Project:</strong> <code>{}</code></p>\n",
html_escape(&p.display().to_string())
));
}
if let Some(t) = session.started_at {
out.push_str(&format!(
"<p><strong>Started:</strong> {}</p>\n",
html_escape(&t.to_rfc3339())
));
}
out.push_str(&format!(
"<p><strong>Messages:</strong> {}</p>\n</header>\n",
session.messages.len()
));
for msg in &session.messages {
let (role_class, role_label): (&'static str, String) = match msg.role {
MessageRole::User => ("user", "👤 User".to_string()),
MessageRole::Assistant => {
let label = match msg.metadata.get("model") {
Some(m) => format!("🤖 Assistant ({})", html_escape(m)),
None => "🤖 Assistant".to_string(),
};
("assistant", label)
}
MessageRole::System => ("system", "⚙️ System".to_string()),
MessageRole::ToolUse => {
let label = match msg.metadata.get("tool_name") {
Some(name) => format!("🔧 Tool call: <code>{}</code>", html_escape(name)),
None => "🔧 Tool call".to_string(),
};
("tool-use", label)
}
MessageRole::ToolResult => ("tool-result", "📤 Tool result".to_string()),
};
out.push_str(&format!("<article class=\"msg msg-{}\">\n", role_class));
out.push_str(&format!("<header><span class=\"role\">{}</span>", role_label));
if let Some(t) = msg.timestamp {
out.push_str(&format!(
" <time datetime=\"{}\">{}</time>",
html_escape(&t.to_rfc3339()),
html_escape(&t.to_rfc3339())
));
}
out.push_str("</header>\n<div class=\"content\">\n");
match msg.role {
MessageRole::ToolUse | MessageRole::ToolResult => {
out.push_str(&format!("<pre><code>{}</code></pre>\n", html_escape(&msg.content)));
}
_ => {
render_text_with_code_fences(&msg.content, &mut out);
}
}
out.push_str("</div>\n</article>\n");
}
out.push_str("<footer>Generated by <code>i-self share render --format html</code>. Self-contained — no external assets.</footer>\n</main>\n</body>\n</html>\n");
out
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn render_text_with_code_fences(text: &str, out: &mut String) {
let mut in_fence = false;
let mut fence_lang: Option<String> = None;
let mut fence_buf = String::new();
let mut prose_buf = String::new();
let flush_prose = |buf: &mut String, out: &mut String| {
if buf.is_empty() {
return;
}
out.push_str("<p>");
let escaped = html_escape(buf.trim_end_matches('\n'));
out.push_str(&escaped.replace('\n', "<br>\n"));
out.push_str("</p>\n");
buf.clear();
};
for line in text.split_inclusive('\n') {
let stripped = line.strip_suffix('\n').unwrap_or(line);
if !in_fence && stripped.starts_with("```") {
flush_prose(&mut prose_buf, out);
in_fence = true;
let lang = stripped.trim_start_matches('`').trim();
fence_lang = if lang.is_empty() { None } else { Some(lang.to_string()) };
continue;
}
if in_fence && stripped.trim_end() == "```" {
in_fence = false;
let lang_attr = fence_lang
.as_deref()
.map(|l| format!(" data-lang=\"{}\"", html_escape(l)))
.unwrap_or_default();
out.push_str(&format!(
"<pre{}><code>{}</code></pre>\n",
lang_attr,
html_escape(fence_buf.trim_end_matches('\n'))
));
fence_buf.clear();
fence_lang = None;
continue;
}
if in_fence {
fence_buf.push_str(line);
} else {
prose_buf.push_str(line);
}
}
if in_fence {
out.push_str(&format!(
"<pre><code>{}</code></pre>\n",
html_escape(&fence_buf)
));
}
flush_prose(&mut prose_buf, out);
}
const EMBEDDED_CSS: &str = r#"
:root {
--bg: #0f1419;
--bg-soft: #1a2028;
--fg: #d3d7de;
--fg-soft: #8a93a3;
--accent: #7aa2f7;
--user: #2d3748;
--assistant: #1e2935;
--tool: #2a2330;
--result: #1e2a1e;
--code-bg: #0a0e13;
--border: #2c3340;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
line-height: 1.55;
}
main { max-width: 880px; margin: 2rem auto; padding: 0 1.25rem; }
.session-meta {
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.session-meta h1 { margin: 0 0 .25rem; font-size: 1.6rem; color: var(--accent); }
.session-meta p { margin: .25rem 0; color: var(--fg-soft); font-size: .92rem; }
.session-id code { background: var(--bg-soft); padding: .15rem .4rem; border-radius: 4px; }
.msg { background: var(--bg-soft); border-left: 3px solid var(--border); margin: 1rem 0; padding: .9rem 1.1rem; border-radius: 6px; }
.msg-user { border-left-color: var(--accent); background: var(--user); }
.msg-assistant { border-left-color: #98c379; background: var(--assistant); }
.msg-tool-use { border-left-color: #d19a66; background: var(--tool); }
.msg-tool-result { border-left-color: #56b6c2; background: var(--result); }
.msg-system { border-left-color: #c678dd; background: var(--bg-soft); }
.msg header { font-size: .85rem; color: var(--fg-soft); margin-bottom: .5rem; }
.msg .role { font-weight: 600; color: var(--fg); }
.msg time { margin-left: .5rem; opacity: .7; }
.msg .content p { margin: .4rem 0; }
.msg pre {
background: var(--code-bg);
color: #e8eaef;
padding: .75rem 1rem;
border-radius: 4px;
overflow-x: auto;
margin: .5rem 0;
font-size: .85rem;
}
.msg code {
font-family: ui-monospace, SFMono-Regular, "Menlo", monospace;
font-size: .9em;
}
.msg p code { background: var(--code-bg); padding: .1rem .3rem; border-radius: 3px; }
footer {
margin: 3rem 0 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--fg-soft);
font-size: .82rem;
text-align: center;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
use super::super::{SessionMessage, MessageRole};
use std::collections::HashMap;
fn sample() -> SharedSession {
SharedSession {
provider: "claude-code".into(),
id: "test-session".into(),
project_path: Some(std::path::PathBuf::from("/Users/foo/proj")),
started_at: Some(chrono::Utc::now()),
messages: vec![
SessionMessage {
role: MessageRole::User,
content: "Refactor auth.rs".into(),
timestamp: None,
metadata: HashMap::new(),
},
SessionMessage {
role: MessageRole::Assistant,
content: "Sure, here's the plan…".into(),
timestamp: None,
metadata: HashMap::from([("model".into(), "claude-opus-4-7".into())]),
},
SessionMessage {
role: MessageRole::ToolUse,
content: "Read({\"path\":\"src/auth.rs\"})".into(),
timestamp: None,
metadata: HashMap::from([("tool_name".into(), "Read".into())]),
},
],
}
}
#[test]
fn markdown_includes_session_metadata() {
let s = render_markdown(&sample());
assert!(s.contains("# claude-code session"));
assert!(s.contains("test-session"));
assert!(s.contains("/Users/foo/proj"));
assert!(s.contains("**Messages:** 3"));
}
#[test]
fn markdown_renders_each_role_distinctly() {
let s = render_markdown(&sample());
assert!(s.contains("👤 User"));
assert!(s.contains("🤖 Assistant (claude-opus-4-7)"));
assert!(s.contains("🔧 Tool call: `Read`"));
assert!(s.contains("```\nRead({\"path\":\"src/auth.rs\"})\n```"));
}
#[test]
fn json_round_trips() {
let session = sample();
let json = render(&session, RenderFormat::Json).unwrap();
let back: SharedSession = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, session.id);
assert_eq!(back.messages.len(), session.messages.len());
}
#[test]
fn format_lookup_is_case_insensitive() {
assert!(matches!(RenderFormat::from_str_lossy("JSON"), RenderFormat::Json));
assert!(matches!(RenderFormat::from_str_lossy("Markdown"), RenderFormat::Markdown));
assert!(matches!(RenderFormat::from_str_lossy("HTML"), RenderFormat::Html));
assert!(matches!(RenderFormat::from_str_lossy("???"), RenderFormat::Markdown));
}
#[test]
fn html_render_is_self_contained_and_dark_themed() {
let s = render(&sample(), RenderFormat::Html).unwrap();
assert!(s.starts_with("<!DOCTYPE html>"));
assert!(s.contains("<style>"));
assert!(!s.contains("rel=\"stylesheet\""));
assert!(!s.contains("<script"));
assert!(s.contains("data-theme=\"dark\""));
assert!(s.contains("--bg: #0f1419"));
}
#[test]
fn html_render_renders_each_role_with_distinct_class() {
let s = render(&sample(), RenderFormat::Html).unwrap();
assert!(s.contains("msg-user"));
assert!(s.contains("msg-assistant"));
assert!(s.contains("msg-tool-use"));
assert!(s.contains("claude-opus-4-7"));
}
#[test]
fn html_escape_handles_dangerous_chars() {
assert_eq!(
html_escape("<script>alert('x')</script> & co"),
"<script>alert('x')</script> & co"
);
}
#[test]
fn html_render_escapes_user_content() {
let mut s = sample();
s.messages[0].content = "<img src=x onerror=alert(1)>".to_string();
let html = render(&s, RenderFormat::Html).unwrap();
assert!(!html.contains("<img src=x onerror"));
assert!(html.contains("<img src=x onerror=alert(1)>"));
}
#[test]
fn html_render_promotes_fenced_blocks_to_pre_code() {
let mut s = sample();
s.messages[1].content = "Here's the diff:\n\n```rust\nfn main() {}\n```\n\nThat's it.".to_string();
let html = render(&s, RenderFormat::Html).unwrap();
assert!(html.contains("data-lang=\"rust\""));
assert!(html.contains("<pre"));
assert!(html.contains("fn main() {}"));
assert!(html.contains("<p>Here's the diff:</p>") || html.contains("<p>Here's the diff:</p>"));
}
#[test]
fn html_render_handles_unterminated_fence_without_losing_content() {
let mut s = sample();
s.messages[1].content = "Output:\n\n```\nrunning forever".to_string();
let html = render(&s, RenderFormat::Html).unwrap();
assert!(html.contains("running forever"));
}
}