1use crate::{ConversationView, ConvoError, Result};
9use std::any::Any;
10
11pub trait ConversationProjector {
34 type Output;
36
37 fn project(&self, view: &ConversationView) -> Result<Self::Output>;
39}
40
41trait 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
61pub struct AnyProjector {
94 inner: Box<dyn ErasedProjector>,
95}
96
97impl AnyProjector {
98 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 pub fn project(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
114 self.inner.project_erased(view)
115 }
116
117 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#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::{Role, TokenUsage, ToolInvocation, ToolResult, Turn};
138
139 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 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]
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]
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 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]
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]
283 fn test_any_projector_project_as_wrong_type() {
284 let any = AnyProjector::new(TurnCounter); let result: Result<String> = any.project_as(&view_with_turns()); assert!(result.is_err());
287 let err = result.unwrap_err();
288 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); let result: Result<bool> = any.project_as(&view_with_turns());
298 assert!(result.is_err());
299 }
300
301 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}