use super::import_session::ImportOptions;
use super::{
MessageRole, SessionImporter, SessionMessage, SessionProvider, SessionSummary, ShareError,
SharedSession,
};
use serde_json::Value;
use std::path::{Path, PathBuf};
const PROVIDER: &str = "continue";
#[derive(Default)]
pub struct ContinueProvider {
root_override: Option<PathBuf>,
}
impl ContinueProvider {
pub fn with_root(root: PathBuf) -> Self {
Self { root_override: Some(root) }
}
fn root(&self) -> Option<PathBuf> {
self.root_override
.clone()
.or_else(|| std::env::var("ISELF_CONTINUE_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::home_dir().map(|h| h.join(".continue").join("sessions")))
}
}
impl SessionProvider for ContinueProvider {
fn name(&self) -> &str {
PROVIDER
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let root = match self.root() {
Some(r) if r.is_dir() => r,
_ => return Ok(Vec::new()),
};
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
if p.file_name() == Some(std::ffi::OsStr::new("sessions.json")) {
continue;
}
let id = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if id.is_empty() {
continue;
}
if let Some(s) = summarize_one(&p, &id) {
out.push(s);
}
}
let aggregate = root.join("sessions.json");
if aggregate.exists() {
if let Ok(content) = std::fs::read_to_string(&aggregate) {
if let Ok(v) = serde_json::from_str::<Value>(&content) {
if let Some(obj) = v.as_object() {
for (id, payload) in obj {
if let Some(s) = summarize_payload(payload, id) {
out.push(s);
}
}
}
}
}
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
let root = self
.root()
.ok_or_else(|| ShareError::NotFound(id.to_string()))?;
let p = root.join(format!("{}.json", id));
if p.exists() {
let content = std::fs::read_to_string(&p)?;
let v: Value = serde_json::from_str(&content)
.map_err(|e| ShareError::Parse(format!("{}: {}", p.display(), e)))?;
return Ok(parse_payload(&v, id));
}
let aggregate = root.join("sessions.json");
if aggregate.exists() {
let content = std::fs::read_to_string(&aggregate)?;
let v: Value = serde_json::from_str(&content)
.map_err(|e| ShareError::Parse(format!("{}: {}", aggregate.display(), e)))?;
if let Some(payload) = v.get(id) {
return Ok(parse_payload(payload, id));
}
}
Err(ShareError::NotFound(id.to_string()))
}
}
fn summarize_one(path: &Path, id: &str) -> Option<SessionSummary> {
let content = std::fs::read_to_string(path).ok()?;
let v: Value = serde_json::from_str(&content).ok()?;
summarize_payload(&v, id)
}
fn summarize_payload(v: &Value, id: &str) -> Option<SessionSummary> {
let history = v.get("history").and_then(|h| h.as_array())?;
let title = v
.get("title")
.and_then(|t| t.as_str())
.map(|s| s.chars().take(80).collect::<String>());
let started_at = v
.get("dateCreated")
.or_else(|| v.get("date"))
.and_then(parse_continue_timestamp);
Some(SessionSummary {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None,
started_at,
message_count: history.len(),
title_hint: title,
imported: false,
})
}
fn parse_payload(v: &Value, id: &str) -> SharedSession {
let history = v
.get("history")
.and_then(|h| h.as_array())
.cloned()
.unwrap_or_default();
let mut messages = Vec::new();
let started_at = v
.get("dateCreated")
.or_else(|| v.get("date"))
.and_then(parse_continue_timestamp);
for entry in &history {
let msg = entry.get("message").unwrap_or(entry);
let role_str = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
let role = match role_str {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
"tool" => MessageRole::ToolResult,
_ => continue,
};
let content = match msg.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()).map(String::from))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
if content.is_empty() {
continue;
}
let mut metadata = std::collections::HashMap::new();
if let Some(model) = msg.get("modelTitle").or_else(|| msg.get("model")).and_then(|m| m.as_str()) {
metadata.insert("model".to_string(), model.to_string());
}
if let Some(items) = entry.get("contextItems").and_then(|c| c.as_array()) {
if !items.is_empty() {
metadata.insert("context_items".to_string(), items.len().to_string());
}
}
messages.push(SessionMessage {
role,
content,
timestamp: None,
metadata,
});
}
SharedSession {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None,
started_at,
messages,
}
}
fn parse_continue_timestamp(v: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some(s) = v.as_str() {
if let Ok(d) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(d.with_timezone(&chrono::Utc));
}
}
if let Some(ms) = v.as_i64() {
return chrono::DateTime::from_timestamp_millis(ms);
}
None
}
#[derive(Default)]
pub struct ContinueImporter;
impl SessionImporter for ContinueImporter {
fn name(&self) -> &str {
PROVIDER
}
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError> {
let root = opts
.dest_root_override
.clone()
.or_else(|| std::env::var("ISELF_CONTINUE_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::home_dir().map(|h| h.join(".continue").join("sessions")))
.ok_or_else(|| ShareError::Parse("no home directory".into()))?;
std::fs::create_dir_all(&root).map_err(ShareError::Io)?;
let new_id = uuid::Uuid::new_v4().to_string();
let path = root.join(format!("{}.json", new_id));
let title = format!(
"[i-self import] {} session {}",
session.provider, session.id
);
let mut history: Vec<Value> = Vec::with_capacity(session.messages.len() + 1);
history.push(serde_json::json!({
"message": {"role": "user", "content": format!(
"[i-self import] Continued from {} session {}. {} prior messages follow.",
session.provider, session.id, session.messages.len()
)},
"contextItems": []
}));
for msg in &session.messages {
let role = match msg.role {
MessageRole::User | MessageRole::ToolResult => "user",
MessageRole::Assistant | MessageRole::ToolUse => "assistant",
MessageRole::System => "system",
};
let content = match msg.role {
MessageRole::ToolUse => {
let name = msg
.metadata
.get("tool_name")
.map(|s| s.as_str())
.unwrap_or("tool");
format!("```{}\n{}\n```", name, msg.content)
}
_ => msg.content.clone(),
};
history.push(serde_json::json!({
"message": {"role": role, "content": content},
"contextItems": [],
}));
}
let payload = serde_json::json!({
"sessionId": new_id,
"title": title,
"dateCreated": chrono::Utc::now().to_rfc3339(),
"history": history,
"imported_from": {"provider": session.provider, "id": session.id},
});
std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap())
.map_err(ShareError::Io)?;
Ok(format!(
"Wrote {} messages to {}. Open Continue → 'Open History' to see the imported session.",
session.messages.len() + 1,
path.display()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_session(root: &Path, id: &str, content: &str) -> PathBuf {
std::fs::create_dir_all(root).unwrap();
let p = root.join(format!("{}.json", id));
std::fs::write(&p, content).unwrap();
p
}
#[test]
fn parses_modern_per_session_layout() {
let tmp = tempfile::tempdir().unwrap();
write_session(
tmp.path(),
"sess-1",
r#"{
"sessionId": "sess-1",
"title": "refactor",
"dateCreated": "2026-05-07T10:00:00Z",
"history": [
{"message": {"role": "user", "content": "hi"}, "contextItems": []},
{"message": {"role": "assistant", "content": "hello", "modelTitle": "claude-3.5-sonnet"}}
]
}"#,
);
let p = ContinueProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("sess-1").unwrap();
assert_eq!(s.messages.len(), 2);
assert_eq!(s.messages[1].metadata.get("model").map(|s| s.as_str()), Some("claude-3.5-sonnet"));
assert!(s.started_at.is_some());
}
#[test]
fn parses_legacy_aggregate_sessions_json() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join("sessions.json"),
r#"{
"old-session": {
"title": "old",
"history": [{"message": {"role": "user", "content": "from legacy"}}]
}
}"#,
)
.unwrap();
let p = ContinueProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("old-session").unwrap();
assert_eq!(s.messages.len(), 1);
assert_eq!(s.messages[0].content, "from legacy");
}
#[test]
fn list_skips_aggregate_file_when_per_session_files_exist() {
let tmp = tempfile::tempdir().unwrap();
write_session(tmp.path(), "modern", r#"{"history":[{"message":{"role":"user","content":"a"}}]}"#);
std::fs::write(
tmp.path().join("sessions.json"),
r#"{"legacy":{"history":[{"message":{"role":"user","content":"b"}}]}}"#,
)
.unwrap();
let p = ContinueProvider::with_root(tmp.path().to_path_buf());
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
}
#[test]
fn round_trip_through_importer() {
let src_dir = tempfile::tempdir().unwrap();
write_session(
src_dir.path(),
"src",
r#"{"history":[
{"message":{"role":"user","content":"refactor"}},
{"message":{"role":"assistant","content":"on it"}}
]}"#,
);
let session = ContinueProvider::with_root(src_dir.path().to_path_buf())
.load_session("src")
.unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let opts = ImportOptions {
dest_root_override: Some(dest_dir.path().to_path_buf()),
..ImportOptions::default()
};
ContinueImporter.import(&session, &opts).unwrap();
let written: Vec<_> = std::fs::read_dir(dest_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "json").unwrap_or(false))
.collect();
assert_eq!(written.len(), 1);
let new_id = written[0].path().file_stem().unwrap().to_str().unwrap().to_string();
let reloaded = ContinueProvider::with_root(dest_dir.path().to_path_buf())
.load_session(&new_id)
.unwrap();
assert_eq!(reloaded.messages.len(), 3);
assert!(reloaded.messages[0].content.contains("[i-self import]"));
}
}