toolpath_cursor/
derive.rs1use crate::provider::session_to_view;
10use crate::types::CursorSession;
11use toolpath::v1::Path;
12
13#[derive(Debug, Clone, Default)]
15pub struct DeriveConfig {
16 pub project_path: Option<String>,
19 pub title: Option<String>,
23}
24
25pub 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
47pub 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 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 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}