use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::storage::models::{Message, MessageContent, MessageRole, Session};
use super::{Watcher, WatcherInfo};
pub struct ContinueDevWatcher;
impl Watcher for ContinueDevWatcher {
fn info(&self) -> WatcherInfo {
WatcherInfo {
name: "continue",
description: "Continue.dev VS Code extension sessions",
default_paths: vec![continue_sessions_path()],
}
}
fn is_available(&self) -> bool {
continue_sessions_path().exists()
}
fn find_sources(&self) -> Result<Vec<PathBuf>> {
find_continue_sessions()
}
fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
let parsed = parse_continue_session(path)?;
match parsed {
Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
_ => Ok(vec![]),
}
}
fn watch_paths(&self) -> Vec<PathBuf> {
vec![continue_sessions_path()]
}
}
fn continue_sessions_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".continue")
.join("sessions")
}
fn find_continue_sessions() -> Result<Vec<PathBuf>> {
let sessions_path = continue_sessions_path();
if !sessions_path.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in fs::read_dir(&sessions_path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "json" {
files.push(path);
}
}
}
}
Ok(files)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ContinueSession {
session_id: String,
#[serde(default)]
workspace_directory: Option<String>,
#[serde(default)]
history: Vec<ContinueChatHistoryItem>,
#[serde(default)]
chat_model_title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ContinueChatHistoryItem {
message: ContinueChatMessage,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ContinueChatMessage {
role: String,
content: ContinueMessageContent,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ContinueMessageContent {
Text(String),
Parts(Vec<ContinueMessagePart>),
}
impl ContinueMessageContent {
fn to_text(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Parts(parts) => parts
.iter()
.filter_map(|p| match p {
ContinueMessagePart::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum ContinueMessagePart {
Text { text: String },
#[serde(rename = "imageUrl")]
#[allow(dead_code)]
ImageUrl {},
}
fn parse_continue_session(path: &Path) -> Result<Option<(Session, Vec<Message>)>> {
let content = fs::read_to_string(path).context("Failed to read Continue session file")?;
let raw_session: ContinueSession =
serde_json::from_str(&content).context("Failed to parse Continue session JSON")?;
if raw_session.history.is_empty() {
return Ok(None);
}
let session_id = Uuid::parse_str(&raw_session.session_id).unwrap_or_else(|_| Uuid::new_v4());
let file_mtime = fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.map(DateTime::<Utc>::from);
let ended_at = file_mtime;
let message_count = raw_session.history.len();
let started_at = ended_at
.map(|t| t - chrono::Duration::minutes(message_count as i64 * 2))
.unwrap_or_else(Utc::now);
let session = Session {
id: session_id,
tool: "continue".to_string(),
tool_version: None,
started_at,
ended_at,
model: raw_session.chat_model_title,
working_directory: raw_session
.workspace_directory
.unwrap_or_else(|| ".".to_string()),
git_branch: None,
source_path: Some(path.to_string_lossy().to_string()),
message_count: message_count as i32,
machine_id: crate::storage::get_machine_id(),
};
let mut messages = Vec::new();
let time_per_message = chrono::Duration::seconds(30);
let mut current_time = started_at;
for (idx, item) in raw_session.history.iter().enumerate() {
let role = match item.message.role.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
"thinking" => continue, "tool" => continue, _ => continue,
};
let content = item.message.content.to_text();
if content.trim().is_empty() {
continue;
}
messages.push(Message {
id: Uuid::new_v4(),
session_id,
parent_id: None,
index: idx as i32,
timestamp: current_time,
role,
content: MessageContent::Text(content),
model: None,
git_branch: None,
cwd: Some(session.working_directory.clone()),
});
current_time += time_per_message;
}
if messages.is_empty() {
return Ok(None);
}
Ok(Some((session, messages)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_session_file(json: &str) -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
file.write_all(json.as_bytes())
.expect("Failed to write content");
file.flush().expect("Failed to flush");
file
}
#[test]
fn test_parse_simple_session() {
let json = r#"{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Test Session",
"workspaceDirectory": "/home/user/project",
"history": [
{
"message": {
"role": "user",
"content": "Hello, can you help me?"
},
"contextItems": []
},
{
"message": {
"role": "assistant",
"content": "Of course! What do you need help with?"
},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (session, messages) = result.expect("Should have session");
assert_eq!(session.tool, "continue");
assert_eq!(session.working_directory, "/home/user/project");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, MessageRole::User);
assert_eq!(messages[1].role, MessageRole::Assistant);
}
#[test]
fn test_parse_session_with_model() {
let json = r#"{
"sessionId": "test-session",
"chatModelTitle": "GPT-4",
"history": [
{
"message": {
"role": "user",
"content": "Test"
},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (session, _) = result.expect("Should have session");
assert_eq!(session.model, Some("GPT-4".to_string()));
}
#[test]
fn test_parse_empty_history() {
let json = r#"{
"sessionId": "test-session",
"history": []
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
assert!(result.is_none());
}
#[test]
fn test_parse_content_with_parts() {
let json = r#"{
"sessionId": "test-session",
"history": [
{
"message": {
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "World"}
]
},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (_, messages) = result.expect("Should have session");
assert_eq!(messages.len(), 1);
if let MessageContent::Text(text) = &messages[0].content {
assert!(text.contains("Hello"));
assert!(text.contains("World"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_parse_skips_thinking_messages() {
let json = r#"{
"sessionId": "test-session",
"history": [
{
"message": {
"role": "user",
"content": "Question"
},
"contextItems": []
},
{
"message": {
"role": "thinking",
"content": "Thinking about this..."
},
"contextItems": []
},
{
"message": {
"role": "assistant",
"content": "Answer"
},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (_, messages) = result.expect("Should have session");
assert_eq!(messages.len(), 2);
}
#[test]
fn test_watcher_parse_source() {
let watcher = ContinueDevWatcher;
let json = r#"{
"sessionId": "test",
"history": [
{
"message": {"role": "user", "content": "Test"},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = watcher
.parse_source(file.path())
.expect("Should parse successfully");
assert!(!result.is_empty());
let (session, _) = &result[0];
assert_eq!(session.tool, "continue");
}
#[test]
fn test_parse_filters_empty_content() {
let json = r#"{
"sessionId": "test-session",
"history": [
{
"message": {
"role": "user",
"content": "Hello"
},
"contextItems": []
},
{
"message": {
"role": "assistant",
"content": ""
},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (_, messages) = result.expect("Should have session");
assert_eq!(messages.len(), 1);
}
#[test]
fn test_session_id_parsing() {
let json = r#"{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"history": [
{
"message": {"role": "user", "content": "Test"},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (session, _) = result.expect("Should have session");
assert_eq!(
session.id.to_string(),
"550e8400-e29b-41d4-a716-446655440000"
);
}
#[test]
fn test_session_id_fallback_for_invalid_uuid() {
let json = r#"{
"sessionId": "not-a-valid-uuid",
"history": [
{
"message": {"role": "user", "content": "Test"},
"contextItems": []
}
]
}"#;
let file = create_temp_session_file(json);
let result = parse_continue_session(file.path()).expect("Should parse");
let (session, _) = result.expect("Should have session");
assert!(!session.id.is_nil());
}
}