1use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10use thiserror::Error;
11
12use super::config::resolve_path;
13use super::sessions::{
14 CURRENT_SESSION_VERSION, MissingSessionCwdError, SessionError, SessionHeader, SessionManager,
15 assert_session_cwd_exists, now_iso,
16};
17
18#[derive(Debug, Error)]
20#[error("File not found: {file_path}")]
21pub struct SessionImportFileNotFoundError {
22 pub file_path: String,
24}
25
26impl SessionImportFileNotFoundError {
27 #[must_use]
29 pub fn new(file_path: impl Into<String>) -> Self {
30 Self {
31 file_path: file_path.into(),
32 }
33 }
34}
35
36#[derive(Debug, Error)]
38pub enum SessionTransferError {
39 #[error(transparent)]
41 ImportFileNotFound(#[from] SessionImportFileNotFoundError),
42 #[error(transparent)]
44 MissingSessionCwd(#[from] MissingSessionCwdError),
45 #[error(transparent)]
47 Session(#[from] SessionError),
48 #[error(transparent)]
50 Json(#[from] serde_json::Error),
51 #[error("I/O error on {path}: {source}")]
53 Io {
54 path: String,
56 source: std::io::Error,
58 },
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum SessionHandoffReason {
64 Resume,
66}
67
68#[derive(Debug)]
70pub struct SessionImportHandoff {
71 pub reason: SessionHandoffReason,
73 pub source_path: PathBuf,
75 pub destination_path: PathBuf,
77 pub session_manager: SessionManager,
79}
80
81fn io_error(path: &Path, source: std::io::Error) -> SessionTransferError {
82 SessionTransferError::Io {
83 path: path.to_string_lossy().into_owned(),
84 source,
85 }
86}
87
88fn output_path(output_path: Option<&str>) -> PathBuf {
89 output_path.map_or_else(
90 || {
91 let timestamp = now_iso().replace([':', '.'], "-");
92 std::env::current_dir()
93 .unwrap_or_else(|_| PathBuf::from("."))
94 .join(format!("session-{timestamp}.jsonl"))
95 },
96 resolve_path,
97 )
98}
99
100pub fn export_branch_to_jsonl(
110 session: &SessionManager,
111 requested_output: Option<&str>,
112) -> Result<String, SessionTransferError> {
113 let path = output_path(requested_output);
114 if let Some(parent) = path
115 .parent()
116 .filter(|parent| !parent.as_os_str().is_empty())
117 {
118 fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
119 }
120
121 let mut header =
122 SessionHeader::new(session.get_session_id(), now_iso(), session.get_cwd(), None);
123 header.version = Some(CURRENT_SESSION_VERSION);
124
125 let branch = session.get_branch(None);
126 let mut document = String::new();
127 document.push_str(&serde_json::to_string(&header)?);
128 document.push('\n');
129 let mut previous_id: Option<String> = None;
130 for entry in branch {
131 let mut value = serde_json::to_value(entry)?;
132 if let Some(object) = value.as_object_mut() {
133 object.insert(
134 "parentId".to_owned(),
135 previous_id.clone().map_or(Value::Null, Value::String),
136 );
137 }
138 document.push_str(&serde_json::to_string(&value)?);
139 document.push('\n');
140 previous_id = entry.id().map(str::to_owned);
141 }
142
143 fs::write(&path, document).map_err(|source| io_error(&path, source))?;
144 Ok(path.to_string_lossy().into_owned())
145}
146
147fn paths_refer_to_same_file(source: &Path, destination: &Path) -> bool {
148 if source == destination {
149 return true;
150 }
151 same_file::is_same_file(source, destination).unwrap_or(false)
152}
153
154pub fn prepare_jsonl_import(
165 input: &str,
166 session_dir: &str,
167 cwd_override: Option<&str>,
168 fallback_cwd: &str,
169) -> Result<SessionImportHandoff, SessionTransferError> {
170 let source = resolve_path(input);
171 if !source.exists() {
172 return Err(SessionImportFileNotFoundError {
173 file_path: source.to_string_lossy().into_owned(),
174 }
175 .into());
176 }
177
178 let directory = resolve_path(session_dir);
179 fs::create_dir_all(&directory).map_err(|error| io_error(&directory, error))?;
180 let file_name = source.file_name().ok_or_else(|| SessionTransferError::Io {
181 path: source.to_string_lossy().into_owned(),
182 source: std::io::Error::new(
183 std::io::ErrorKind::InvalidInput,
184 "import path has no file name",
185 ),
186 })?;
187 let destination = directory.join(file_name);
188
189 if !paths_refer_to_same_file(&source, &destination) {
190 fs::copy(&source, &destination).map_err(|error| io_error(&destination, error))?;
191 }
192
193 let destination_text = destination.to_string_lossy().into_owned();
194 let directory_text = directory.to_string_lossy().into_owned();
195 let session_manager =
196 SessionManager::open(&destination_text, Some(&directory_text), cwd_override)?;
197 assert_session_cwd_exists(&session_manager, fallback_cwd)?;
198
199 Ok(SessionImportHandoff {
200 reason: SessionHandoffReason::Resume,
201 source_path: source,
202 destination_path: destination,
203 session_manager,
204 })
205}
206
207pub fn import_jsonl_into_session_dir(
213 input: &str,
214 session_dir: &str,
215 cwd_override: Option<&str>,
216 fallback_cwd: &str,
217) -> Result<SessionImportHandoff, SessionTransferError> {
218 prepare_jsonl_import(input, session_dir, cwd_override, fallback_cwd)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use tempfile::tempdir;
225
226 fn write_fixture(
227 path: &Path,
228 cwd: &Path,
229 entries: &[Value],
230 ) -> Result<(), Box<dyn std::error::Error>> {
231 let header = serde_json::json!({
232 "type": "session",
233 "version": 3,
234 "id": "fixture-session",
235 "timestamp": "2026-01-01T00:00:00.000Z",
236 "cwd": cwd,
237 });
238 let mut text = serde_json::to_string(&header)?;
239 text.push('\n');
240 for entry in entries {
241 text.push_str(&serde_json::to_string(entry)?);
242 text.push('\n');
243 }
244 fs::write(path, text)?;
245 Ok(())
246 }
247
248 fn message(id: &str, parent_id: Option<&str>, text: &str) -> Value {
249 serde_json::json!({
250 "type": "message",
251 "id": id,
252 "parentId": parent_id,
253 "timestamp": "2026-01-01T00:00:01.000Z",
254 "message": {"role": "user", "content": text, "timestamp": 1}
255 })
256 }
257
258 #[test]
259 fn exports_current_branch_with_new_header_and_linear_parents()
260 -> Result<(), Box<dyn std::error::Error>> {
261 let root = tempdir()?;
262 let source = root.path().join("source.jsonl");
263 write_fixture(
264 &source,
265 root.path(),
266 &[
267 message("root", None, "root"),
268 message("discarded", Some("root"), "discarded"),
269 message("leaf", Some("root"), "leaf"),
270 ],
271 )?;
272 let manager = SessionManager::open(
273 &source.to_string_lossy(),
274 Some(&root.path().to_string_lossy()),
275 None,
276 )?;
277 let output = root.path().join("nested/export.jsonl");
278 export_branch_to_jsonl(&manager, Some(&output.to_string_lossy()))?;
279 let values: Vec<Value> = fs::read_to_string(output)?
280 .lines()
281 .map(serde_json::from_str)
282 .collect::<Result<_, _>>()?;
283 assert_eq!(values.len(), 3);
284 assert_eq!(values[0]["type"], "session");
285 assert_eq!(values[0]["version"], 3);
286 assert_eq!(values[0]["id"], "fixture-session");
287 assert_eq!(values[1]["id"], "root");
288 assert_eq!(values[1]["parentId"], Value::Null);
289 assert_eq!(values[2]["id"], "leaf");
290 assert_eq!(values[2]["parentId"], "root");
291 Ok(())
292 }
293
294 #[test]
295 fn default_export_name_matches_pi_pattern() {
296 let path = output_path(None);
297 let name = path.file_name().and_then(|value| value.to_str());
298 assert!(name.is_some_and(|value| {
299 value.starts_with("session-") && value.ends_with("Z.jsonl") && !value.contains(':')
300 }));
301 }
302
303 #[test]
304 fn missing_import_has_exact_typed_error() -> Result<(), Box<dyn std::error::Error>> {
305 let root = tempdir()?;
306 let missing = root.path().join("missing.jsonl");
307 let result = prepare_jsonl_import(
308 &missing.to_string_lossy(),
309 &root.path().join("sessions").to_string_lossy(),
310 None,
311 &root.path().to_string_lossy(),
312 );
313 let error = result.err().ok_or("expected missing-file error")?;
314 assert!(matches!(error, SessionTransferError::ImportFileNotFound(_)));
315 assert_eq!(
316 error.to_string(),
317 format!("File not found: {}", missing.display())
318 );
319 Ok(())
320 }
321
322 #[test]
323 fn import_copies_opens_and_returns_typed_handoff() -> Result<(), Box<dyn std::error::Error>> {
324 let root = tempdir()?;
325 let source = root.path().join("source.jsonl");
326 write_fixture(&source, root.path(), &[message("entry", None, "hello")])?;
327 let session_dir = root.path().join("sessions");
328 let handoff = prepare_jsonl_import(
329 &source.to_string_lossy(),
330 &session_dir.to_string_lossy(),
331 None,
332 &root.path().to_string_lossy(),
333 )?;
334 assert_eq!(handoff.reason, SessionHandoffReason::Resume);
335 assert_eq!(handoff.destination_path, session_dir.join("source.jsonl"));
336 assert_eq!(handoff.session_manager.get_session_id(), "fixture-session");
337 assert_eq!(handoff.session_manager.get_entries().len(), 1);
338 Ok(())
339 }
340
341 #[test]
342 fn import_same_file_skips_copy_and_preserves_contents() -> Result<(), Box<dyn std::error::Error>>
343 {
344 let root = tempdir()?;
345 let source = root.path().join("same.jsonl");
346 write_fixture(&source, root.path(), &[message("entry", None, "same")])?;
347 let before = fs::read(&source)?;
348 let handoff = prepare_jsonl_import(
349 &source.to_string_lossy(),
350 &root.path().to_string_lossy(),
351 None,
352 &root.path().to_string_lossy(),
353 )?;
354 assert_eq!(handoff.source_path, handoff.destination_path);
355 assert_eq!(fs::read(source)?, before);
356 Ok(())
357 }
358
359 #[test]
360 fn import_asserts_stored_cwd_and_accepts_override() -> Result<(), Box<dyn std::error::Error>> {
361 let root = tempdir()?;
362 let missing_cwd = root.path().join("gone");
363 let source = root.path().join("missing-cwd.jsonl");
364 write_fixture(&source, &missing_cwd, &[])?;
365 let first_dir = root.path().join("first");
366 let result = prepare_jsonl_import(
367 &source.to_string_lossy(),
368 &first_dir.to_string_lossy(),
369 None,
370 &root.path().to_string_lossy(),
371 );
372 assert!(matches!(
373 result,
374 Err(SessionTransferError::MissingSessionCwd(_))
375 ));
376
377 let second_dir = root.path().join("second");
378 let handoff = prepare_jsonl_import(
379 &source.to_string_lossy(),
380 &second_dir.to_string_lossy(),
381 Some(&root.path().to_string_lossy()),
382 &root.path().to_string_lossy(),
383 )?;
384 assert_eq!(
385 handoff.session_manager.get_cwd(),
386 root.path().to_string_lossy()
387 );
388 Ok(())
389 }
390}