Skip to main content

smc/cmd/
export.rs

1/// smc export — export a session as markdown.
2use std::io::Write;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::models::{ContentBlock, MessageContent};
8use crate::output::Emitter;
9use crate::util::discover::SessionFile;
10
11// ── Opts ───────────────────────────────────────────────────────────────────
12
13pub struct ExportOpts {
14    pub session: String,
15    /// Write markdown to stdout (via emitter raw lines).
16    pub to_stdout: bool,
17    /// Save markdown to this file path.
18    pub md_path: Option<String>,
19}
20
21// ── Records ────────────────────────────────────────────────────────────────
22
23#[derive(Serialize, Debug)]
24struct ExportDone {
25    #[serde(rename = "type")]
26    record_type: &'static str,
27    session_id: String,
28    project: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    output_file: Option<String>,
31    messages: usize,
32}
33
34// ── run ────────────────────────────────────────────────────────────────────
35
36pub fn run<W: Write>(opts: &ExportOpts, file: &SessionFile, em: &mut Emitter<W>) -> Result<()> {
37    let records = crate::cmd::parse_records(file)?;
38
39    let mut md = String::new();
40    md.push_str(&format!(
41        "# Session: {}\n\n**Project:** {}  \n**Size:** {}\n\n---\n\n",
42        file.session_id, file.project_name, file.size_human()
43    ));
44
45    let mut msg_count = 0usize;
46
47    for record in &records {
48        let Some(msg) = record.as_message() else { continue };
49        msg_count += 1;
50
51        let role = record.role();
52        let ts = msg.timestamp.as_deref().unwrap_or("unknown");
53        let ts_short = ts.get(..19).unwrap_or(ts);
54
55        md.push_str(&format!("## {} ({})\n\n", role.to_uppercase(), ts_short));
56
57        match &msg.message.content {
58            MessageContent::Text(s) => {
59                md.push_str(s);
60                md.push_str("\n\n");
61            }
62            MessageContent::Blocks(blocks) => {
63                for block in blocks {
64                    match block {
65                        ContentBlock::Text { text } => {
66                            md.push_str(text);
67                            md.push_str("\n\n");
68                        }
69                        ContentBlock::Thinking { thinking } => {
70                            md.push_str(&format!(
71                                "<details>\n<summary>Thinking</summary>\n\n{}\n\n</details>\n\n",
72                                thinking
73                            ));
74                        }
75                        ContentBlock::ToolUse { name, input, .. } => {
76                            let pretty = serde_json::to_string_pretty(input)
77                                .unwrap_or_else(|_| input.to_string());
78                            md.push_str(&format!("**Tool: {}**\n```json\n{}\n```\n\n", name, pretty));
79                        }
80                        ContentBlock::ToolResult { content: Some(c), .. } => {
81                            let s = c.to_string();
82                            let preview: String = s.chars().take(2000).collect();
83                            md.push_str(&format!("**Result:**\n```\n{}\n```\n\n", preview));
84                        }
85                        _ => {}
86                    }
87                }
88            }
89        }
90
91        md.push_str("---\n\n");
92    }
93
94    // write markdown
95    if opts.to_stdout {
96        // Emit as raw lines so it's readable markdown, not JSON-wrapped
97        for line in md.lines() {
98            em.raw(line)?;
99        }
100    }
101
102    let output_file = if let Some(p) = &opts.md_path {
103        std::fs::write(p, &md)?;
104        Some(p.clone())
105    } else if !opts.to_stdout {
106        let path = format!("{}.md", &file.session_id[..8.min(file.session_id.len())]);
107        std::fs::write(&path, &md)?;
108        Some(path)
109    } else {
110        None
111    };
112
113    if !opts.to_stdout {
114        let done = ExportDone {
115            record_type: "export",
116            session_id: file.session_id.clone(),
117            project: file.project_name.clone(),
118            output_file,
119            messages: msg_count,
120        };
121        em.emit(&done)?;
122    }
123
124    em.flush()?;
125    Ok(())
126}