1use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use anyhow::{anyhow, bail, Context, Result};
12
13use crate::durable_fs::{entry_exists, StagedDirectory};
14use crate::hashing::sha256;
15use crate::models::SessionWithTranscript;
16
17const EXPORT_NAME_PREFIX_CHARS: usize = 80;
18const EXPORT_ID_HASH_HEX_CHARS: usize = 16;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ExportFormat {
23 Text,
25 Markdown,
27 Json,
29}
30
31impl ExportFormat {
32 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Text => "text",
36 Self::Markdown => "markdown",
37 Self::Json => "json",
38 }
39 }
40
41 pub const fn extension(self) -> &'static str {
43 match self {
44 Self::Text => "txt",
45 Self::Markdown => "md",
46 Self::Json => "json",
47 }
48 }
49}
50
51impl fmt::Display for ExportFormat {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter.write_str(self.as_str())
54 }
55}
56
57impl FromStr for ExportFormat {
58 type Err = anyhow::Error;
59
60 fn from_str(value: &str) -> Result<Self> {
61 match value {
62 "text" => Ok(Self::Text),
63 "markdown" | "md" => Ok(Self::Markdown),
64 "json" => Ok(Self::Json),
65 other => Err(anyhow!("unsupported export format: {other}")),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ExportDocument {
73 format: ExportFormat,
74 content: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
79pub struct ExportPublicationReceipt {
80 pub destination: PathBuf,
81 pub format: String,
82 pub sessions: usize,
83 pub files: Vec<PathBuf>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ExportPublicationPlan {
89 destination: PathBuf,
90 format: ExportFormat,
91}
92
93impl ExportPublicationPlan {
94 pub fn new(destination: impl Into<PathBuf>, format: ExportFormat) -> Result<Self> {
96 let plan = Self {
97 destination: destination.into(),
98 format,
99 };
100 plan.preflight()?;
101 Ok(plan)
102 }
103
104 pub fn destination(&self) -> &Path {
105 &self.destination
106 }
107
108 pub const fn format(&self) -> ExportFormat {
109 self.format
110 }
111
112 pub fn preflight(&self) -> Result<()> {
114 if !self.destination.is_absolute() {
115 bail!("export bundle destination must be absolute");
116 }
117 if self.destination.file_name().is_none() {
118 bail!("export bundle destination must name a directory");
119 }
120 if entry_exists(&self.destination)? {
121 bail!(
122 "export bundle destination already exists: {}",
123 self.destination.display()
124 );
125 }
126 Ok(())
127 }
128
129 pub fn publish<I>(&self, documents: I) -> Result<ExportPublicationReceipt>
131 where
132 I: IntoIterator<Item = Result<(String, ExportDocument)>>,
133 {
134 self.preflight()?;
135 let parent = self
136 .destination
137 .parent()
138 .context("export bundle destination has no parent")?;
139 fs::create_dir_all(parent).with_context(|| {
140 format!("failed to create export bundle parent {}", parent.display())
141 })?;
142 let staging = StagedDirectory::begin(parent, "export")?;
143 let mut files = Vec::new();
144 for document in documents {
145 let (session_id, document) = document?;
146 if document.format() != self.format {
147 bail!(
148 "export document format {} does not match publication format {}",
149 document.format(),
150 self.format
151 );
152 }
153 let name = export_file_name(&session_id, self.format);
154 staging.write(&name, document.content().as_bytes())?;
155 files.push(self.destination.join(name));
156 }
157 if files.is_empty() {
158 bail!("no sessions matched; export bundle was not created");
159 }
160 staging.publish(&self.destination)?;
161 Ok(ExportPublicationReceipt {
162 destination: self.destination.clone(),
163 format: self.format.as_str().to_string(),
164 sessions: files.len(),
165 files,
166 })
167 }
168}
169
170fn export_file_name(session_id: &str, format: ExportFormat) -> PathBuf {
171 let mut prefix = String::with_capacity(session_id.len().min(EXPORT_NAME_PREFIX_CHARS));
172 for character in session_id.chars().take(EXPORT_NAME_PREFIX_CHARS) {
173 prefix.push(
174 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
175 character
176 } else {
177 '_'
178 },
179 );
180 }
181 if prefix.is_empty() || prefix == "." || prefix == ".." {
182 prefix = "session".to_string();
183 }
184 let hash = sha256(session_id.as_bytes());
185 PathBuf::from(format!(
186 "{prefix}-{}.{}",
187 &hash[..EXPORT_ID_HASH_HEX_CHARS],
188 format.extension()
189 ))
190}
191
192impl ExportDocument {
193 pub const fn format(&self) -> ExportFormat {
195 self.format
196 }
197
198 pub fn content(&self) -> &str {
200 &self.content
201 }
202
203 pub fn into_content(self) -> String {
205 self.content
206 }
207}
208
209pub fn render_full(
218 session: &SessionWithTranscript,
219 format: ExportFormat,
220) -> Result<ExportDocument> {
221 let title = session
222 .session
223 .title
224 .as_deref()
225 .unwrap_or(&session.session.id);
226 let content = match format {
227 ExportFormat::Text => format!("{title}\n\n{}\n", session.transcript_text),
228 ExportFormat::Markdown => {
229 let fence = markdown_fence(&session.transcript_text);
230 format!(
231 "# {title}\n\n- Provider: {}\n- Session ID: {}\n- CWD: {}\n- Updated At: {}\n\n## Preview\n\n{}\n\n## Transcript\n\n{fence}\n{}\n{fence}\n",
232 session.session.provider,
233 session.session.provider_session_id,
234 session.session.cwd.as_deref().unwrap_or("-"),
235 session
236 .session
237 .updated_at
238 .map(|value| value.to_rfc3339())
239 .unwrap_or_else(|| "-".to_string()),
240 session.session.preview_text,
241 session.transcript_text
242 )
243 }
244 ExportFormat::Json => serde_json::to_string_pretty(&serde_json::to_value(session)?)?,
246 };
247 Ok(ExportDocument { format, content })
248}
249
250fn markdown_fence(transcript: &str) -> String {
251 let mut longest = 0usize;
252 let mut current = 0usize;
253 for character in transcript.chars() {
254 if character == '`' {
255 current += 1;
256 longest = longest.max(current);
257 } else {
258 current = 0;
259 }
260 }
261 "`".repeat(longest.saturating_add(1).max(3))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::models::{Provider, SessionRecord};
268
269 fn fixture() -> SessionWithTranscript {
270 SessionWithTranscript {
271 session: SessionRecord {
272 id: "claude:abc".into(),
273 provider: Provider::Claude,
274 provider_session_id: "abc".into(),
275 source_path: "/sessions/abc.jsonl".into(),
276 discovery_source: "configured".into(),
277 parent_session_id: None,
278 agent_label: None,
279 cwd: Some("/repo".into()),
280 repo_root: Some("/repo".into()),
281 title: Some("Example".into()),
282 summary: None,
283 preview_text: "preview".into(),
284 created_at: None,
285 updated_at: None,
286 last_message_at: None,
287 message_count: Some(2),
288 parse_version: "test".into(),
289 raw_metadata_json: None,
290 parse_warning: None,
291 },
292 transcript_text: "[user] hello\n\n[assistant] hi".into(),
293 }
294 }
295
296 #[test]
297 fn formats_parse_without_adapter_specific_alias_logic() {
298 assert_eq!("text".parse::<ExportFormat>().unwrap(), ExportFormat::Text);
299 assert_eq!(
300 "markdown".parse::<ExportFormat>().unwrap(),
301 ExportFormat::Markdown
302 );
303 assert_eq!(
304 "md".parse::<ExportFormat>().unwrap(),
305 ExportFormat::Markdown
306 );
307 assert_eq!("json".parse::<ExportFormat>().unwrap(), ExportFormat::Json);
308 assert_eq!(
309 "html".parse::<ExportFormat>().unwrap_err().to_string(),
310 "unsupported export format: html"
311 );
312 }
313
314 #[test]
315 fn text_and_markdown_preserve_cli_export_bytes() {
316 let session = fixture();
317 assert_eq!(
318 render_full(&session, ExportFormat::Text).unwrap().content(),
319 "Example\n\n[user] hello\n\n[assistant] hi\n"
320 );
321 assert_eq!(
322 render_full(&session, ExportFormat::Markdown)
323 .unwrap()
324 .content(),
325 "# Example\n\n- Provider: claude\n- Session ID: abc\n- CWD: /repo\n- Updated At: -\n\n## Preview\n\npreview\n\n## Transcript\n\n```\n[user] hello\n\n[assistant] hi\n```\n"
326 );
327 }
328
329 #[test]
330 fn json_is_the_existing_pretty_structured_session_shape() {
331 let session = fixture();
332 let document = render_full(&session, ExportFormat::Json).unwrap();
333 let legacy = serde_json::to_string_pretty(&serde_json::json!(session)).unwrap();
334 assert_eq!(document.content(), legacy);
335 let value: serde_json::Value = serde_json::from_str(document.content()).unwrap();
336 assert_eq!(value["id"], "claude:abc");
337 assert_eq!(value["transcript_text"], session.transcript_text);
338 assert_eq!(document.format(), ExportFormat::Json);
339 }
340
341 #[test]
342 fn markdown_uses_a_longer_fence_than_embedded_code_blocks() {
343 let mut session = fixture();
344 session.transcript_text = "example:\n```rust\nfn main() {}\n```".into();
345
346 let document = render_full(&session, ExportFormat::Markdown).unwrap();
347
348 assert!(document.content().contains("\n````\nexample:"));
349 assert!(document.content().ends_with("```\n````\n"));
350 }
351
352 #[test]
353 fn export_bundle_is_atomic_portable_and_no_replace() {
354 let dir = tempfile::tempdir().unwrap();
355 let destination = dir.path().join("exports");
356 let plan = ExportPublicationPlan::new(&destination, ExportFormat::Markdown).unwrap();
357 let document = render_full(&fixture(), ExportFormat::Markdown).unwrap();
358
359 let receipt = plan
360 .publish([Ok(("claude:../abc".to_string(), document))])
361 .unwrap();
362
363 assert_eq!(receipt.sessions, 1);
364 assert_eq!(receipt.format, "markdown");
365 assert_eq!(receipt.files.len(), 1);
366 assert_eq!(receipt.files[0].parent(), Some(destination.as_path()));
367 assert!(!receipt.files[0]
368 .file_name()
369 .unwrap()
370 .to_string_lossy()
371 .contains(':'));
372 assert!(receipt.files[0].is_file());
373 assert!(plan.publish(std::iter::empty()).is_err());
374 }
375
376 #[test]
377 fn export_bundle_rejects_relative_empty_and_mixed_format_publication() {
378 assert!(ExportPublicationPlan::new("relative", ExportFormat::Json).is_err());
379 let dir = tempfile::tempdir().unwrap();
380 let empty =
381 ExportPublicationPlan::new(dir.path().join("empty"), ExportFormat::Json).unwrap();
382 let error = empty
383 .publish(std::iter::empty::<Result<(String, ExportDocument)>>())
384 .unwrap_err();
385 assert!(error.to_string().contains("no sessions matched"));
386 assert!(!empty.destination().exists());
387
388 let mixed =
389 ExportPublicationPlan::new(dir.path().join("mixed"), ExportFormat::Json).unwrap();
390 let markdown = render_full(&fixture(), ExportFormat::Markdown).unwrap();
391 assert!(mixed
392 .publish([Ok(("claude:abc".to_string(), markdown))])
393 .is_err());
394 assert!(!mixed.destination().exists());
395 }
396}