use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use crate::models::{FileEdit, ParsedSession, Provider, SessionRecord, SourceFile};
use crate::providers::{spawn, walk_roots, ProviderDiscovery};
use crate::util::{
apply_user_role_authorship, extract_text, find_repo_root, format_transcript_line,
minimal_record, normalize_path, preview_from_text, substantive_text, truncate_for_display,
RawMessage, UserRoleAuthorshipEvidence,
};
pub struct CursorAdapter {
roots: Vec<PathBuf>,
}
impl CursorAdapter {
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots }
}
pub fn discover(&self) -> Vec<SourceFile> {
self.discover_with_warnings().sources
}
pub(crate) fn discover_with_warnings(&self) -> ProviderDiscovery {
let mut files = Vec::new();
let walked = walk_roots(&self.roots, None);
for entry in walked.entries {
let path = &entry.path;
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
if !path
.components()
.any(|component| component.as_os_str() == "agent-transcripts")
{
continue;
}
let mtime_ns = entry
.metadata
.modified()
.ok()
.and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
.map(|value| value.as_nanos() as i64)
.unwrap_or_default();
files.push(SourceFile {
provider: Provider::Cursor,
path: entry.path,
mtime_ns,
size_bytes: entry.metadata.len() as i64,
});
}
ProviderDiscovery {
sources: files,
warnings: walked.warnings,
}
}
pub fn parse(&self, source: &SourceFile) -> ParsedSession {
match self.parse_inner(&source.path) {
Ok(parsed) => parsed,
Err(err) => minimal_record(Provider::Cursor, &source.path, format!("{err:#}")),
}
}
fn parse_inner(&self, path: &Path) -> Result<ParsedSession> {
let file = std::fs::File::open(path)?;
self.parse_reader(std::io::BufReader::new(file), path)
}
pub fn parse_reader<R: std::io::BufRead>(
&self,
reader: R,
path: &Path,
) -> Result<ParsedSession> {
let mut line_count: usize = 0;
let mut malformed_line_count: usize = 0;
let mut valid_record_count: usize = 0;
let spawned = spawn::subagents_dir_origin(path);
let provider_session_id = match &spawned {
Some(origin) => origin.session_id(),
None => path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("unknown")
.to_string(),
};
let cwd = infer_cursor_workspace(path);
let mut created_at: Option<DateTime<Utc>> = None;
let updated_at = file_modified_at(path);
let mut messages = Vec::new();
let mut transcript_lines = Vec::new();
let mut file_edits: Vec<FileEdit> = Vec::new();
let mut file_edit_seq: i64 = 0;
let mut tool_use_names: HashMap<String, String> = HashMap::new();
for line in crate::util::lines_replacing_invalid_utf8(reader) {
let line = line?;
line_count += 1;
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(_) => {
malformed_line_count += 1;
continue;
}
};
valid_record_count += 1;
let Some(role) = value.get("role").and_then(Value::as_str) else {
continue;
};
if !matches!(role, "user" | "assistant") {
continue;
}
if let Some(message) = value.get("message") {
super::claude::collect_file_edits(
message,
updated_at,
&mut file_edit_seq,
&mut file_edits,
);
super::claude::collect_tool_use_names(message, &mut tool_use_names);
super::claude::append_tool_use_messages(message, updated_at, &mut messages);
if role == "user" && super::claude::is_tool_result(message) {
let output = extract_text(message);
let output = output.trim();
if !output.is_empty() {
let tool_name = super::claude::tool_result_id(message)
.and_then(|id| tool_use_names.get(id).cloned());
messages.push(RawMessage::tool_result_with_name(
tool_name,
output.to_string(),
super::claude::tool_result_id(message),
updated_at,
));
}
continue;
}
}
let text = cursor_message_text(&value);
if !substantive_text(&text) {
continue;
}
if created_at.is_none() {
created_at = updated_at;
}
messages.push(RawMessage::message(role, text.clone(), updated_at, None));
transcript_lines.push(format_transcript_line(role, updated_at, &text));
}
let first_user = messages
.iter()
.find(|message| message.role() == "user" && substantive_text(message.content()))
.map(|message| message.content().to_string());
let last_user = messages
.iter()
.rev()
.find(|message| message.role() == "user" && substantive_text(message.content()))
.map(|message| message.content().to_string());
let title = last_user
.clone()
.or_else(|| first_user.clone())
.map(|text| truncate_for_display(&text, 100));
let preview = last_user
.clone()
.or_else(|| first_user.clone())
.map(|text| preview_from_text(&text))
.unwrap_or_else(|| "(no preview available)".to_string());
let repo_root = cwd.as_deref().and_then(find_repo_root);
let mut raw_metadata = json!({
"line_count": line_count,
"valid_record_count": valid_record_count,
"session_path": normalize_path(path),
});
if malformed_line_count > 0 {
raw_metadata["malformed_line_count"] = json!(malformed_line_count);
}
let raw_metadata_json = Some(serde_json::to_string(&raw_metadata)?);
let session = SessionRecord {
id: format!("cursor:{provider_session_id}"),
provider: Provider::Cursor,
provider_session_id,
title,
summary: first_user.map(|text| truncate_for_display(&text, 180)),
cwd,
repo_root,
created_at,
updated_at,
last_message_at: updated_at,
preview_text: preview,
source_path: normalize_path(path),
message_count: Some(messages.len() as i64),
parse_version: crate::util::provider_parse_version(Provider::Cursor).to_string(),
raw_metadata_json,
parse_warning: super::malformed_jsonl_warning(malformed_line_count),
discovery_source: "jsonl".to_string(),
parent_session_id: spawned
.as_ref()
.map(|origin| origin.parent_link(Provider::Cursor)),
agent_label: spawned.as_ref().map(|origin| origin.run_suffix.clone()),
};
let mut messages = crate::util::to_messages_with_tools(messages);
apply_user_role_authorship(
&mut messages,
if spawned.is_some() {
UserRoleAuthorshipEvidence::AgentDelegationEvent
} else {
UserRoleAuthorshipEvidence::HumanInputEvent
},
);
Ok(ParsedSession {
session,
transcript_text: transcript_lines.join("\n\n"),
messages,
file_edits,
})
}
}
fn cursor_message_text(value: &Value) -> String {
let Some(message) = value.get("message") else {
return String::new();
};
let mut parts = Vec::new();
if let Some(content) = message.get("content") {
collect_text_content(content, &mut parts);
}
let text = parts.join("\n");
extract_tag(&text, "user_query")
.unwrap_or(text)
.trim()
.to_string()
}
fn collect_text_content(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(text) => {
if !text.trim().is_empty() {
out.push(text.trim().to_string());
}
}
Value::Array(items) => {
for item in items {
if item.get("type").and_then(Value::as_str) == Some("text") {
if let Some(text) = item.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
out.push(text.trim().to_string());
}
}
}
}
}
_ => {}
}
}
fn extract_tag(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(text[start..end].trim().to_string())
}
fn file_modified_at(path: &Path) -> Option<DateTime<Utc>> {
path.metadata()
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(DateTime::<Utc>::from)
}
fn infer_cursor_workspace(path: &Path) -> Option<String> {
let mut previous: Option<String> = None;
for component in path.components() {
if component.as_os_str() == "agent-transcripts" {
return previous
.as_deref()
.and_then(decode_cursor_project_dir)
.map(|path| path.to_string_lossy().to_string());
}
previous = component.as_os_str().to_str().map(ToOwned::to_owned);
}
None
}
fn decode_cursor_project_dir(encoded: &str) -> Option<PathBuf> {
if encoded == "empty-window" {
return None;
}
let parts: Vec<&str> = encoded.split('-').collect();
if parts.first().copied() != Some("Users") || parts.len() < 3 {
return None;
}
let suffixes = partition_suffixes(&parts[1..]);
suffixes
.into_iter()
.map(|suffix| {
let mut path = PathBuf::from("/");
path.push("Users");
for part in suffix {
path.push(part);
}
path
})
.find(|candidate| candidate.exists())
}
fn partition_suffixes(parts: &[&str]) -> Vec<Vec<String>> {
fn walk(parts: &[&str], index: usize, current: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
if index >= parts.len() {
out.push(current.clone());
return;
}
for end in index + 1..=parts.len() {
current.push(parts[index..end].join("-"));
walk(parts, end, current, out);
current.pop();
}
}
let mut out = Vec::new();
walk(parts, 0, &mut Vec::new(), &mut out);
out
}
#[cfg(test)]
mod tests {
use super::{cursor_message_text, decode_cursor_project_dir, extract_tag, CursorAdapter};
use crate::models::Provider;
use serde_json::json;
use std::fs;
use tempfile::tempdir;
#[test]
fn discovers_and_parses_cursor_parent_transcripts() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("projects");
let session_id = "9f3b844f-072d-4c2a-bdb8-474fe89dbca1";
let transcript_dir = root
.join("Users-adamzhao-Desktop-aise")
.join("agent-transcripts")
.join(session_id);
fs::create_dir_all(&transcript_dir).expect("create transcript dir");
let transcript_path = transcript_dir.join(format!("{session_id}.jsonl"));
fs::write(
&transcript_path,
r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Tuesday</timestamp>\n<user_query>\nMake Cursor threads searchable\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"I will wire a Cursor provider."},{"type":"tool_use","name":"ReadFile","input":{"path":"/tmp/nope"}}]}}
{"role":"user","message":{"content":[{"type":"text","text":"Great, add tests too"}]}}
"#,
)
.expect("write transcript");
let subagent_dir = transcript_dir.join("subagents");
fs::create_dir_all(&subagent_dir).expect("create subagent dir");
fs::write(
subagent_dir.join("subagent.jsonl"),
r#"{"role":"user","message":{"content":[{"type":"text","text":"subagent"}]}}"#,
)
.expect("write subagent transcript");
let adapter = CursorAdapter::new(vec![root]);
let sources = adapter.discover();
assert_eq!(sources.len(), 2, "{sources:#?}");
let parent_source = sources
.iter()
.find(|source| source.path == transcript_path)
.expect("the parent transcript is discovered");
assert_eq!(parent_source.provider, Provider::Cursor);
let parsed = adapter.parse(parent_source);
assert_eq!(parsed.session.id, format!("cursor:{session_id}"));
assert_eq!(parsed.session.provider_session_id, session_id);
assert_eq!(
parsed.session.title.as_deref(),
Some("Great, add tests too")
);
assert_eq!(
parsed.session.summary.as_deref(),
Some("Make Cursor threads searchable")
);
assert_eq!(parsed.session.message_count, Some(4));
assert!(parsed
.transcript_text
.contains("Make Cursor threads searchable"));
assert!(parsed
.transcript_text
.contains("I will wire a Cursor provider."));
assert!(!parsed.transcript_text.contains("ReadFile"));
assert!(!parsed.transcript_text.contains("subagent"));
assert!(parsed.messages.iter().any(|m| {
m.tool_name.as_deref() == Some("ReadFile")
&& m.content.contains(r#""kind":"tool_call""#)
&& m.content.contains(r#""path":"/tmp/nope""#)
}));
assert_eq!(
parsed.messages[0].provenance.authorship,
crate::models::MessageAuthorship::Human
);
assert_eq!(
parsed.messages[2].provenance.authorship,
crate::models::MessageAuthorship::Agent
);
}
#[test]
fn a_cursor_subagent_is_indexed_under_its_parents_id() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("projects");
let parents = [
"9f3b844f-072d-4c2a-bdb8-474fe89dbca1",
"1c0dd0f2-6f4f-4a0e-8f01-9b1c6a4a44b7",
];
for parent in parents {
let transcript_dir = root
.join("Users-adamzhao-Desktop-aise")
.join("agent-transcripts")
.join(parent);
fs::create_dir_all(transcript_dir.join("subagents")).expect("create dirs");
fs::write(
transcript_dir.join(format!("{parent}.jsonl")),
r#"{"role":"user","message":{"content":[{"type":"text","text":"parent turn"}]}}"#,
)
.expect("write parent transcript");
fs::write(
transcript_dir.join("subagents").join("subagent.jsonl"),
format!(
r#"{{"role":"user","message":{{"content":[{{"type":"text","text":"run for {parent}"}}]}}}}"#
),
)
.expect("write subagent transcript");
}
let adapter = CursorAdapter::new(vec![root]);
let sources = adapter.discover();
assert_eq!(sources.len(), 4, "two parents and two runs: {sources:#?}");
for parent in parents {
let source = sources
.iter()
.find(|source| {
source.path.ends_with("subagent.jsonl")
&& source.path.to_string_lossy().contains(parent)
})
.expect("each parent's subagent transcript is discovered");
let parsed = adapter.parse(source);
assert_eq!(
parsed.session.provider_session_id,
format!("{parent}/subagent"),
"a name shared by every parent's run has to be qualified to stay distinct"
);
assert_eq!(
parsed.session.parent_session_id.as_deref(),
Some(format!("cursor:{parent}").as_str())
);
assert_eq!(parsed.session.agent_label.as_deref(), Some("subagent"));
assert!(parsed
.transcript_text
.contains(&format!("run for {parent}")));
assert_eq!(
parsed.messages[0].provenance.authorship,
crate::models::MessageAuthorship::Agent
);
}
}
#[test]
fn extracts_user_query_from_cursor_message() {
let value = json!({
"role": "user",
"message": {
"content": [{
"type": "text",
"text": "<timestamp>Tuesday</timestamp>\n<user_query>\nFind the billing bug\n</user_query>"
}]
}
});
assert_eq!(cursor_message_text(&value), "Find the billing bug");
}
#[test]
fn ignores_tool_use_payloads_in_cursor_messages() {
let value = json!({
"role": "assistant",
"message": {
"content": [
{"type": "text", "text": "I found it."},
{"type": "tool_use", "name": "ReadFile", "input": {"path": "/tmp/secret"}}
]
}
});
assert_eq!(cursor_message_text(&value), "I found it.");
}
#[test]
fn indexes_cursor_tool_results_and_file_edits() {
use crate::models::Role;
let temp = tempdir().expect("tempdir");
let root = temp.path().join("projects");
let session_id = "11111111-2222-3333-4444-555555555555";
let dir = root
.join("Users-x-proj")
.join("agent-transcripts")
.join(session_id);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{session_id}.jsonl"));
fs::write(
&path,
concat!(
r#"{"role":"user","message":{"content":[{"type":"text","text":"edit the file"}]}}"#,
"\n",
r#"{"role":"assistant","message":{"content":[{"type":"text","text":"editing"},{"type":"tool_use","id":"tu_e","name":"Edit","input":{"file_path":"/p/app.ts","old_string":"a","new_string":"b"}},{"type":"tool_use","id":"tu_p","name":"ApplyPatch","input":{"path":"/p/x.ts","patch":"@@ -1 +1 @@"}}]}}"#,
"\n",
r#"{"role":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_e","content":"edit applied"}]}}"#,
"\n",
),
)
.unwrap();
let adapter = CursorAdapter::new(vec![root]);
let sources = adapter.discover();
let parsed = adapter.parse(&sources[0]);
let files: Vec<&str> = parsed
.file_edits
.iter()
.map(|e| e.file_name.as_str())
.collect();
assert!(files.contains(&"app.ts"), "Edit file recorded: {files:?}");
assert!(
files.contains(&"x.ts"),
"ApplyPatch file recorded (path-only): {files:?}"
);
let tool_input = parsed
.messages
.iter()
.find(|m| m.role == Role::Tool && m.content.contains(r#""old_string":"a""#))
.expect("tool_use input indexed as a Role::Tool message");
assert_eq!(tool_input.tool_name.as_deref(), Some("Edit"));
assert_eq!(tool_input.kind, crate::models::MessageKind::ToolCall);
assert_eq!(tool_input.tool_call_id.as_deref(), Some("tu_e"));
let tool_result = parsed
.messages
.iter()
.find(|m| m.role == Role::Tool && m.content == "edit applied")
.expect("tool_result indexed as a Role::Tool message");
assert_eq!(tool_result.tool_name.as_deref(), Some("Edit"));
assert_eq!(tool_result.kind, crate::models::MessageKind::ToolResult);
assert_eq!(tool_result.tool_call_id.as_deref(), Some("tu_e"));
assert!(!parsed.transcript_text.contains("edit applied"));
assert!(!parsed.transcript_text.contains("ApplyPatch"));
}
#[test]
fn extracts_tag_body() {
assert_eq!(
extract_tag("prefix <user_query>hello</user_query> suffix", "user_query"),
Some("hello".to_string())
);
}
#[test]
fn skips_empty_window_workspace() {
assert!(decode_cursor_project_dir("empty-window").is_none());
}
#[test]
fn streaming_parse_output_is_stable() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("projects");
let session_id = "9f3b844f-072d-4c2a-bdb8-474fe89dbca1";
let transcript_dir = root
.join("Users-x-Desktop-proj")
.join("agent-transcripts")
.join(session_id);
fs::create_dir_all(&transcript_dir).expect("create transcript dir");
let transcript_path = transcript_dir.join(format!("{session_id}.jsonl"));
let content = concat!(
r#"{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\nfirst\n</user_query>"}]}}"#,
"\n",
"{bad json\n",
"\n",
r#"{"role":"assistant","message":{"content":[{"type":"text","text":"on it"},{"type":"tool_use","name":"Write","input":{"file_path":"/p/a.rs","content":"hello"}}]}}"#,
"\n",
r#"{"role":"user","message":{"content":[{"type":"text","text":"second"}]}}"#,
);
fs::write(&transcript_path, content).expect("write transcript");
let adapter = CursorAdapter::new(vec![root]);
let sources = adapter.discover();
assert_eq!(sources.len(), 1);
let parsed = adapter.parse(&sources[0]);
assert!(
parsed
.session
.raw_metadata_json
.as_deref()
.unwrap()
.contains("\"line_count\":5"),
"line_count must be 5, got: {:?}",
parsed.session.raw_metadata_json
);
assert_eq!(parsed.session.provider, Provider::Cursor);
let contents: Vec<&str> = parsed.messages.iter().map(|m| m.content.as_str()).collect();
assert_eq!(
contents,
vec![
"first",
r#"{"args":{"content":"hello","file_path":"/p/a.rs"},"kind":"tool_call","tool_name":"Write"}"#,
"on it",
"second"
]
);
let roles: Vec<&str> = parsed.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["user", "tool", "assistant", "user"]);
assert_eq!(parsed.file_edits.len(), 1);
assert_eq!(parsed.file_edits[0].file_path, "/p/a.rs");
assert_eq!(parsed.file_edits[0].new_content.as_deref(), Some("hello"));
assert_eq!(
parsed.session.parse_warning.as_deref(),
Some("skipped 1 malformed JSONL record")
);
}
#[test]
fn non_utf8_garbage_is_diagnosed_without_aborting_the_parse() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("projects");
let session_id = "9f3b844f-072d-4c2a-bdb8-474fe89dbca1";
let transcript_dir = root
.join("Users-x-Desktop-proj")
.join("agent-transcripts")
.join(session_id);
fs::create_dir_all(&transcript_dir).expect("create transcript dir");
let transcript_path = transcript_dir.join(format!("{session_id}.jsonl"));
fs::write(&transcript_path, [b'{', 0xFF, 0xFE, b'}', b'\n']).expect("write");
let adapter = CursorAdapter::new(vec![root]);
let sources = adapter.discover();
assert_eq!(sources.len(), 1);
let parsed = adapter.parse(&sources[0]);
assert!(parsed.messages.is_empty());
assert_eq!(
parsed.session.parse_warning.as_deref(),
Some("skipped 1 malformed JSONL record")
);
assert_eq!(parsed.session.message_count, Some(0));
}
}