1use crate::diagnostic::is_terminal_diagnostic_content;
7use crate::{SessionEntry, SessionError, SessionMetadata, SessionStore};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TranscriptEntry {
18 pub role: String,
20
21 pub content: String,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub timestamp: Option<DateTime<Utc>>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub metadata: Option<SessionMetadata>,
31}
32
33impl From<&SessionEntry> for TranscriptEntry {
34 fn from(entry: &SessionEntry) -> Self {
35 let metadata = if entry.metadata.is_empty() {
36 None
37 } else {
38 Some(entry.metadata.clone())
39 };
40 Self {
41 role: entry.role.clone(),
42 content: entry.content.clone(),
43 timestamp: Some(entry.timestamp),
44 metadata,
45 }
46 }
47}
48
49pub fn export_json(entries: &[SessionEntry]) -> Result<String, serde_json::Error> {
53 let transcript: Vec<TranscriptEntry> = entries
54 .iter()
55 .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
56 .map(TranscriptEntry::from)
57 .collect();
58 serde_json::to_string_pretty(&transcript)
59}
60
61pub fn export_markdown(entries: &[SessionEntry]) -> String {
75 export_markdown_filtered(entries, false)
76}
77
78#[allow(dead_code)]
79pub fn export_markdown_with_thinking(entries: &[SessionEntry]) -> String {
80 export_markdown_filtered(entries, true)
81}
82
83fn export_markdown_filtered(entries: &[SessionEntry], include_thinking: bool) -> String {
84 if entries.is_empty() {
85 return String::new();
86 }
87
88 let mut output = String::new();
89 let mut first = true;
90 for entry in entries
91 .iter()
92 .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
93 {
94 if !first {
95 output.push_str("\n\n");
96 }
97 first = false;
98
99 if include_thinking
100 && let Some(ref reasoning) = entry.metadata.reasoning
101 && let Some(text) = talos_core::message::project_displayable_reasoning(reasoning)
102 {
103 output.push_str("## Thinking\n");
104 for line in text.lines() {
105 output.push_str(&format!("| {line}\n"));
106 }
107 output.push_str("\n\n");
108 }
109
110 let role_header = match entry.role.as_str() {
111 "user" => "User",
112 "assistant" => "Assistant",
113 "system" => "System",
114 other => other,
115 };
116 output.push_str(&format!("## {}\n{}", role_header, entry.content));
117 }
118 output.push('\n');
119 output
120}
121
122pub fn read_transcript(
127 store: &dyn SessionStore,
128 file_path: &Path,
129) -> Result<Vec<TranscriptEntry>, SessionError> {
130 let entries = store.read_entries(file_path)?;
131 Ok(entries
132 .iter()
133 .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
134 .map(TranscriptEntry::from)
135 .collect())
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::SessionEntry;
142 use crate::store::{CompactTextSessionStore, JsonlSessionStore};
143 use chrono::Utc;
144 use talos_core::message::{AssistantReasoning, ReasoningBlock};
145 use uuid::Uuid;
146
147 fn make_entry(role: &str, content: &str) -> SessionEntry {
148 SessionEntry {
149 id: Uuid::new_v4().to_string(),
150 parent_id: None,
151 timestamp: Utc::now(),
152 role: role.into(),
153 content: content.into(),
154 metadata: SessionMetadata::default(),
155 }
156 }
157
158 fn make_entry_with_metadata(role: &str, content: &str) -> SessionEntry {
159 SessionEntry {
160 id: Uuid::new_v4().to_string(),
161 parent_id: None,
162 timestamp: Utc::now(),
163 role: role.into(),
164 content: content.into(),
165 metadata: SessionMetadata {
166 turn_id: None,
167 provider: Some("anthropic".into()),
168 model: Some("claude-sonnet-4-20250514".into()),
169 token_count: Some(42),
170 working_directory: Some("/tmp/project".into()),
171 reasoning: None,
172 raw_content: None,
173 },
174 }
175 }
176
177 fn make_entry_with_reasoning(content: &str, reasoning_text: &str) -> SessionEntry {
178 let mut entry = make_entry("assistant", content);
179 entry.metadata.reasoning = Some(AssistantReasoning {
180 provider: "fixture-provider".into(),
181 model: "fixture-model".into(),
182 blocks: vec![ReasoningBlock::Plain {
183 text: reasoning_text.into(),
184 }],
185 });
186 entry
187 }
188
189 #[test]
192 fn export_json_produces_valid_json() {
193 let entries = vec![
194 make_entry("user", "Hello"),
195 make_entry("assistant", "Hi there"),
196 ];
197 let json = export_json(&entries).expect("operation should succeed");
198 let parsed: Vec<serde_json::Value> =
200 serde_json::from_str(&json).expect("operation should succeed");
201 assert_eq!(parsed.len(), 2);
202 }
203
204 #[test]
205 fn export_json_has_correct_fields() {
206 let entries = vec![make_entry("user", "test content")];
207 let json = export_json(&entries).expect("operation should succeed");
208 let parsed: Vec<serde_json::Value> =
209 serde_json::from_str(&json).expect("operation should succeed");
210
211 let entry = &parsed[0];
212 assert_eq!(entry["role"], "user");
213 assert_eq!(entry["content"], "test content");
214 assert!(entry.get("timestamp").is_some());
215 }
216
217 #[test]
218 fn export_json_empty_produces_empty_array() {
219 let entries: Vec<SessionEntry> = Vec::new();
220 let json = export_json(&entries).expect("operation should succeed");
221 assert_eq!(json, "[]");
222 }
223
224 #[test]
225 fn export_json_preserves_metadata() {
226 let entries = vec![make_entry_with_metadata("assistant", "response")];
227 let json = export_json(&entries).expect("operation should succeed");
228 let parsed: Vec<serde_json::Value> =
229 serde_json::from_str(&json).expect("operation should succeed");
230
231 let meta = &parsed[0]["metadata"];
232 assert_eq!(meta["provider"], "anthropic");
233 assert_eq!(meta["model"], "claude-sonnet-4-20250514");
234 assert_eq!(meta["token_count"], 42);
235 assert_eq!(meta["working_directory"], "/tmp/project");
236 }
237
238 #[test]
239 fn export_json_omits_empty_metadata() {
240 let entries = vec![make_entry("user", "no metadata")];
241 let json = export_json(&entries).expect("operation should succeed");
242 let parsed: Vec<serde_json::Value> =
243 serde_json::from_str(&json).expect("operation should succeed");
244
245 assert!(parsed[0].get("metadata").is_none());
247 }
248
249 #[test]
252 fn export_markdown_produces_role_headers() {
253 let entries = vec![
254 make_entry("user", "Hello"),
255 make_entry("assistant", "Hi there"),
256 make_entry("system", "Event"),
257 ];
258 let md = export_markdown(&entries);
259
260 assert!(md.contains("## User"));
261 assert!(md.contains("## Assistant"));
262 assert!(md.contains("## System"));
263 }
264
265 #[test]
266 fn export_markdown_contains_content() {
267 let entries = vec![make_entry("user", "Hello, world!")];
268 let md = export_markdown(&entries);
269 assert!(md.contains("Hello, world!"));
270 }
271
272 #[test]
273 fn export_markdown_empty_produces_empty_string() {
274 let entries: Vec<SessionEntry> = Vec::new();
275 let md = export_markdown(&entries);
276 assert!(md.is_empty());
277 }
278
279 #[test]
280 fn export_markdown_separates_entries() {
281 let entries = vec![
282 make_entry("user", "first"),
283 make_entry("assistant", "second"),
284 ];
285 let md = export_markdown(&entries);
286 assert!(md.contains("\n\n## Assistant"));
288 }
289
290 #[test]
291 fn export_markdown_excludes_reasoning_and_derived_thinking_title_by_default() {
292 let entries = vec![make_entry_with_reasoning(
293 "Visible answer",
294 "**Secret Title**\n\nreasoning about api_key=sk-leak",
295 )];
296
297 let md = export_markdown(&entries);
298
299 assert!(md.contains("Visible answer"));
300 assert!(!md.contains("## Thinking"));
301 assert!(!md.contains("Secret Title"));
302 assert!(!md.contains("reasoning about"));
303 assert!(!md.contains("sk-leak"));
304 }
305
306 #[test]
307 fn export_markdown_with_thinking_keeps_explicit_opt_in_behavior() {
308 let entries = vec![make_entry_with_reasoning(
309 "Visible answer",
310 "**Section Title**\n\nreasoning details",
311 )];
312
313 let md = export_markdown_with_thinking(&entries);
314
315 assert!(md.contains("## Thinking"));
316 assert!(md.contains("**Section Title**"));
317 assert!(md.contains("reasoning details"));
318 assert!(md.contains("Visible answer"));
319 }
320
321 #[test]
324 fn read_transcript_works_with_jsonl_store() {
325 let dir = tempfile::tempdir().expect("operation should succeed");
326 let path = dir.path().join("test.jsonl");
327
328 let store = JsonlSessionStore;
329 let entry = make_entry("user", "Hello from JSONL");
330 store
331 .append_entry(&path, &entry)
332 .expect("operation should succeed");
333
334 let transcript = read_transcript(&store, &path).expect("operation should succeed");
335 assert_eq!(transcript.len(), 1);
336 assert_eq!(transcript[0].role, "user");
337 assert_eq!(transcript[0].content, "Hello from JSONL");
338 }
339
340 #[test]
341 fn read_transcript_works_with_tlog_store() {
342 let dir = tempfile::tempdir().expect("operation should succeed");
343 let path = dir.path().join("test.tlog");
344
345 let store = CompactTextSessionStore;
346 let entry = make_entry("assistant", "Hello from TLOG");
347 store
348 .append_entry(&path, &entry)
349 .expect("operation should succeed");
350
351 let transcript = read_transcript(&store, &path).expect("operation should succeed");
352 assert_eq!(transcript.len(), 1);
353 assert_eq!(transcript[0].role, "assistant");
354 assert_eq!(transcript[0].content, "Hello from TLOG");
355 }
356
357 #[test]
358 fn read_transcript_empty_session() {
359 let dir = tempfile::tempdir().expect("operation should succeed");
360 let path = dir.path().join("empty.jsonl");
361
362 let store = JsonlSessionStore;
363 let transcript = read_transcript(&store, &path).expect("operation should succeed");
364 assert!(transcript.is_empty());
365 }
366
367 #[test]
368 fn read_transcript_multiple_entries() {
369 let dir = tempfile::tempdir().expect("operation should succeed");
370 let path = dir.path().join("multi.tlog");
371
372 let store = CompactTextSessionStore;
373 let entries = vec![
374 make_entry("user", "Question"),
375 make_entry("assistant", "Answer"),
376 make_entry("system", "Event"),
377 ];
378 for e in &entries {
379 store
380 .append_entry(&path, e)
381 .expect("operation should succeed");
382 }
383
384 let transcript = read_transcript(&store, &path).expect("operation should succeed");
385 assert_eq!(transcript.len(), 3);
386 assert_eq!(transcript[0].role, "user");
387 assert_eq!(transcript[1].role, "assistant");
388 assert_eq!(transcript[2].role, "system");
389 }
390
391 #[test]
392 fn read_transcript_preserves_metadata() {
393 let dir = tempfile::tempdir().expect("operation should succeed");
394 let path = dir.path().join("meta.jsonl");
395
396 let store = JsonlSessionStore;
397 let entry = make_entry_with_metadata("assistant", "with metadata");
398 store
399 .append_entry(&path, &entry)
400 .expect("operation should succeed");
401
402 let transcript = read_transcript(&store, &path).expect("operation should succeed");
403 assert_eq!(transcript.len(), 1);
404 let meta = transcript[0]
405 .metadata
406 .as_ref()
407 .expect("operation should succeed");
408 assert_eq!(meta.provider, Some("anthropic".into()));
409 assert_eq!(meta.model, Some("claude-sonnet-4-20250514".into()));
410 }
411
412 #[test]
415 fn export_json_round_trip() {
416 let entries = vec![
417 make_entry("user", "Hello"),
418 make_entry_with_metadata("assistant", "Response"),
419 ];
420 let json = export_json(&entries).expect("operation should succeed");
421 let parsed: Vec<TranscriptEntry> =
422 serde_json::from_str(&json).expect("operation should succeed");
423
424 assert_eq!(parsed.len(), 2);
425 assert_eq!(parsed[0].role, "user");
426 assert_eq!(parsed[0].content, "Hello");
427 assert_eq!(parsed[1].role, "assistant");
428 assert_eq!(parsed[1].content, "Response");
429 assert!(parsed[1].metadata.is_some());
430 }
431
432 #[test]
433 fn export_markdown_round_trip_readability() {
434 let entries = vec![
435 make_entry("user", "What is Rust?"),
436 make_entry("assistant", "Rust is a systems programming language."),
437 ];
438 let md = export_markdown(&entries);
439
440 assert!(md.contains("## User"));
442 assert!(md.contains("What is Rust?"));
443 assert!(md.contains("## Assistant"));
444 assert!(md.contains("Rust is a systems programming language."));
445 }
446}