Skip to main content

ytil_agents/agent/session_parser/
cursor.rs

1use std::path::PathBuf;
2
3use jiff::Timestamp;
4use rootcause::prelude::ResultExt;
5use rootcause::report;
6use serde::Deserialize;
7
8use crate::agent::Agent;
9use crate::agent::session::SearchTextBuilder;
10use crate::agent::session::Session;
11
12/// Parse Cursor chat `meta.json` into a session.
13///
14/// # Errors
15/// Returns an error when the JSON is invalid, `cwd` is empty, or a timestamp is out of range.
16pub fn parse_chat_meta(json: &str, session_id: String) -> rootcause::Result<CursorSession> {
17    let doc = serde_json::from_str::<CursorChatMeta>(json)
18        .context("failed to parse Cursor chat metadata".to_owned())
19        .attach(format!("meta_json={json}"))?;
20
21    let cwd = doc.cwd.trim();
22    if cwd.is_empty() {
23        return Err(report!("Cursor chat meta cwd is empty").attach(format!("session_id={session_id}")));
24    }
25    let workspace_dir = PathBuf::from(cwd);
26
27    let created_at = Timestamp::from_millisecond(doc.created_at_ms)
28        .context("Cursor createdAtMs is out of range".to_owned())
29        .attach(format!("session_id={session_id}"))
30        .attach(format!("created_at_ms={}", doc.created_at_ms))?;
31    let updated_at = match doc.updated_at_ms {
32        Some(updated_at_ms) => Timestamp::from_millisecond(updated_at_ms)
33            .context("Cursor updatedAtMs is out of range".to_owned())
34            .attach(format!("session_id={session_id}"))
35            .attach(format!("updated_at_ms={updated_at_ms}"))?,
36        None => created_at,
37    };
38
39    let name = doc.title.filter(|title| !title.trim().is_empty()).unwrap_or_else(|| {
40        workspace_dir
41            .file_name()
42            .and_then(|name| name.to_str())
43            .filter(|name| !name.is_empty())
44            .map_or_else(|| session_id.clone(), str::to_owned)
45    });
46
47    Ok(CursorSession {
48        id: session_id,
49        name: name.clone(),
50        search_text: name,
51        workspace: workspace_dir,
52        created_at,
53        updated_at,
54        has_conversation: doc.has_conversation.unwrap_or(true),
55    })
56}
57
58pub fn build_search_text_from_prompts(session_name: &str, prompts: &[String]) -> String {
59    let mut search_text = SearchTextBuilder::default();
60    for prompt in prompts {
61        search_text.push(prompt);
62    }
63    search_text.build(session_name)
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct CursorSession {
68    pub id: String,
69    pub name: String,
70    pub search_text: String,
71    pub workspace: PathBuf,
72    pub created_at: Timestamp,
73    pub updated_at: Timestamp,
74    pub has_conversation: bool,
75}
76
77impl CursorSession {
78    pub fn into_session(self, path: PathBuf) -> Session {
79        let mut session = Session::new(Agent::Cursor, self.id, self.workspace, path, None, self.created_at);
80        session.name = self.name;
81        session.search_text = self.search_text;
82        session.updated_at = self.updated_at;
83        session
84    }
85}
86
87#[derive(Debug, Deserialize)]
88struct CursorChatMeta {
89    #[serde(rename = "createdAtMs")]
90    created_at_ms: i64,
91    #[serde(rename = "updatedAtMs")]
92    updated_at_ms: Option<i64>,
93    title: Option<String>,
94    cwd: String,
95    #[serde(rename = "hasConversation")]
96    has_conversation: Option<bool>,
97}
98
99#[cfg(test)]
100mod tests {
101    use test_that::prelude::*;
102
103    use super::*;
104
105    #[test]
106    fn test_parse_chat_meta_when_json_has_cwd_returns_workspace_and_title() {
107        let json = r#"{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":true,"title":"Status Line","updatedAtMs":1774877739013,"cwd":"/Users/gianlu/data/dev/work/pws-api/pws-api"}"#;
108
109        let cursor_session_result = parse_chat_meta(json, "session-id".to_owned());
110        assert_that!(cursor_session_result.as_ref().map(|_| ()), ok(eq(())));
111        let cursor_session = cursor_session_result.expect("Cursor chat metadata should parse");
112        let session = cursor_session.into_session(PathBuf::from("session-id"));
113
114        assert_that!(session.agent, eq(Agent::Cursor));
115        assert_that!(session.id, eq("session-id"));
116        assert_that!(session.name, eq("Status Line"));
117        assert_that!(
118            session.workspace,
119            eq(PathBuf::from("/Users/gianlu/data/dev/work/pws-api/pws-api"))
120        );
121    }
122
123    #[test]
124    fn test_parse_chat_meta_when_cwd_is_empty_returns_error() {
125        let json = r#"{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":true,"title":"Status Line","cwd":"  "}"#;
126
127        assert_that!(
128            (parse_chat_meta(json, "session-id".to_owned())).map(|_| ()),
129            err(displays_as(contains_substring("Cursor chat meta cwd is empty")))
130        );
131    }
132
133    #[test]
134    fn test_build_search_text_from_prompts_when_prompts_repeat_keeps_unique_snippets() {
135        let search_text = build_search_text_from_prompts(
136            "Status Line",
137            &[
138                "first prompt".to_owned(),
139                "first prompt".to_owned(),
140                "second prompt".to_owned(),
141            ],
142        );
143
144        assert_that!(search_text, eq("Status Line first prompt second prompt"));
145    }
146}