Skip to main content

toolpath_pi/
derive.rs

1//! Thin wrapper: PiSession → ConversationView → toolpath_convo::derive.
2//!
3//! Converts Pi sessions into Toolpath `Path` and `Graph` documents by
4//! delegating to [`toolpath_convo::derive_path`] once the session has been
5//! mapped to a provider-agnostic [`toolpath_convo::ConversationView`] via
6//! [`crate::provider::session_to_view`].
7
8use crate::PiConvo;
9use crate::provider::session_to_view;
10use crate::reader::PiSession;
11use toolpath::v1::{Graph, GraphIdentity, GraphMeta, Path, PathOrRef};
12use toolpath_convo::DeriveConfig;
13
14/// Derive a Toolpath [`Path`] from a single Pi session.
15///
16/// Thin wrapper: converts the session to a provider-agnostic
17/// `ConversationView` and hands off to [`toolpath_convo::derive_path`].
18pub fn derive_path(session: &PiSession, config: &DeriveConfig) -> Path {
19    toolpath_convo::derive_path(&session_to_view(session), config)
20}
21
22/// Derive a Toolpath [`Graph`] from multiple Pi sessions.
23///
24/// Each session becomes one `PathOrRef::Path` entry in the graph. `title`
25/// becomes `graph.meta.title`; empty input produces a graph with no paths
26/// and `graph.id == "graph-pi-empty"`.
27pub fn derive_graph(sessions: &[PiSession], title: Option<&str>, config: &DeriveConfig) -> Graph {
28    let id_suffix = sessions
29        .first()
30        .map(|s| s.header.id.chars().take(8).collect::<String>())
31        .unwrap_or_else(|| "empty".to_string());
32    let graph_id = format!("graph-pi-{}", id_suffix);
33
34    let paths: Vec<PathOrRef> = sessions
35        .iter()
36        .map(|s| PathOrRef::Path(Box::new(derive_path(s, config))))
37        .collect();
38
39    let meta = title.map(|t| GraphMeta {
40        title: Some(t.to_string()),
41        ..Default::default()
42    });
43
44    Graph {
45        graph: GraphIdentity { id: graph_id },
46        paths,
47        meta,
48    }
49}
50
51/// Derive a [`Graph`] from all sessions in a project.
52pub fn derive_project(
53    manager: &PiConvo,
54    project: &str,
55    title: Option<&str>,
56    config: &DeriveConfig,
57) -> crate::Result<Graph> {
58    let sessions = manager.read_all_sessions(project)?;
59    Ok(derive_graph(&sessions, title, config))
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::types::{AgentMessage, Entry, EntryBase, MessageContent, SessionHeader};
66    use std::collections::HashMap;
67    use std::path::PathBuf;
68
69    fn make_session(id: &str) -> PiSession {
70        let header = SessionHeader {
71            version: 3,
72            id: id.into(),
73            timestamp: "2026-04-16T00:00:00Z".into(),
74            cwd: "/tmp/p".into(),
75            parent_session: None,
76            extra: HashMap::new(),
77        };
78        let msg = Entry::Message {
79            base: EntryBase {
80                id: "u1".into(),
81                parent_id: None,
82                timestamp: "2026-04-16T00:00:01Z".into(),
83            },
84            message: AgentMessage::User {
85                content: MessageContent::Text("hi".into()),
86                timestamp: 1,
87                extra: HashMap::new(),
88            },
89            extra: HashMap::new(),
90        };
91        PiSession {
92            header: header.clone(),
93            entries: vec![Entry::Session(header), msg],
94            file_path: PathBuf::from("/tmp/fake.jsonl"),
95            parent: None,
96        }
97    }
98
99    #[test]
100    fn test_derive_path_wraps_provider() {
101        let session = make_session("abcd1234xxxx");
102        let path = derive_path(&session, &DeriveConfig::default());
103        assert_eq!(path.steps.len(), 1);
104        assert!(
105            path.path.id.starts_with("path-pi-"),
106            "got: {}",
107            path.path.id
108        );
109    }
110
111    #[test]
112    fn test_derive_path_respects_config_overrides() {
113        let session = make_session("abcd1234");
114        let cfg = DeriveConfig {
115            path_id: Some("custom-id".into()),
116            ..Default::default()
117        };
118        let path = derive_path(&session, &cfg);
119        assert_eq!(path.path.id, "custom-id");
120    }
121
122    #[test]
123    fn test_derive_graph_empty_sessions() {
124        let g = derive_graph(&[], None, &DeriveConfig::default());
125        assert!(g.paths.is_empty());
126        assert_eq!(g.graph.id, "graph-pi-empty");
127    }
128
129    #[test]
130    fn test_derive_graph_single_session() {
131        let s = make_session("sess-alpha");
132        let g = derive_graph(std::slice::from_ref(&s), None, &DeriveConfig::default());
133        assert_eq!(g.paths.len(), 1);
134        assert!(matches!(&g.paths[0], PathOrRef::Path(_)));
135    }
136
137    #[test]
138    fn test_derive_graph_multiple_sessions() {
139        let s1 = make_session("sess-one");
140        let s2 = make_session("sess-two");
141        let g = derive_graph(&[s1, s2], None, &DeriveConfig::default());
142        assert_eq!(g.paths.len(), 2);
143    }
144
145    #[test]
146    fn test_derive_graph_with_title() {
147        let s = make_session("sess-alpha");
148        let g = derive_graph(&[s], Some("My Release"), &DeriveConfig::default());
149        assert_eq!(
150            g.meta.as_ref().and_then(|m| m.title.as_deref()),
151            Some("My Release")
152        );
153    }
154
155    #[test]
156    fn test_derive_graph_no_title() {
157        let s = make_session("sess-alpha");
158        let g = derive_graph(&[s], None, &DeriveConfig::default());
159        assert!(g.meta.is_none());
160    }
161}