Skip to main content

toolpath_gemini/
derive.rs

1//! Derive Toolpath documents from Gemini CLI conversation logs.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]. All
4//! Gemini-specific work (sub-agent linearization, file-diff synthesis,
5//! producer/base population) happens in
6//! [`crate::provider::to_view`]; nothing provider-specific lives in
7//! this module.
8
9use crate::provider::to_view;
10use crate::types::Conversation;
11use toolpath::v1::Path;
12
13/// Configuration for deriving Toolpath documents from Gemini conversations.
14#[derive(Debug, Clone, Default)]
15pub struct DeriveConfig {
16    /// Override the project path used for `path.base.uri`.
17    pub project_path: Option<String>,
18    /// Include thinking blocks in the `conversation.append` text payload.
19    pub include_thinking: bool,
20}
21
22/// Derive a single Toolpath [`Path`] from a Gemini conversation.
23pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
24    let view = to_view(conversation);
25    let prefix: String = view.id.chars().take(8).collect();
26    let base_uri = config.project_path.as_ref().map(|p| {
27        if p.starts_with('/') {
28            format!("file://{}", p)
29        } else {
30            p.clone()
31        }
32    });
33    let cfg = toolpath_convo::DeriveConfig {
34        base_uri,
35        title: Some(format!("Gemini session: {}", prefix)),
36        include_thinking: config.include_thinking,
37        ..Default::default()
38    };
39    toolpath_convo::derive_path(&view, &cfg)
40}
41
42/// Derive Toolpath Paths from multiple conversations.
43pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
44    conversations
45        .iter()
46        .map(|c| derive_path(c, config))
47        .collect()
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::types::{ChatFile, Conversation};
54    use toolpath::v1::Graph;
55
56    fn make_convo() -> Conversation {
57        let main_json = r#"{
58            "sessionId": "sess-1",
59            "messages": [
60                {"id":"u1","timestamp":"2026-04-17T15:23:55Z","type":"user","content":[{"text":"make a pickle"}]},
61                {"id":"g1","timestamp":"2026-04-17T15:23:57Z","type":"gemini","content":"done","model":"gemini-3-flash-preview"}
62            ]
63        }"#;
64        let main: ChatFile = serde_json::from_str(main_json).unwrap();
65        Conversation {
66            session_uuid: "abcdef01-2345-6789-abcd-ef0123456789".into(),
67            main,
68            sub_agents: vec![],
69            project_path: Some("/tmp/proj".into()),
70            started_at: None,
71            last_activity: None,
72        }
73    }
74
75    #[test]
76    fn derive_path_basic_shape() {
77        let convo = make_convo();
78        let path = derive_path(&convo, &DeriveConfig::default());
79        assert!(path.path.id.starts_with("path-gemini-cli-"));
80        let base = path.path.base.as_ref().expect("base");
81        assert_eq!(base.uri, "file:///tmp/proj");
82    }
83
84    #[test]
85    fn derive_path_producer_in_meta_extra() {
86        let convo = make_convo();
87        let path = derive_path(&convo, &DeriveConfig::default());
88        let producer = path.meta.as_ref().unwrap().extra.get("producer").unwrap();
89        assert_eq!(producer["name"], "gemini-cli");
90    }
91
92    #[test]
93    fn derive_path_actors_populated() {
94        let convo = make_convo();
95        let path = derive_path(&convo, &DeriveConfig::default());
96        let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
97        assert!(actors.contains_key("human:user"));
98        assert!(actors.contains_key("agent:gemini-3-flash-preview"));
99    }
100
101    #[test]
102    fn derive_path_validates_as_single_path_graph() {
103        let convo = make_convo();
104        let path = derive_path(&convo, &DeriveConfig::default());
105        let doc = Graph::from_path(path);
106        let json = doc.to_json().unwrap();
107        let parsed = Graph::from_json(&json).unwrap();
108        let pp = parsed.single_path().expect("single-path graph");
109        let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
110        assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
111    }
112}