Skip to main content

toolpath_convo/
project.rs

1//! [`ConversationProjector`] trait and [`AnyProjector`] type-erasing wrapper.
2//!
3//! A projector is the "serialize" half of a serde-like pattern for conversation
4//! portability — the inverse of [`ConversationProvider`](crate::ConversationProvider).
5//! Where a provider reads provider-specific data and produces a [`ConversationView`],
6//! a projector consumes a [`ConversationView`] and produces some output type.
7
8use crate::{ConversationView, ConvoError, Result};
9use std::any::Any;
10
11// ── Trait ─────────────────────────────────────────────────────────────
12
13/// Convert a [`ConversationView`] into an output type.
14///
15/// Implement this trait to serialize, render, or transform a conversation
16/// into any target representation (e.g. Toolpath `Path`, Markdown, JSON-LD).
17///
18/// # Example
19///
20/// ```
21/// use toolpath_convo::{ConversationView, ConversationProjector, Result};
22///
23/// struct TurnCounter;
24///
25/// impl ConversationProjector for TurnCounter {
26///     type Output = usize;
27///
28///     fn project(&self, view: &ConversationView) -> Result<usize> {
29///         Ok(view.turns.len())
30///     }
31/// }
32/// ```
33pub trait ConversationProjector {
34    /// The type produced by projecting a [`ConversationView`].
35    type Output;
36
37    /// Project `view` into `Self::Output`.
38    fn project(&self, view: &ConversationView) -> Result<Self::Output>;
39}
40
41// ── Internal erased trait ─────────────────────────────────────────────
42
43trait ErasedProjector: Send + Sync {
44    fn project_erased(&self, view: &ConversationView) -> Result<Box<dyn Any>>;
45}
46
47struct ErasedWrapper<P>(P);
48
49impl<P> ErasedProjector for ErasedWrapper<P>
50where
51    P: ConversationProjector + Send + Sync,
52    P::Output: 'static,
53{
54    fn project_erased(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
55        self.0
56            .project(view)
57            .map(|out| Box::new(out) as Box<dyn Any>)
58    }
59}
60
61// ── AnyProjector ─────────────────────────────────────────────────────
62
63/// A type-erased [`ConversationProjector`] for dynamic dispatch.
64///
65/// Wraps any concrete projector so it can be stored in trait objects,
66/// passed across module boundaries, or held in collections alongside
67/// projectors with different output types.
68///
69/// # Example
70///
71/// ```
72/// use toolpath_convo::{ConversationView, ConversationProjector, Result};
73/// use toolpath_convo::project::AnyProjector;
74/// use std::collections::HashMap;
75///
76/// struct TurnCounter;
77/// impl ConversationProjector for TurnCounter {
78///     type Output = usize;
79///     fn project(&self, view: &ConversationView) -> Result<usize> {
80///         Ok(view.turns.len())
81///     }
82/// }
83///
84/// let view = ConversationView {
85///     id: "s1".into(),
86///     ..Default::default()
87/// };
88///
89/// let projector = AnyProjector::new(TurnCounter);
90/// let count = projector.project_as::<usize>(&view).unwrap();
91/// assert_eq!(count, 0);
92/// ```
93pub struct AnyProjector {
94    inner: Box<dyn ErasedProjector>,
95}
96
97impl AnyProjector {
98    /// Wrap a concrete [`ConversationProjector`] for type-erased dispatch.
99    pub fn new<P>(projector: P) -> Self
100    where
101        P: ConversationProjector + Send + Sync + 'static,
102        P::Output: 'static,
103    {
104        Self {
105            inner: Box::new(ErasedWrapper(projector)),
106        }
107    }
108
109    /// Project `view` and return the result as `Box<dyn Any>`.
110    ///
111    /// Use [`project_as`](AnyProjector::project_as) when the concrete output
112    /// type is known at the call site.
113    pub fn project(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
114        self.inner.project_erased(view)
115    }
116
117    /// Project `view` and downcast the result to `T`.
118    ///
119    /// Returns `Err(ConvoError::Provider(...))` if the downcast fails, which
120    /// means `T` does not match the projector's actual `Output` type.
121    pub fn project_as<T: 'static>(&self, view: &ConversationView) -> Result<T> {
122        let boxed = self.project(view)?;
123        boxed.downcast::<T>().map(|b| *b).map_err(|_| {
124            ConvoError::Provider(format!(
125                "AnyProjector::project_as: output is not of type {}",
126                std::any::type_name::<T>()
127            ))
128        })
129    }
130}
131
132// ── Tests ─────────────────────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::{Role, TokenUsage, ToolInvocation, ToolResult, Turn};
138
139    // ── helpers ──────────────────────────────────────────────────────
140
141    fn empty_view() -> ConversationView {
142        ConversationView {
143            id: "sess-1".into(),
144            started_at: None,
145            last_activity: None,
146            turns: vec![],
147            total_usage: None,
148            provider_id: None,
149            files_changed: vec![],
150            session_ids: vec![],
151            events: vec![],
152            ..Default::default()
153        }
154    }
155
156    fn make_turn(id: &str, role: Role, text: &str) -> Turn {
157        Turn {
158            id: id.into(),
159            parent_id: None,
160            group_id: None,
161            role,
162            timestamp: "2026-01-01T00:00:00Z".into(),
163            text: text.into(),
164            thinking: None,
165            tool_uses: vec![],
166            model: None,
167            stop_reason: None,
168            token_usage: None,
169            attributed_token_usage: None,
170            environment: None,
171            delegations: vec![],
172            file_mutations: Vec::new(),
173        }
174    }
175
176    fn view_with_turns() -> ConversationView {
177        ConversationView {
178            id: "sess-2".into(),
179            started_at: None,
180            last_activity: None,
181            turns: vec![
182                make_turn("t1", Role::User, "hello"),
183                make_turn("t2", Role::Assistant, "world"),
184                make_turn("t3", Role::User, "done"),
185            ],
186            total_usage: None,
187            provider_id: Some("test-provider".into()),
188            files_changed: vec![],
189            session_ids: vec![],
190            events: vec![],
191            ..Default::default()
192        }
193    }
194
195    // ── concrete projectors used in tests ────────────────────────────
196
197    struct TurnCounter;
198    impl ConversationProjector for TurnCounter {
199        type Output = usize;
200        fn project(&self, view: &ConversationView) -> Result<usize> {
201            Ok(view.turns.len())
202        }
203    }
204
205    struct ProviderIdExtractor;
206    impl ConversationProjector for ProviderIdExtractor {
207        type Output = Option<String>;
208        fn project(&self, view: &ConversationView) -> Result<Option<String>> {
209            Ok(view.provider_id.clone())
210        }
211    }
212
213    struct AlwaysFails;
214    impl ConversationProjector for AlwaysFails {
215        type Output = String;
216        fn project(&self, _view: &ConversationView) -> Result<String> {
217            Err(ConvoError::Provider("intentional failure".into()))
218        }
219    }
220
221    // ── Test 1: concrete projector usage ────────────────────────────
222
223    #[test]
224    fn test_concrete_projector_empty() {
225        let proj = TurnCounter;
226        let count = proj.project(&empty_view()).unwrap();
227        assert_eq!(count, 0);
228    }
229
230    #[test]
231    fn test_concrete_projector_with_turns() {
232        let proj = TurnCounter;
233        let count = proj.project(&view_with_turns()).unwrap();
234        assert_eq!(count, 3);
235    }
236
237    #[test]
238    fn test_concrete_projector_option_output() {
239        let proj = ProviderIdExtractor;
240        let id = proj.project(&view_with_turns()).unwrap();
241        assert_eq!(id.as_deref(), Some("test-provider"));
242
243        let id_none = proj.project(&empty_view()).unwrap();
244        assert!(id_none.is_none());
245    }
246
247    // ── Test 2: AnyProjector::project (returns Box<dyn Any>) ────────
248
249    #[test]
250    fn test_any_projector_project_returns_box_any() {
251        let any = AnyProjector::new(TurnCounter);
252        let boxed = any.project(&view_with_turns()).unwrap();
253        // We can downcast it manually
254        let count = boxed.downcast::<usize>().unwrap();
255        assert_eq!(*count, 3);
256    }
257
258    #[test]
259    fn test_any_projector_project_empty() {
260        let any = AnyProjector::new(TurnCounter);
261        let boxed = any.project(&empty_view()).unwrap();
262        let count = boxed.downcast::<usize>().unwrap();
263        assert_eq!(*count, 0);
264    }
265
266    // ── Test 3: AnyProjector::project_as (successful downcast) ──────
267
268    #[test]
269    fn test_any_projector_project_as_success() {
270        let any = AnyProjector::new(TurnCounter);
271        let count: usize = any.project_as(&view_with_turns()).unwrap();
272        assert_eq!(count, 3);
273    }
274
275    #[test]
276    fn test_any_projector_project_as_option_output() {
277        let any = AnyProjector::new(ProviderIdExtractor);
278        let id: Option<String> = any.project_as(&view_with_turns()).unwrap();
279        assert_eq!(id.as_deref(), Some("test-provider"));
280    }
281
282    // ── Test 4: AnyProjector::project_as with wrong type (error) ────
283
284    #[test]
285    fn test_any_projector_project_as_wrong_type() {
286        let any = AnyProjector::new(TurnCounter); // Output = usize
287        let result: Result<String> = any.project_as(&view_with_turns()); // ask for String
288        assert!(result.is_err());
289        let err = result.unwrap_err();
290        // Should be a Provider error describing the type mismatch
291        assert!(matches!(err, ConvoError::Provider(_)));
292        let msg = err.to_string();
293        assert!(msg.contains("AnyProjector::project_as"), "msg was: {}", msg);
294    }
295
296    #[test]
297    fn test_any_projector_project_as_wrong_type_bool() {
298        let any = AnyProjector::new(ProviderIdExtractor); // Output = Option<String>
299        let result: Result<bool> = any.project_as(&view_with_turns());
300        assert!(result.is_err());
301    }
302
303    // ── Test 5: AnyProjector with actual turn data ───────────────────
304
305    struct TextCollector;
306    impl ConversationProjector for TextCollector {
307        type Output = Vec<String>;
308        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
309            Ok(view.turns.iter().map(|t| t.text.clone()).collect())
310        }
311    }
312
313    struct ToolNameCollector;
314    impl ConversationProjector for ToolNameCollector {
315        type Output = Vec<String>;
316        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
317            Ok(view
318                .turns
319                .iter()
320                .flat_map(|t| t.tool_uses.iter().map(|u| u.name.clone()))
321                .collect())
322        }
323    }
324
325    #[test]
326    fn test_any_projector_with_turn_text_data() {
327        let any = AnyProjector::new(TextCollector);
328        let texts: Vec<String> = any.project_as(&view_with_turns()).unwrap();
329        assert_eq!(texts, vec!["hello", "world", "done"]);
330    }
331
332    #[test]
333    fn test_any_projector_with_tool_use_data() {
334        let view = ConversationView {
335            id: "s3".into(),
336            started_at: None,
337            last_activity: None,
338            events: vec![],
339            turns: vec![Turn {
340                id: "t1".into(),
341                parent_id: None,
342                group_id: None,
343                role: Role::Assistant,
344                timestamp: "2026-01-01T00:00:00Z".into(),
345                text: "reading file".into(),
346                thinking: None,
347                tool_uses: vec![
348                    ToolInvocation {
349                        id: "u1".into(),
350                        name: "Read".into(),
351                        input: serde_json::json!({"file": "src/main.rs"}),
352                        result: Some(ToolResult {
353                            content: "fn main() {}".into(),
354                            is_error: false,
355                        }),
356                        category: None,
357                    },
358                    ToolInvocation {
359                        id: "u2".into(),
360                        name: "Bash".into(),
361                        input: serde_json::json!({"command": "cargo test"}),
362                        result: None,
363                        category: None,
364                    },
365                ],
366                model: None,
367                stop_reason: None,
368                token_usage: None,
369                attributed_token_usage: None,
370                environment: None,
371                delegations: vec![],
372                file_mutations: Vec::new(),
373            }],
374            total_usage: None,
375            provider_id: None,
376            files_changed: vec![],
377            session_ids: vec![],
378            ..Default::default()
379        };
380
381        let any = AnyProjector::new(ToolNameCollector);
382        let names: Vec<String> = any.project_as(&view).unwrap();
383        assert_eq!(names, vec!["Read", "Bash"]);
384    }
385
386    #[test]
387    fn test_any_projector_propagates_projector_error() {
388        let any = AnyProjector::new(AlwaysFails);
389        let result: Result<String> = any.project_as(&empty_view());
390        assert!(result.is_err());
391        assert!(matches!(result.unwrap_err(), ConvoError::Provider(_)));
392    }
393
394    #[test]
395    fn test_any_projector_with_token_usage() {
396        struct TotalInputTokens;
397        impl ConversationProjector for TotalInputTokens {
398            type Output = u32;
399            fn project(&self, view: &ConversationView) -> Result<u32> {
400                Ok(view
401                    .turns
402                    .iter()
403                    .filter_map(|t| t.token_usage.as_ref())
404                    .filter_map(|u| u.input_tokens)
405                    .sum())
406            }
407        }
408
409        let view = ConversationView {
410            id: "s4".into(),
411            started_at: None,
412            last_activity: None,
413            events: vec![],
414            turns: vec![
415                Turn {
416                    id: "t1".into(),
417                    parent_id: None,
418                    group_id: None,
419                    role: Role::Assistant,
420                    timestamp: "2026-01-01T00:00:00Z".into(),
421                    text: "turn 1".into(),
422                    thinking: None,
423                    tool_uses: vec![],
424                    model: None,
425                    stop_reason: None,
426                    token_usage: Some(TokenUsage {
427                        input_tokens: Some(100),
428                        output_tokens: Some(50),
429                        cache_read_tokens: None,
430                        cache_write_tokens: None,
431                        ..Default::default()
432                    }),
433                    attributed_token_usage: None,
434                    environment: None,
435                    delegations: vec![],
436                    file_mutations: Vec::new(),
437                },
438                Turn {
439                    id: "t2".into(),
440                    parent_id: Some("t1".into()),
441                    group_id: None,
442                    role: Role::Assistant,
443                    timestamp: "2026-01-01T00:00:01Z".into(),
444                    text: "turn 2".into(),
445                    thinking: None,
446                    tool_uses: vec![],
447                    model: None,
448                    stop_reason: None,
449                    token_usage: Some(TokenUsage {
450                        input_tokens: Some(200),
451                        output_tokens: Some(75),
452                        cache_read_tokens: None,
453                        cache_write_tokens: None,
454                        ..Default::default()
455                    }),
456                    attributed_token_usage: None,
457                    environment: None,
458                    delegations: vec![],
459                    file_mutations: Vec::new(),
460                },
461            ],
462            total_usage: None,
463            provider_id: None,
464            files_changed: vec![],
465            session_ids: vec![],
466            ..Default::default()
467        };
468
469        let any = AnyProjector::new(TotalInputTokens);
470        let total: u32 = any.project_as(&view).unwrap();
471        assert_eq!(total, 300);
472    }
473}