1use crate::types::{ContentPart, Conversation, MessageContent, MessageRole};
9use serde_json::json;
10use std::collections::HashMap;
11use toolpath::v1::{
12 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
13 StepIdentity, StructuralChange,
14};
15
16#[derive(Default)]
18pub struct DeriveConfig {
19 pub project_path: Option<String>,
21 pub include_thinking: bool,
23}
24
25pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
32 let session_short = safe_prefix(&conversation.session_id, 8);
33 let convo_artifact = format!("claude://{}", conversation.session_id);
34
35 let mut steps = Vec::new();
36 let mut last_step_id: Option<String> = None;
37 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
38
39 for entry in &conversation.entries {
40 if entry.uuid.is_empty() {
41 continue;
42 }
43
44 let message = match &entry.message {
45 Some(m) => m,
46 None => continue,
47 };
48
49 let (actor, role_str) = match message.role {
50 MessageRole::User => {
51 actors
52 .entry("human:user".to_string())
53 .or_insert_with(|| ActorDefinition {
54 name: Some("User".to_string()),
55 ..Default::default()
56 });
57 ("human:user".to_string(), "user")
58 }
59 MessageRole::Assistant => {
60 let (actor_key, model_str) = if let Some(model) = &message.model {
61 (format!("agent:{}", model), model.clone())
62 } else {
63 ("agent:claude-code".to_string(), "claude-code".to_string())
64 };
65 actors.entry(actor_key.clone()).or_insert_with(|| {
66 let mut identities = vec![Identity {
67 system: "anthropic".to_string(),
68 id: model_str.clone(),
69 }];
70 if let Some(version) = &entry.version {
71 identities.push(Identity {
72 system: "claude-code".to_string(),
73 id: version.clone(),
74 });
75 }
76 ActorDefinition {
77 name: Some("Claude Code".to_string()),
78 provider: Some("anthropic".to_string()),
79 model: Some(model_str),
80 identities,
81 ..Default::default()
82 }
83 });
84 (actor_key, "assistant")
85 }
86 MessageRole::System => continue,
87 };
88
89 let mut file_changes: HashMap<String, ArtifactChange> = HashMap::new();
91 let mut text_parts: Vec<String> = Vec::new();
92 let mut tool_uses: Vec<String> = Vec::new();
93
94 match &message.content {
95 Some(MessageContent::Parts(parts)) => {
96 for part in parts {
97 match part {
98 ContentPart::Text { text } => {
99 if !text.trim().is_empty() {
100 text_parts.push(text.clone());
101 }
102 }
103 ContentPart::Thinking { thinking, .. } => {
104 if config.include_thinking && !thinking.trim().is_empty() {
105 text_parts.push(format!("[thinking] {}", thinking));
106 }
107 }
108 ContentPart::ToolUse { name, input, .. } => {
109 tool_uses.push(name.clone());
110 if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str())
111 {
112 match name.as_str() {
113 "Write" | "Edit" => {
114 file_changes.insert(
115 file_path.to_string(),
116 ArtifactChange {
117 raw: None,
118 structural: None,
119 },
120 );
121 }
122 _ => {}
123 }
124 }
125 }
126 _ => {}
127 }
128 }
129 }
130 Some(MessageContent::Text(text)) => {
131 if !text.trim().is_empty() {
132 text_parts.push(text.clone());
133 }
134 }
135 None => {}
136 }
137
138 if text_parts.is_empty() && tool_uses.is_empty() && file_changes.is_empty() {
140 continue;
141 }
142
143 let mut convo_extra = HashMap::new();
145 convo_extra.insert("role".to_string(), json!(role_str));
146 if !text_parts.is_empty() {
147 let combined = text_parts.join("\n\n");
148 convo_extra.insert("text".to_string(), json!(truncate(&combined, 2000)));
149 }
150 if !tool_uses.is_empty() {
151 convo_extra.insert("tool_uses".to_string(), json!(tool_uses.clone()));
152 }
153
154 let convo_change = ArtifactChange {
155 raw: None,
156 structural: Some(StructuralChange {
157 change_type: "conversation.append".to_string(),
158 extra: convo_extra,
159 }),
160 };
161
162 let mut changes = HashMap::new();
163 changes.insert(convo_artifact.clone(), convo_change);
164 changes.extend(file_changes);
165
166 let step_id = format!("step-{}", safe_prefix(&entry.uuid, 8));
169 let parents = if entry.is_sidechain {
170 entry
171 .parent_uuid
172 .as_ref()
173 .map(|p| vec![format!("step-{}", safe_prefix(p, 8))])
174 .unwrap_or_default()
175 } else {
176 last_step_id.iter().cloned().collect()
177 };
178
179 let step = Step {
180 step: StepIdentity {
181 id: step_id.clone(),
182 parents,
183 actor,
184 timestamp: entry.timestamp.clone(),
185 },
186 change: changes,
187 meta: None,
188 };
189
190 if !entry.is_sidechain {
191 last_step_id = Some(step_id);
192 }
193 steps.push(step);
194 }
195
196 let head = last_step_id.unwrap_or_else(|| "empty".to_string());
197 let base_uri = config
198 .project_path
199 .as_deref()
200 .or(conversation.project_path.as_deref())
201 .map(|p| format!("file://{}", p));
202
203 Path {
204 path: PathIdentity {
205 id: format!("path-claude-{}", session_short),
206 base: base_uri.map(|uri| Base { uri, ref_str: None }),
207 head,
208 },
209 steps,
210 meta: Some(PathMeta {
211 title: Some(format!("Claude session: {}", session_short)),
212 source: Some("claude-code".to_string()),
213 actors: if actors.is_empty() {
214 None
215 } else {
216 Some(actors)
217 },
218 ..Default::default()
219 }),
220 }
221}
222
223pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
225 conversations
226 .iter()
227 .map(|c| derive_path(c, config))
228 .collect()
229}
230
231fn truncate(s: &str, max: usize) -> String {
234 let char_count = s.chars().count();
235 if char_count <= max {
236 s.to_string()
237 } else {
238 let truncated: String = s.chars().take(max - 3).collect();
239 format!("{}...", truncated)
240 }
241}
242
243fn safe_prefix(s: &str, n: usize) -> String {
245 s.chars().take(n).collect()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::types::{ContentPart, ConversationEntry, Message, MessageContent};
252
253 fn make_entry(
254 uuid: &str,
255 role: MessageRole,
256 content: &str,
257 timestamp: &str,
258 ) -> ConversationEntry {
259 ConversationEntry {
260 parent_uuid: None,
261 is_sidechain: false,
262 entry_type: match role {
263 MessageRole::User => "user",
264 MessageRole::Assistant => "assistant",
265 MessageRole::System => "system",
266 }
267 .to_string(),
268 uuid: uuid.to_string(),
269 timestamp: timestamp.to_string(),
270 session_id: Some("test-session".to_string()),
271 cwd: None,
272 git_branch: None,
273 version: None,
274 message: Some(Message {
275 role,
276 content: Some(MessageContent::Text(content.to_string())),
277 model: None,
278 id: None,
279 message_type: None,
280 stop_reason: None,
281 stop_sequence: None,
282 usage: None,
283 }),
284 user_type: None,
285 request_id: None,
286 tool_use_result: None,
287 snapshot: None,
288 message_id: None,
289 extra: Default::default(),
290 }
291 }
292
293 fn make_conversation(entries: Vec<ConversationEntry>) -> Conversation {
294 let mut convo = Conversation::new("test-session-12345678".to_string());
295 for entry in entries {
296 convo.add_entry(entry);
297 }
298 convo
299 }
300
301 #[test]
304 fn test_truncate_short() {
305 assert_eq!(truncate("hello", 10), "hello");
306 }
307
308 #[test]
309 fn test_truncate_exact() {
310 assert_eq!(truncate("hello", 5), "hello");
311 }
312
313 #[test]
314 fn test_truncate_long() {
315 let result = truncate("hello world, this is long", 10);
316 assert!(result.ends_with("..."));
317 assert_eq!(result.chars().count(), 10);
318 }
319
320 #[test]
321 fn test_truncate_multibyte() {
322 let s = "café résumé naïve";
324 let result = truncate(s, 8);
325 assert!(result.ends_with("..."));
326 assert_eq!(result.chars().count(), 8);
327 }
328
329 #[test]
332 fn test_safe_prefix_normal() {
333 assert_eq!(safe_prefix("abcdef1234", 8), "abcdef12");
334 }
335
336 #[test]
337 fn test_safe_prefix_short() {
338 assert_eq!(safe_prefix("abc", 8), "abc");
339 }
340
341 #[test]
342 fn test_safe_prefix_unicode() {
343 assert_eq!(safe_prefix("日本語テスト", 3), "日本語");
344 }
345
346 #[test]
349 fn test_derive_path_basic() {
350 let entries = vec![
351 make_entry(
352 "uuid-1111-aaaa",
353 MessageRole::User,
354 "Hello",
355 "2024-01-01T00:00:00Z",
356 ),
357 make_entry(
358 "uuid-2222-bbbb",
359 MessageRole::Assistant,
360 "Hi there",
361 "2024-01-01T00:00:01Z",
362 ),
363 ];
364 let convo = make_conversation(entries);
365 let config = DeriveConfig::default();
366
367 let path = derive_path(&convo, &config);
368
369 assert!(path.path.id.starts_with("path-claude-"));
370 assert_eq!(path.steps.len(), 2);
371 assert_eq!(path.steps[0].step.actor, "human:user");
372 assert!(path.steps[1].step.actor.starts_with("agent:"));
373 }
374
375 #[test]
376 fn test_derive_path_step_parents() {
377 let entries = vec![
378 make_entry(
379 "uuid-1111",
380 MessageRole::User,
381 "Hello",
382 "2024-01-01T00:00:00Z",
383 ),
384 make_entry(
385 "uuid-2222",
386 MessageRole::Assistant,
387 "Hi",
388 "2024-01-01T00:00:01Z",
389 ),
390 make_entry(
391 "uuid-3333",
392 MessageRole::User,
393 "More",
394 "2024-01-01T00:00:02Z",
395 ),
396 ];
397 let convo = make_conversation(entries);
398 let config = DeriveConfig::default();
399
400 let path = derive_path(&convo, &config);
401
402 assert!(path.steps[1].step.parents.contains(&path.steps[0].step.id));
404 assert!(path.steps[2].step.parents.contains(&path.steps[1].step.id));
406 }
407
408 #[test]
409 fn test_derive_path_conversation_artifact() {
410 let entries = vec![make_entry(
411 "uuid-1111",
412 MessageRole::User,
413 "Hello",
414 "2024-01-01T00:00:00Z",
415 )];
416 let convo = make_conversation(entries);
417 let config = DeriveConfig::default();
418
419 let path = derive_path(&convo, &config);
420
421 let convo_key = format!("claude://{}", convo.session_id);
423 assert!(path.steps[0].change.contains_key(&convo_key));
424
425 let change = &path.steps[0].change[&convo_key];
426 let structural = change.structural.as_ref().unwrap();
427 assert_eq!(structural.change_type, "conversation.append");
428 assert_eq!(structural.extra["role"], "user");
429 }
430
431 #[test]
432 fn test_derive_path_no_meta_intent() {
433 let entries = vec![make_entry(
434 "uuid-1111",
435 MessageRole::User,
436 "Hello",
437 "2024-01-01T00:00:00Z",
438 )];
439 let convo = make_conversation(entries);
440 let config = DeriveConfig::default();
441
442 let path = derive_path(&convo, &config);
443
444 assert!(path.steps[0].meta.is_none());
446 }
447
448 #[test]
449 fn test_derive_path_actors() {
450 let entries = vec![
451 make_entry(
452 "uuid-1111",
453 MessageRole::User,
454 "Hello",
455 "2024-01-01T00:00:00Z",
456 ),
457 make_entry(
458 "uuid-2222",
459 MessageRole::Assistant,
460 "Hi",
461 "2024-01-01T00:00:01Z",
462 ),
463 ];
464 let convo = make_conversation(entries);
465 let config = DeriveConfig::default();
466
467 let path = derive_path(&convo, &config);
468 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
469
470 assert!(actors.contains_key("human:user"));
471 assert!(actors.contains_key("agent:claude-code"));
473 }
474
475 #[test]
476 fn test_derive_path_with_project_path_config() {
477 let convo = make_conversation(vec![make_entry(
478 "uuid-1",
479 MessageRole::User,
480 "Hello",
481 "2024-01-01T00:00:00Z",
482 )]);
483 let config = DeriveConfig {
484 project_path: Some("/my/project".to_string()),
485 ..Default::default()
486 };
487
488 let path = derive_path(&convo, &config);
489 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///my/project");
490 }
491
492 #[test]
493 fn test_derive_path_skips_empty_content() {
494 let mut entry = make_entry("uuid-1111", MessageRole::User, "", "2024-01-01T00:00:00Z");
495 entry.message.as_mut().unwrap().content = Some(MessageContent::Text(" ".to_string()));
497
498 let convo = make_conversation(vec![entry]);
499 let config = DeriveConfig::default();
500
501 let path = derive_path(&convo, &config);
502 assert!(path.steps.is_empty());
503 }
504
505 #[test]
506 fn test_derive_path_skips_system_messages() {
507 let entries = vec![
508 make_entry(
509 "uuid-1111",
510 MessageRole::System,
511 "System prompt",
512 "2024-01-01T00:00:00Z",
513 ),
514 make_entry(
515 "uuid-2222",
516 MessageRole::User,
517 "Hello",
518 "2024-01-01T00:00:01Z",
519 ),
520 ];
521 let convo = make_conversation(entries);
522 let config = DeriveConfig::default();
523
524 let path = derive_path(&convo, &config);
525 assert_eq!(path.steps.len(), 1);
527 assert_eq!(path.steps[0].step.actor, "human:user");
528 }
529
530 #[test]
531 fn test_derive_path_with_tool_use() {
532 let mut convo = Conversation::new("test-session-12345678".to_string());
533 let entry = ConversationEntry {
534 parent_uuid: None,
535 is_sidechain: false,
536 entry_type: "assistant".to_string(),
537 uuid: "uuid-tool".to_string(),
538 timestamp: "2024-01-01T00:00:00Z".to_string(),
539 session_id: Some("test-session".to_string()),
540 message: Some(Message {
541 role: MessageRole::Assistant,
542 content: Some(MessageContent::Parts(vec![
543 ContentPart::Text {
544 text: "Let me write that".to_string(),
545 },
546 ContentPart::ToolUse {
547 id: "t1".to_string(),
548 name: "Write".to_string(),
549 input: serde_json::json!({"file_path": "/tmp/test.rs"}),
550 },
551 ])),
552 model: Some("claude-sonnet-4-5-20250929".to_string()),
553 id: None,
554 message_type: None,
555 stop_reason: None,
556 stop_sequence: None,
557 usage: None,
558 }),
559 cwd: None,
560 git_branch: None,
561 version: None,
562 user_type: None,
563 request_id: None,
564 tool_use_result: None,
565 snapshot: None,
566 message_id: None,
567 extra: Default::default(),
568 };
569 convo.add_entry(entry);
570 let config = DeriveConfig::default();
571
572 let path = derive_path(&convo, &config);
573
574 assert_eq!(path.steps.len(), 1);
575 assert!(path.steps[0].change.contains_key("/tmp/test.rs"));
577 let convo_key = format!("claude://{}", convo.session_id);
578 assert!(path.steps[0].change.contains_key(&convo_key));
579 }
580
581 #[test]
582 fn test_derive_path_sidechain_uses_parent_uuid() {
583 let mut convo = Conversation::new("test-session-12345678".to_string());
584
585 let e1 = make_entry(
586 "uuid-main-11",
587 MessageRole::User,
588 "Hello",
589 "2024-01-01T00:00:00Z",
590 );
591 let e2 = make_entry(
592 "uuid-main-22",
593 MessageRole::Assistant,
594 "Hi",
595 "2024-01-01T00:00:01Z",
596 );
597 let mut e3 = make_entry(
598 "uuid-side-33",
599 MessageRole::User,
600 "Side",
601 "2024-01-01T00:00:02Z",
602 );
603 e3.is_sidechain = true;
604 e3.parent_uuid = Some("uuid-main-11".to_string());
605
606 convo.add_entry(e1);
607 convo.add_entry(e2);
608 convo.add_entry(e3);
609
610 let config = DeriveConfig::default();
611 let path = derive_path(&convo, &config);
612
613 assert_eq!(path.steps.len(), 3);
614 let sidechain_step = &path.steps[2];
616 let expected_parent = format!("step-{}", safe_prefix("uuid-main-11", 8));
617 assert!(sidechain_step.step.parents.contains(&expected_parent));
618 }
619
620 #[test]
623 fn test_derive_project() {
624 let c1 = make_conversation(vec![make_entry(
625 "uuid-1",
626 MessageRole::User,
627 "Hello",
628 "2024-01-01T00:00:00Z",
629 )]);
630 let mut c2 = Conversation::new("session-2".to_string());
631 c2.add_entry(make_entry(
632 "uuid-2",
633 MessageRole::User,
634 "World",
635 "2024-01-02T00:00:00Z",
636 ));
637
638 let config = DeriveConfig::default();
639 let paths = derive_project(&[c1, c2], &config);
640
641 assert_eq!(paths.len(), 2);
642 }
643
644 #[test]
645 fn test_derive_path_head_is_last_non_sidechain() {
646 let entries = vec![
647 make_entry(
648 "uuid-1111",
649 MessageRole::User,
650 "Hello",
651 "2024-01-01T00:00:00Z",
652 ),
653 make_entry(
654 "uuid-2222",
655 MessageRole::Assistant,
656 "Hi",
657 "2024-01-01T00:00:01Z",
658 ),
659 ];
660 let convo = make_conversation(entries);
661 let config = DeriveConfig::default();
662
663 let path = derive_path(&convo, &config);
664
665 assert_eq!(path.path.head, path.steps.last().unwrap().step.id);
667 }
668}