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            role,
161            timestamp: "2026-01-01T00:00:00Z".into(),
162            text: text.into(),
163            thinking: None,
164            tool_uses: vec![],
165            model: None,
166            stop_reason: None,
167            token_usage: None,
168            environment: None,
169            delegations: vec![],
170            file_mutations: Vec::new(),
171        }
172    }
173
174    fn view_with_turns() -> ConversationView {
175        ConversationView {
176            id: "sess-2".into(),
177            started_at: None,
178            last_activity: None,
179            turns: vec![
180                make_turn("t1", Role::User, "hello"),
181                make_turn("t2", Role::Assistant, "world"),
182                make_turn("t3", Role::User, "done"),
183            ],
184            total_usage: None,
185            provider_id: Some("test-provider".into()),
186            files_changed: vec![],
187            session_ids: vec![],
188            events: vec![],
189            ..Default::default()
190        }
191    }
192
193    // ── concrete projectors used in tests ────────────────────────────
194
195    struct TurnCounter;
196    impl ConversationProjector for TurnCounter {
197        type Output = usize;
198        fn project(&self, view: &ConversationView) -> Result<usize> {
199            Ok(view.turns.len())
200        }
201    }
202
203    struct ProviderIdExtractor;
204    impl ConversationProjector for ProviderIdExtractor {
205        type Output = Option<String>;
206        fn project(&self, view: &ConversationView) -> Result<Option<String>> {
207            Ok(view.provider_id.clone())
208        }
209    }
210
211    struct AlwaysFails;
212    impl ConversationProjector for AlwaysFails {
213        type Output = String;
214        fn project(&self, _view: &ConversationView) -> Result<String> {
215            Err(ConvoError::Provider("intentional failure".into()))
216        }
217    }
218
219    // ── Test 1: concrete projector usage ────────────────────────────
220
221    #[test]
222    fn test_concrete_projector_empty() {
223        let proj = TurnCounter;
224        let count = proj.project(&empty_view()).unwrap();
225        assert_eq!(count, 0);
226    }
227
228    #[test]
229    fn test_concrete_projector_with_turns() {
230        let proj = TurnCounter;
231        let count = proj.project(&view_with_turns()).unwrap();
232        assert_eq!(count, 3);
233    }
234
235    #[test]
236    fn test_concrete_projector_option_output() {
237        let proj = ProviderIdExtractor;
238        let id = proj.project(&view_with_turns()).unwrap();
239        assert_eq!(id.as_deref(), Some("test-provider"));
240
241        let id_none = proj.project(&empty_view()).unwrap();
242        assert!(id_none.is_none());
243    }
244
245    // ── Test 2: AnyProjector::project (returns Box<dyn Any>) ────────
246
247    #[test]
248    fn test_any_projector_project_returns_box_any() {
249        let any = AnyProjector::new(TurnCounter);
250        let boxed = any.project(&view_with_turns()).unwrap();
251        // We can downcast it manually
252        let count = boxed.downcast::<usize>().unwrap();
253        assert_eq!(*count, 3);
254    }
255
256    #[test]
257    fn test_any_projector_project_empty() {
258        let any = AnyProjector::new(TurnCounter);
259        let boxed = any.project(&empty_view()).unwrap();
260        let count = boxed.downcast::<usize>().unwrap();
261        assert_eq!(*count, 0);
262    }
263
264    // ── Test 3: AnyProjector::project_as (successful downcast) ──────
265
266    #[test]
267    fn test_any_projector_project_as_success() {
268        let any = AnyProjector::new(TurnCounter);
269        let count: usize = any.project_as(&view_with_turns()).unwrap();
270        assert_eq!(count, 3);
271    }
272
273    #[test]
274    fn test_any_projector_project_as_option_output() {
275        let any = AnyProjector::new(ProviderIdExtractor);
276        let id: Option<String> = any.project_as(&view_with_turns()).unwrap();
277        assert_eq!(id.as_deref(), Some("test-provider"));
278    }
279
280    // ── Test 4: AnyProjector::project_as with wrong type (error) ────
281
282    #[test]
283    fn test_any_projector_project_as_wrong_type() {
284        let any = AnyProjector::new(TurnCounter); // Output = usize
285        let result: Result<String> = any.project_as(&view_with_turns()); // ask for String
286        assert!(result.is_err());
287        let err = result.unwrap_err();
288        // Should be a Provider error describing the type mismatch
289        assert!(matches!(err, ConvoError::Provider(_)));
290        let msg = err.to_string();
291        assert!(msg.contains("AnyProjector::project_as"), "msg was: {}", msg);
292    }
293
294    #[test]
295    fn test_any_projector_project_as_wrong_type_bool() {
296        let any = AnyProjector::new(ProviderIdExtractor); // Output = Option<String>
297        let result: Result<bool> = any.project_as(&view_with_turns());
298        assert!(result.is_err());
299    }
300
301    // ── Test 5: AnyProjector with actual turn data ───────────────────
302
303    struct TextCollector;
304    impl ConversationProjector for TextCollector {
305        type Output = Vec<String>;
306        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
307            Ok(view.turns.iter().map(|t| t.text.clone()).collect())
308        }
309    }
310
311    struct ToolNameCollector;
312    impl ConversationProjector for ToolNameCollector {
313        type Output = Vec<String>;
314        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
315            Ok(view
316                .turns
317                .iter()
318                .flat_map(|t| t.tool_uses.iter().map(|u| u.name.clone()))
319                .collect())
320        }
321    }
322
323    #[test]
324    fn test_any_projector_with_turn_text_data() {
325        let any = AnyProjector::new(TextCollector);
326        let texts: Vec<String> = any.project_as(&view_with_turns()).unwrap();
327        assert_eq!(texts, vec!["hello", "world", "done"]);
328    }
329
330    #[test]
331    fn test_any_projector_with_tool_use_data() {
332        let view = ConversationView {
333            id: "s3".into(),
334            started_at: None,
335            last_activity: None,
336            events: vec![],
337            turns: vec![Turn {
338                id: "t1".into(),
339                parent_id: None,
340                role: Role::Assistant,
341                timestamp: "2026-01-01T00:00:00Z".into(),
342                text: "reading file".into(),
343                thinking: None,
344                tool_uses: vec![
345                    ToolInvocation {
346                        id: "u1".into(),
347                        name: "Read".into(),
348                        input: serde_json::json!({"file": "src/main.rs"}),
349                        result: Some(ToolResult {
350                            content: "fn main() {}".into(),
351                            is_error: false,
352                        }),
353                        category: None,
354                    },
355                    ToolInvocation {
356                        id: "u2".into(),
357                        name: "Bash".into(),
358                        input: serde_json::json!({"command": "cargo test"}),
359                        result: None,
360                        category: None,
361                    },
362                ],
363                model: None,
364                stop_reason: None,
365                token_usage: None,
366                environment: None,
367                delegations: vec![],
368                file_mutations: Vec::new(),
369            }],
370            total_usage: None,
371            provider_id: None,
372            files_changed: vec![],
373            session_ids: vec![],
374            ..Default::default()
375        };
376
377        let any = AnyProjector::new(ToolNameCollector);
378        let names: Vec<String> = any.project_as(&view).unwrap();
379        assert_eq!(names, vec!["Read", "Bash"]);
380    }
381
382    #[test]
383    fn test_any_projector_propagates_projector_error() {
384        let any = AnyProjector::new(AlwaysFails);
385        let result: Result<String> = any.project_as(&empty_view());
386        assert!(result.is_err());
387        assert!(matches!(result.unwrap_err(), ConvoError::Provider(_)));
388    }
389
390    #[test]
391    fn test_any_projector_with_token_usage() {
392        struct TotalInputTokens;
393        impl ConversationProjector for TotalInputTokens {
394            type Output = u32;
395            fn project(&self, view: &ConversationView) -> Result<u32> {
396                Ok(view
397                    .turns
398                    .iter()
399                    .filter_map(|t| t.token_usage.as_ref())
400                    .filter_map(|u| u.input_tokens)
401                    .sum())
402            }
403        }
404
405        let view = ConversationView {
406            id: "s4".into(),
407            started_at: None,
408            last_activity: None,
409            events: vec![],
410            turns: vec![
411                Turn {
412                    id: "t1".into(),
413                    parent_id: None,
414                    role: Role::Assistant,
415                    timestamp: "2026-01-01T00:00:00Z".into(),
416                    text: "turn 1".into(),
417                    thinking: None,
418                    tool_uses: vec![],
419                    model: None,
420                    stop_reason: None,
421                    token_usage: Some(TokenUsage {
422                        input_tokens: Some(100),
423                        output_tokens: Some(50),
424                        cache_read_tokens: None,
425                        cache_write_tokens: None,
426                    }),
427                    environment: None,
428                    delegations: vec![],
429                    file_mutations: Vec::new(),
430                },
431                Turn {
432                    id: "t2".into(),
433                    parent_id: Some("t1".into()),
434                    role: Role::Assistant,
435                    timestamp: "2026-01-01T00:00:01Z".into(),
436                    text: "turn 2".into(),
437                    thinking: None,
438                    tool_uses: vec![],
439                    model: None,
440                    stop_reason: None,
441                    token_usage: Some(TokenUsage {
442                        input_tokens: Some(200),
443                        output_tokens: Some(75),
444                        cache_read_tokens: None,
445                        cache_write_tokens: None,
446                    }),
447                    environment: None,
448                    delegations: vec![],
449                    file_mutations: Vec::new(),
450                },
451            ],
452            total_usage: None,
453            provider_id: None,
454            files_changed: vec![],
455            session_ids: vec![],
456            ..Default::default()
457        };
458
459        let any = AnyProjector::new(TotalInputTokens);
460        let total: u32 = any.project_as(&view).unwrap();
461        assert_eq!(total, 300);
462    }
463}