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::{Document, 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 [`Document::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<Document> {
58    let sessions = manager.read_all_sessions(project)?;
59    let graph = derive_graph(&sessions, title, config);
60    Ok(Document::Graph(graph))
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::types::{AgentMessage, Entry, EntryBase, MessageContent, SessionHeader};
67    use std::collections::HashMap;
68    use std::path::PathBuf;
69
70    fn make_session(id: &str) -> PiSession {
71        let header = SessionHeader {
72            version: 3,
73            id: id.into(),
74            timestamp: "2026-04-16T00:00:00Z".into(),
75            cwd: "/tmp/p".into(),
76            parent_session: None,
77            extra: HashMap::new(),
78        };
79        let msg = Entry::Message {
80            base: EntryBase {
81                id: "u1".into(),
82                parent_id: None,
83                timestamp: "2026-04-16T00:00:01Z".into(),
84            },
85            message: AgentMessage::User {
86                content: MessageContent::Text("hi".into()),
87                timestamp: 1,
88                extra: HashMap::new(),
89            },
90            extra: HashMap::new(),
91        };
92        PiSession {
93            header: header.clone(),
94            entries: vec![Entry::Session(header), msg],
95            file_path: PathBuf::from("/tmp/fake.jsonl"),
96            parent: None,
97        }
98    }
99
100    #[test]
101    fn test_derive_path_wraps_provider() {
102        let session = make_session("abcd1234xxxx");
103        let path = derive_path(&session, &DeriveConfig::default());
104        assert_eq!(path.steps.len(), 1);
105        assert!(
106            path.path.id.starts_with("path-pi-"),
107            "got: {}",
108            path.path.id
109        );
110    }
111
112    #[test]
113    fn test_derive_path_respects_config_overrides() {
114        let session = make_session("abcd1234");
115        let cfg = DeriveConfig {
116            path_id: Some("custom-id".into()),
117            ..Default::default()
118        };
119        let path = derive_path(&session, &cfg);
120        assert_eq!(path.path.id, "custom-id");
121    }
122
123    #[test]
124    fn test_derive_graph_empty_sessions() {
125        let g = derive_graph(&[], None, &DeriveConfig::default());
126        assert!(g.paths.is_empty());
127        assert_eq!(g.graph.id, "graph-pi-empty");
128    }
129
130    #[test]
131    fn test_derive_graph_single_session() {
132        let s = make_session("sess-alpha");
133        let g = derive_graph(std::slice::from_ref(&s), None, &DeriveConfig::default());
134        assert_eq!(g.paths.len(), 1);
135        assert!(matches!(&g.paths[0], PathOrRef::Path(_)));
136    }
137
138    #[test]
139    fn test_derive_graph_multiple_sessions() {
140        let s1 = make_session("sess-one");
141        let s2 = make_session("sess-two");
142        let g = derive_graph(&[s1, s2], None, &DeriveConfig::default());
143        assert_eq!(g.paths.len(), 2);
144    }
145
146    #[test]
147    fn test_derive_graph_with_title() {
148        let s = make_session("sess-alpha");
149        let g = derive_graph(&[s], Some("My Release"), &DeriveConfig::default());
150        assert_eq!(
151            g.meta.as_ref().and_then(|m| m.title.as_deref()),
152            Some("My Release")
153        );
154    }
155
156    #[test]
157    fn test_derive_graph_no_title() {
158        let s = make_session("sess-alpha");
159        let g = derive_graph(&[s], None, &DeriveConfig::default());
160        assert!(g.meta.is_none());
161    }
162}