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///     started_at: None,
87///     last_activity: None,
88///     turns: vec![],
89///     total_usage: None,
90///     provider_id: None,
91///     files_changed: vec![],
92///     session_ids: vec![],
93///     events: vec![],
94/// };
95///
96/// let projector = AnyProjector::new(TurnCounter);
97/// let count = projector.project_as::<usize>(&view).unwrap();
98/// assert_eq!(count, 0);
99/// ```
100pub struct AnyProjector {
101    inner: Box<dyn ErasedProjector>,
102}
103
104impl AnyProjector {
105    /// Wrap a concrete [`ConversationProjector`] for type-erased dispatch.
106    pub fn new<P>(projector: P) -> Self
107    where
108        P: ConversationProjector + Send + Sync + 'static,
109        P::Output: 'static,
110    {
111        Self {
112            inner: Box::new(ErasedWrapper(projector)),
113        }
114    }
115
116    /// Project `view` and return the result as `Box<dyn Any>`.
117    ///
118    /// Use [`project_as`](AnyProjector::project_as) when the concrete output
119    /// type is known at the call site.
120    pub fn project(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
121        self.inner.project_erased(view)
122    }
123
124    /// Project `view` and downcast the result to `T`.
125    ///
126    /// Returns `Err(ConvoError::Provider(...))` if the downcast fails, which
127    /// means `T` does not match the projector's actual `Output` type.
128    pub fn project_as<T: 'static>(&self, view: &ConversationView) -> Result<T> {
129        let boxed = self.project(view)?;
130        boxed.downcast::<T>().map(|b| *b).map_err(|_| {
131            ConvoError::Provider(format!(
132                "AnyProjector::project_as: output is not of type {}",
133                std::any::type_name::<T>()
134            ))
135        })
136    }
137}
138
139// ── Tests ─────────────────────────────────────────────────────────────
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::{Role, TokenUsage, ToolInvocation, ToolResult, Turn};
145    use std::collections::HashMap;
146
147    // ── helpers ──────────────────────────────────────────────────────
148
149    fn empty_view() -> ConversationView {
150        ConversationView {
151            id: "sess-1".into(),
152            started_at: None,
153            last_activity: None,
154            turns: vec![],
155            total_usage: None,
156            provider_id: None,
157            files_changed: vec![],
158            session_ids: vec![],
159            events: vec![],
160        }
161    }
162
163    fn make_turn(id: &str, role: Role, text: &str) -> Turn {
164        Turn {
165            id: id.into(),
166            parent_id: None,
167            role,
168            timestamp: "2026-01-01T00:00:00Z".into(),
169            text: text.into(),
170            thinking: None,
171            tool_uses: vec![],
172            model: None,
173            stop_reason: None,
174            token_usage: None,
175            environment: None,
176            delegations: vec![],
177            extra: HashMap::new(),
178        }
179    }
180
181    fn view_with_turns() -> ConversationView {
182        ConversationView {
183            id: "sess-2".into(),
184            started_at: None,
185            last_activity: None,
186            turns: vec![
187                make_turn("t1", Role::User, "hello"),
188                make_turn("t2", Role::Assistant, "world"),
189                make_turn("t3", Role::User, "done"),
190            ],
191            total_usage: None,
192            provider_id: Some("test-provider".into()),
193            files_changed: vec![],
194            session_ids: vec![],
195            events: vec![],
196        }
197    }
198
199    // ── concrete projectors used in tests ────────────────────────────
200
201    struct TurnCounter;
202    impl ConversationProjector for TurnCounter {
203        type Output = usize;
204        fn project(&self, view: &ConversationView) -> Result<usize> {
205            Ok(view.turns.len())
206        }
207    }
208
209    struct ProviderIdExtractor;
210    impl ConversationProjector for ProviderIdExtractor {
211        type Output = Option<String>;
212        fn project(&self, view: &ConversationView) -> Result<Option<String>> {
213            Ok(view.provider_id.clone())
214        }
215    }
216
217    struct AlwaysFails;
218    impl ConversationProjector for AlwaysFails {
219        type Output = String;
220        fn project(&self, _view: &ConversationView) -> Result<String> {
221            Err(ConvoError::Provider("intentional failure".into()))
222        }
223    }
224
225    // ── Test 1: concrete projector usage ────────────────────────────
226
227    #[test]
228    fn test_concrete_projector_empty() {
229        let proj = TurnCounter;
230        let count = proj.project(&empty_view()).unwrap();
231        assert_eq!(count, 0);
232    }
233
234    #[test]
235    fn test_concrete_projector_with_turns() {
236        let proj = TurnCounter;
237        let count = proj.project(&view_with_turns()).unwrap();
238        assert_eq!(count, 3);
239    }
240
241    #[test]
242    fn test_concrete_projector_option_output() {
243        let proj = ProviderIdExtractor;
244        let id = proj.project(&view_with_turns()).unwrap();
245        assert_eq!(id.as_deref(), Some("test-provider"));
246
247        let id_none = proj.project(&empty_view()).unwrap();
248        assert!(id_none.is_none());
249    }
250
251    // ── Test 2: AnyProjector::project (returns Box<dyn Any>) ────────
252
253    #[test]
254    fn test_any_projector_project_returns_box_any() {
255        let any = AnyProjector::new(TurnCounter);
256        let boxed = any.project(&view_with_turns()).unwrap();
257        // We can downcast it manually
258        let count = boxed.downcast::<usize>().unwrap();
259        assert_eq!(*count, 3);
260    }
261
262    #[test]
263    fn test_any_projector_project_empty() {
264        let any = AnyProjector::new(TurnCounter);
265        let boxed = any.project(&empty_view()).unwrap();
266        let count = boxed.downcast::<usize>().unwrap();
267        assert_eq!(*count, 0);
268    }
269
270    // ── Test 3: AnyProjector::project_as (successful downcast) ──────
271
272    #[test]
273    fn test_any_projector_project_as_success() {
274        let any = AnyProjector::new(TurnCounter);
275        let count: usize = any.project_as(&view_with_turns()).unwrap();
276        assert_eq!(count, 3);
277    }
278
279    #[test]
280    fn test_any_projector_project_as_option_output() {
281        let any = AnyProjector::new(ProviderIdExtractor);
282        let id: Option<String> = any.project_as(&view_with_turns()).unwrap();
283        assert_eq!(id.as_deref(), Some("test-provider"));
284    }
285
286    // ── Test 4: AnyProjector::project_as with wrong type (error) ────
287
288    #[test]
289    fn test_any_projector_project_as_wrong_type() {
290        let any = AnyProjector::new(TurnCounter); // Output = usize
291        let result: Result<String> = any.project_as(&view_with_turns()); // ask for String
292        assert!(result.is_err());
293        let err = result.unwrap_err();
294        // Should be a Provider error describing the type mismatch
295        assert!(matches!(err, ConvoError::Provider(_)));
296        let msg = err.to_string();
297        assert!(msg.contains("AnyProjector::project_as"), "msg was: {}", msg);
298    }
299
300    #[test]
301    fn test_any_projector_project_as_wrong_type_bool() {
302        let any = AnyProjector::new(ProviderIdExtractor); // Output = Option<String>
303        let result: Result<bool> = any.project_as(&view_with_turns());
304        assert!(result.is_err());
305    }
306
307    // ── Test 5: AnyProjector with actual turn data ───────────────────
308
309    struct TextCollector;
310    impl ConversationProjector for TextCollector {
311        type Output = Vec<String>;
312        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
313            Ok(view.turns.iter().map(|t| t.text.clone()).collect())
314        }
315    }
316
317    struct ToolNameCollector;
318    impl ConversationProjector for ToolNameCollector {
319        type Output = Vec<String>;
320        fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
321            Ok(view
322                .turns
323                .iter()
324                .flat_map(|t| t.tool_uses.iter().map(|u| u.name.clone()))
325                .collect())
326        }
327    }
328
329    #[test]
330    fn test_any_projector_with_turn_text_data() {
331        let any = AnyProjector::new(TextCollector);
332        let texts: Vec<String> = any.project_as(&view_with_turns()).unwrap();
333        assert_eq!(texts, vec!["hello", "world", "done"]);
334    }
335
336    #[test]
337    fn test_any_projector_with_tool_use_data() {
338        let view = ConversationView {
339            id: "s3".into(),
340            started_at: None,
341            last_activity: None,
342            events: vec![],
343            turns: vec![Turn {
344                id: "t1".into(),
345                parent_id: None,
346                role: Role::Assistant,
347                timestamp: "2026-01-01T00:00:00Z".into(),
348                text: "reading file".into(),
349                thinking: None,
350                tool_uses: vec![
351                    ToolInvocation {
352                        id: "u1".into(),
353                        name: "Read".into(),
354                        input: serde_json::json!({"file": "src/main.rs"}),
355                        result: Some(ToolResult {
356                            content: "fn main() {}".into(),
357                            is_error: false,
358                        }),
359                        category: None,
360                    },
361                    ToolInvocation {
362                        id: "u2".into(),
363                        name: "Bash".into(),
364                        input: serde_json::json!({"command": "cargo test"}),
365                        result: None,
366                        category: None,
367                    },
368                ],
369                model: None,
370                stop_reason: None,
371                token_usage: None,
372                environment: None,
373                delegations: vec![],
374                extra: HashMap::new(),
375            }],
376            total_usage: None,
377            provider_id: None,
378            files_changed: vec![],
379            session_ids: vec![],
380        };
381
382        let any = AnyProjector::new(ToolNameCollector);
383        let names: Vec<String> = any.project_as(&view).unwrap();
384        assert_eq!(names, vec!["Read", "Bash"]);
385    }
386
387    #[test]
388    fn test_any_projector_propagates_projector_error() {
389        let any = AnyProjector::new(AlwaysFails);
390        let result: Result<String> = any.project_as(&empty_view());
391        assert!(result.is_err());
392        assert!(matches!(result.unwrap_err(), ConvoError::Provider(_)));
393    }
394
395    #[test]
396    fn test_any_projector_with_token_usage() {
397        struct TotalInputTokens;
398        impl ConversationProjector for TotalInputTokens {
399            type Output = u32;
400            fn project(&self, view: &ConversationView) -> Result<u32> {
401                Ok(view
402                    .turns
403                    .iter()
404                    .filter_map(|t| t.token_usage.as_ref())
405                    .filter_map(|u| u.input_tokens)
406                    .sum())
407            }
408        }
409
410        let view = ConversationView {
411            id: "s4".into(),
412            started_at: None,
413            last_activity: None,
414            events: vec![],
415            turns: vec![
416                Turn {
417                    id: "t1".into(),
418                    parent_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                    }),
432                    environment: None,
433                    delegations: vec![],
434                    extra: HashMap::new(),
435                },
436                Turn {
437                    id: "t2".into(),
438                    parent_id: Some("t1".into()),
439                    role: Role::Assistant,
440                    timestamp: "2026-01-01T00:00:01Z".into(),
441                    text: "turn 2".into(),
442                    thinking: None,
443                    tool_uses: vec![],
444                    model: None,
445                    stop_reason: None,
446                    token_usage: Some(TokenUsage {
447                        input_tokens: Some(200),
448                        output_tokens: Some(75),
449                        cache_read_tokens: None,
450                        cache_write_tokens: None,
451                    }),
452                    environment: None,
453                    delegations: vec![],
454                    extra: HashMap::new(),
455                },
456            ],
457            total_usage: None,
458            provider_id: None,
459            files_changed: vec![],
460            session_ids: vec![],
461        };
462
463        let any = AnyProjector::new(TotalInputTokens);
464        let total: u32 = any.project_as(&view).unwrap();
465        assert_eq!(total, 300);
466    }
467}