Skip to main content

pi/core/
session_transfer.rs

1//! JSONL branch export and import preparation.
2//!
3//! Runtime teardown and replacement are deliberately outside this module. An
4//! import returns a typed handoff request for the owning runtime to apply.
5
6use 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/// A missing JSONL import source.
19#[derive(Debug, Error)]
20#[error("File not found: {file_path}")]
21pub struct SessionImportFileNotFoundError {
22    /// Fully resolved source path.
23    pub file_path: String,
24}
25
26impl SessionImportFileNotFoundError {
27    /// Construct from a resolved path.
28    #[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/// Session transfer failures.
37#[derive(Debug, Error)]
38pub enum SessionTransferError {
39    /// Import source does not exist.
40    #[error(transparent)]
41    ImportFileNotFound(#[from] SessionImportFileNotFoundError),
42    /// Imported session records a cwd that no longer exists.
43    #[error(transparent)]
44    MissingSessionCwd(#[from] MissingSessionCwdError),
45    /// Session parsing, migration, or validation failed.
46    #[error(transparent)]
47    Session(#[from] SessionError),
48    /// JSON serialization failed.
49    #[error(transparent)]
50    Json(#[from] serde_json::Error),
51    /// A filesystem operation failed.
52    #[error("I/O error on {path}: {source}")]
53    Io {
54        /// Path being accessed.
55        path: String,
56        /// Underlying error.
57        source: std::io::Error,
58    },
59}
60
61/// Runtime action requested after an import is copied, opened, and validated.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum SessionHandoffReason {
64    /// Resume the imported session.
65    Resume,
66}
67
68/// Validated import result handed to the runtime owner.
69#[derive(Debug)]
70pub struct SessionImportHandoff {
71    /// Runtime switch reason.
72    pub reason: SessionHandoffReason,
73    /// Resolved import source.
74    pub source_path: PathBuf,
75    /// Session file used by the opened manager.
76    pub destination_path: PathBuf,
77    /// Opened and cwd-validated session manager.
78    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
100/// Export only the current branch as linear v3 JSONL.
101///
102/// A fresh header is followed by root-to-leaf branch entries. Every `parentId`
103/// is replaced so the output is independent of branches omitted from export.
104/// Parent directories are created recursively.
105///
106/// # Errors
107///
108/// Returns a JSON or filesystem error.
109pub 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
154/// Copy, open, and cwd-validate an imported JSONL session.
155///
156/// This function performs no lifecycle hooks and does not tear down the active
157/// runtime. The caller may run its cancelable `session_before_switch` hook
158/// before calling this primitive, then consume the returned handoff.
159///
160/// # Errors
161///
162/// Returns a distinct file-not-found error, copy/open failures, or the existing
163/// typed missing-session-cwd error.
164pub 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
207/// Compatibility name for the import preparation primitive.
208///
209/// # Errors
210///
211/// See [`prepare_jsonl_import`].
212pub 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}