Skip to main content

toolpath_claude/
derive.rs

1//! Derive Toolpath documents from Claude conversation logs.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]. All
4//! Claude-specific work (cwd / git_branch / version → `view.base` and
5//! `view.producer`, headerless preamble + non-message entries →
6//! `view.events`, tool-result cross-entry assembly, file-write diff
7//! synthesis via `git show HEAD:<path>`) happens in
8//! [`crate::provider::to_view`]; nothing provider-specific lives in this
9//! module.
10
11use crate::provider::to_view;
12use crate::types::Conversation;
13use toolpath::v1::Path;
14
15/// Configuration for deriving Toolpath documents from Claude conversations.
16#[derive(Default)]
17pub struct DeriveConfig {
18    /// Override the project path used for `path.base.uri`.
19    pub project_path: Option<String>,
20    /// Include thinking blocks in the conversation artifact.
21    pub include_thinking: bool,
22}
23
24/// Derive a Toolpath [`Path`] from a Claude [`Conversation`].
25pub 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
44/// Derive Toolpath Paths from multiple conversations in a project.
45pub 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        // Base populated from first entry's cwd / git_branch.
140        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}