1use std::cmp::Reverse;
2use std::collections::HashSet;
3use std::fs;
4use std::hash::{Hash, Hasher};
5use std::io::{BufRead, BufReader, Read};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::time::SystemTime;
9
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rusqlite::{Connection, OpenFlags};
13use serde::Deserialize;
14use serde_json::{Value, json};
15use walkdir::WalkDir;
16
17use crate::error::{Result, XurlError};
18use crate::model::{ProviderKind, ResolutionMeta, ResolvedThread, WriteRequest, WriteResult};
19use crate::provider::{Provider, WriteEventSink, append_passthrough_args};
20
21static FILE_URI_RE: Lazy<Regex> =
22 Lazy::new(|| Regex::new(r#"file:///[^\x00-\x1f"']+"#).expect("file uri regex must be valid"));
23
24const MAX_PROTO_SCAN_DEPTH: usize = 8;
25
26#[derive(Debug, Clone)]
27pub struct CursorProvider {
28 root: PathBuf,
29}
30
31#[derive(Debug, Clone, Default)]
32pub(crate) struct CursorMaterializedMetadata {
33 pub name: Option<String>,
34 pub mode: Option<String>,
35 pub model: Option<String>,
36 pub workspace_path: Option<String>,
37}
38
39#[derive(Debug, Clone)]
40pub(crate) struct CursorMaterialization {
41 pub path: PathBuf,
42 pub search_text: String,
43 pub metadata: CursorMaterializedMetadata,
44}
45
46#[derive(Debug, Deserialize)]
47struct CursorChatMeta {
48 #[serde(rename = "agentId")]
49 agent_id: String,
50 #[serde(rename = "latestRootBlobId")]
51 latest_root_blob_id: String,
52 #[serde(default)]
53 name: Option<String>,
54 #[serde(default)]
55 mode: Option<String>,
56 #[serde(rename = "lastUsedModel", default)]
57 last_used_model: Option<String>,
58}
59
60#[derive(Debug, Clone)]
61struct CursorMessage {
62 id: String,
63 role: String,
64 parts: Vec<Value>,
65}
66
67#[derive(Debug, Default)]
68struct CursorCollector {
69 known_blob_ids: HashSet<String>,
70 visited_blob_ids: HashSet<String>,
71 seen_message_keys: HashSet<String>,
72 messages: Vec<CursorMessage>,
73 workspace_path: Option<String>,
74}
75
76impl CursorProvider {
77 pub fn new(root: impl Into<PathBuf>) -> Self {
78 Self { root: root.into() }
79 }
80
81 fn chats_root(&self) -> PathBuf {
82 self.root.join("chats")
83 }
84
85 fn materialized_path(&self, session_id: &str) -> PathBuf {
86 let mut hasher = std::collections::hash_map::DefaultHasher::new();
87 self.root.hash(&mut hasher);
88 let root_key = format!("{:016x}", hasher.finish());
89
90 std::env::temp_dir()
91 .join("xurl-cursor")
92 .join(root_key)
93 .join(format!("{session_id}.jsonl"))
94 }
95
96 pub(crate) fn find_store_candidates(&self, session_id: &str) -> Vec<PathBuf> {
97 let chats_root = self.chats_root();
98 if !chats_root.exists() {
99 return Vec::new();
100 }
101
102 WalkDir::new(chats_root)
103 .min_depth(3)
104 .max_depth(3)
105 .into_iter()
106 .filter_map(std::result::Result::ok)
107 .filter(|entry| entry.file_type().is_file())
108 .map(|entry| entry.into_path())
109 .filter(|path| {
110 path.file_name().and_then(|name| name.to_str()) == Some("store.db")
111 && path
112 .parent()
113 .and_then(Path::file_name)
114 .and_then(|name| name.to_str())
115 == Some(session_id)
116 })
117 .collect()
118 }
119
120 fn choose_latest(paths: Vec<PathBuf>) -> Option<(PathBuf, usize)> {
121 if paths.is_empty() {
122 return None;
123 }
124
125 let mut scored = paths
126 .into_iter()
127 .map(|path| {
128 let modified = fs::metadata(&path)
129 .and_then(|meta| meta.modified())
130 .unwrap_or(SystemTime::UNIX_EPOCH);
131 (path, modified)
132 })
133 .collect::<Vec<_>>();
134
135 scored.sort_by_key(|(_, modified)| Reverse(*modified));
136 let count = scored.len();
137 scored.into_iter().next().map(|(path, _)| (path, count))
138 }
139
140 fn open_store(path: &Path) -> Result<Connection> {
141 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|source| {
142 XurlError::Sqlite {
143 path: path.to_path_buf(),
144 source,
145 }
146 })
147 }
148
149 fn decode_hex_bytes(raw: &str, path: &Path) -> Result<Vec<u8>> {
150 if raw.len() % 2 != 0 {
151 return Err(XurlError::InvalidMode(format!(
152 "cursor meta payload in {} is not valid hex",
153 path.display()
154 )));
155 }
156
157 let bytes = raw.as_bytes();
158 let mut output = Vec::with_capacity(bytes.len() / 2);
159 let mut index = 0;
160 while index < bytes.len() {
161 let hi = Self::decode_hex_nibble(bytes[index]).ok_or_else(|| {
162 XurlError::InvalidMode(format!(
163 "cursor meta payload in {} is not valid hex",
164 path.display()
165 ))
166 })?;
167 let lo = Self::decode_hex_nibble(bytes[index + 1]).ok_or_else(|| {
168 XurlError::InvalidMode(format!(
169 "cursor meta payload in {} is not valid hex",
170 path.display()
171 ))
172 })?;
173 output.push((hi << 4) | lo);
174 index += 2;
175 }
176
177 Ok(output)
178 }
179
180 fn decode_hex_nibble(ch: u8) -> Option<u8> {
181 match ch {
182 b'0'..=b'9' => Some(ch - b'0'),
183 b'a'..=b'f' => Some(10 + ch - b'a'),
184 b'A'..=b'F' => Some(10 + ch - b'A'),
185 _ => None,
186 }
187 }
188
189 fn bytes_to_hex(bytes: &[u8]) -> String {
190 let mut output = String::with_capacity(bytes.len() * 2);
191 for byte in bytes {
192 output.push_str(&format!("{byte:02x}"));
193 }
194 output
195 }
196
197 fn parse_chat_meta(conn: &Connection, db_path: &Path) -> Result<CursorChatMeta> {
198 let raw = conn
199 .query_row(
200 "SELECT value FROM meta WHERE key = '0' LIMIT 1",
201 [],
202 |row| row.get::<_, String>(0),
203 )
204 .map_err(|source| XurlError::Sqlite {
205 path: db_path.to_path_buf(),
206 source,
207 })?;
208 let bytes = Self::decode_hex_bytes(&raw, db_path)?;
209 serde_json::from_slice::<CursorChatMeta>(&bytes).map_err(|source| {
210 XurlError::InvalidMode(format!(
211 "failed parsing cursor chat meta {}: {source}",
212 db_path.display()
213 ))
214 })
215 }
216
217 fn fetch_blob_bytes(
218 conn: &Connection,
219 db_path: &Path,
220 blob_id: &str,
221 ) -> Result<Option<Vec<u8>>> {
222 conn.query_row(
223 "SELECT data FROM blobs WHERE id = ?1 LIMIT 1",
224 [blob_id],
225 |row| row.get::<_, Vec<u8>>(0),
226 )
227 .map(Some)
228 .or_else(|source| match source {
229 rusqlite::Error::QueryReturnedNoRows => Ok(None),
230 other => Err(XurlError::Sqlite {
231 path: db_path.to_path_buf(),
232 source: other,
233 }),
234 })
235 }
236
237 fn load_blob_index(conn: &Connection, db_path: &Path) -> Result<HashSet<String>> {
238 let mut stmt =
239 conn.prepare("SELECT id FROM blobs")
240 .map_err(|source| XurlError::Sqlite {
241 path: db_path.to_path_buf(),
242 source,
243 })?;
244 let rows = stmt
245 .query_map([], |row| row.get::<_, String>(0))
246 .map_err(|source| XurlError::Sqlite {
247 path: db_path.to_path_buf(),
248 source,
249 })?;
250
251 let mut ids = HashSet::new();
252 for row in rows {
253 ids.insert(row.map_err(|source| XurlError::Sqlite {
254 path: db_path.to_path_buf(),
255 source,
256 })?);
257 }
258
259 Ok(ids)
260 }
261
262 pub(crate) fn materialize_store(
263 &self,
264 store_path: &Path,
265 session_id: &str,
266 ) -> Result<CursorMaterialization> {
267 let conn = Self::open_store(store_path)?;
268 let chat_meta = Self::parse_chat_meta(&conn, store_path)?;
269 if chat_meta.agent_id.to_ascii_lowercase() != session_id {
270 return Err(XurlError::InvalidMode(format!(
271 "cursor store {} belongs to session {} instead of {}",
272 store_path.display(),
273 chat_meta.agent_id,
274 session_id
275 )));
276 }
277
278 let known_blob_ids = Self::load_blob_index(&conn, store_path)?;
279 let mut collector = CursorCollector {
280 known_blob_ids,
281 ..CursorCollector::default()
282 };
283 collector.walk_blob(&conn, store_path, &chat_meta.latest_root_blob_id)?;
284
285 let metadata = CursorMaterializedMetadata {
286 name: chat_meta.name,
287 mode: chat_meta.mode,
288 model: chat_meta.last_used_model,
289 workspace_path: collector.workspace_path.clone(),
290 };
291 let search_text = collector.search_text();
292 let output = Self::render_jsonl(session_id, &metadata, &collector.messages);
293 let materialized_path = self.materialized_path(session_id);
294 if let Some(parent) = materialized_path.parent() {
295 fs::create_dir_all(parent).map_err(|source| XurlError::Io {
296 path: parent.to_path_buf(),
297 source,
298 })?;
299 }
300 fs::write(&materialized_path, output).map_err(|source| XurlError::Io {
301 path: materialized_path.clone(),
302 source,
303 })?;
304
305 Ok(CursorMaterialization {
306 path: materialized_path,
307 search_text,
308 metadata,
309 })
310 }
311
312 fn render_jsonl(
313 session_id: &str,
314 metadata: &CursorMaterializedMetadata,
315 messages: &[CursorMessage],
316 ) -> String {
317 let mut session_metadata = serde_json::Map::new();
318 if let Some(name) = &metadata.name {
319 session_metadata.insert("name".to_string(), Value::String(name.clone()));
320 }
321 if let Some(mode) = &metadata.mode {
322 session_metadata.insert("mode".to_string(), Value::String(mode.clone()));
323 }
324 if let Some(model) = &metadata.model {
325 session_metadata.insert("model".to_string(), Value::String(model.clone()));
326 }
327 if let Some(cwd) = &metadata.workspace_path {
328 session_metadata.insert("cwd".to_string(), Value::String(cwd.clone()));
329 }
330
331 let mut lines = Vec::with_capacity(messages.len() + 1);
332 lines.push(json!({
333 "type": "session",
334 "sessionId": session_id,
335 "metadata": session_metadata,
336 }));
337
338 for message in messages {
339 lines.push(json!({
340 "type": "message",
341 "id": message.id,
342 "sessionId": session_id,
343 "message": {
344 "role": message.role,
345 },
346 "parts": message.parts,
347 }));
348 }
349
350 let mut output = String::new();
351 for line in lines {
352 let encoded = serde_json::to_string(&line).expect("json serialization should succeed");
353 output.push_str(&encoded);
354 output.push('\n');
355 }
356 output
357 }
358
359 fn cursor_bin() -> String {
360 std::env::var("XURL_CURSOR_BIN").unwrap_or_else(|_| "cursor-agent".to_string())
361 }
362
363 fn spawn_cursor_command(args: &[String]) -> Result<std::process::Child> {
364 let bin = Self::cursor_bin();
365 let mut command = Command::new(&bin);
366 command
367 .args(args)
368 .stdin(Stdio::null())
369 .stdout(Stdio::piped())
370 .stderr(Stdio::piped());
371 command.spawn().map_err(|source| {
372 if source.kind() == std::io::ErrorKind::NotFound {
373 XurlError::CommandNotFound { command: bin }
374 } else {
375 XurlError::Io {
376 path: PathBuf::from(bin),
377 source,
378 }
379 }
380 })
381 }
382
383 fn run_create_chat() -> Result<String> {
384 let bin = Self::cursor_bin();
385 let output = Command::new(&bin)
386 .arg("create-chat")
387 .output()
388 .map_err(|source| {
389 if source.kind() == std::io::ErrorKind::NotFound {
390 XurlError::CommandNotFound {
391 command: bin.clone(),
392 }
393 } else {
394 XurlError::Io {
395 path: PathBuf::from(&bin),
396 source,
397 }
398 }
399 })?;
400
401 if !output.status.success() {
402 return Err(XurlError::CommandFailed {
403 command: format!("{bin} create-chat"),
404 code: output.status.code(),
405 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
406 });
407 }
408
409 let session_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
410 if session_id.is_empty() {
411 return Err(XurlError::WriteProtocol(
412 "cursor create-chat did not return a session id".to_string(),
413 ));
414 }
415
416 Ok(session_id)
417 }
418
419 fn collect_text_part_texts(parts: &[Value]) -> String {
420 parts
421 .iter()
422 .filter_map(|part| {
423 if part.get("type").and_then(Value::as_str) == Some("text") {
424 part.get("text").and_then(Value::as_str)
425 } else {
426 None
427 }
428 })
429 .filter(|text| !text.trim().is_empty())
430 .map(ToString::to_string)
431 .collect::<Vec<_>>()
432 .join("\n\n")
433 }
434
435 fn extract_assistant_text(value: &Value) -> Option<String> {
436 if value.get("type").and_then(Value::as_str) == Some("assistant")
437 && let Some(message) = value.get("message")
438 {
439 let parts = message.get("content")?.as_array()?;
440 let text = Self::collect_text_part_texts(parts);
441 if !text.is_empty() {
442 return Some(text);
443 }
444 }
445
446 if value.get("type").and_then(Value::as_str) == Some("result") {
447 return value
448 .get("result")
449 .and_then(Value::as_str)
450 .filter(|text| !text.is_empty())
451 .map(ToString::to_string);
452 }
453
454 None
455 }
456
457 fn run_write(
458 &self,
459 session_id: String,
460 args: &[String],
461 req: &WriteRequest,
462 sink: &mut dyn WriteEventSink,
463 warnings: Vec<String>,
464 ) -> Result<WriteResult> {
465 let mut child = Self::spawn_cursor_command(args)?;
466 let stdout = child.stdout.take().ok_or_else(|| {
467 XurlError::WriteProtocol("cursor-agent stdout pipe is unavailable".to_string())
468 })?;
469 let stderr = child.stderr.take().ok_or_else(|| {
470 XurlError::WriteProtocol("cursor-agent stderr pipe is unavailable".to_string())
471 })?;
472 let stderr_handle = std::thread::spawn(move || {
473 let mut reader = BufReader::new(stderr);
474 let mut content = String::new();
475 let _ = reader.read_to_string(&mut content);
476 content
477 });
478
479 let stream_path = Path::new("<cursor-agent:stdout>");
480 let mut current_session_id = Some(session_id);
481 let mut final_text = None::<String>;
482 let mut last_emitted_assistant = None::<String>;
483 let reader = BufReader::new(stdout);
484 for line in reader.lines() {
485 let line = line.map_err(|source| XurlError::Io {
486 path: stream_path.to_path_buf(),
487 source,
488 })?;
489 let trimmed = line.trim();
490 if trimmed.is_empty() {
491 continue;
492 }
493
494 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
495 continue;
496 };
497
498 if value.get("type").and_then(Value::as_str) == Some("system")
499 && value.get("subtype").and_then(Value::as_str) == Some("init")
500 && let Some(found_session_id) = value.get("session_id").and_then(Value::as_str)
501 && current_session_id.as_deref() != Some(found_session_id)
502 {
503 sink.on_session_ready(ProviderKind::Cursor, found_session_id)?;
504 current_session_id = Some(found_session_id.to_string());
505 }
506
507 if let Some(text) = Self::extract_assistant_text(&value) {
508 if last_emitted_assistant.as_deref() != Some(text.as_str()) {
509 sink.on_text_delta(&text)?;
510 last_emitted_assistant = Some(text.clone());
511 }
512 final_text = Some(text);
513 }
514 }
515
516 let status = child.wait().map_err(|source| XurlError::Io {
517 path: PathBuf::from(Self::cursor_bin()),
518 source,
519 })?;
520 let stderr_content = stderr_handle.join().unwrap_or_default();
521 if !status.success() {
522 return Err(XurlError::CommandFailed {
523 command: format!("{} {}", Self::cursor_bin(), args.join(" ")),
524 code: status.code(),
525 stderr: stderr_content.trim().to_string(),
526 });
527 }
528
529 let session_id = current_session_id
530 .or(req.session_id.clone())
531 .ok_or_else(|| {
532 XurlError::WriteProtocol("missing session id in cursor-agent output".to_string())
533 })?;
534
535 Ok(WriteResult {
536 provider: ProviderKind::Cursor,
537 session_id,
538 final_text,
539 warnings,
540 })
541 }
542}
543
544impl Provider for CursorProvider {
545 fn kind(&self) -> ProviderKind {
546 ProviderKind::Cursor
547 }
548
549 fn resolve(&self, session_id: &str) -> Result<ResolvedThread> {
550 let candidates = self.find_store_candidates(session_id);
551 if let Some((selected, count)) = Self::choose_latest(candidates) {
552 let materialized = self.materialize_store(&selected, session_id)?;
553 let mut metadata = ResolutionMeta {
554 source: "cursor:store.db".to_string(),
555 candidate_count: count,
556 warnings: Vec::new(),
557 };
558 if count > 1 {
559 metadata.warnings.push(format!(
560 "multiple cursor stores found ({count}) for session_id={session_id}; selected latest: {}",
561 selected.display()
562 ));
563 }
564
565 return Ok(ResolvedThread {
566 provider: ProviderKind::Cursor,
567 session_id: session_id.to_string(),
568 path: materialized.path,
569 metadata,
570 });
571 }
572
573 Err(XurlError::ThreadNotFound {
574 provider: ProviderKind::Cursor.to_string(),
575 session_id: session_id.to_string(),
576 searched_roots: vec![self.chats_root()],
577 })
578 }
579
580 fn write(&self, req: &WriteRequest, sink: &mut dyn WriteEventSink) -> Result<WriteResult> {
581 if req.options.role.is_some() {
582 return Err(XurlError::InvalidMode(
583 "cursor does not support role-based write URI".to_string(),
584 ));
585 }
586
587 let session_id = match &req.session_id {
588 Some(session_id) => session_id.clone(),
589 None => {
590 let session_id = Self::run_create_chat()?;
591 sink.on_session_ready(ProviderKind::Cursor, &session_id)?;
592 session_id
593 }
594 };
595
596 let mut args = vec![
597 "--resume".to_string(),
598 session_id.clone(),
599 "--print".to_string(),
600 "--output-format".to_string(),
601 "stream-json".to_string(),
602 "--trust".to_string(),
603 ];
604 append_passthrough_args(&mut args, &req.options.params);
605 args.push(req.prompt.clone());
606
607 self.run_write(session_id, &args, req, sink, Vec::new())
608 }
609}
610
611impl CursorCollector {
612 fn walk_blob(&mut self, conn: &Connection, db_path: &Path, blob_id: &str) -> Result<()> {
613 if !self.visited_blob_ids.insert(blob_id.to_string()) {
614 return Ok(());
615 }
616
617 let Some(bytes) = CursorProvider::fetch_blob_bytes(conn, db_path, blob_id)? else {
618 return Ok(());
619 };
620 self.inspect_bytes(conn, db_path, &bytes, Some(blob_id), 0)
621 }
622
623 fn inspect_bytes(
624 &mut self,
625 conn: &Connection,
626 db_path: &Path,
627 bytes: &[u8],
628 source_id: Option<&str>,
629 depth: usize,
630 ) -> Result<()> {
631 if let Some(value) = parse_json_bytes(bytes) {
632 self.handle_json_value(source_id, &value);
633 }
634
635 if self.workspace_path.is_none() {
636 self.workspace_path = extract_workspace_path_from_bytes(bytes);
637 }
638
639 if depth >= MAX_PROTO_SCAN_DEPTH {
640 return Ok(());
641 }
642
643 let mut index = 0;
644 while index < bytes.len() {
645 let Some(field_key) = read_varint(bytes, &mut index) else {
646 break;
647 };
648
649 match field_key & 0x07 {
650 0 => {
651 if read_varint(bytes, &mut index).is_none() {
652 break;
653 }
654 }
655 1 => {
656 if index + 8 > bytes.len() {
657 break;
658 }
659 index += 8;
660 }
661 2 => {
662 let Some(length) = read_varint(bytes, &mut index) else {
663 break;
664 };
665 let Ok(length) = usize::try_from(length) else {
666 break;
667 };
668 if index + length > bytes.len() {
669 break;
670 }
671 let payload = &bytes[index..index + length];
672 if length == 32 {
673 let referenced_id = CursorProvider::bytes_to_hex(payload);
674 if self.known_blob_ids.contains(&referenced_id) {
675 self.walk_blob(conn, db_path, &referenced_id)?;
676 index += length;
677 continue;
678 }
679 }
680
681 self.inspect_bytes(conn, db_path, payload, None, depth + 1)?;
682 index += length;
683 }
684 5 => {
685 if index + 4 > bytes.len() {
686 break;
687 }
688 index += 4;
689 }
690 _ => break,
691 }
692 }
693
694 Ok(())
695 }
696
697 fn handle_json_value(&mut self, source_id: Option<&str>, value: &Value) {
698 if self.workspace_path.is_none() {
699 self.workspace_path = extract_workspace_path_from_value(value);
700 }
701
702 let Some(message) = build_cursor_message(source_id, value) else {
703 return;
704 };
705 let message_key = serde_json::to_string(value).unwrap_or_else(|_| message.id.clone());
706 if self.seen_message_keys.insert(message_key) {
707 self.messages.push(message);
708 }
709 }
710
711 fn search_text(&self) -> String {
712 self.messages
713 .iter()
714 .map(|message| {
715 message
716 .parts
717 .iter()
718 .filter_map(|part| {
719 if part.get("type").and_then(Value::as_str) == Some("text") {
720 part.get("text").and_then(Value::as_str)
721 } else {
722 None
723 }
724 })
725 .collect::<Vec<_>>()
726 .join("\n")
727 })
728 .filter(|text| !text.trim().is_empty())
729 .collect::<Vec<_>>()
730 .join("\n")
731 }
732}
733
734fn parse_json_bytes(bytes: &[u8]) -> Option<Value> {
735 serde_json::from_slice::<Value>(bytes).ok()
736}
737
738fn read_varint(bytes: &[u8], index: &mut usize) -> Option<u64> {
739 let mut shift = 0_u32;
740 let mut value = 0_u64;
741 while *index < bytes.len() && shift <= 63 {
742 let byte = bytes[*index];
743 *index += 1;
744 value |= u64::from(byte & 0x7f) << shift;
745 if byte & 0x80 == 0 {
746 return Some(value);
747 }
748 shift += 7;
749 }
750
751 None
752}
753
754fn extract_workspace_path_from_value(value: &Value) -> Option<String> {
755 value
756 .get("cwd")
757 .and_then(Value::as_str)
758 .map(ToString::to_string)
759 .or_else(|| {
760 extract_workspace_path_from_bytes(serde_json::to_string(value).ok()?.as_bytes())
761 })
762}
763
764fn extract_workspace_path_from_bytes(bytes: &[u8]) -> Option<String> {
765 let haystack = String::from_utf8_lossy(bytes);
766 let matched = FILE_URI_RE.find(&haystack)?.as_str();
767 decode_file_uri_path(matched)
768}
769
770fn decode_file_uri_path(uri: &str) -> Option<String> {
771 let path = uri.strip_prefix("file://")?;
772 let mut output = Vec::with_capacity(path.len());
773 let bytes = path.as_bytes();
774 let mut index = 0;
775 while index < bytes.len() {
776 match bytes[index] {
777 b'%' if index + 2 < bytes.len() => {
778 let hi = CursorProvider::decode_hex_nibble(bytes[index + 1])?;
779 let lo = CursorProvider::decode_hex_nibble(bytes[index + 2])?;
780 output.push((hi << 4) | lo);
781 index += 3;
782 }
783 byte => {
784 output.push(byte);
785 index += 1;
786 }
787 }
788 }
789
790 String::from_utf8(output).ok()
791}
792
793fn build_cursor_message(source_id: Option<&str>, value: &Value) -> Option<CursorMessage> {
794 let role = value.get("role").and_then(Value::as_str)?;
795 if role != "user" && role != "assistant" {
796 return None;
797 }
798 if value
799 .get("providerOptions")
800 .and_then(|options| options.get("cursor"))
801 .and_then(|cursor| cursor.get("isSummary"))
802 .and_then(Value::as_bool)
803 .unwrap_or(false)
804 {
805 return None;
806 }
807
808 let mut parts = Vec::new();
809 match value.get("content") {
810 Some(Value::String(text)) => {
811 if !text.trim().is_empty() {
812 parts.push(json!({
813 "type": "text",
814 "text": text,
815 }));
816 }
817 }
818 Some(Value::Array(items)) => {
819 for item in items {
820 let Some(item_type) = item.get("type").and_then(Value::as_str) else {
821 continue;
822 };
823 match item_type {
824 "text" => {
825 if let Some(text) = item.get("text").and_then(Value::as_str)
826 && !text.trim().is_empty()
827 {
828 parts.push(json!({
829 "type": "text",
830 "text": text,
831 }));
832 }
833 }
834 "file" => {
835 let label = item
836 .get("filename")
837 .and_then(Value::as_str)
838 .map(|name| format!("[File: {name}]"))
839 .unwrap_or_else(|| "[File]".to_string());
840 parts.push(json!({
841 "type": "text",
842 "text": label,
843 }));
844 }
845 "image" => {
846 parts.push(json!({
847 "type": "text",
848 "text": "[Image]",
849 }));
850 }
851 "reasoning" | "redacted-reasoning" => {}
852 _ => {}
853 }
854 }
855 }
856 _ => {}
857 }
858
859 if parts.is_empty() {
860 return None;
861 }
862
863 let id = value
864 .get("id")
865 .and_then(Value::as_str)
866 .map(ToString::to_string)
867 .or_else(|| source_id.map(ToString::to_string))
868 .unwrap_or_else(|| {
869 serde_json::to_string(value)
870 .unwrap_or_else(|_| role.to_string())
871 .chars()
872 .take(64)
873 .collect()
874 });
875
876 Some(CursorMessage {
877 id,
878 role: role.to_string(),
879 parts,
880 })
881}