1use crate::provider::to_view;
12use crate::types::Conversation;
13use toolpath::v1::Path;
14
15#[derive(Default)]
17pub struct DeriveConfig {
18 pub project_path: Option<String>,
20 pub include_thinking: bool,
22}
23
24pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
26 let view = to_view(conversation);
27 let prefix: String = conversation.session_id.chars().take(8).collect();
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 cfg = toolpath_convo::DeriveConfig {
36 base_uri,
37 title: Some(format!("Claude session: {}", prefix)),
38 include_thinking: config.include_thinking,
39 ..Default::default()
40 };
41 toolpath_convo::derive_path(&view, &cfg)
42}
43
44pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
46 conversations
47 .iter()
48 .map(|c| derive_path(c, config))
49 .collect()
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
56 use std::collections::HashMap;
57 use toolpath::v1::Graph;
58
59 fn user_entry(uuid: &str, parent: Option<&str>, text: &str, cwd: &str) -> ConversationEntry {
60 ConversationEntry {
61 entry_type: "user".into(),
62 uuid: uuid.into(),
63 parent_uuid: parent.map(str::to_string),
64 session_id: Some("sess-1".into()),
65 timestamp: "2026-01-01T00:00:00Z".into(),
66 cwd: Some(cwd.into()),
67 git_branch: Some("main".into()),
68 version: Some("1.0.0".into()),
69 user_type: None,
70 request_id: None,
71 message_id: None,
72 snapshot: None,
73 tool_use_result: None,
74 is_sidechain: false,
75 message: Some(Message {
76 role: MessageRole::User,
77 content: Some(MessageContent::Text(text.into())),
78 model: None,
79 id: None,
80 message_type: None,
81 stop_reason: None,
82 stop_sequence: None,
83 usage: None,
84 }),
85 extra: HashMap::new(),
86 }
87 }
88
89 fn assistant_entry(uuid: &str, parent: Option<&str>, text: &str) -> ConversationEntry {
90 ConversationEntry {
91 entry_type: "assistant".into(),
92 uuid: uuid.into(),
93 parent_uuid: parent.map(str::to_string),
94 session_id: Some("sess-1".into()),
95 timestamp: "2026-01-01T00:00:01Z".into(),
96 cwd: Some("/tmp/proj".into()),
97 git_branch: Some("main".into()),
98 version: Some("1.0.0".into()),
99 user_type: None,
100 request_id: None,
101 message_id: None,
102 snapshot: None,
103 tool_use_result: None,
104 is_sidechain: false,
105 message: Some(Message {
106 role: MessageRole::Assistant,
107 content: Some(MessageContent::Text(text.into())),
108 model: Some("claude-opus-4-7".into()),
109 id: None,
110 message_type: None,
111 stop_reason: Some("end_turn".into()),
112 stop_sequence: None,
113 usage: None,
114 }),
115 extra: HashMap::new(),
116 }
117 }
118
119 fn make_convo() -> Conversation {
120 Conversation {
121 session_id: "sess-1abc".into(),
122 project_path: Some("/tmp/proj".into()),
123 entries: vec![
124 user_entry("u1", None, "Fix bug", "/tmp/proj"),
125 assistant_entry("a1", Some("u1"), "Done"),
126 ],
127 preamble: vec![],
128 started_at: None,
129 last_activity: None,
130 session_ids: vec![],
131 }
132 }
133
134 #[test]
135 fn derive_path_basic_shape() {
136 let convo = make_convo();
137 let path = derive_path(&convo, &DeriveConfig::default());
138 assert!(path.path.id.starts_with("path-claude-code-"));
139 let base = path.path.base.as_ref().expect("base");
141 assert_eq!(base.uri, "file:///tmp/proj");
142 assert_eq!(base.branch.as_deref(), Some("main"));
143 }
144
145 #[test]
146 fn derive_path_producer_in_meta_extra() {
147 let convo = make_convo();
148 let path = derive_path(&convo, &DeriveConfig::default());
149 let producer = path.meta.as_ref().unwrap().extra.get("producer").unwrap();
150 assert_eq!(producer["name"], "claude-code");
151 assert_eq!(producer["version"], "1.0.0");
152 }
153
154 #[test]
155 fn derive_path_actors_populated() {
156 let convo = make_convo();
157 let path = derive_path(&convo, &DeriveConfig::default());
158 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
159 assert!(actors.contains_key("human:user"));
160 assert!(actors.contains_key("agent:claude-opus-4-7"));
161 }
162
163 #[test]
164 fn derive_path_validates_as_single_path_graph() {
165 let convo = make_convo();
166 let path = derive_path(&convo, &DeriveConfig::default());
167 let doc = Graph::from_path(path);
168 let json = doc.to_json().unwrap();
169 let parsed = Graph::from_json(&json).unwrap();
170 let pp = parsed.single_path().expect("single-path graph");
171 let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
172 assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
173 }
174}