Skip to main content

ai_session_search/
export.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed session export with destination-independent rendering and explicit publication plans.
5
6use 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/// Supported session export representations.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ExportFormat {
23    /// Human-readable title followed by the transcript.
24    Text,
25    /// Markdown metadata, preview, and fenced transcript.
26    Markdown,
27    /// Pretty-printed structured session JSON.
28    Json,
29}
30
31impl ExportFormat {
32    /// Stable lowercase name used by command and language adapters.
33    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    /// Portable extension used by immutable export bundles.
42    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/// Fully rendered export whose destination remains under caller control.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ExportDocument {
73    format: ExportFormat,
74    content: String,
75}
76
77/// Receipt for one atomically published, immutable multi-session export directory.
78#[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/// Validated destination and representation for one multi-session export transaction.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ExportPublicationPlan {
89    destination: PathBuf,
90    format: ExportFormat,
91}
92
93impl ExportPublicationPlan {
94    /// Create a plan with an explicit absolute destination that must not already exist.
95    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    /// Validate without creating directories or files.
113    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    /// Publish lazily rendered documents as one no-replace directory transaction.
130    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    /// Representation used to produce this document.
194    pub const fn format(&self) -> ExportFormat {
195        self.format
196    }
197
198    /// Borrow the complete rendered document.
199    pub fn content(&self) -> &str {
200        &self.content
201    }
202
203    /// Consume the document and return its rendered bytes as UTF-8 text.
204    pub fn into_content(self) -> String {
205        self.content
206    }
207}
208
209/// Render one resolved session, including its entire transcript, without performing I/O.
210///
211/// The complete document is allocated in memory. Callers should expose this operation as an
212/// explicit full export rather than using it for bounded previews.
213///
214/// # Errors
215///
216/// Returns an error if structured JSON serialization fails.
217pub 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        // Preserve the legacy CLI's Value intermediate so pretty-JSON key ordering stays stable.
245        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}