Skip to main content

toolpath_copilot/
derive.rs

1//! Derive Toolpath documents from GitHub Copilot CLI sessions.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]: convert the
4//! session to a provider-agnostic [`toolpath_convo::ConversationView`] via
5//! [`crate::provider::to_view`] and hand off. All Copilot-specific data (cwd,
6//! file mutations, producer) is captured during `to_view`; this module only
7//! sets the title and any CLI overrides.
8
9use crate::provider::to_view;
10use crate::types::Session;
11use toolpath::v1::Path;
12
13/// Configuration for deriving a Toolpath Path from a Copilot session.
14#[derive(Debug, Clone, Default)]
15pub struct DeriveConfig {
16    /// Override `path.base.uri`. Defaults to the cwd from `session.start`.
17    pub project_path: Option<String>,
18}
19
20/// Derive a [`Path`] from a Copilot [`Session`].
21pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
22    let view = to_view(session);
23    let prefix: String = view.id.chars().take(8).collect();
24    let base_uri = config.project_path.as_ref().map(|p| {
25        if p.starts_with('/') {
26            format!("file://{}", p)
27        } else {
28            p.clone()
29        }
30    });
31    let cfg = toolpath_convo::DeriveConfig {
32        base_uri,
33        title: Some(format!("Copilot session: {}", prefix)),
34        ..Default::default()
35    };
36    toolpath_convo::derive_path(&view, &cfg)
37}
38
39/// Derive a [`Path`] from multiple sessions. Used for bulk exports.
40pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
41    sessions.iter().map(|s| derive_path(s, config)).collect()
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::reader::EventReader;
48    use std::fs;
49    use tempfile::TempDir;
50    use toolpath::v1::Graph;
51
52    fn fixture_session(body: &str) -> (TempDir, Session) {
53        let temp = TempDir::new().unwrap();
54        let dir = temp.path().join("session-state").join("sess-019dabc6");
55        fs::create_dir_all(&dir).unwrap();
56        fs::write(dir.join("events.jsonl"), body).unwrap();
57        let session = EventReader::read_session_dir(&dir).unwrap();
58        (temp, session)
59    }
60
61    fn minimal_body() -> String {
62        [
63            r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"copilotVersion":"1.0.66","model":"gpt-5-copilot","context":{"cwd":"/tmp/proj"}}}"#,
64            r#"{"type":"user.message","timestamp":"2026-06-30T10:00:01.000Z","data":{"content":"build me a thing"}}"#,
65            r#"{"type":"assistant.turn_start","timestamp":"2026-06-30T10:00:02.000Z","data":{}}"#,
66            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"creating"}}"#,
67            r#"{"type":"tool.execution_start","timestamp":"2026-06-30T10:00:04.000Z","data":{"toolCallId":"c2","toolName":"create_file","arguments":{"path":"a.rs","content":"fn main() {}\n"}}}"#,
68            r#"{"type":"tool.execution_complete","timestamp":"2026-06-30T10:00:05.000Z","data":{"toolCallId":"c2","success":true}}"#,
69            r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:06.000Z","data":{"content":"done"}}"#,
70            r#"{"type":"assistant.turn_end","timestamp":"2026-06-30T10:00:07.000Z","data":{}}"#,
71        ]
72        .join("\n")
73    }
74
75    #[test]
76    fn derive_path_basic() {
77        let (_t, session) = fixture_session(&minimal_body());
78        let path = derive_path(&session, &DeriveConfig::default());
79        assert!(path.path.id.starts_with("path-copilot-"));
80        assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
81    }
82
83    #[test]
84    fn derive_path_actors_populated() {
85        let (_t, session) = fixture_session(&minimal_body());
86        let path = derive_path(&session, &DeriveConfig::default());
87        let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
88        assert!(actors.contains_key("human:user"));
89        assert!(actors.contains_key("agent:gpt-5-copilot"));
90    }
91
92    #[test]
93    fn derive_path_producer_in_canonical_slot() {
94        let (_t, session) = fixture_session(&minimal_body());
95        let path = derive_path(&session, &DeriveConfig::default());
96        let meta_extra = &path.meta.as_ref().unwrap().extra;
97        let producer = meta_extra
98            .get("producer")
99            .and_then(|v| v.as_object())
100            .expect("meta.extra.producer object");
101        assert_eq!(
102            producer.get("name").and_then(|v| v.as_str()),
103            Some("copilot-cli")
104        );
105        assert_eq!(
106            producer.get("version").and_then(|v| v.as_str()),
107            Some("1.0.66")
108        );
109    }
110
111    #[test]
112    fn derive_path_file_write_emits_sibling() {
113        let (_t, session) = fixture_session(&minimal_body());
114        let path = derive_path(&session, &DeriveConfig::default());
115        let file_step = path
116            .steps
117            .iter()
118            .find(|s| s.change.contains_key("a.rs"))
119            .expect("no step carries the file artifact");
120        let change = &file_step.change["a.rs"];
121        assert!(change.raw.is_some(), "raw perspective must be populated");
122        assert!(change.raw.as_ref().unwrap().contains("+fn main() {}"));
123        let structural = change.structural.as_ref().unwrap();
124        assert_eq!(structural.change_type, "file.write");
125        assert_eq!(structural.extra["operation"], "add");
126    }
127
128    #[test]
129    fn derive_path_validates_as_single_path_graph() {
130        let (_t, session) = fixture_session(&minimal_body());
131        let path = derive_path(&session, &DeriveConfig::default());
132        let doc = Graph::from_path(path);
133        let json = doc.to_json().unwrap();
134        let parsed = Graph::from_json(&json).unwrap();
135        let p = parsed.single_path().expect("single-path graph");
136        let anc = toolpath::v1::query::ancestors(&p.steps, &p.path.head);
137        assert_eq!(anc.len(), p.steps.len(), "all steps on head ancestry");
138    }
139
140    #[test]
141    fn derive_path_base_carries_workspace_git_context() {
142        let temp = TempDir::new().unwrap();
143        let dir = temp.path().join("session-state").join("sess-ws");
144        fs::create_dir_all(&dir).unwrap();
145        fs::write(dir.join("events.jsonl"), minimal_body()).unwrap();
146        fs::write(
147            dir.join("workspace.yaml"),
148            "git_root: /tmp/proj\nrepository: git@github.com:o/r.git\nbranch: main\ncommit: deadbeef\n",
149        )
150        .unwrap();
151        let session = EventReader::read_session_dir(&dir).unwrap();
152        let path = derive_path(&session, &DeriveConfig::default());
153        let base = path.path.base.as_ref().unwrap();
154        assert_eq!(base.uri, "file:///tmp/proj");
155        assert_eq!(base.branch.as_deref(), Some("main"));
156        assert_eq!(base.ref_str.as_deref(), Some("deadbeef"));
157    }
158
159    #[test]
160    fn derive_path_kind_is_agent_coding_session() {
161        let (_t, session) = fixture_session(&minimal_body());
162        let path = derive_path(&session, &DeriveConfig::default());
163        assert_eq!(
164            path.meta.as_ref().unwrap().kind.as_deref(),
165            Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION)
166        );
167    }
168}