Skip to main content

toolpath_cursor/
derive.rs

1//! Derive Toolpath documents from Cursor sessions.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]. All
4//! Cursor-specific work (content-blob lookup, tool classification,
5//! producer/base population) happens in
6//! [`crate::provider::session_to_view`]; nothing provider-specific
7//! lives in this module.
8
9use crate::provider::session_to_view;
10use crate::types::CursorSession;
11use toolpath::v1::Path;
12
13/// Configuration for deriving a Toolpath `Path` from a Cursor session.
14#[derive(Debug, Clone, Default)]
15pub struct DeriveConfig {
16    /// Override `path.base.uri`. Defaults to `file://<workspace>` when
17    /// the composer's workspace identifier is known.
18    pub project_path: Option<String>,
19    /// Override `path.meta.title`. Defaults to
20    /// `"cursor session: <composer-id-prefix>"` (via the shared
21    /// derive's default).
22    pub title: Option<String>,
23}
24
25/// Derive a [`Path`] from a Cursor [`CursorSession`].
26pub fn derive_path(session: &CursorSession, config: &DeriveConfig) -> Path {
27    let view = session_to_view(session);
28    let base_uri = config.project_path.as_ref().map(|p| {
29        if p.starts_with('/') {
30            format!("file://{}", p)
31        } else {
32            p.clone()
33        }
34    });
35    let title = config
36        .title
37        .clone()
38        .or_else(|| session.title().map(|t| format!("cursor session: {t}")));
39    let cfg = toolpath_convo::DeriveConfig {
40        base_uri,
41        title,
42        ..Default::default()
43    };
44    toolpath_convo::derive_path(&view, &cfg)
45}
46
47/// Derive a `Path` from each of several Cursor sessions.
48pub fn derive_project(sessions: &[CursorSession], config: &DeriveConfig) -> Vec<Path> {
49    sessions.iter().map(|s| derive_path(s, config)).collect()
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use crate::CursorConvo;
56    use crate::reader::tests::{BASIC_FIXTURE, fixture_db};
57    use std::fs;
58    use tempfile::TempDir;
59    use toolpath::v1::Graph;
60
61    fn setup() -> (TempDir, CursorConvo) {
62        let temp = TempDir::new().unwrap();
63        let user_data = temp.path().join("UserData");
64        let global = user_data.join("User").join("globalStorage");
65        fs::create_dir_all(&global).unwrap();
66        let src = fixture_db(BASIC_FIXTURE);
67        fs::copy(src.path(), global.join("state.vscdb")).unwrap();
68        let resolver = crate::PathResolver::new()
69            .with_home(temp.path())
70            .with_anysphere_dir(temp.path().join(".cursor"))
71            .with_user_data_dir(user_data);
72        (temp, CursorConvo::with_resolver(resolver))
73    }
74
75    #[test]
76    fn derive_basic_shape() {
77        let (_t, mgr) = setup();
78        let session = mgr.read_session("c1").unwrap();
79        let path = derive_path(&session, &DeriveConfig::default());
80
81        assert!(path.path.id.starts_with("path-cursor-"));
82        assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///p");
83
84        let conversation_steps = path
85            .steps
86            .iter()
87            .filter(|s| {
88                s.change.values().any(|c| {
89                    c.structural
90                        .as_ref()
91                        .is_some_and(|sc| sc.change_type == "conversation.append")
92                })
93            })
94            .count();
95        // 3 bubbles → 3 conversation.append steps.
96        assert_eq!(conversation_steps, 3);
97    }
98
99    #[test]
100    fn derive_emits_producer() {
101        let (_t, mgr) = setup();
102        let session = mgr.read_session("c1").unwrap();
103        let path = derive_path(&session, &DeriveConfig::default());
104        let producer = path.meta.as_ref().unwrap().extra.get("producer").unwrap();
105        assert_eq!(producer["name"], "cursor");
106        assert_eq!(producer["version"], "cursor-agent");
107    }
108
109    #[test]
110    fn derive_emits_file_write_with_real_diff() {
111        let (_t, mgr) = setup();
112        let session = mgr.read_session("c1").unwrap();
113        let path = derive_path(&session, &DeriveConfig::default());
114        let file_step = path
115            .steps
116            .iter()
117            .find(|s| s.change.contains_key("/p/x.rs"))
118            .expect("no step carries /p/x.rs");
119        let change = &file_step.change["/p/x.rs"];
120        let structural = change.structural.as_ref().unwrap();
121        assert_eq!(structural.change_type, "file.write");
122        assert_eq!(structural.extra["operation"], "add");
123        assert_eq!(structural.extra["tool_id"], "tool_a");
124        assert_eq!(structural.extra["tool"], "edit_file_v2");
125        // unified_diff was generated from the resolved blobs.
126        let raw = change.raw.as_ref().expect("expected raw unified diff");
127        assert!(raw.contains("+fn main() {}"));
128    }
129
130    #[test]
131    fn derive_emits_path_kind_marker() {
132        let (_t, mgr) = setup();
133        let session = mgr.read_session("c1").unwrap();
134        let path = derive_path(&session, &DeriveConfig::default());
135        let kind = path.meta.as_ref().unwrap().kind.as_deref().unwrap();
136        assert_eq!(kind, toolpath::v1::PATH_KIND_AGENT_CODING_SESSION);
137    }
138
139    #[test]
140    fn derive_validates_as_single_path_graph() {
141        let (_t, mgr) = setup();
142        let session = mgr.read_session("c1").unwrap();
143        let path = derive_path(&session, &DeriveConfig::default());
144        let doc = Graph::from_path(path);
145        let json = doc.to_json().unwrap();
146        let parsed = Graph::from_json(&json).unwrap();
147        let pp = parsed.single_path().expect("single-path graph");
148        let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
149        assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
150    }
151
152    #[test]
153    fn derive_title_override() {
154        let (_t, mgr) = setup();
155        let session = mgr.read_session("c1").unwrap();
156        let path = derive_path(
157            &session,
158            &DeriveConfig {
159                title: Some("explicit".into()),
160                ..Default::default()
161            },
162        );
163        assert_eq!(path.meta.unwrap().title.unwrap(), "explicit");
164    }
165}